Crappy documentation for Apple’s new WatchConnectivity stuff

Update: I added some code, see below.

It’s taken me a LOT of trial and error to get my app, When’s That, working properly with watchOS2, partly due to Xcode updating my project settings, and failing to do everything that it should’ve, and partly because Apple’s documentation on WatchConnectivity is written for people who already know how it works.

There’s no sample code with a working example for all of the new features, so you have to guess as to how the information in the documentation should actually be implemented.

The internet, usually a good source for this kind of thing, is practically bereft of help. Kristina Thai has some information here, but that doesn’t cover transferring files.

A few gotchas:

  • You can have only one delegate on each side, Watch extension and iOS app, so you have to pick the right place for them.
  • Glances are flakey right now. Tapping the Glance will launch the Watch app and WatchConnectivity will be broken.
  • I used to send images as NSData via an MMWormhole, and decided to use sendUserInfo, but there’s a limit on the amount of information that can be sent via this method. I had to split it out to send the actual file across instead.
  • There is no information on how to move a file around once it hits the Watch extension, so you’ll have to write a lot of code to deal with that. For example, When’s That has an image for each event. When you transfer the file to the Watch extension, what happens if you delete the event? You have to delete the file on the iOS side AND the Watch side. Obvious, yes, but this was unnecessary before, and if you aren’t careful you’ll end up with orphaned files unless you write code to delete those no longer in use.

I’ll provide some code here tonight, if I have time. Hopefully, it will help some of you to avoid the frustrations I’ve experienced.

Update: Here’s the code I promised. Remember, you can only have one file designated as the WKExtensionDelegate. I found the best way for my app was to use the ExtensionDelegate.h/m. If you only need to access the session in one controller, you could make that the delegate instead.

ExtensionDelegate.h:

#import <WatchKit/WatchKit.h>

@interface ExtensionDelegate : NSObject 
@end

ExtensionDelegate.m:

#import "ExtensionDelegate.h"
@import WatchConnectivity;

@interface ExtensionDelegate() 
@property WCSession *session;
@end

@implementation ExtensionDelegate

- (instancetype)init
{
	self = [super init];
	if(self) {
		// Start a WatchKit connectivity session
		if([WCSession isSupported]) {
			_session = [WCSession defaultSession];
			_session.delegate = self;
			[_session activateSession];
		}
	}
	return self;
}

Your ExtensionDelegate.m probably doesn’t have the ‘init’ method I’ve got. That’s important. You really do need it, and it’s annoying that Apple don’t put it in. You need to be able to start the WCSession when the extension is initiated, not just when the application has finished launching.

Files such as InterfaceController.h shouldn’t really have anything in them. They should look something like this:

#import <WatchKit/WatchKit.h>
#import <Foundation/Foundation.h>

@interface InterfaceController : WKInterfaceController
@end

And InterfaceController.m:

#import "ExtensionDelegate.h"
#import "InterfaceController.h"
#import <UIKit/UIKit.h>

@interface InterfaceController()
@property (nonatomic, weak) id delegate;
@end

@implementation InterfaceController

- (void)awakeWithContext:(id)context
{
	[super awakeWithContext:context];
	self.delegate = [[WKExtension sharedExtension] delegate];
}

Notice that the InterfaceController.m here creates a delegate object? This is how you access the WCSession from the other files, such as the GlanceController. So, for example, you could call a method in the delegate, called myMethod, with this:

[self.delegate myMethod];

Now that your Watch Extension is setup correctly, you will need to setup your iOS app. So, as you can only have one session delegate, mine is setup in my MasterController. MasterController.h doesn’t need anything, but here’s what to do in the .m file:

@import WatchConnectivity;

@interface MasterController () <WCSessionDelegate>

@implementation MasterController

- (void)viewDidLoad
{
	// Start a WatchKit connectivity session
	if([WCSession isSupported]) {
		WCSession *session = [WCSession defaultSession];
		session.delegate = self;
		[session activateSession];

	} else {
		NSLog(@"WCSession NOT supported");
	}
}

Now that you’ve got the delegates setup correctly, you need to be able to send and receive data. Here’s how to send a message from the ExtensionDelegate.m file: (the dictionary sent as the message is irrelevant, it’s just here as an example)

if([_session isReachable]) {
	[_session sendMessage:@{@"doSomethingWithThisThing" : @"ABC"}
		replyHandler:^(NSDictionary *reply) {
			// Handle reply, maybe provide some success haptic feedback?
			[[WKInterfaceDevice currentDevice] playHaptic:WKHapticTypeFailure];
		}
		errorHandler:^(NSError *error) {
			// Handle error, maybe a failure haptic?
			[[WKInterfaceDevice currentDevice] playHaptic:WKHapticTypeFailure];
		}
	];

} else {
	NSLog(@"Watch not reachable");
	[[WKInterfaceDevice currentDevice] playHaptic:WKHapticTypeFailure];
}

The way my app is setup, I only need to send messages from the delegate, but the idea is the same. Just setup a WCSession object like you did in ExtensionDelegate.m, and send the message dictionary. Remember to check that the session is reachable first.

Now, on the iOS app side, receiving the message is pretty easy:

