CloudKit Sync using NSPersistentCloudKitContainer in iOS13 - core-data

I am using NSPersistentCloudKitContainer for my Core Data application. During testing, I checked that changes made on the device are sync to CloudKit within second.
However, when I disabled iCloud on my device then re-enable immediately, all my data on the device disappeared. I check that the data in my private database still exist on CloudKit. It took more than 1 day before data on CloudKit are sync back to my device.
This will cause confusion to users when they change device and see that their data have disappeared at first. Question: How can I control how fast data on CloudKit is sync back to my device?

Frustratingly, I think this is 'normal' behaviour, particularly with a large database with a large number of relationships to sync - there is no way to see the progress (to show the user) nor can you speed it up.
NSPersistentCloudKitContainer seems to treat each relationship as an individual CKRecord, with syncing still bound by the same limitations (ie. no more than 400 'requests' at a time), you'll often see these .limitExceeded errors in the Console, but with little other information (ie. if/when will it retry??).
I'm finding this results in the database taking days to sync fully, with the data looking messed up and incomplete in the meantime. I have thousands of many-to-many relationships and when a user restores their database from an external file, all those CKRecords need to be recreated.
The main concern I have here is that there is no way to query NSPersistentCloudKitContainer whether there are pending requests, how much has yet to sync, etc. so you can relay this to the users in the UI so they don't keep deleting and restoring thinking it's 'failed'.
One way around the local data being deleted when you turn off syncing - and potentially saving having to have it all 're-sync' when you turn it back on - is to use a NSPersistentContainer when it's off, and an NSPersistentCloudKitContainer when it's on.
NSPersistentCloudKitContainer is a subclass of NSPersistentContainer.
I am currently doing this in my App in my custom PersistenceService Class:
static var useCloudSync = UserDefaults.standard.bool(forKey: "useCloudSync")
static var persistentContainer: NSPersistentContainer = {
let container: NSPersistentContainer?
if useCloudSync {
container = NSPersistentCloudKitContainer(name: "MyApp")
} else {
container = NSPersistentContainer(name: "MyApp")
let description = container!.persistentStoreDescriptions.first
description?.setOption(true as NSNumber,
forKey: NSPersistentHistoryTrackingKey)
}
container!.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container!
}()
This at least results in the local data being untouched when iCloud is turned off within your App and doesn't require everything being re-synced when turned back on.
I think you can also query iOS to see if the user has turned off iCloud in System Settings and switch between the two before NSPersistentCloudKitContainer deletes all the local data.
EDIT: Added the NSPersistentHistoryTrackingKey as without it, switching back to NSPersistentContainer from NSPersistentCloudKitContainer fails.
It is working properly in my app. When the user re-enables iCloud Syncing within my app (and switches from NSPersistentContainer to NSPersistentCloudKitContainer ) it syncs anything that was added/changed since the last sync which is exactly what I want!
EDIT 2: Here is a better implementation of the above
Essentially, whether the user is syncing to iCloud or not simply requires changing the .options on the container to use an NSPersistentCloudKitContainerOptions(containerIdentifier:) or nil. There appears no need to toggle between an NSPersistentCloudKitContainer and an NSPersistentContainer at all.
static var synciCloudData = {
return defaults.bool(forKey: Settings.synciCloudData.key)
}()
static var persistentContainer: NSPersistentContainer = {
let container = NSPersistentCloudKitContainer(name: "AppName")
guard let description = container.persistentStoreDescriptions.first else {
fatalError("Could not retrieve a persistent store description.")
}
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
if synciCloudData {
let cloudKitContainerIdentifier = "iCloud.com.yourID.AppName"
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: cloudKitContainerIdentifier)
description.cloudKitContainerOptions = options
} else {
description.cloudKitContainerOptions = nil
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
return container
}()
Finally, you can see the state of iCloud syncing, albeit crudely (ie. you can't see if anything is 'in sync' only that a sync is either pending/in progress and whether it succeeded or failed. Having said that, this is enough for my use case in my App.
See the reply by user ggruen towards the bottom of this post:
NSPersistentCloudKitContainer: How to check if data is synced to CloudKit

Related

NSPersistentHistoryTrackingKey Error, Core Data and CloudKit

Stackoverflow
Hello everybody,
I’m working with Swift on my first app.
Recently I ran into a problem with Core Data and CloudKit that I can’t get my head around. So I’m hoping someone in here can help me.
I’m saving several entities with multiple attributes in core data and syncing it (if the user activated iCloud syncing) with CloudKit between all user devices (private database).
It all worked well, I already published it to the AppStore, got my first users, all fine…
Now I’m working on new features and a few days ago I added a new entity with an optional attribute to my core data model and obviously messed something up. Something didn’t really work well so I revoked the changes.
But now always when testing the App, I get a message in the debugger:
[error] fault: Store opened without NSPersistentHistoryTrackingKey but previously had been opened with NSPersistentHistoryTrackingKey - Forcing into Read Only mode store at 'file:///Users/.../LeftySetAccObjData.sqlite'
[error] CoreData: Store opened without NSPersistentHistoryTrackingKey but previously had been opened with NSPersistentHistoryTrackingKey - Forcing into Read Only mode store at 'file:///Users/.../LeftySetAccObjData.sqlite'
When testing the app anyway, it seems to work perfectly fine, locally on the simulator.
But when syncing changes via iCloud, several values for different attributes get mixed up, and get false values…
Here is my code for setting up my persistence Store.
private func setupContainer() -> NSPersistentContainer {
let iCloud = defaults.bool(forKey: "UseCloud")
do {
let newContainer = try PersistentContainer.getContainer(iCloud: iCloud)
guard let description = newContainer.persistentStoreDescriptions.first else { fatalError("No description found") }
if iCloud {
newContainer.viewContext.automaticallyMergesChangesFromParent = true
newContainer.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
} else {
description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
}
description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
description.cloudKitContainerOptions?.databaseScope = .private
newContainer.loadPersistentStores { (storeDescription, error) in
if let error = error as NSError? { fatalError("Unresolved error \(error), \(error.userInfo)") }
}
return newContainer
} catch {
print("#01 error: \(error)")
}
fatalError("Could not setup Container")
}
Here comes the strange thing. Even when loading an older version before I made any changes to my core data model, I get the same Issue.
And even stranger, I set up a VM with a fresh macOS and Xcode install, loading an older version from GitHub, and still get the same error…

How to handle watchOS CoreData background save correctly?

My watchOS app uses core data for local storage. Saving the managed context is done in background:
var backgroundContext = persistentContainer.newBackgroundContext()
//…
backgroundContext.perform {
//…
let saveError = self.saveManagedContext(managedContext: self.backgroundContext)
completion(saveError)
}
//…
func saveManagedContext(managedContext: NSManagedObjectContext) -> Error? {
if !managedContext.hasChanges { return nil }
do {
try managedContext.save()
return nil
} catch let error as NSError {
return error
}
}
Very rarely, my context is not saved. One reason I can think of is the following:
After my data are changed, I initiate a background core data context save operation.
But before the background task starts, the watch extension is put by the user into background, and is then terminated by watchOS.
This probably also prevents the core data background save to execute.
My questions are:
- Is this scenario possible?
- If so, what would be the correct handling of a core data background context save?
PS: On the iOS side, I do the same, but here it is possible to request additional background processing time using
var bgTask: UIBackgroundTaskIdentifier = application.beginBackgroundTask(expirationHandler: {
//…
application.endBackgroundTask(bgTask)
}
By now, I think I can answer my question:
If the watch extension is put by the user into background, the extension delegate calls applicationDidEnterBackground(). The docs say:
The system typically suspends your app shortly after this method
returns; therefore, you should not call any asynchronous methods from
your applicationDidEnterBackground() implementation. Asynchronous
methods may not be able to complete before the app is suspended.
I think this also applies to background tasks that have been initiated before, so it is actually possible that a core data background save does not complete.
Thus, the core data save should be done on the main thread. My current solution is the following:
My background context is no longer set up using persistentContainer.newBackgroundContext(), since such a context is connected directly to the persistentContainer, and when this context is saved, changes are written to the persistent store, which may take relatively long. Instead, I now set up the background context by
var backgroundContext = NSManagedObjectContext.init(concurrencyType: .privateQueueConcurrencyType)
and set its parent property as
backgroundContext.parent = container.viewContext
where container is the persistent container. Now, when the background context is saved, it is not written to the persistent store, but to its parent, the view content that is handled by the main thread. Since this saving is only done in memory, it is pretty fast.
Additionally, in applicationDidEnterBackground() of the extension delegate, I save the view context. Since this is done on the main thread, The docs say:
The applicationDidEnterBackground() method is your last chance to
perform any cleanup before the app is terminated.
In normal circumstances, enough time should be provided by watchOS. If not, other docs say:
If needed, you can request additional background execution time by
calling the ProcessInfo class’s
performExpiringActivity(withReason:using:) method.
This is probably equivalent to setting up a background task in iOS as shown in my question.
Hope this helps somebody!

How to detect that iOS App was launched for first time by specific user (not on specific device)?

I am using in my App cloudKit + Core Data in my App (iOS 13+) (swift).
I cannot figure out how to detect very first run of the app regardless of device to initialize some default data.
There are many posts how to detect first launch of a iOS app on specific device - that's easy. I cannot find solution for detecting the first run of app for specific user or in other words - if in user's iCloud does exist initialized container with specific containerIdentifier.
If user had already used the app on another device before, so during first launch on new device, there will be sync with iCloud and app will use user's data. But if the user has never used the app before I need to initialize some data.
I am searching for clue how to deal with it for hours, cannot find nothing relevant.
Any idea?
Thanks for help in advance.
A bit more information on your cloudkit schema would help, but assuming you are using a publicDB to store information, a unique record will be create for the user when they first take an action that saves data to cloudkit.
So you could check and look at the createDate timestamp of the User object in cloudkit and compare to the current time (a bit clunky, but possible).
Example code to fetch the user:
iCloudUserIDAsync { (recordID: CKRecord.ID?, error: NSError?) in
if let userID = recordID?.recordName {
self.loggedInUserID = userID
self.loggedInWithiCloud = true
} else {
self.loggedInWithiCloud = false
print("Fetched iCloudID was nil")
}
}
Alternatively, and more elegantly, you could write a boolean flag to the user object CloudKit (or locally in CoreData) on first launch. Then on any launch get the entire user object for the logged in iCloud user, you can then initialize it and then act on your Boolean variable from there.
Example code to get the full user and initialize it locally:
CKContainer.default().publicCloudDatabase.fetch(withRecordID: userRecordID) { (results, error ) in
if results != nil {
//now that you have the user, you can perform your checks
self.currentUser = MyUser(record: results!)
}
if let error = error {
print("couldn't set user reference")
}
DispatchQueue.main.async {
completion(nil)
}
}

What is the proper way to do GameCenter authentication?

I have seen in posts around stack overflow that shows snippets of handling GameCenter authentication. However, none of these solutions address any of the problems that real world use cases cover. Namely, the [GKLocalPlayer localPlayer].authenticateHandler is just a call back of the status, and not much else. It provides a view controller, but there are massive inconsistencies in .authenticated, and error states.
There are a few things I am trying to do:
1. Not pop up the game center login until a feature uses it
2. Try to authenticate silently on app launch
3. Provide some info to the user why GameCenter features are not working
4. Provide a recovery mechanism
Namely if there is an error reported how can I show the login dialog anyways?
I get this error with no viewController:
Case 1:
Error in GameCenterManager::authenticateLocalPlayer [The Internet connection appears to be offline.]
Despite its error message, the device is completely online, as safari loads cnn.com just fine.
Case 2:
Someone closes the login screen because they are not ready, in which case .authenticated comes back as true, viewController remains at nil, yet all game center calls will fail. Why is the [GKLocalPlayer localPlayer].authenticated set to true when it isn't?
Case 3:
Error in GameCenterManager::authenticateLocalPlayer [The operation
couldn’t be completed. (NSURLErrorDomain error -1009.)]
This keeps occurring yet there is nothing the app can do for the user. In this case what should the messaging be? Switch apps to Game Center and login there?
Case 4:
Error in GameCenterManager::authenticateLocalPlayer [The requested
operation has been canceled or disabled by the user.]
This happens if the user cancels the viewController the app was told to present by apple. Yet, there is also no recovery or detecting this state.
Case 5:
Error in GameCenterManager::createMatch [The requested operation could
not be completed because local player has not been authenticated.]
This happens if the user was logged in, but for whatever reason logs out of GameCenter then returns to the app. The app will be told the user is still authenticated when it is clearly not, yet there are no calls I can make to bring up another login.
So essentially, if GameCenter doesn't just silently work, what are we to do as app designers? Alert view and tell them to go login using the game center app and restart the app?
Here is my authentication code:
//******************************************************
// Authenticate
//******************************************************
-(void)authenticateLocalPlayer:(bool)showLogin
{
if( showLogin && self.loginScreen != nil )
{ [[WordlingsViewController instance] presentViewController:self.loginScreen animated:YES completion:nil]; }
if( [GKLocalPlayer localPlayer].isAuthenticated )
{
NSDLog(NSDLOG_GAME_CENTER,#"GameCenterManager::authenticateLocalPlayer LocalPlayer authenticated");
}
__weak GameCenterManager* weakSelf = self;
[GKLocalPlayer localPlayer].authenticateHandler = ^(UIViewController *viewController, NSError *error)
{
if (error != nil)
{
NSDLog(NSDLOG_GAME_CENTER,#"Error in GameCenterManager::authenticateLocalPlayer [%#]", [error localizedDescription]);
}
else
{
if (viewController != nil)
{
NSDLog(NSDLOG_GAME_CENTER,#"GameCenter: No authentication error, but we need to login");
weakSelf.loginScreen = viewController;
}
else
{
if ( [GKLocalPlayer localPlayer].authenticated )
{
NSDLog(NSDLOG_GAME_CENTER,#"GameCenter localPlayer authenticated");
weakSelf.gameCenterAvailable = YES;
weakSelf.localPlayer = [GKLocalPlayer localPlayer];
[weakSelf retrieveFriends];
[weakSelf loadPlayerPhoto:weakSelf.localPlayer];
for ( id<GameCenterDelegate> listener in weakSelf.listeners )
{ [listener onPlayerAuthenticated]; }
}
else
{
weakSelf.gameCenterAvailable = NO;
}
}
}
};
}
This function is called twice: once at app startup to hopefully create a valid login state, and 2nd if the user is not authenticated and they try to use an app feature that requires game center. In this app, it is creating a turn based match or viewing friends
You're encountering many of the same complaints I have about the Game Center API. I've been trying to achieve the same 4 things you are. The TL;DR version: Game Center simply doesn't support it. >< But there are some things you can do to reduce the pain.
One general thing that helped me: make sure to check both the NSError as well as it's .underlyingError property. I've seen several cases where the NSError is too vague to be helpful, but the underlying error has more specific details.
Case 1: Can you share the error domain and error code for the both the NSError and the underlyingError?
Case 2: I have an open bug with Apple on this, for a looooong time. There are several cases, including being in Airplane mode, where the authentication fails but .authenticated returns true. When I wrote a bug on this, Apple closed it saying this was "by design" so players could continue to play the game using any previously cached data. So, I appended the bug with several scenarios where cached data causes significant problems. My bug was re-opened and has sat there ever since. The design philosophy seems to be: "well, just keep going and maybe it will all work out in the end." But it doesn't work out in the end, the user gets stuck, unable to play and they blame my game, not Apple.
The only mitigation I have found is exactly what you're already doing: Always always always check the NSError first in the authentication handler. The view controller and .authenticated are totally unreliable if an error has been set.
If there is an error, I pass it to one dedicated error handler that displays alerts to users and tells them what they need to do to recover.
Case 3: I have hit -1009 as well. From what I can discern it happens when I have network connection, but Game Center never replied. That could come from any disruption anywhere between my router up-to-and-including Game Center servers not responding. I used to see this a lot when using the GC Test Servers. Not so much now that the test servers were merged into the prod environment.
Case 4: You are 100% correct. there is no in-game recovery. If the user cancels the authentication, that's the end of the line. The only way to recover is to kill the game (not just leave and re-enter) and restart it. Then, and only then, you can present another login view controller.
There are some things you can do to mitigate this, though. It will directly break your #1 goal of delaying login until needed, but I haven't found anything better:
Disable the "start game" button (or whatever you have in your game) until you've confirmed the login succeeded with no errors AND you can successfully download a sample leaderboard from GC. This proves end-to-end connectivity.
If the user cancels the login, your authentication handler will receive an NSError of domain = GKErrorDomain and code = GKErrorCanceled. WHen I see that combo, I put up a warning to the user that they cannot play network games until they've successfully logged in and to login they will have to stop and restart the game.
Users were confused why the "start" button was disabled, so I added an alert there too. I show a button that looks disabled but is really enabled. And when they try to click it, I again present an alert telling them they have to login in to game center to play a network game.
It sucks, but at least the user isn't stuck.
Case 5: This is one of the examples I cited in my bug referred to in case 2. By letting the user think they're logged in when they really aren't, they try to do things they really can't do, and eventually something bad will happen.
The best mitigation I have found for this is the same as Case 4: don't let the user start a session until you see the authentication handler fire with no errors AND you can successfully download a sample leaderboard to prove the network connection.
In fact, doing a search through all of my code bases, I never use .authenticated for any decisions anymore.
Having said all of that, here's my authentication handler. I won't say it's pretty, but thus far, users don't get stuck in unrecoverable situations. I guess it's a case of desperate times (working with a crap API) requires desperate measures (kludgy work arounds).
[localPlayer setAuthenticateHandler:^(UIViewController *loginViewController, NSError *error)
{
//this handler is called once when you call setAuthenticated, and again when the user completes the login screen (if necessary)
VLOGS (LOWLOG, SYMBOL_FUNC_START, #"setAuthenticateHandler completion handler");
//did we get an error? Could be the result of either the initial call, or the result of the login attempt
if (error)
{
//Here's a fun fact... even if you're in airplane mode and can't communicate to the server,
//when this call back fires with an error code, localPlayer.authenticated is set to YES despite the total failure. ><
//error.code == -1009 -> authenticated = YES
//error.code == 2 -> authenticated = NO
//error.code == 3 -> authenticated = YES
if ([GKLocalPlayer localPlayer].authenticated == YES)
{
//Game center blatantly lies!
VLOGS(LOWLOG, SYMBOL_ERROR, #"error.code = %ld but localPlayer.authenticated = %d", (long)error.code, [GKLocalPlayer localPlayer].authenticated);
}
//show the user an appropriate alert
[self processError:error file:__FILE__ func:__func__ line:__LINE__];
//disable the start button, if it's not already disabled
[[NSNotificationCenter defaultCenter] postNotificationName:EVENT_ENABLEBUTTONS_NONETWORK object:self ];
return;
}
//if we received a loginViewContoller, then the user needs to log in.
if (loginViewController)
{
//the user isn't logged in, so show the login screen.
[appDelegate presentViewController:loginViewController animated:NO completion:^
{
VLOGS(LOWLOG, SYMBOL_FUNC_START, #"presentViewController completion handler");
//was the login successful?
if ([GKLocalPlayer localPlayer].authenticated)
{
//Possibly. Can't trust .authenticated alone. Let's validate that the player actually has some meaningful data in it, instead.
NSString *alias = [GKLocalPlayer localPlayer].alias;
NSString *name = [GKLocalPlayer localPlayer].displayName;
if (alias && name)
{
//Load our matches from the server. If this succeeds, it will enable the network game button
[gameKitHelper loadMatches];
}
}
}];
}
//if there was not loginViewController and no error, then the user is already logged in
else
{
//the user is already logged in, so load matches and enable the network game button
[gameKitHelper loadMatches];
}
}];

NSFetchedResultsController with external changes?

I'm reading a CoreData database in a WatchKit extension, and changing the store from the parent iPhone application. I'd like to use NSFetchedResultsController to drive changes to the watch UI, but NSFetchedResultsController in the extension doesn't respond to changes made to the store in the parent application. Is there any way to get the secondary process to respond to changes made in the first process?
Some things to try/consider:
Do you have App Groups enabled?
If so, is your data store in a location shared between your host app and the extension?
If so does deleting the cached data, as referenced here help?
Read this answer to very similar question: https://stackoverflow.com/a/29566287/1757229
Also make sure you set stalenessInterval to 0.
I faced the same problem. My solution applies if you want to update the watch app on main app updates, but it could be easily extended to go both ways.
Note that I'm using a simple extension on NSNotificationCenter in order to be able to post and observe Darwin notification more easily.
1. Post the Darwin notification
In my CoreData store manager, whenever I save the main managed object context, I post a Darwin notification:
notificationCenter.addObserverForName(NSManagedObjectContextDidSaveNotification, object: self.managedObjectContext, queue: NSOperationQueue.mainQueue(), usingBlock: { [weak s = self] notification in
if let moc = notification.object as? NSManagedObjectContext where moc == s?.managedObjectContext {
notificationCenter.postDarwinNotificationWithName(IPCNotifications.DidUpdateStoreNotification)
}
})
2. Listen for the Darwin notification (but only on Watch)
I listen for the same Darwin notification in the same class, but making sure I am on the actual watch extension (in order to avoid to refresh the context that just got updated). I'm not using a framework (must target also iOS 7) so I just added the same CoreDataManager on both main app and watch extension. In order to determine where I am, I use a compile time flag.
#if WATCHAPP
notificationCenter.addObserverForDarwinNotification(self, selector: "resetContext", name: IPCNotifications.DidUpdateStoreNotification)
#endif
3. Reset context
When the watch extension receives the notification, it resets the MOC context, and sends an internal notification to tell FRCs to update themselves. I'm not sure why, but it wasn't working fine without using a little delay (suggestions are welcome)
func resetContext() {
self.managedObjectContext?.reset()
delay(1) {
NSNotificationCenter.defaultCenter().postNotificationName(Notifications.ForceDataReload, object: self.managedObjectContext?.persistentStoreCoordinator)
}
}
4. Finally, update the FRCs
In my case, I was embedding a plain FRC in a data structure so I added the observer outside of the FRC scope. Anyway you could easily subclass NSFetchedResultsController and add the following line in its init method (remember to stop observing on dealloc)
NSNotificationCenter.defaultCenter().addObserver(fetchedResultController, selector: "forceDataReload:", name: CoreDataStore.Notifications.ForceDataReload, object: fetchedResultController.managedObjectContext.persistentStoreCoordinator)
and
extension NSFetchedResultsController {
func forceDataReload(notification: NSNotification) {
var error : NSError?
if !self.performFetch(&error) {
Log.error("Error performing fetch update after forced data reload request: \(error)")
}
if let delegate = self.delegate {
self.delegate?.controllerDidChangeContent?(self)
}
}
At WWDC ‘17, Apple introduced a number of new Core Data features, one of which is Persistent History Tracking or NSPersistentHistory. But as of the time of writing, its API is still undocumented. Thus, the only real reference is the What’s New in Core Data WWDC session.
More info and an example here

Resources