Reporting progress on list of Publishers in Combine - swiftui

I am writing a Hacker News iOS application using SwiftUI/Combine. They have an API call for getting the ids of top posts and then you are supposed to request each story by itself. For this I have created storyIds(:) -> AnyPublisher<[Int], Error> and story(for:) -> AnyPublisher<Post, Error> for those calls.
Now I want to combine them into one function, getStories() which first download the identifiers and then goes through them fetching the stories one by one. I suppose you can use MergeMany or something else in the API for achieving this but I am not sure
The last thing I need is a function that combine these into stories() -> AnyPublisher<[Post], Error>. I found another question doing almost that. What I miss however, is a way to report progress of the task. I would like to update a counter for each fetched story to show the user how much is left of the download. How can I do this?
struct Agent {
struct Response<T> {
let value: T
let response: URLResponse
}
func run<T: Decodable>(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<Response<T>, Error> {
return URLSession.shared
.dataTaskPublisher(for: request)
.tryMap { result -> Response<T> in
let value = try decoder.decode(T.self, from: result.data)
return Response(value: value, response: result.response)
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
enum HackerNewsAPI {
static let agent = Agent()
static let base = URL(string: "https://hacker-news.firebaseio.com/v0/")!
}
extension HackerNewsAPI {
static func storyIds() -> AnyPublisher<[Int], Error> {
let request = URLRequest(url: base.appendingPathComponent("topstories.json"))
return agent.run(request)
.print()
.map(\.value)
.eraseToAnyPublisher()
}
}
extension HackerNewsAPI {
static func story(for id: Int) -> AnyPublisher<Post, Error> {
let request = URLRequest(url: base.appendingPathComponent("item/\(id).json"))
return agent.run(request)
.map(\.value)
.eraseToAnyPublisher()
}
}
extension HackerNewsAPI {
static func stories() -> AnyPublisher<[Post], Error> {
HackerNewsAPI.storyIds()
.flatMap { storyIDs -> AnyPublisher<[Post], Error> in
let stories = storyIDs.map { story(for: $0) }
return Publishers.MergeMany(stories)
.collect()
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}

I expect something like the below code would work.
You end up with a count publisher that emits the number of articles that have been downloaded so far and a array publisher that emits the entire array of Posts after they have all been downloaded.
func getStories() -> (count: AnyPublisher<Int, Error>, stories: AnyPublisher<[Post], Error>) {
let posts = HackerNewsAPI.storyIds()
.map { $0.map { HackerNewsAPI.story(for: $0) } }
.flatMap { Publishers.Sequence(sequence: $0) }
.flatMap { $0 }
.share()
let count = posts
.scan(0) { count, _ in
return count + 1
}
let array = posts
.reduce([Post]()) { current, post in
current + [post]
}
return (count.eraseToAnyPublisher(), array.eraseToAnyPublisher())
}

Related

How to test Contacts Framework

hi i want to test CNContacts Store since this is my first time doing test, i don't have any idea how to conduct a unit test. This the code i want to test.
private func fetchContacts() {
var contacts: [Contact] = []
let keys: [CNKeyDescriptor] = [CNContactFormatter.descriptorForRequiredKeys(for: .fullName),
CNContactPhoneNumbersKey as CNKeyDescriptor]
let request = CNContactFetchRequest(keysToFetch: keys)
do {
try contactStore.enumerateContacts(with: request) {
(contact, stop) in
let name: String = CNContactFormatter.string(from: contact, style: .fullName) ?? contact.nickname
contacts.append(contentsOf: contact.phoneNumbers.compactMap({ phoneNumber in
let phoneNumberString: String = phoneNumber.value.stringValue
return .init(name: name, phoneNumber: phoneNumberString)
}))
}
allContacts = contacts
isContactsFetched = true
filterContacts()
}
catch {
print("unable to fetch contacts")
}
}
I'm using sourcery to generate mock from CNContactStore this is the enumerated mock i generate using sorcery
//MARK: - enumerateContacts
var enumerateContactsWithUsingBlockThrowableError: Error?
var enumerateContactsWithUsingBlockCallsCount = 0
var enumerateContactsWithUsingBlockCalled: Bool {
return enumerateContactsWithUsingBlockCallsCount > 0
}
var enumerateContactsWithUsingBlockReceivedArguments: (fetchRequest: CNContactFetchRequest, block: (CNContact, UnsafeMutablePointer<ObjCBool>) -> Void)?
var enumerateContactsWithUsingBlockReceivedInvocations: [(fetchRequest: CNContactFetchRequest, block: (CNContact, UnsafeMutablePointer<ObjCBool>) -> Void)] = []
var enumerateContactsWithUsingBlockClosure: ((CNContactFetchRequest, #escaping (CNContact, UnsafeMutablePointer<ObjCBool>) -> Void) throws -> Void)?
func enumerateContacts(with fetchRequest: CNContactFetchRequest, usingBlock block: #escaping (CNContact, UnsafeMutablePointer<ObjCBool>) -> Void) throws {
if let error = enumerateContactsWithUsingBlockThrowableError {
throw error
}
enumerateContactsWithUsingBlockCallsCount += 1
enumerateContactsWithUsingBlockReceivedArguments = (fetchRequest: fetchRequest, block: block)
enumerateContactsWithUsingBlockReceivedInvocations.append((fetchRequest: fetchRequest, block: block))
try enumerateContactsWithUsingBlockClosure?(fetchRequest, block)
}
what i did so far for unit test is this
it("should fetch contacts") {
let contact = CNContact()
let stop = UnsafeMutablePointer<ObjCBool>.allocate(capacity: 1)
stop[0] = true
// When
viewModel.onViewDidAppear()
// Then
mockContactStore.enumerateContactsWithUsingBlockClosure = { (_, args) in
args(contact, stop)
expect(mockContactStore.enumerateContactsWithUsingBlockCallsCount).to(equal(1))
}
}
Please help
if you want to test this ->
let request = CNContactFetchRequest(keysToFetch: keys)
you can do like this ->
protocol CNContactFetchRequestProtocol {
}
extension CNContactFetchRequest: CNContactFetchRequestProtocol {
}
let request: CNContactFetchRequestProtocol = CNContactFetchRequest(keysToFetch: keys)
and finally you can create mock
class MockContact: CNContactFetchRequestProtocol {
}
and then you can tests like this:
let request: CNContactFetchRequestProtocol = MockContact()

SwiftUI - Publish Background Thread Not Allowed - on code that does not update ui

New to swiftui and don't understand why the JSONDecoder() line in the first code throws
[SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
This to me is not updating ui so why is this showing?
do {
// pass the request type, bearer token, and email / password ( which are sent as POST body params )
let request = L.getRequest(requestType:"POST", token: token, email: self.username, password: self.psw)
L.fetchData(from: request) { result in
switch result {
case .success(let data):
// covert the binary data to a swift dictionary
do {
let response = try JSONDecoder().decode(WpJson.self, from: data)
for (key, title) in response.allowedReadings {
let vimeoId = Int( key )!
let vimeoUri = self.buildVimeoUri(vimeoId: key)
self.addReadingEntity(vimeoUri: vimeoUri, vimeoId: vimeoId, title: title)
}
self.writeToKeychain(jwt:response.jwt, displayName: response.displayName)
readings = self.fetchReadings()
}
catch {
self.error = error.localizedDescription
}
case .failure(let error):
self.error = error.localizedDescription
}
}
}
I tried wrapping a main queue around the do-catch in the L.fetchData(from: request) { result in but this did not help
DispatchQueue.main.async { [weak self] in
Here is the Login protocol, again without any ui work:
import Foundation
import SwiftUI
struct Login: Endpoint {
var url: URL?
init(url: URL?) {
self.url = url
}
}
protocol Endpoint {
var url: URL? { get set }
init(url: URL?)
}
extension Endpoint {
func getRequestUrl() -> URLRequest {
guard let requestUrl = url else { fatalError() }
// Prepare URL Request Object
return URLRequest(url: requestUrl)
}
func getRequest(requestType:String="POST", token:String, email:String="", password:String="") -> URLRequest {
var request = self.getRequestUrl()
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if ( "" != email && "" != password && requestType == "POST") {
let parameters:[String:String?] = [
"email": email,
"password": password
]
// Run the request
do {
// pass dictionary to nsdata object and set it as request body
request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: .prettyPrinted)
} catch let error {
print(error.localizedDescription)
}
}
return request;
}
func fetchData(from request: URLRequest, completion: #escaping (Result<Data, NetworkError>) -> Void) {
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
completion(.success(data))
} else if error != nil {
// any sort of network failure
completion(.failure(.requestFailed))
} else {
// this ought not to be possible, yet here we are
completion(.failure(.unknown))
}
}.resume()
}
}
extension URLSession {
func dataTask(with request: URLRequest, completionHandler: #escaping (Result<(Data, HTTPURLResponse), Error>) -> Void) -> URLSessionDataTask {
return dataTask(with: request, completionHandler: { (data, urlResponse, error) in
if let error = error {
completionHandler(.failure(error))
} else if let data = data, let urlResponse = urlResponse as? HTTPURLResponse {
completionHandler(.success((data, urlResponse)))
}
})
}
}
Do you have any idea on how to fix this?
Wrap it right in place of assignment
catch {
DispatchQueue.main.async {
self.error = error.localizedDescription
}
}
case .failure(let error):
DispatchQueue.main.async {
self.error = error.localizedDescription
}
}

Cannot fetch multiple CKReference records from public Database in a for loop

I have a contact CKRecord with many location CKRecords ( 1 to many relationship)
Both contact CKRecord and Location CKRecord are created in public Database. I add CKReference fro contact to locaiotn via a field named owningContact on location.
ckRecord["owningContact"] = CKReference(record: contactRecord!, action: .deleteSelf)
I go to cloudKit dashboard and verify both the records exist. The location CKRecord has field owningContact that has the recordName of the contact CKRecord. I defined a function to get locations like this:
private func iCloudFetchLocations(withContactCKRecord: CKRecord, completionHandler: #escaping ([CKRecord]?, Error?) -> Void) {
var records = [CKRecord]()
let recordToMatch = CKReference(recordID: withContactCKRecord.recordID, action: .deleteSelf)
let predicate = NSPredicate(format: "owningContact == %#", recordToMatch)
// Create the query object.
let query = CKQuery(recordType: "location", predicate: predicate)
let queryOp = CKQueryOperation(query: query)
queryOp.resultsLimit = 1
queryOp.qualityOfService = .userInteractive
queryOp.recordFetchedBlock = {
records.append($0)
print($0)
}
queryOp.queryCompletionBlock = { (cursor, error) in
guard error == nil else {
if let ckerror = error as? CKError {
self.aErrorHandler.handleCkError(ckerror: ckerror)
}
return
}
if (cursor != nil) {
let newOperation = CKQueryOperation(cursor: cursor!)
newOperation.resultsLimit = queryOp.resultsLimit
newOperation.recordFetchedBlock = queryOp.recordFetchedBlock
newOperation.queryCompletionBlock = queryOp.queryCompletionBlock
self.publicDB?.add(newOperation)
}
completionHandler(records, error)
}
self.publicDB?.add(queryOp)
}
Then I call the code to fetch location CKRecord based on contact CKRecord like this:
let predicate = NSPredicate(format: "TRUEPREDICATE")
let query = CKQuery(recordType: Cloud.Entity.Contact, predicate: predicate)
publicDB?.perform(query, inZoneWith: nil, completionHandler: { (records, error) in
guard error == nil else {
if let ckerror = error as? CKError {
self.aErrorHandler.handleCkError(ckerror: ckerror)
}
return
completion(false)
}
if let contactRecords = records {
for aContactRecord in contactRecords {
// fetch Location Data
self.iCloudFetchLocations(withContactCKRecord: aContactRecord, completionHandler: { records, error in
guard error == nil else {
if let ckerror = error as? CKError {
self.aErrorHandler.handleCkError(ckerror: ckerror)
}
return
completion(false)
}
if let locationRecords = records {
}
})
}
}
})
I have two contacts the first one has been CKReferenc'ed to the location, where as the second contact is still not yet CKReferenc'ed to the location.
I think here is the problem: First time in the loop contact CKRecord information is sent by calling iCloudFetchLocations which returns immediately without waiting for cloud response, and the for loop sends the second contact and calls iCloudFetchLocations again. Since the second contact has no CKReference to the location, the call fails and I can never get to the first contact's location since it hasn't returned yet.
How to fix this?
I found that I had not set the CKReference field: owningContact as Queryable. The way I found out is printing error like this: 
if let ckerror = error as? CKError {
print(ckerror.userInfo)
print(ckerror.errorUserInfo)
self.aErrorHandler.handleCkError(ckerror: ckerror)
}
As soon as I did that it started working, Since I was in a for loop it was timing out on previous fetch I think.

Migrating Alamofire.Request extension from Swift 2 to Swift 3

I'm trying to migrate an extension to Alamofire.Request but am getting the error Cannot call value of non-function type 'HTTPURLResponse?'.
I know the compiler thinks I'm referring to the member response and not the function.
I've already replaced Request.JSONResponseSerializer with DataRequest.jsonResponseSerializer.
Can anyone see what I'm missing?
extension Alamofire.Request {
public func responseSwiftyJSON(_ queue: DispatchQueue? = nil, options: JSONSerialization.ReadingOptions = .allowFragments, completionHandler: #escaping (NSURLRequest, HTTPURLResponse?, SwiftyJSON.JSON, Error?) -> Void) -> Self {
return response(responseSerializer: Alamofire.DataRequest.jsonResponseSerializer(options: options)) { response in
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
var responseJSON: JSON
if response.result.isFailure {
responseJSON = JSON.null
} else {
responseJSON = SwiftyJSON.JSON(response.result.value!)
}
dispatch_async(queue ?? dispatch_get_main_queue(), {
completionHandler(self.request!, self.response, responseJSON, response.result.error)
})
}
}
}
}
I'm not sure if this is compatible with older versions, but I suggest you rewrite your extensions as this:
extension DataRequest {
public func responseSwiftyJSON(queue: DispatchQueue? = nil, options: JSONSerialization.ReadingOptions = .allowFragments, completionHandler: #escaping (URLRequest?, HTTPURLResponse?, SwiftyJSON.JSON, Error?) -> Void) -> Self {
return responseJSON(queue: queue, options: options) {
let responseJSON: JSON
switch result {
case let .success(json): responseJSON = JSON(json)
case .failure: responseJSON = JSON.null
}
completionHandler(request, response, responseJSON, error)
}
}
}

UIWebView: ics and vcard-Links not handled

I do have a UIWebView included where a public URL is loaded; unfortunately, vcard and ical-Links are not handled, i.e. nothing happens when I click on them.
I tried to set all data detectors, no luck unfortunately.
In the Xcode-log, I get this here when clicking on such a link:
2017-07-14 13:43:00.982413+0200 xxx[2208:967973] WF: _userSettingsForUser mobile: {
filterBlacklist = (
);
filterWhitelist = (
);
restrictWeb = 1;
useContentFilter = 0;
useContentFilterOverrides = 0;
whitelistEnabled = 0;
}
In Safari, the same stuff works as expected.
If I use UIApplication.shared.openURL(icsOrVcardUrl) Safari gets opened and from there everything works as expected again, but I don't want the user to leave the app...
EDIT
This doesn't work either:
func webView(_ webView: UIWebView, shouldStartLoadWith request: URLRequest, navigationType: UIWebViewNavigationType) -> Bool {
if let url = request.url {
if url.absoluteString.contains("=vcard&") || url.absoluteString.contains("/ical/") {
let sessionConfig = URLSessionConfiguration.default
let session = URLSession(configuration: sessionConfig)
let request = URLRequest(url:url)
let task = session.downloadTask(with: request) { (tempLocalUrl, response, error) in
if let tempLocalUrl = tempLocalUrl, error == nil {
DispatchQueue.main.async {
self.documentController.url = tempLocalUrl
self.documentController.presentPreview(animated: true)
}
}
}
task.resume()
return false
}
}
return true
}
Use a UIDocumentInteractionController to preview without leaving your app.
I tested it quickly with an .ics file and it works fine.
Implement the UIDocumentInteractionControllerDelegate protocol
extension MainViewController: UIDocumentInteractionControllerDelegate {
func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController {
return self;
}
}
Create an instance of the interaction controller:
let documentController = UIDocumentInteractionController()
Intercept the clicks in your UIWebView in shouldStartLoadWithRequest, return false for links you want to handle with the in-app preview and true for all the rest. And finally:
func previewDocument(_ url: URL) {
documentController.url = url
documentController.presentPreview(animated: true)
}
Here it is in the simulator
EDIT:
In response to the comment to this answer:
The reason it doesn't work for you is because the UIDocumentInteractionController depends on the file extension. The extension of the temp file is .tmp
Renaming the file after the download solves the problem. Quick and dirty example:
let task = session.downloadTask(with: url!) { (tempLocalUrl, response, error) in
if let tempLocalUrl = tempLocalUrl, error == nil {
do {
let filemgr = FileManager.default
let newUrl = tempLocalUrl.appendingPathExtension("ics")
try filemgr.moveItem(at: tempLocalUrl, to: newUrl)
DispatchQueue.main.async {
self.documentController.url = newUrl
self.documentController.presentPreview(animated: true)
}
} catch let error {
print("Error!!!: \(error.localizedDescription)")
}
}
}
task.resume()
In this case it is advisable to clean after yourself, because the file won't be deleted after the task completes although the OS will delete it eventually, when space is needed. If you often access the same urls, Library/Caches/ may be a better place for this files, just come up with good naming schema, and check if the file doesn't exist already.