4

I'm setting up registrations for notifications of iCloud changes.

Say a new device is added to the icloud account, I'm just wondering how that device will get the private database records.

Do I need to do a one off query?

I'm hoping that notifications will be used at all other times.

Jules
  • 7,568
  • 14
  • 102
  • 186

1 Answers1

3

Let's start with some relevant characteristics of subscription notifications:

First: Subscription Notifications are specific to a user + device pair. If I install you app on my phone, I start getting notifications. I won't get the notifications on another device until I install the app there, too.

Second: Notifications are unreliable. Apple docs are quite clear that they do not guarantee delivery. When you receive a notification, there could have been several prior notifications. Thus, Apple offer's two mechanisms to track which notifications you've seen:

  1. Read/unread status: you can mark notifs as read. Apple's docs contradict themselves about what this actually does. This page says

If you mark one or more notifications as read using a CKMarkNotificationsReadOperation object, those notifications are not returned, even if you specify nil for previousServerChangeToken.

However, this isn't true. The fetch operation clearly returns both read and unread notifications. WWDC 2014 Video 231 (Advanced Cloudkit) contradicts the documentation page, explaining that unread tokens are always returned as well as read tokens so multiple devices can sync up. The video gives a specific example that shows the benefits of this behavior. This behavior is also documented on SO: CKFetchNotificationChangesOperation returning old notifications

  1. change token: each fetch operation will return a change token that you can cache. If you pass the token to a fetch, the fetch will only return tokens from that point, whether read or unread.

At first glance, it would seem that Apple is providing for the behavior you want: install the app on one device, start processing notifications, install the app on a second device, and fetch all those prior notifications in order to catch up.

Unfortunately, as I've documented in CKFetchNotificationChangesOperation: why are READ notifications all nil?, any time I fetch notifications, the ones previously marked as "read" all have nil contents. All the info in the read notifications is lost.

In my scenario, I chose to:

  1. Always fetch the latest record(s) at startup
  2. Fetch notifications using the previously saved change token (if it exists)
  3. Process the new notifications
  4. Mark the notifications as read
  5. save the latest change token for use on the next fetch

For your scenario, you could try:

  1. Fetch notifications using the previously saved change token (if it exists)
  2. process the notifications (DO NOT MARK THEM AS READ)
  3. save the latest change token for use on the next fetch

Your first device will ignore old notifications on each subsequent fetch because you're starting each fetch from the change token point. Your second device will start with a nil change token on the first execution, and thus pick up all of the old notifications.

One word of caution: even though aforementioned WWDC video clearly says Apple keeps all the old notifications, I have found no documentation that says how long they hold this info. It may be forever, it may not.

updated with notification fetch example

Here's how I'm fetching notifications, marking them read, and caching the change token:

@property CKServerChangeToken *notificationServerChangeToken;

Then...

-(void)checkForUnreadNotifications
{
    //check for unread cloudkit messages
    CKFetchNotificationChangesOperation *op = [[CKFetchNotificationChangesOperation alloc] initWithPreviousServerChangeToken:_notificationServerChangeToken];

    op.notificationChangedBlock = ^(CKNotification *notification)
    {
        //this fires for each received notification. Take action as needed.
    };

    //maintain a pointer to the op. We will need to look at a property on the
    //op from within the completion block. Use __weak to prevent retain problems
    __weak CKFetchNotificationChangesOperation *operationLocal = op;

    op.fetchNotificationChangesCompletionBlock = ^(CKServerChangeToken *newServerChangeToken, NSError *opError)
    {
        //this fires once, at the end, after all notifications have been returned.
        //this is where I mark the notifications as read, for example. I've
        //omitted that step because it probably doesn't fit your scenario.

        //update the change token so we know where we left off
        [self setNotificationServerChangeToken:newServerChangeToken]; 

        if (operationLocal.moreComing)
        {
            //more notifications are waiting, recursively keep reading
            [self checkForUnreadNotifications];
            return;
        }
    };

    [[CKContainer defaultContainer] addOperation:op];
}

To set and retrieve the cached change token from the user defaults, I use the following two functions:

-(void)setNotificationServerChangeToken:(CKServerChangeToken *)newServerChangeToken
{

    //update the change token so we know where we left off
    _notificationServerChangeToken = newServerChangeToken;
    NSData *encodedServerChangeToken = [NSKeyedArchiver archivedDataWithRootObject:newServerChangeToken];
    NSUserDefaults *userSettings = [NSUserDefaults standardUserDefaults];
    [userSettings setObject:encodedServerChangeToken forKey:UD_KEY_NOTIFICATION_TOKEN_CKSERVERCHANGETOKEN_PROD];

    //Note, the development and production cloudkit environments have separate change tokens. Depending on your needs, you may need to save both.
}

and...

-(void)getNotificationServerChangeToken
{
    NSUserDefaults *userSettings = [NSUserDefaults standardUserDefaults];
    NSData *encodedServerChangeToken = [userSettings objectForKey:UD_KEY_NOTIFICATION_TOKEN_CKSERVERCHANGETOKEN_PROD];
    _notificationServerChangeToken = [NSKeyedUnarchiver unarchiveObjectWithData:encodedServerChangeToken];    
}
Community
  • 1
  • 1
Thunk
  • 4,099
  • 7
  • 28
  • 47
  • I'll add an example of my approach. I store the change token in the user defaults (the encoding / decoding is a bit of a pain), and my fetch operation. – Thunk Mar 11 '17 at 19:18
  • Yeah, call it on initial setup to make sure that device is "caught up." You could call it in `didFinishLaunchingWithOptions` but I prefer to use `applicationDidBecomeActive` instead as that will also cover switching out/back in to the app, as well as restarting the app. I also call it in `didReceiveRemoteNotification` so the same code is always used to bring the device up to the latest notif. – Thunk Mar 11 '17 at 20:53
  • Yeah, exactly. You can put that code in the `op.notificationChangedBlock`'s completion handler. Then you're certain the same evaluation will run on every notification, even those that miss sending a push notice. You wind up with 3 ways to trigger processing of a notif: starting an app (`applicationDidBecomeActive`), resuming an app (`applicationDidBecomeActive`) and actually receiving a push notice (`didReceiveRemoteNotification`). All 3 will then use the operation to check for all pending notifs. – Thunk Mar 11 '17 at 21:10
  • Actually, make that 4 ways... because you're also calling it when you instantiate the class. – Thunk Mar 11 '17 at 21:11
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/137861/discussion-between-thunk-and-jules). – Thunk Mar 11 '17 at 23:29