Merging UIDocument changes for iCloud conflicts

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,

George

Replies

I'm upvoting this. I'm in a very similar situation of wanting to do my own merge to manage conflicts, so the hints here are valuable to me, but seem odd and probably not as the system was designed to work. Surely there must be a better way. I'd really like to hear some kind of confirmation or otherwise that this is the only way such a merge can be done successfully.

Thanks for the up vote. I've posted the identical question on Stackoverflow but had zero replies there.

The silence has been deafening.

I've found a contradiction in the UIDocument API documentation.


UIDocument's initWithFileURL: documentation states:

Declaration - (instancetype)initWithFileURL:(NSURL *)url;
.
.
.
Returns A UIDocument object or nil if the object could not be created.


However auto-complete on XCode states:

- (instancetype) _Nonnull initWithFileURL:(nonnull NSURL *) url


I'm inclined to believe that the XCode (v9.4) auto-complete is correct from the testing I've done.

I never managed to get that init to return a Nil for any non-existant UIDocment file.

I was trying to code to handle such a case but now I think the documentation is misleading.

I've got something that I'm 99% happy with. Putting through TestFlight this week to see if it holds up.


My initial approach to this single document App I'm working on was to only open it when needing to update.

I had it in my mind that it would be more efficient with memory that way.

However that proved to be impossible to do in a stable way.

The constant opening and closing of the document inevitably leads to a sync error and a hang or a crash occuring.

Better instead to just open the document and keep it open using a singleton for the life time of the app running.

This also makes doing the conflict handling much easier too so I'm guessing that this is the correct approach.


I managed to get UIDocument's readFromURL: to work.

I said in my initial post that it didn't work which I'm putting down to trying to work with a closed document.


The only remaining issue is the unexplained crash I experience with revertToContentsOfURL: which crashed the iPad simulator and everytime I re-launched would crash it again repeatedly. The error given was just "SIGABRT" with no stack trace.

After encasing the call to it in a @try {} it triggered the catch(e){} once and that seemed to move past the problem.

Something was allowed to continue and clear whatever was causing the problem.

The catch has never since been sprung as I've had a breakpoint sitting on it ever since.


Happy to share the code and take any critiques of this approach?



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

- (void) handleDocumentStateChange: (NSNotification *) notification {
    if (_myDocument.documentState & UIDocumentStateInConflict) {
        if (_resolvingConflicts) {
            return;
        }
        
        NSError *error;
        NSArray *conflictVersions = [NSFileVersion unresolvedConflictVersionsOfItemAtURL:_myDocument.fileURL];
        if ([conflictVersions count] == 0) {
            [NSFileVersion removeOtherVersionsOfItemAtURL:_myDocument.fileURL error:&error];
            if (error) {
                NSLog(@"removeOtherVersionsOfItemAtURL: error => %@",[error userInfo]);
            }
            return;
        }
        _resolvingConflicts = YES;
        NSMutableArray *docsData = [NSMutableArray new];
        [docsData addObject:_myDocument.data];
        for (NSFileVersion *conflictVersion in conflictVersions) {
            UIDocument *cvDoc = [[UIDocument alloc] initWithFileURL:conflictVersion.URL];
            [cvDoc readFromURL:conflictVersion.URL error:&error];
            if ((error == Nil) && (cvDoc.data != Nil)) {
                [docsData addObject:cvDoc.data];
            }
        }
        @try {
            [_myDocument revertToContentsOfURL:_myDocument.fileURL completionHandler:^(BOOL success) {
                if ([self mergeDocumentsData:docsData]) {
                    [self saveChangesToDocument];
                }
                NSArray *conflictVersions = [NSFileVersion unresolvedConflictVersionsOfItemAtURL:self.myDocument.fileURL];
                for (NSFileVersion *fileVersion in conflictVersions) {
                    fileVersion.resolved = YES;
                    [self deleteiCloudConflictVersionFile:fileVersion];
                }
                [self deleteiCloudConflictVersionsOfFile:self.myDocument.fileURL];
                self.resolvingConflicts = NO;
                NSLog(@"Conflicts resolved");
            }];
        }
        @catch (NSException *e) {
            NSLog(@"[_myDocument revertToContentsOfURL: ] exception=%@",e.reason);
            _resolvingConflicts = NO;
        }
    }
}

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

- (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]);
                                             }
                                         }];
    });
}

Thanks for posting the fruits of your labours! I'm still struggling with this so your sample code is very helpful.


A couple of questions:

1) Have you actually experienced having zero conflict versions despite document marked as being in conflict?

2) What are you doing in [self saveChangesToDocument] - I assume using docsData to amend contents of myDocument,but how exactly?


Unfortunately I can't recall where exactly, but somewhere it Apple docs there was a note that there is a possiblity that the document conflict could be resolved in another instance of your app *before* you have a chance to complete the conflict resolution in your current instance. Do you have any thoughts on whether that is actually something that might need additional logic to handle correctly ie. to abandon current conflict resolution of a new notification arrives that indicates no conflict exists?

1) Yes. It was worrying at first but I've learned to live with it. I suspect there's a period of instability as the devices sharing the container all come to an agreement pre & post conflict. The conflict seems to be immediately flagged, even before all the necessary information is gathered. This might be to allow you to stop trying to update and complicate things more.

Notice I've used a _resolvingConflicts flag to prevent race conditions. That function isn't syncronous due to the revertToContentsOfURL completion block. If you add in extra debugs you'll see it ignoring a few document change notifications while in the process of resolving.

Line 16 is there because the document will often report conflicts but when you ask it for versions it has none.

Likewise, post merge, after you've told it to deleted all other versions you'll continue to get document change notifications with the conflict bit still set. It would be more worrying if this never stopped but it does clear quickly now with this solution I find..


2) saveChangesToDocument just formats my proprietary data, puts it in the sub-classed UIDocument using a [_myDocument setData:data] type of call which is subclassed to include [self updateChangeCount:UIDocumentChangeDone];

That increment of the change count will trigger an auto-save.


I did wonder about sync issues. If you have two devices running on the same container at the same time then both could attempt to resolve the conflict at the same time. Both should create the same result but of course you end up with two different timestamped files with identical contents which will be deemed to be in conflict again. How likely is that though? Rare I'd think.

Notice that my mergeDocumentsData routine returns a BOOL which indicates if there was anything to save. You need to check if the data from each version is the same and only save if there are differences.

I've reverted the code sample back to using two functions for removing the conflict versions.

Don't know what I was thinking using just a file delete instead of the version handlers.

It's probably over kill using both [fileVersion removeAndReturnError:&error] and [NSFileVersion removeOtherVersionsOfItemAtURL: since either will probably work alone but worth documenting them here since it is an example piece of code.


Here's a log of document change notifications during a forced conflict using Xcode simulators


00.246804Document open
01.759431Document editing disabled
01.774706Document in conflict, Document editing disabled
2 versions found (current + 1 conflict). Start merge process
02.282179Document in conflict (ignored. Already processing)
02.298896Document merged & changes saved
02.305884deleteiCloudConflictVersionFile: success
02.500675Document editing disabled, Document progress update
02.508037deleteiCloudConflictVersionsOfFile
02.750071Document editing disabled
03.028884Document open

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.


I'm about to release this into the wild in my App. 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.


gpdawson asked: "Have you actually experienced having zero conflict versions despite document marked as being in conflict?"

More recently, no, 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 tipthat 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]);
                                             }
                                         }];
    });
}