- (void)session:(WCSession *)session didReceiveMessage:(NSDictionary *)message
{
	dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0ul);
	dispatch_async(queue, ^(void) {
		dispatch_async(dispatch_get_main_queue(), ^{
			// Do something with the dictionary value for "thisThing", e.g. this will update a label with the "ABC" text you sent in the message:
			[self.myLabel setText:[message objectForKey:@"doSomethingWithThisThing"]];
		});
	});
}

The example above is only one message. Since you can only have one instance of the didReceiveMessage method and you may need to send different messages, you need to send more than one item in the dictionary and determine what action to take, for example: @{@”action” : @”deleteThisThing”, @”theThingIsCalled” : @”thing1″}. You would then do a simple if([[message objectForKey:@”action”] isEqualToString:@”deleteThisThing”]) {} else if([[message objectForKey:@”action”] isEqualToString:@”saveThisOtherThing”]) {} etc.

You can also send something called the “application context”. This is a dictionary of initial data you want to send to the Watch. When you send this, it overwrites any existing application context you might have already sent. This way, the Watch app is always going to receive the latest information from your iOS app. On the other hand, there’s “userInfo”. This is sent into a queue, and doesn’t replace whatever is already in the queue. You could use this to, for example, add a new row to a table as and when the new information is ready from the iOS app.

Think of it like you’re listing the runners in a marathon when they cross the finish line. You’d send the first one as userInfo, and the Watch would receive it. Then, runners 2 and 3 arrive quite soon after, and you send runner 2 then 3. On the Watch side, it received runner 2 first so adds that to the table. Then it gets runner 3 and adds that to the table.

To send application context, do something like this:

NSDictionary *applicationDict = @{@"someStuff" : myArrayOfStuff};
NSError *theError = nil;
[[WCSession defaultSession] updateApplicationContext:applicationDict error:&theError];
if(theError != nil) {
	NSLog(@"Error sending the event application context to the Watch: %@", theError);
}

On the Watch side, in the delegate:

- (void)session:(nonnull WCSession *)session didReceiveApplicationContext:(nonnull NSDictionary *)applicationContext
{
	myStuff = [applicationContext objectForKey:@"someStuff"];
	// Do something with the array you just received, maybe setup your root controllers?
}

To send userInfo, from the iOS app, it’s the same sort of thing as application context:

NSDictionary *userInfoDict = @{@"runner" : @"runner1", @"time" : @"3:24:02.769"};
[[WCSession defaultSession] transferUserInfo:userInfoDict];

When you get your second runner:

NSDictionary *userInfoDict = @{@"runner" : @"runner2", @"time" : @"3:26:19.410"};
[[WCSession defaultSession] transferUserInfo:userInfoDict];

On the Watch side, in the delegate:

- (void)session:(nonnull WCSession *)session didReceiveUserInfo:(nonnull NSDictionary *)userInfo
{
	newRunnerName = [userInfo objectForKey:@"runner"];
	newRunnerTime = [userInfo objectForKey:@"time"];
}

Pretty simple stuff, right? Now, file transfers. These are a little more complex because you send the file to the Watch, and the OS decides when to actually initiate the transfer. This is based on battery remaining, connectivity strength etc.

So, to start a file transfer from the iOS app:

if([[WCSession defaultSession] isReachable]) {
	NSURL *theURL = [NSURL fileURLWithPath:myFileName.path];
	[[WCSession defaultSession] transferFile:theURL metadata:@{@"UID" : theUIDString}];
}

I’m sending an NSDictionary of metadata here just because I need to know the UID of the file. You could use the fileSize or a hash to do a checksum and make sure the file is correct. You can also supply nil if you don’t need to send any metadata.

Now, on the Watch side, the file will be received by your delegate at some point. This is not immediate, because the OS decides when to send it. When the delegate method is called, if you don’t do anything with the file, it’ll be deleted when the method ends. Here’s the code to receive it, and move it to a directory I’ve created:

- (void)session:(WCSession *)session didReceiveFile:(WCSessionFile *)file
{
	NSURL *destURL = [self getImagesDirectory];  // This is the directory I've created
	if(destURL != nil) {
		// Create the destination file
		NSFileManager *fm = [NSFileManager defaultManager];
		NSString *newFileName = [NSString stringWithFormat:@"watch-%@.jpg", [file.metadata objectForKey:@"UID"]];
		destURL = [destURL URLByAppendingPathComponent:newFileName];

		// Move the file, and delete the old version if it already exists
		BOOL moveFile = NO;
		NSError *theError = nil;
		if(![fm fileExistsAtPath:destURL.path]) {
			moveFile = YES;

		} else {
			moveFile = [fm removeItemAtPath:destURL.path error:&theError];
		}

		if(moveFile) {
			// Move the new file to the images directory
			if(![fm moveItemAtPath:file.fileURL.path toPath:destURL.path error:&theError]) {
				NSLog(@"Couldn't move file from %@ to %@", file.fileURL.path, destURL.path);
				NSLog(@"Error = %@", theError);
			}
		}
	}
}

Notice that I’ve added a few lines in there to delete the old file if it already exists? This is because even though the file is already on the Watch, it may be different, so I don’t want the old one.

That’s pretty much it. It would’ve been of greater help if Apple had written something like this, but they didn’t. Their documentation assumes you already know all this. For a brand new set of APIs, something like this would’ve been helpful.

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s