Your Watch Complication Might Be Broken

10 December 2015

Update 2016-03-09

The issue described in this blog post has been resolved as of iOS 9.3 beta 6.

Update 2016-01-07

Apple has acknowledged the issue laid out in this blog post and it is being investigated.


With watchOS 2, Apple granted third-party developers access to the watch face of the Apple Watch, allowing apps to get their data in front of users in the most direct way possible. Providing a complication not only allows you to provide up-to-date information to your users in a convenient manner, but also allows users to launch your app directly from the watch face, thereby increasing engagement. However, the API to provide data to watch-face complications has a sting in its tail which is easily missed, and could result in inaccurate, out-of-date information being shown to your users.

The Problem

To transfer complication data from an iPhone to a paired Apple Watch, WCSession gives us:

public func transferCurrentComplicationUserInfo(userInfo: [String : AnyObject]) -> WCSessionUserInfoTransfer

Data sent using this method finds its way to the WCSessionDelegate of the companion Apple Watch app. If you've written a watch complication, you'll have used this mechanism and seen that it works exactly as described.

However, when the iPhone is locked, all calls to transferCurrentComplicationUserInfo(_ userInfo: [String : AnyObject]) -> WCSessionUserInfoTransfer fail immediately. The watch complication data on the watch is therefore not updated. The problem only occurs when the iPhone supplying the complication data is locked, which is why this problem is easily missed during development.

Demonstration

To demonstrate this behaviour, I've create a simple sample project which you can download from github.

The iPhone app is very simple - it has a counter which begins at zero when the app is first launched.

The example iOS app.

In order to increment the counter, the companion watchOS app has a simple interface with a single "Increment" button.

The companion watchOS app.
The companion watchOS app.

Tapping this button sends a message to the iPhone, which increments the counter, and sends the updated counter value to the watch as complication data, so that the current counter value can be viewed on the user's watch face.

To follow along at home, start by building and running the iOS app on an iPhone with a paired Apple Watch. Once the companion watch app has installed on your Apple Watch, edit your watch face to include the Watch Complication Data Bug complication.

Adding the Watch Complication Data Bug complication.
Adding the Watch Complication Data Bug complication.

Once you have added the complication to your watch face, launch the watch app and tap the Increment button. This will run the following code:

self.session.sendMessage([ActionKey : IncrementAction], replyHandler: nil, errorHandler: nil)

This simply sends a message to the iPhone requesting that the counter be incremented. In response, the phone executes the following:

// Increment the current count
Counter.sharedCounter.increment()

// Send updated complication data
session.transferCurrentComplicationUserInfo([CounterUserInfoKey : Counter.sharedCounter.value])

Hurrah! The counter has been incremented on the iPhone, which now shows our counter to have a value of 1. The Xcode terminal should also show:

session(_:didFinishUserInfoTransfer:error:)
Transfer: <WCSessionUserInfoTransfer: 0x14453e440, current complication info: YES, transferring: NO, hasUserInfo: YES>
Error: nil

Check your watch face - you will see that the complication has also updated:

Note that the complication has updated to display the current counter value of 1.
Note that the complication has updated to display the current counter value of 1.

Now, lock your iPhone. Go back in to the watch app and tap the increment button again. You will notice the following in Xcode’s terminal:

sessionReachabilityDidChange
Reachable
session(_:didFinishUserInfoTransfer:error:)
Transfer: <WCSessionUserInfoTransfer: 0x144610460, current complication info: YES, transferring: YES, hasUserInfo: YES>
Error: Optional(Error Domain=WCErrorDomain Code=7001 “Unknown WatchConnectivity error.” UserInfo={NSLocalizedDescription=Unknown WatchConnectivity error.})

We have received an "Unknown" error when trying to send the updated complication data. Curiously, our session reachability did change to be reachable, and yet the data transfer failed anyway. Finally, unlock your iPhone. Notice that the counter has been incremented to 2. Returning to your watch face however will show a complication that still has a value of 1. Our application and watch complication are now out of sync.

The Solution

This solution isn't really a solution, but is a workaround that will allow you to provide data to your complication while the user's iPhone is locked. To view the workaround in the sample project, checkout the feature/locked_iphone_workaround branch.

The workaround relies on the fact that when the iPhone is locked, the watch application's "Application Context" can still be updated. By using updateApplicationContext​(applicationContext: [String : AnyObject]) when the device is locked, we can still send data to the watch. We also have to detect when it is appropriate to use this method, rather than sending the complication data the usual way. Unfortunately, there is no Cocoa Touch API to determine when the device is locked. The best we can do, as a proxy, is to use the current application state:

if (UIApplication.sharedApplication().applicationState == .Active) {
    self.session.transferCurrentComplicationUserInfo(complicationData)
} else {
    do {
        try self.session.updateApplicationContext(complicationData)
    }
    catch {
        print("An error occurred \(error)")
    }
}

This isn't perfect, as we will still take the "update application context" path when the phone is unlocked with the app running in the background. However, it does allow us to use the complication update method when the app is in the foreground. If we decided to extend our application to have an "Increment" button on the iPhone, the updated complication data would be sent perfectly safely using transferCurrentComplicationUserInfo.

This of course means that the watch app also has to implement session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : AnyObject]) in order to receive complication data:

func session(session: WCSession, didReceiveUserInfo userInfo: [String : AnyObject]) {
    updateComplicationsWithDictionary(userInfo)
}

func session(session: WCSession, didReceiveApplicationContext applicationContext: [String : AnyObject]) {
    updateComplicationsWithDictionary(applicationContext)
}

With these two changes, our watch complication is now updated even when the companion iPhone is locked.

Addendum

Having raised a TSI for this issue with Apple, they responded by saying that the issue didn't reproduce on an iPhone 6. However, the issue did reproduce on an iPhone 6s Plus. The test hardware I used was an iPhone 6s, so perhaps the issue only manifests on 6s era hardware. DTS requested that I file a radar, which I have done (number 23802271). If you can reproduce the bug it would be helpful to Apple if you could dupe the radar and include your own hardware configuration details.