How to Process Today Extension Data In Real-Time

In the next version of Streaks, we are adding a "Today Extension" (also known as a widget) so users can mark tasks as done without even needing to go into the app.

It is currently not possible for a Today Extension to silently wake up its host app so data can be processed in the background.

I set out to find a solution, so the final workflow goes something like:

  1. Mark a task as done on the widget
  2. Process the completion on the host app
  3. Send updated complication information to Apple Watch.

This is not as easy as it could be. Here's what I tried.

Solution 1: Always Open the Host App

  1. User marks task as done
  2. Today Extension launches app in foreground to process.

This defeats the purpose of a widget if the app has to open in the foreground.

Solution 2: Queue Up Actions to Process

  1. Create a shared data container between the Today Extension and Streaks
  2. User marks task as done
  3. Save the completion to an "action queue" in shared container
  4. Wait for user to launch Streaks
  5. Process the action queue.

Ignoring the fact the user needs to manually launch the app, this presents two more problems:

  1. The application badge is now wrong (it should have decreased by one when the task was complete).
  2. The scheduled task reminder will still arrive, but it shouldn't since the task is now complete.

Solution 3: Manage Notifications from the Today Extension

Luckily, since iOS 10 introduced the UserNotification framework, it is possible to manipulate notifications for the host app from the extension.

The next solution I tried was:

  1. Repeat steps 1-3 from solution 2
  2. Cancel the pending reminder notification from the host app (using
    UNUserNotificationCenter.removePendingNotificationRequestsWithIdentifiers)
  3. Update the application badge.

One problem though: it is not possible to access UIApplication from a today extension, meaning you can't set UIApplication.sharedApplication().applicationIconBadgeNumber.

However, it is possible to schedule a local notification to update the badge. The following code schedules a badge update one second in the future.

let content = UNMutableNotificationContent()  
content.badge = badge

// The notification doesn't trigger on a 0 badge, so this hack forces it to
if badge == 0 {  
    content.title = " "
}


let date       = NSDate().dateByAddingTimeInterval(1)  
let calendar   = NSCalendar.currentCalendar()  
let components = calendar.components(  
    [ .Year, .Month, .Day, .Hour, .Minute, .Second ], 
    fromDate: date
)

let trigger             = UNCalendarNotificationTrigger(dateMatchingComponents: components, repeats: false)  
let notificationRequest = UNNotificationRequest(  
    identifier: "today.badge", 
    content: content, 
    trigger: trigger
)

UNUserNotificationCenter.currentNotificationCenter().addNotificationRequest(notificationRequest) { error in  
    // Badge notification scheduled
}

This now keeps the notifications somewhat up-to-date for the current day.

Solution 4: Open The Host App Periodically

Overriding the badge and pending notifications works somewhat, but at some point, the host app needs to wake up and refresh the application state (process pending actions, update notifications and update the Apple Watch).

This prompted the next solution:

  1. Use all of the steps from solution 3.
  2. Add a "last processed" timestamp to the shared app container, which is updated by the host app and read by the Today Extension.
  3. If the Today Extension detects that the action queue hasn't been opened for a day, force the app to open immediately so the data is processed.

This solution somewhat worked, but there were two problems:

  1. The app would still come into the foreground when the user marks a task as complete in the widget.
  2. The Apple Watch state isn't updated until the host app is opened

Solution 5: iCloud

The only way I could figure out to force the host app to open in the background was to use iCloud.

The full solution is the same as Solution 4, but it adds the following:

  1. Create a new record type (I called it WidgetUpdate).
  2. In the host app, subscribe to new records in WidgetUpdate (using CKSubscription).
  3. In the Today Extension, insert a new WidgetUpdate record into iCloud.

When using CKSubscription to receive background push notifications, shouldSendContentAvailable must be set to true. In reality though, I also had to set an empty alertBody (this triggers the notification, but because it's empty, remains silent):

let notificationInfo = CKNotificationInfo()  
notificationInfo.shouldSendContentAvailable = true  
notificationInfo.alertBody = ""  

Also note that this solution will still open the app periodically if the action queue isn't processed. This is a final fallback in case iCloud subscriptions don't work as expected (if iCloud successfully triggers a background update, the timestamp that triggers a forced foreground opening will keep being pushed back).

Now, when a user marks a task as done:

  1. New record is inserted into iCloud.
  2. A silent push notification is sent to host app.
  3. Host app wakes up, processes action queue.
  4. Host app refreshes all notifications (and application badge) so everything is in sync.
  5. Host app notifies Apple Watch so it has the latest data.

There are a few extra things to be aware of:

  • Clear the WidgetUpdate record(s) after the queue is processed. In the case of Streaks, it only attempts to clear it if there was at least 1 item processed from the queue.
  • No need to store the task completion data in iCloud - it's all stored in the shared container (in this case, at least)
  • No need to subscribe to iCloud every time the app is launched: just do it once (but you'll need to remove/recreate the subscription if you need to change its settings)
  • Store a device ID with WidgetUpdate in order to differentiate between a user's multiple devices (in case they're running the app on both). Only delete the records related to that device.

Conclusion

If a user taps an action in a notification, the host app will wake up and process the action in the background.

Hopefully in a future update to iOS, Apple will add functionality to allow widgets to trigger a background process in a similar way to notification actions.