3
votes

I've spent some days now trying to find, or figure out for myself, how to programmatically merge UIDocument changes when the notification UIDocumentStateChangedNotification fires and the document's state has UIDocumentStateInConflict set.

All the examples that I can find (Apples, Ray Wenderlich's etc etc) all detail the prompt user to select a version method. I can't find any that demostrate the correct way to programmatically merge. This worries me as it makes me think it's too erratic to trust and is generally avoided as a solution? My experience with it so far strengthens that position.

Let me detail each problematic area in my attempts.

1) What is the correct way to read the current document contents and the NSFileVersion conflict versions for the purpose of a merge? Using anything with a completion block is really messy in syncing. UIDocument's openWithCompletionHandler: isn't tempting to use. In fact, as a rule, what is the recommended way to read-only a UIDocument? Why open a document just for reading? I've tried using UIDocument's readFromURL: which is fine for the current document but if I try to use it on any of the NSFileVersion's conflict versions it reads the current version, not the version at the URL (I've used MacOS terminal to dig deep into the ../data/.DocumentRevisions-V100/PerUID/... files to confirm this.). For the conflict versions the only way it works for me is to directly read access those files. (e.g. NSData initWithContentsOfFile:)

2) Once past the reading in of the variations of the file, and having managed to merge, how does one correctly save the merge? This one is really not documented anywhere I can find. The only approach I've succeeded with is by re-using one of the NSFileVersion's conflict files, overwriting it, and then using UIDocument's replaceItemAtURL: to make it current. I have also attempted to use UIDocument's revertToContentsOfURL: after using the replaceItemAtURL: but it just crashes with no reason given. Since the merge appears to work fine without it I'm not worried but thought I'd include this as a detail.

3) The iPhone/iPad Simulator (V10.0) doesn't notify of conflicts until I relaunch the app. Is that to be expected or am I doing something wrong? I ask because under the simulator's Debug menu there's Trigger iCloud Sync which syncs but the conflicts don't get flagged until the next app re-boot. Is this just a limitation of the simulator?

Thanks,

2

2 Answers

1
votes

I've simplified my UIDocument merge code after a few weeks of testing and learning what works and what doesn't. One of the wrong assumptions I made was that there was a need to include UIDocument's revertToContentsOfURL: as part of the resolution process. It's a highly unstable API call to make and best avoided I find, even using it within a @try() doesn't protect from unnecessary crashes. This made me remove it just to see what would happen and the conflicts cleared up just fine without it. There was, on developer.apple.com, example code for document conflict resolution which implied that this should be used. It appears to have disappeared post-WWDC2018.

The only issue remaining is that if you have 2 devices, both open at the same time, you can get into a race condition as both merge the document continuously.

I had, earlier, experienced having zero conflict versions despite document marked as being in conflict but more recently I've not seen this happening. Must have been something I was doing wrong earlier. I'm keeping the code in there though since it does no harm.

One other gotcha that I think is worth mentioning here is that if you are new to UIDocument it's worth remembering that it's part of UIKit and you need to ensure that updates are done on the main thread. I found this useful tip that fixed a few remaining issues I still had.

- (void) foobar {
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(handleDocumentStateChange:)
                                                 name:UIDocumentStateChangedNotification
                                               object:_myDocument];
}

- (void) handleDocumentStateChange: (NSNotification *) notification {
    if (_myDocument.documentState & UIDocumentStateInConflict) {
        if (_resolvingConflicts) {
            return;
        }

        NSArray *conflictVersions = [NSFileVersion unresolvedConflictVersionsOfItemAtURL:_myDocument.fileURL];
        if ([conflictVersions count] == 0) {
            return;
        }
        NSMutableArray *docs = [NSMutableArray new];
        [docsData addObject:_myDocument.data]; // Current document data
        _resolvingConflicts = YES;
        for (NSFileVersion *conflictVersion in conflictVersions) {
            MyDocument *myDoc = [[MyDocument alloc] initWithFileURL:conflictVersion.URL];
            NSError *error;
            [myDoc readFromURL:conflictVersion.URL error:&error];
            if ((error == Nil) && (myDoc.data != Nil)) {
                [docs addObject:myDoc.data];
            }
        }

        if ([self mergeDocuments:docs]) {
            [self saveChangesToDocument];
        }

        for (NSFileVersion *fileVersion in conflictVersions) {
            fileVersion.resolved = YES;
        }
        [self deleteiCloudConflictVersionsOfFile:_myDocument.fileURL
                                      completion:^(BOOL success){
                                          self.resolvingConflicts = NO;
                                          dispatch_async(dispatch_get_main_queue(), ^{
                                              // On main thread for UI updates
                                              [[NSNotificationCenter defaultCenter] postNotificationName:kMyDocsUpdateNotification object:nil];
                                          });
                                      }];
    }
}

- (void) deleteiCloudConflictVersionsOfFile : (NSURL *) fileURL {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void) {
        NSFileCoordinator* fileCoordinator = [[NSFileCoordinator alloc] initWithFilePresenter:nil];
        [fileCoordinator coordinateWritingItemAtURL:fileURL
                                            options:NSFileCoordinatorWritingForDeleting
                                              error:nil
                                         byAccessor:^(NSURL* writingURL) {
                                             NSError *error;
                                             if ([NSFileVersion removeOtherVersionsOfItemAtURL:writingURL error:&error]) {
                                                 NSLog(@"deleteiCloudConflictVersionsOfFile: success");
                                             } else {
                                                 NSLog(@"deleteiCloudConflictVersionsOfFile: error; %@", [error description]);
                                             }
                                         }];
    });
}
0
votes

Here's an answer to the part "Why open a document just for reading?".

You just need to ensure that reading is "coordinated" i.e. no clashes with files which are already open by another process and may have unsaved changes.

Here is a way to iterate through an array of NSDocument urls and reading each one in a synchronous manner i.e. this routine doesn't return until all files have been read. It forces any files with unsaved changes to save themselves before any reading occurs.

// NSArray *urls - the urls of UIDocument files you want to read in bulk
NSFileCoordinator *coordinator = [[NSFileCoordinator alloc] init];
NSError *error = nil;
[coordinator prepareForReadingItemsAtURLs:urls options:NSFileCoordinatorReadingWithoutChanges writingItemsAtURLs:@[] options:0 error:&error byAccessor:^(void (^ _Nonnull completionHandler)(void)) {
    for (NSURL *url in self->_urls) {
        NSError *error = nil;
        [coordinator coordinateReadingItemAtURL:url options:0 error:&error byAccessor:^(NSURL * _Nonnull newURL) {
            // Read contents of newURL here and process as required
            // ...

        }];
        if (error) {
            NSLog(@"Error reading: %@ %@", url.path, error.localizedDescription);
        }
    }
    completionHandler();
}];
if (error) {
    NSLog(@"Error preparing for read: %@", error.localizedDescription);
}