I emailed an Apple engineer last week about a problem with my NSMetadataQuery.
Here’s the email:
Hi,
I'm writing a document-based app or iOS and my method for renaming (moving the document to a new location) seems to conflict with the running NSMetadataQuery.
The query updates a couple of time after the move method is called, the first time it has the old URL of the item that just moved, and the next it has the new URL. However, because of my updating method (below) if a URL has been removed since the update, my model removes the deleted URL and vice versa for if it finds a URL which doesn't exist yet.
I think my problem is one of two issue, either the NSMetadataQuery's update method is insufficient and doesn't check an item's URL for the 'correct' attributes before deleting it (although looking over documentation I can't see anything that would suggest I'm missing something) or my renaming method isn't doing something it should.
I have tried disabling updates at the start of the renaming method and reenabling once all completion blocks are finished but it doesn't make any difference.
My NSMetadataQuery's update method:
func metadataQueryDidUpdate(notification: NSNotification) {
ubiquitousItemsQuery?.disableUpdates()
var ubiquitousItemURLs = [NSURL]()
if ubiquitousItemsQuery != nil && UbiquityManager.sharedInstance.ubiquityIsAvailable {
for var i = 0; i < ubiquitousItemsQuery?.resultCount; i++ {
if let result = ubiquitousItemsQuery?.resultAtIndex(i) as? NSMetadataItem {
if let itemURLValue = result.valueForAttribute(NSMetadataItemURLKey) as? NSURL {
ubiquitousItemURLs.append(itemURLValue)
}
}
}
// Remove deleted items
//
for (index, fileRepresentation) in enumerate(fileRepresentations) {
if fileRepresentation.fileURL != nil && !contains(ubiquitousItemURLs, fileRepresentation.fileURL!) {
removeFileRepresentations([fileRepresentation], fromDisk: false)
}
}
// Load documents
//
for (index, fileURL) in enumerate(ubiquitousItemURLs) {
loadDocumentAtFileURL(fileURL, completionHandler: nil)
}
ubiquitousItemsQuery?.enableUpdates()
}
}
And my renaming method:
func renameFileRepresentation(fileRepresentation: FileRepresentation, toNewNameWithoutExtension newName: String) {
if fileRepresentation.name == newName || fileRepresentation.fileURL == nil || newName.isEmpty {
return
}
let newNameWithExtension = newName.stringByAppendingPathExtension(NotableDocumentExtension)!
// Update file representation
//
fileRepresentation.nameWithExtension = newNameWithExtension
if let indexPath = self.indexPathForFileRepresentation(fileRepresentation) {
self.reloadFileRepresentationsAtIndexPaths([indexPath])
}
UbiquityManager.automaticDocumentsDirectoryURLWithCompletionHandler { (documentsDirectoryURL) -> Void in
let sourceURL = fileRepresentation.fileURL!
let destinationURL = documentsDirectoryURL.URLByAppendingPathComponent(newNameWithExtension)
// Update file representation
//
fileRepresentation.fileURL = destinationURL
if let indexPath = self.indexPathForFileRepresentation(fileRepresentation) {
self.reloadFileRepresentationsAtIndexPaths([indexPath])
}
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), { () -> Void in
let coordinator = NSFileCoordinator(filePresenter: nil)
var coordinatorError: NSError?
coordinator.coordinateWritingItemAtURL(sourceURL, options: .ForMoving, writingItemAtURL: destinationURL, options: .ForReplacing, error: &coordinatorError, byAccessor: { (newSourceURL, newDestinationURL) -> Void in
var moveError: NSError?
let moveSuccess = NSFileManager().moveItemAtURL(newSourceURL, toURL: newDestinationURL, error: &moveError)
dispatch_async(dispatch_get_main_queue(), { () -> Void in
assert(moveError == nil || moveSuccess, "Error renaming (moving) document from \(newSourceURL) to \(newDestinationURL).\nSuccess? \(moveSuccess).\nError message: \(moveError).")
if let query = self.ubiquitousItemsQuery {
query.enableUpdates()
}
if moveError != nil || moveSuccess {
// TODO: Implement resetting file rep
}
})
})
})
}
}
I had a reply almost instantly but since then there’s been no reply.
Here’s the reply
One of the big things that jumps out at me is your usage of disableUpdates() and enableUpdates(). You’re executing them both on the same turn of the run loop, but NSMetadataQuery delivers results asynchronously. Since this code executes within your update notification, it is executing synchronously with respect to the query. So from the query’s point-of-view, it’s going to begin delivering updates by posting the notification. Posting a notification is a synchronous process, so while it’s posting the notification, updates will be disabled and the re-enabled. Thus, by the time the query is done posting the notification, it’s back in the exact same state it was in when it started delivering results. It sounds like that’s not the behavior you’re wanting.
Here’s where I need help
I took this to assume that NSMetadataQuery has some kind of cache which it adds results to while updates are disabled and when enabled, those (perhaps many) cache results are looped through and each are sent via the updates notification.
Anyway, I had a look at run loops on iOS and although I understand them as much as I can on my own, I don’t understand how the reply is helpful, i.e how to actually fix the problem - or what’s even causing the problem.
If anyone has any good idea I’d love your help!
Thanks.
Update
Here’s my log of when functions start and end:
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
start renameFileRepresentation:toNewNameWithoutExtension
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
end renameFileRepresentation:toNewNameWithoutExtension
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
start metadataQueryDidUpdate:
end metadataQueryDidUpdate:
I was having the same problem. NSMetaDataQuery updates tell you if there is a change, but does not tell you what that change was. If the change is a rename, there is no way to identify the previous name, so I can find the old entry in my tableView. Very frustrating.
But, you can get the information by using NSFileCoordinator and NSFilePresenter.
Use the NSFilePresenter method presentedSubitemAtURL(oldURL: NSURL, didMoveToURL newURL: NSURL)
As you noted, the query changed notification is called once with the old URL, and once with the new URL. The method above is called between those two notifications.
Related
https://www.loom.com/share/de410c2626644dd796ad407fcee7e5c7
^^^ I've attached a loom video demonstrating the bug im facing as well as the code I currently have.
The problem is that the UI doesn't update right away and may confuse users. All the code in terms of updating the backend function correctly (its the updating of the UI thats not working properly), I'm pretty sure it has to do with the way i'm either calling the functions or the function itself.
Any help would be greatly appreciated!
#Published var userRequestInboxUsers = [User]()
#Published var emergencyContactUsers = [User]()
// function to fetch the user requests
func fetchTheUsersRequests() {
guard let uid = user.id else { return }
let query = COLLECTION_FOLLOWERS.document(uid).collection("inbox").whereField(
"currentStatus", isEqualTo: "isPending")
query.addSnapshotListener(includeMetadataChanges: true) { snapshot, error in
if let error = error {
print("There was an error querying the inbox requests: \(error.localizedDescription)")
} else {
for request in snapshot!.documents {
COLLECTION_USERS.document(request.documentID).getDocument { snapshot, error in
if let error = error {
print("There was an error fetching the user data: \(error)")
} else {
DispatchQueue.main.async {
guard let userRequestInInbox = try? snapshot?.data(as: User.self) else { return }
self.userRequestInboxUsers.append(userRequestInInbox)
}
}
}
}
}
}
}
//function that fetches the users contacts (request that have been approved)
func fetchTheUsersContacts() {
guard let uid = user.id else { return }
let query = COLLECTION_FOLLOWERS.document(uid).collection("inbox").whereField(
"currentStatus", isEqualTo: "emergencyContact")
query.addSnapshotListener(includeMetadataChanges: true) { snapshot, error in
if let error = error {
print("There was an error querying the emergency contacts: \(error.localizedDescription)")
} else {
for userContact in snapshot!.documents {
COLLECTION_USERS.document(userContact.documentID).getDocument { snapshot, error in
if let error = error {
print("There was an error fetching the user data: \(error)")
} else {
DispatchQueue.main.async {
guard let userEmergencyContactsInInbox = try? snapshot?.data(as: User.self) else {
return
}
self.emergencyContactUsers.append(userEmergencyContactsInInbox)
}
}
}
}
}
}
}
I've tried calling the function every time the view appears but that leads to duplicated results.
I'm currently using snapshot listeners to get real time access but even then this doesn't work.
I've structured my backend to have a contacts sub collection and a requests sub collection but I get the same problem with much more lines of code...
I've thought of switching to async/await but i would prefer my app be compatible to ios 14+ rather than just 15 and up.
I could try using strictly Combine rather than call backs but I don't think that would be effective in attacking the problem head on.
The problem is that you're appending the documents to the published property. This will lead to duplicating the entries.
Instead, just assign all of the mapped documents to the emergencyContactUsers property.
Check out Mapping Firestore Data in Swift - The Comprehensive Guide | Peter Friese for more details about this. I've also got a number of other posts about Firestore and SwiftUI on my blog that might be useful.
As for the "duplicate IDs" warning you see - that might actually also contribute to the issue. SwiftUI lists require all list items to have a unique ID, and it seems like your user objects might not have unique IDs. This might either be due to the fact you have multiple copies of the same user in the published properties, or that your User struct is not identifiable.
I noticed that you're using DispatchQueue.async to make sure you're on the main thread. This is not necessary, as Firestore will make sure to call your code on the main thread. See https://twitter.com/peterfriese/status/1489683949014196226 for an explanation.
Also - I am curious about what you said in the beginning of the video about not being able to find documentation about this. We're always looking for ways to make the Firebase docs better - what did you look for / what didn't you find?
So I have been trying to use the MusicKit APIs for a few days now. I have been attempting to use the MPMusicPlayerApplicationController and MutableQueue APIs.
I have queue initialized already using setQueue(with: [String]) with an array of store identifiers for Apple Music songs. Then I want to allow the user to reorder the songs in the queue. I use the following code to attempt that.
let musicPlayerController = MPMusicPlayerController.applicationQueuePlayer
musicPlayerController.perform(queueTransaction: { queue in
let afterItem = queue.items.first(where: { $0.playbackStoreID == predecessorId })
let descriptor = MPMusicPlayerStoreQueueDescriptor(storeIDs: [newItemId])
queue.insert(descriptor, after: afterItem)
}) { (queue, error) in
// Completion for when items' position update
if error != nil {
print(error!)
}
}
The code above works as expected if afterItem is nil (i.e. the song is correctly inserted at the front of the queue). However, if afterItem is not nil, nothing happens. The queue stays the exact same as if no insert happened and there is no error provided in the completion handler. This problem happens regardless of whether the song being inserted is already in the queue or not.
Am I attempting modifying the queue incorrectly?
Ok, I found the solution.
If you want the queue to be mutated.
You need to return the query
let musicPlayerController = MPMusicPlayerController.applicationQueuePlayer
musicPlayerController.perform(queueTransaction: { queue in
let afterItem = queue.items.first(where: { $0.playbackStoreID == predecessorId })
let descriptor = MPMusicPlayerStoreQueueDescriptor(storeIDs: [newItemId])
//return the modification here.
return queue.insert(descriptor, after: afterItem)
}) { (queue, error) in
// Completion for when items' position update
if error != nil {
print(error!)
}
}
I am relatively new to swift and Firebase but I am definitely encountering a weird problem. What seems to be happening after messing around in the debugger is that the following function seems to be exhibiting weird behavior such as skipping the line storageRef.put()
So whats been happening is this, this function is triggered when the user clicks on a save button. As I observe in the debugger, storageRef is called but the if else statements are never invoked. Then, after my function returns the object which wasn't properly initalized, it then returns into the if else statement with the proper values... By then it is too late as the value returned and uploaded to the database is already incorrect..
func toAnyObject() -> [String : Any] {
beforeImageUrl = ""
let storageRef = FIRStorage.storage().reference().child("myImage.png")
let uploadData = UIImagePNGRepresentation(beforeImage!)
storageRef.put(uploadData!, metadata: nil) { (metadata, error) in
if (error != nil) {
print(error)
} else {
self.beforeImageUrl = (metadata?.downloadURL()?.absoluteString)!
print("upload complete: \(metadata?.downloadURL())")
}
}
let firebaseJobObject : [String: Any] = ["jobType" : jobType! as Any,
"jobDescription" : jobDescription! as Any,
"beforeImageUrl" : beforeImageUrl! as Any,]
return firebaseJobObject
}
Consider a change in your approach here. The button target-action is typical of a solution that requires an immediate response.
However, when you involve other processes (via networks) like the - (FIRStorageUploadTask *)putData:(NSData *)uploadData method above, then you must use some form of delegation to perform the delayed action whenever the server side method returns a value.
Keep in mind that when you are trying to use the above method, that it is not meant for use with large files. You should use - (FIRStorageUploadTask *)putFile:(NSURL *)fileURL method.
I'd suggest that you rework the solution to ensure that the follow-up action only happens when the put succeeds or fails. Keep in mind that network traffic means that the upload could take some time. If you want to validate that the put is completing with a success or failure, just add a breakpoint at the appropriate location inside the completion block and run the method on a device with/and without network access (to test both code flows).
I have a UITableview I'm keeping updated with recent items. (That is, added to my CoreData within the last 5 minutes.) I have a field in my Item entity called 'expire_date' which is a Date type. When I download a new item in the background, I add it to CoreData, setting the expire_date to NSDate() plus 5 minutes:
item.expire_date = NSDate(timeIntervalSince1970: NSDate().timeIntervalSince1970+5*60);
My setup for the NSFetchedResultsController looks like:
let fetchRequest=NSFetchRequest(entityName: "Item")
fetchRequest.predicate = NSPredicate(format: "expire_date > now()")
let sortDescriptor = NSSortDescriptor(key: "expire_date")
fetchRequest.sortDescriptors=[sortDescriptor]
myFRC=NSFetchedResultsController(fetchRequest:fetchRequest, managedObjectContext:myMOC)
do {
try myFRC!.performFetch()
} catch {
print(error)
}
The NSFetchedResultsControllerDelegate code is the standard boilerplate for this kind of thing when using a UITableView.
This all works great when starting the app: only existing items that haven't 'timed out' show up. It also works great when new items are added.
The problem is that when an item in the list times out, it doesn't get removed from the results
Anyone have any idea how to accomplish this?
I had the possibility of one or more Items being selected in the UITableView, and I didn't want to lose those selections so I couldn't use W.K.S.'s answer unfortunately. It was strictly speaking correct from the information I had given, although it didn't update the list immediately after any item expires. However, Wain gave me the spark of an idea that worked the way I wanted.
It seems the Item is re-examined by the query when that item is updated, so the trick is to update the item in question when it has expired. I added an NSTimer variable:
var timeoutTimer:NSTimer?
I added a function, addMinimumTimeout() that (re)sets the timeout timer:
func addMinimumTimeout() {
if let _timeoutTimer=timeoutTimer {
_timeoutTimer.invalidate();
timeoutTimer=nil;
}
if myFRC?.fetchedObjects?.count==0 {
return;
}
if let firstItem = myFRC?.fetchedObjects?[0] as? Item {
let timeout=(firstItem.expire_date!.timeIntervalSince1970-NSDate().timeIntervalSince1970)+1.0;
timeoutTimer=NSTimer.scheduledTimerWithTimeInterval(timeout, target: self, selector: #selector(itemTimedOut), userInfo: firstItem, repeats: false);
}
}
My itemTimedOut code looks like this:
func itemTimedOut(timer:NSTimer) {
guard let item=timer.userInfo as? Item else {
return;
}
item.expire_date! = item.expire_date!;
do {
try item.managedObjectContext!.save();
} catch {
let saveError = error as NSError;
print("Error saving: \(saveError)")
return;
}
}
Then in my viewDidLoad, right after I perform my fetch, I call:
addMinimumTimeout();
and I also add it at the end of the boilerplate controller(controller, didChangeObject, atIndexPath, forChangeType, newIndexPath) function.
This way, if there's no items in the list, there's no timeout timer created, but as soon as one is added, the timeout timer is created. When an item is removed for whatever reason, the timer is updated, and if the last one is timed out or removed, there's no timeout timer running.
I'm using a button to populate a UIPickerView on a hidden UIVisualEffectView. The user clicks the button, the VisualEffectView blurs everything else, and the PickerView displays all the names in their contact list (I'm using SwiftAddressBook to do this.)
This works fine except when the user clicks the button, the UI locks up for about 5-10 seconds. I can't find any evidence of heavy CPU or memory usage. If I just print the sorted array to the console, it happens almost immediately. So something about showing the window is causing this bug.
#IBAction func getBffContacts(sender: AnyObject) {
swiftAddressBook?.requestAccessWithCompletion({ (success, error) -> Void in
if success {
if let people = swiftAddressBook?.allPeople {
self.pickerDataSource = [String]()
for person in people {
if (person.firstName != nil && person.lastName != nil) {
//println("\(person.firstName!) \(person.lastName!)")
self.pickerDataSource.append(person.firstName!)
}
}
//println(self.pickerDataSource)
println("done")
self.sortedNames = self.pickerDataSource.sorted { $0.localizedCaseInsensitiveCompare($1) == NSComparisonResult.OrderedAscending }
self.pickerView.reloadAllComponents()
self.blurView.hidden = false
}
}
else {
//no success, access denied. Optionally evaluate error
}
})
}
You have a threading issue. Read. The. Docs!
requestAccessWithCompletion is merely a wrapper for ABAddressBookRequestAccessWithCompletion. And what do we find there?
The completion handler is called on an arbitrary queue
So your code is running in the background. And you must never, never, never attempt to interact with the user interface on a background thread. All of your code is wrong. You need to step out to the main thread immediately at the start of the completion handler. If you don't, disaster awaits.