跳转至

4.Watch Connectivity

Written by Scott Grosch

The magic of the Apple Watch experience comes from seamless interactions between the watch and your iOS apps.

Note

This is the first chapter that requires the app to run on both the Apple Watch and iPhone at the same time. While this setup is possible by starting both simulators from Xcode, the connectivity mechanisms you’ll use in this chapter rarely work between them. You may need to run the example projects on real devices to see them in action. Even if you don’t have the hardware, it’s still a good read.

Watch Connectivity, an Apple-provided framework, lets an iOS app and its counterpart watchOS app transfer data and files back and forth. If both apps are active, communication occurs mostly in real time. Otherwise, communication happens in the background, so data is available as soon as the receiving app launches.

The OS takes many factors into account when determining exactly how quickly to pass data packets between devices. While the transfers frequently happen within a matter of moments, sometimes you’ll see a significant lag.

Be aware, to transfer data between devices, multiple system resources, such as Bluetooth, must be on. This can result in significant energy use.

Note

Bundle messages together whenever possible to limit battery consumption.

In this chapter, after learning about the different types of messaging options, you’ll implement data transfers between the iPhone and Apple Watch versions of CinemaTime, an app for patrons of a fictional theater. It lets customers view movie showtimes and buy tickets right from their iPhones and Apple Watches.

Device-to-Device Communication

The Watch Connectivity framework provides five different methods for transferring data between devices. Four of those methods send arbitrary data, while the fifth sends files between devices. All of the methods are part of WCSession.

Note

Although most data transfer methods accept a dictionary of type [String: Any], this doesn’t mean you can send just anything. The dictionary can only accept primitive types. See the Property List Programming Guide, for a complete list of supported types.

Those five methods are further subdivided into two categories: interactive messaging and background transfers.

Interactive Messaging

Interactive messaging works best in situations where you need to transfer information immediately. For example, if a watchOS app needs to trigger the iOS app to check the user’s current location, the interactive messaging API can transfer the request from the Apple Watch to the iPhone.

However, remember there’s no guarantee that interactive messages will be delivered. They’re sent as soon as possible and delivered asynchronously in first-in, first-out, or FIFO, order.

If you send an interactive message from your watchOS app, the corresponding iOS app will wake in the background and become reachable. When you send an interactive message from your iOS app, and the watch app isn’t active, the watchOS app will not wake up.

If you have a dictionary of data, keyed by string, use sendMessage(_:replyHandler:errorHandler:). If, instead, you have a Data object, then use sendMessageData(_:replyHandler:errorHandler:).

Reply Handlers

When sending an interactive message, you probably expect a reply from the peer device. You may pass a closure of type ([String: Any]) -> Void as the replyHandler, which will receive the message that the peer device sends back.

If you ask the iPhone to generate some type of data, the message will return to the replyHandler.

Error Handlers

When you wish to know when something goes wrong during a message transfer, you can use the errorHandler and pass a (Error) -> Void. For example, you’d call the handler if the network fails.

Background Transfers

If only one of the apps is active, it can still send data to its counterpart app using background transfer methods.

Background transfers let iOS and watchOS choose a good time to transfer data between apps, based on characteristics such as battery use and how much other data is waiting to transfer.

There are three types of background transfers:

  • Guaranteed user information
  • Application context
  • Files

Guaranteed User Information

transferUserInfo(_:) makes the first type of background transfer. When calling this method, you specify the data is critical and must be delivered as soon as possible.

The device will continue to attempt to send the data until the peer device receives it. Once the data transfer beings, the operation will continue even if the app suspends.

transferUserInfo(_:) delivers every data packet you send in a FIFO manner.

Next, you’ll take a look at a similar type of background transfer, application context.

Application Context, aka High Priority User Information

High priority messages, delivered via updateApplicationContext(_:), are similar to guaranteed user information with two important differences:

  1. The OS sends the data when it feels it’s appropriate to send it.
  2. It only sends the most recent message.

If you have data that updates frequently, and you only need the most recent data, you should use updateApplicationContext(_:).

Here are a few examples of when application context makes sense:

  • Sending the most recent score in a game. The paired device only needs the most up-to-date score.
  • Updating the Apple Watch’s dock image.
  • A gas-finding app might send updates on prices. It doesn’t matter what the gas used to cost. You only need the current price.

While guaranteed user information and high priority message work for some situations, you need a different approach with files.

Files

Sometimes you need to send actual files between devices, as opposed to just data. For example, the iPhone might download an image from the network and then send that image to the Apple Watch.

You send a file via transferFile(_:metadata:). You can send any type of dictionary data, keyed by String, via the metadata parameter. Use metadata to provide information like the file’s name, size and when it was created.

Now you’re ready to get started.

Getting Started

Open the CinemaTime starter project in Xcode. Then build and run the CinemaTimescheme. The simulator for the iPhone will appear.

Now build and run the CinemaTime Watch App scheme to start the Apple Watch simulator.

Take a moment to compare the two apps.

Compare and Contrast

In the Apple Watch simulator, tap Purchase Tickets. Explore the app to see what you have to work with:

img

Now switch to the iPhone simulator and do the same thing:

img

While the initial screen looks pretty similar, there are a few noticeable differences on the second screen:

  • The iPhone version’s grouped list items are collapsible, whereas the watchOS version is not.
  • The iPhone includes the movie’s poster. There’s no room on the Apple Watch to display a poster.
  • The Apple Watch version doesn’t include details about the movie.

While you could include a poster on the Apple Watch version, the image would be so small your customers wouldn’t gain any benefit from having it. More importantly, including an image would mean the movie title would have to be even smaller to still fit in a decent amount of space.

The phone has plenty of space to include a short synopsis of the movie, but the Apple Watch doesn’t. If you were to include the synopsis, then the person running your app would have to scroll quite a bit more than they likely want to.

Explore the app further by placing an order.

Place Your Order

Buy a movie ticket in either app. Then view the list of purchased movie tickets in the other app. Do you see the issue?

Movie tickets purchased on one device don’t show as purchased on the other device! The apps aren’t transferring ticket purchase data between them. Imagine if a customer bought a movie ticket in the iPhone app, then tried to use the Apple Watch app to get into the theater. They’d be turned away, as the Apple Watch wouldn’t have a ticket!

Customers have a reasonable expectation that data should be accessible from both versions of an app regardless of which app created the data.

In the rest of this chapter, you’ll use the Watch Connectivity framework to sync the customer’s purchased movie tickets between the iOS and watchOS versions of the app.

First, you’ll set up watch connectivity.

Setting Up Watch Connectivity

You should handle all connectivity between your devices from a single location in your code. Hopefully, the term singleton comes to mind!

The code you write will be nearly the same for both iOS and watchOS. So, under the Sharedfolder, create a new file called Connectivity with the following contents:

import Foundation
// 1
import WatchConnectivity

final class Connectivity {
  // 2
  static let shared = Connectivity()

  // 3
  private init() {
    // 4
    #if !os(watchOS)
    guard WCSession.isSupported() else {
      return
    }
    #endif

    // 5
    WCSession.default.activate()
  }
}

Here’s a code breakdown:

  1. You must include the WatchConnectivity framework.
  2. Use static to access singletons.
  3. Ensure the initializer is private so the class is only accessible via shared.
  4. You should only start a session if it’s supported. An Apple Watch will always support a session. An iOS device will only support a session if there’s a paired Apple Watch.
  5. When you initialize Connectivity, you tell the device to activate the session, which lets you talk to a paired device.

Note

You don’t have to wrap the isSupported call in an OS check, but I like to make it clear when something is only necessary on a certain type of device.

Be sure to add the file to the target membership of both the iOS app and the extension in the file inspector. Press OptionCommand1 to bring up the File Inspector.

img

If you were to run the above code right now, you’d receive an error log message. The documentation explicitly says that activating a session without a delegate is a no-no. So, you’ll fix that now!

Preparing for WCSessionDelegate

The WCSessionDelegate protocol extends NSObjectProtocol. That means for Connectivity to be the delegate, it must inherit from NSObject.

Change the class declaration to this:

final class Connectivity: NSObject {

As soon as you subclass from NSObject, you’ll receive a compiler error on the initializer. Since you’ve subclassed, you have to override the initializer and call the parent’s initializer. Change the init you already have to start like this:

override private init() {
  super.init()

Next, you’ll implement the delegate.

Implementing WCSessionDelegate

You need to make Connectivity conform to WCSessionDelegate. At the bottom of the file, add:

// MARK: - WCSessionDelegate
extension Connectivity: WCSessionDelegate {
  func session(
      _ session: WCSession,
      activationDidCompleteWith activationState: WCSessionActivationState,
      error: Error?
  ) {
  }

  func sessionDidBecomeInactive(_ session: WCSession) {
  }

  func sessionDidDeactivate(_ session: WCSession) {
  }
}

Almost immediately, you’ll receive compiler errors on the last two methods. Those methods are part of the delegate on iOS, but they’re not present in watchOS.

Since the methods are only valid for iOS, wrap them in a compiler check:

#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {
}

func sessionDidDeactivate(_ session: WCSession) {
}
#endif

While it may boggle the mind, some people have more than one Apple Watch! If the user swaps watches, the session will deactivate. Apple suggests when the session deactivates, simply restart it. That covers the case of swapping watches.

Add these lines to sessionDidDeactivate(_:):

// If the person has more than one watch, and they switch,
// reactivate their session on the new device.
WCSession.default.activate()

Now jump back up to init and mark the class as the delegate on the line right before calling WCSession.default.activate():

WCSession.default.delegate = self

Now that you’ve established a connection, you need some way to send a message to the paired device.

Sending Messages

In the class, add:

public func send(movieIds: [Int]) {
  guard WCSession.default.activationState == .activated else {
    return
  }
}

Whenever you purchase a ticket to a new movie or delete a ticket, you’ll want to tell the companion app which movies are now valid. Whenever sending a message, the first thing you must do is ensure the session is active.

Remember, the session state could change for any number of reasons. For example, one device’s battery might die, or the user might be in the middle of swapping watches.

Notice how there are no errors when the session isn’t active. There’s nothing to do, and you don’t want to display a confusing message to your end-user. In Chapter 2, “Project Structure”, you learned that a watchOS app doesn’t always need a companion iOS app, and vice versa. That means, before sending any message, you need to verify the peer device is there.

Add the following code to your send(movieIds:):

// 1
#if os(watchOS)
// 2
guard WCSession.default.isCompanionAppInstalled else {
  return
}
#else
// 3
guard WCSession.default.isWatchAppInstalled else {
  return
}
#endif

There are three key points to note in the above code:

  1. You use a compiler check to ensure you call the proper method.
  2. The Apple Watch checks if the app is on the phone. If it’s not, there’s nothing else to do.
  3. The iOS device checks if the app is on the Apple Watch. If it’s not, there’s nothing else to do.

Unfortunately, due to legacy watchOS code, the two operating systems have separately named methods.

Now that you’ve found an active session with your app installed on both devices, it’s time to send the data. Add these lines to the end of the method you’re working on:

// 1
let userInfo: [String: [Int]] = [
  ConnectivityUserInfoKey.purchased.rawValue: movieIds
]

// 2
WCSession.default.transferUserInfo(userInfo)

Here’s a code breakdown:

  1. When sending data to a paired device, you must use a dictionary keyed by a string. The sample project includes an enum to specify which keys are valid for transfers, so you don’t hardcode strings.
  2. Once the data is ready, you can transfer it to the paired device.

Refer back to the start of this chapter, and you’ll likely conclude that transferUserInfo(_:) is the wrong method to use. You’re correct!

Before the end of the chapter, you’ll use each type of connectivity and build out a mostly generic connectivity class you can use as a base in all your projects.

Receiving Messages

After transferring the user information, you need some way to receive it on the other device. You’ll receive the data in the WCSessionDelegate of session(_:didReceiveUserInfo:). Using the Combine framework is a great way to let your app know about the updates.

Modify your class to implement ObservableObject, and then add a publisher:

final class Connectivity: NSObject, ObservableObject {
  @Published var purchasedIds: [Int] = []

Note

You can learn more about @Published in our RW book: Combine: Asynchronous Programming with Swift.

Using @Published, you enable other parts of your program to observe changes to purchasedIds.

Add the following method to the delegate extension:

// 1
func session(
  _ session: WCSession,
  didReceiveUserInfo userInfo: [String: Any] = [:]
) {
  // 2
  let key = ConnectivityUserInfoKey.purchased.rawValue
  guard let ids = userInfo[key] as? [Int] else {
    return
  }

  // 3
  self.purchasedIds = ids
}

Here’s a code breakdown:

  1. When transferring data via transferUserInfo(_:), you receive it via the aptly named session(_:didReceiveUserInfo:).
  2. Check to see if the provided user information contains a list of purchased movie keys. If not, quietly do nothing.
  3. Assigning to purchasedIds automatically notifies all observers about the change.

Build your project to ensure you aren’t missing anything at this point. You won’t get any errors or warnings, but you can’t run yet as there’s still nothing visible to see. Almost there!

The Ticket Office

When you purchase or delete a ticket, you need to let companion devices know. Shared/TicketOffice handles purchasing and deleting tickets, so it seems like a good place to handle connectivity! Open that file.

Look through it. You’ll see both purchase(_:) and delete(at:). These methods need to send a message to the companion app.

Since you would never consider duplicating code, add a private method to the bottom of the file:

private func updateCompanion() {
  // 1
  let ids = purchased.map { $0.id }

  // 2
  Connectivity.shared.send(movieIds: ids)
}

In the preceding code:

  1. There’s no need to transfer entire Movie objects between devices. Instead, you grab the ID of each ticket you’ve purchased.
  2. Using your newly created Connectivity, you send the data to the other device.

Add a call to updateCompanion() as the last step in both delete(at:) and purchase(_:).

You’re now sending and receiving ticket purchase updates. However, if you build and run the app at this point, the tickets still won’t update. Why not?

Look at PurchasedTicketsListView in either the iOS or watchOS target. You’ll see the list of tickets comes from the purchased property of TicketOffice. Update the TicketOffice initializer to include the following code at the end of the method:

// 1
Connectivity.shared.$purchasedIds
  // 2
  .dropFirst()
  // 3
  .map { ids in movies.filter { ids.contains($0.id) }}
  // 4
  .receive(on: DispatchQueue.main)
  // 5
  .assign(to: \.purchased, on: self)
  //6
  .store(in: &cancellable)

That’s quite the chain of calls! If you’re not familiar with the Combine framework, that probably looks pretty scary. Here’s a line-by-line breakdown:

  1. By prefacing the property with $, you tell Swift to look at the publishing item rather than just the value.
  2. You declared purchasedIds with an initial value of [], meaning an empty array. By dropping the first item, you essentially skip the empty assignment.
  3. Next, you retrieve Movie objects identified by the IDs sent to the device. Performing the inner filter ensures an ID sent for a movie that doesn’t exist doesn’t throw an error.
  4. Because you’ll use the list of purchased movies to update the user interface, you switch over to the main thread. Always make UI updates on the main thread!
  5. Now that you’ve created a list of Movie objects, assign that to purchased.
  6. Finally, include the standard boilerplate for Combine, which stores the chain in Set<AnyCancellable>.

It’s the moment of truth! Build and run the updated app on both devices. Then purchase a ticket on the phone.

After a few moments, the Apple Watch will update.

Note

Remember, updates can take some time. While writing this book, I saw the iPhone update the Apple Watch almost immediately, whereas the watch sometimes updated the phone three minutes or so later.

Application Context

While functional, using transferUserInfo(:_) isn’t the best choice for CinemaTime. If you purchase a movie ticket on one device, you don’t need to see it immediately on the other device. Unless you’re standing right outside the theater, it’s OK if the transfer happens later. Even if you are outside the theater, you’d still use the device you purchased the ticket on.

In this case, the best choice is to use updateApplicationContext(_:). You need the message to arrive, so none of the sendMessage variants would make sense.

Add a new file named Shared/Delivery with the following contents:

enum Delivery {
  /// Deliver immediately. No retries on failure.
  case failable

  /// Deliver as soon as possible. Automatically retries on failure.
  /// All instances of the data will be transferred sequentially.
  case guaranteed

  /// High priority data like app settings. Only the most recent value is
  /// used. Any transfers of this type not yet delivered will be replaced
  /// with the new one.
  case highPriority
}

Note

Remember to update the target membership to include both the iOS app andthe watchOS extension.

You’ll use that enum to specify the type of delivery the caller wants to use. I chose the labels I did because they make more sense to me. guaranteed and highPriority make sense to me when I glance at code later, instead of trying to remember whether it was a user information transfer or an updated application context guaranteed to arrive quickly.

Now jump back to Shared/Connectivity. Add a couple of extra parameters to the send method:

public func send(
  movieIds: [Int],
  delivery: Delivery,
  errorHandler: ((Error) -> Void)? = nil
) {

Now you can specify both the type of delivery you’d like to use as well as an optional error handler.

Then, at the bottom of that method, replace the call to transferUserInfo(_:) with:

switch delivery {
case .failable:
  break

case .guaranteed:
  WCSession.default.transferUserInfo(userInfo)

case .highPriority:
  do {
    try WCSession.default.updateApplicationContext(userInfo)
  } catch {
    errorHandler?(error)
  }
}

You’ll handle the .failable case in a moment. In the case of a high-priority delivery, there’s a new wrinkle to handle.

Updating the application context might throw an exception, which is why the send method now accepts an optional error handler.

Note

Remember, you call the session delegate methods on a background thread, so be kind to your consumers and dispatch back to the main queue for them.

In the delegate, you need to receive the application context like you received user information. Since the code is going to be the same, do a quick bit of refactoring.

Extract the contents of session(_:didReceiveUserInfo:) into a private method:

private func update(from dictionary: [String: Any]) {
  let key = ConnectivityUserInfoKey.purchased.rawValue
  guard let ids = dictionary[key] as? [Int] else {
    return
  }

  self.purchasedIds = ids
}

Then call it from the delegate method:

func session(
  _ session: WCSession,
  didReceiveUserInfo userInfo: [String: Any] = [:]
) {
  update(from: userInfo)
}

Also call it from a new delegate method for receiving the application context:

func session(
  _ session: WCSession,
  didReceiveApplicationContext applicationContext: [String: Any]
) {
  update(from: applicationContext)
}

Switch back to Shared/TicketOffice and replace the connectivity call in updateCompanion() with:

Connectivity.shared.send(movieIds: ids, delivery: .highPriority, errorHandler: {
  print($0.localizedDescription)
})

Now, when a user purchases a ticket, you’ll use the application context transference method instead. A production app would, however, likely want to do something better than just printing to the console in case of an error.

Optional Messages

Remember, interactive messages might fail to send. While that makes them inappropriate for the CinemaTime app, you’ll make the appropriate updates to Connectivity to support them so you can reuse the code later.

It’s a bit trickier to deal with interactive messages. They take an optional reply handler and an optional error handler. If you can’t send the message or ask for a reply and can’t receive it, the error handler is called.

The most common reason for an error is when the paired device isn’t reachable, but other errors are possible.

Note

Remember, if you’re not expecting a reply from the peer device, you must pass nil as the reply handler when calling sendMessage(_:replyHandler:errorHandler:). If you pass a closure to the parameter, you tell the OS you expect a reply, and it should generate an error if one isn’t received.

Also, don’t directly pass replyHandler and errorHandler from your custom send method, as the handlers would then run on a background thread, not the main thread.

How do you handle both situations? With the addition of a small helper function to Shared/Connectivity. It’s quite clean.

Add this to the end of the Connectivity class:

// 1
typealias OptionalHandler<T> = ((T) -> Void)?

// 2
private func optionalMainQueueDispatch<T>(handler: OptionalHandler<T>) -> OptionalHandler<T> {
  // 3
  guard let handler = handler else {
    return nil
  }

  // 4
  return { item in
    // 5
    Task { @MainActor in
      handler(item)
    }
  }
}

There are a ton of powerful features here in a few lines of code. Here’s a breakdown:

  1. While not strictly necessary, using typealias makes the rest of the code easier to read. You create an alias for an optional method that takes a single generic parameter T and has no return value.
  2. You declare a method of that same T type and take an optional handler. Remember that OptionalHandler<T> is already optional, so you don’t add ? to the end of the type.
  3. If no handler was provided, then return nil.
  4. The return type is an OptionalHandler<T>, meaning you need to return a closure to represent the call. The closure will take a single item of type T, as expected by the definition of the type.
  5. You dispatch the provided handler to the main thread using the delivered data.

It’s quite a bit to wrap your head around if you’ve never returned a closure from another method. So, don’t be afraid to work through the code a few times to be sure you understand it.

Non-Binary Data

Optional messages might or might not expect a reply from the peer device. So, add a new replyHandler to your send method in Shared/Connectivity so it looks like this:

public func send(
  movieIds: [Int],
  delivery: Delivery,
  replyHandler: (([String: Any]) -> Void)? = nil,
  errorHandler: ((Error) -> Void)? = nil
) {

Now it’s time to enjoy the fruits of your labor. Replace the break statement in the .failable case with the following:

WCSession.default.sendMessage(
  userInfo,
  replyHandler: optionalMainQueueDispatch(handler: replyHandler),
  errorHandler: optionalMainQueueDispatch(handler: errorHandler)
)

By implementing optionalMainQueueDispatch(_:), you keep a clean case that handles all the complexities of passing a handler vs. nil. You also ensure that both handlers, if provided, are called on the main thread.

Technically, you’d be OK always providing an error handler. But why make the OS perform the extra work of configuring and dispatching an error if you’re going to ignore it anyway?

To handle receiving messages, add two separate delegate methods:

// This method is called when a message is sent with failable priority
// *and* a reply was requested.
func session(
  _ session: WCSession,
  didReceiveMessage message: [String: Any],
  replyHandler: @escaping ([String: Any]) -> Void
) {
  update(from: message)

  let key = ConnectivityUserInfoKey.verified.rawValue
  replyHandler([key: true])
}

// This method is called when a message is sent with failable priority
// and a reply was *not* requested.
func session(
  _ session: WCSession,
  didReceiveMessage message: [String: Any]
) {
  update(from: message)
}

It’s unfortunate, but Apple implemented two completely separate delegate methods instead of having one with an optional reply handler. In both instances, you’ll call update(from:), just like in the other delegate methods you’ve added so far.

The only difference is that in the delegate method which expects to send a reply back to the other device, you have to invoke the provided handler. The data you send back is arbitrary. For this example, you send back a true response.

Binary Data

Optional messages can also transfer binary data. It’s unclear why only optional messages provide a binary option.

You’ll need a separate sending method in Connectivity to handle the Data type. As you coded it, you’d quickly see that both methods need all the same guard clauses. So, refactor them into a method of their own:

private func canSendToPeer() -> Bool {
  guard WCSession.default.activationState == .activated else {
    return false
  }

  #if os(watchOS)
  guard WCSession.default.isCompanionAppInstalled else {
    return false
  }
  #else
  guard WCSession.default.isWatchAppInstalled else {
    return false
  }
  #endif

  return true
}

Those are the same checks you performed previously. Now, you move them to a method that returns true when you can send.

Remove those guard clauses from send(movieIds:delivery:replyHandler:errorHandler:) and replace them with a call to the new method:

guard canSendToPeer() else { return }

Now you can implement the method to handle binary data:

public func send(
  data: Data,
  replyHandler: ((Data) -> Void)? = nil,
  errorHandler: ((Error) -> Void)? = nil
) {
  guard canSendToPeer() else { return }

  WCSession.default.sendMessageData(
    data,
    replyHandler: optionalMainQueueDispatch(handler: replyHandler),
    errorHandler: optionalMainQueueDispatch(handler: errorHandler)
  )
}

Receiving binary data, of course, results in two more delegate methods:

func session(
  _ session: WCSession,
  didReceiveMessageData messageData: Data
) {
}

func session(
  _ session: WCSession,
  didReceiveMessageData messageData: Data,
  replyHandler: @escaping (Data) -> Void
) {
}

I haven’t provided a sample implementation as that would be specific to the type of binary data your app is transferring.

Transferring Files

If you run the app on your iOS device and purchase a ticket, you’ll notice that the movie details include a QR code. Ostensibly, that QR code is what the theater would scan to grant entry. Purchasing a ticket on the Apple Watch, however, does not display a QR code.

You’ll see a file called CinemaTime/QRCode that generates the QR code using the CoreImage library. Unfortunately, CoreImage does not exist in watchOS.

An image like this is an excellent example wherein you might opt to use file transfers. When you purchase a ticket on the Apple Watch, the iOS device gets a message with the new movie list. Wouldn’t that be a great time to ask the iOS device to generate a QR code and send it back?

Note

When debugging file transfer issues, consider using the .failable delivery type, so the transfer is attempted immediately.

QR Codes

Move CinemaTime/QRCode into Shared. Then add the watch app to the target membership. To fix the error that immediately appears, wrap both the import of CoreImage and generate(movie:size:) in a compiler check:

import SwiftUI
#if canImport(CoreImage)
import CoreImage.CIFilterBuiltins
#endif

enum QRCode {
  #if canImport(CoreImage)
  static func generate(movie: Movie, size: CGSize) -> UIImage? {
    // Code removed for brevity. [...]
  }
  #endif
}

When the Apple Watch displays the details of a purchased ticket, it needs to know where to look for the QR code’s image. Add a helper method to the enum but outside the canImport check:

#if os(watchOS)
static func url(for movieId: Int) -> URL {
  let documents = FileManager.default.urls(
    for: .documentDirectory,
    in: .userDomainMask
  )[0]

  return documents.appendingPathComponent("\(movieId).png")
}
#endif

iOS doesn’t need to look at a file URL, but watchOS does. The preceding code gets the path to the app’s documents directory and then appends the movie’s ID to the path.

Note

If you don’t name the file with a .png suffix, then the OS will refuse to turn that file into an image.

Open Shared/Connectivity and edit send(movieIds:delivery:replyHandler:errorHandler:) to add wantedQrCodes:

public func send(
  movieIds: [Int],
  delivery: Delivery,
  wantedQrCodes: [Int]? = nil,
  replyHandler: (([String: Any]) -> Void)? = nil,
  errorHandler: ((Error) -> Void)? = nil
) {

Then, change userInfo from a let to a var and assign the wanted QR codes:

var userInfo: [String: [Int]] = [
  ConnectivityUserInfoKey.purchased.rawValue: movieIds
]

if let wantedQrCodes {
  let key = ConnectivityUserInfoKey.qrCodes.rawValue
  userInfo[key] = wantedQrCodes
}

That provides a way for the Apple Watch to request a QR code for a list of movies from the iOS device. Why a list instead of just one ID? The previous request to deliver an image might have failed for any number of possible reasons.

Now you can implement the method that will run on the iOS device to generate and send the QR code images. Add this code to the end of Connectivity:

#if os(iOS)
public func sendQrCodes(_ data: [String: Any]) {
  // 1
  let key = ConnectivityUserInfoKey.qrCodes.rawValue
  guard let ids = data[key] as? [Int], !ids.isEmpty else { return }

  let tempDir = FileManager.default.temporaryDirectory

  // 2
  TicketOffice.shared
    .movies
    .filter { ids.contains($0.id) }
    .forEach { movie in
      // 3
      let image = QRCode.generate(
        movie: movie,
        size: .init(width: 100, height: 100)
      )

      // 4
      guard let data = image?.pngData() else { return }

      // 5
      let url = tempDir.appendingPathComponent(UUID().uuidString)
      guard let _ = try? data.write(to: url) else {
        return
      }

      // 6
      WCSession.default.transferFile(url, metadata: [key: movie.id])
    }
}
#endif

Here’s a code breakdown:

  1. If the data passed to the method doesn’t contain a list of movie IDs that require a QR code, then exit the method.
  2. Grab all of the movies which exist with the requested IDs. Silently ignore bad IDs.
  3. Now that you have an actual Movie object, generate a QR code for it at a size that will work well on the Apple Watch.
  4. If either the QR code failed to generate an image or the image couldn’t generate PNG data, there’s nothing more to do here. So, move on to the next one.
  5. Generate a temporary file with a unique name and write the PNG data to that file. If it fails, quietly return.
  6. Finally, initiate a file transfer to the peer device, meaning the Apple Watch.

Notice how metadata contains the ID of the movie whose QR code you just generated.

Call that method first in update(from:):

#if os(iOS)
sendQrCodes(dictionary)
#endif

It’s important to do so before the rest of the code because you might have been asked for a QR code again and had no new purchases.

Of course, it wouldn’t be connectivity code if you didn’t have to implement another delegate! Add the following method to the delegate section:

// 1
#if os(watchOS)
func session(_ session: WCSession, didReceive file: WCSessionFile) {
  // 2
  let key = ConnectivityUserInfoKey.qrCodes.rawValue
  guard let id = file.metadata?[key] as? Int else {
    return
  }

  // 3
  let destination = QRCode.url(for: id)

  // 4
  try? FileManager.default.removeItem(at: destination)
  try? FileManager.default.moveItem(at: file.fileURL, to: destination)
}
#endif

In the preceding code, you:

  1. Only receive a file transfer on watchOS, so you wrap it in a compiler check.
  2. Pull the movie’s ID from the metadata. If no ID exists, quietly exit the method.
  3. Determine where you should write the indicated movie’s QR code.
  4. Remove the QR code if there’s already one there, and then move the received file to the proper location.

Note

watchOS will delete the received file if it still exists when the method ends. You must synchronously move the file to a new location if you wish to keep it.

Now that you have a way to request and receive QR codes, you just need to modify the TicketOffice code. Open Shared/TicketOffice and edit the delete method. If you delete a ticket from the Apple Watch, you might as well remove the image, too. At the start of the method, add:

#if os(watchOS)
offsets
  .map { purchased[$0].id }
  .forEach { id in
    let url = QRCode.url(for: id)
    try? FileManager.default.removeItem(at: url)
  }
#endif

Here, you map each row you delete to the corresponding movie’s ID. Once you have a list of IDs, you determine where the QR code is stored and then remove it.

Now edit updateCompanion to figure out which movies still need a QR code. Replace the existing connectivity call with:

// 1
var wantedQrCodes: [Int] = []

// 2
#if os(watchOS)
wantedQrCodes = ids.filter { id in
  let url = QRCode.url(for: id)
  return !FileManager.default.fileExists(atPath: url.path)
}
#endif

// 3
Connectivity.shared.send(
  movieIds: ids,
  delivery: .highPriority,
  wantedQrCodes: wantedQrCodes
)

You add a few minor updates here:

  1. You store a list of all QR codes you need to request, defaulting to none.
  2. If running on the Apple Watch, you identify all purchased movies that don’t have a stored QR code yet.
  3. Finally, you include wantedQrCodes in the connectivity call.

Next, you’ll add some code to the end of the Movie struct to retrieve the locally stored QR code. Open Shared/Movie and add the following:

#if os(watchOS)
func qrCodeImage() -> Image? {
  let path = QRCode.url(for: id).path
  if let image = UIImage(contentsOfFile: path) {
    return Image(uiImage: image)
  } else {
    return Image(systemName: "xmark.circle")
  }
}
#endif

If the QR code exists where it’s supposed to, you return it as a SwiftUI Image. If the image doesn’t exist, then you return an appropriate default image instead.

Finally, you need to check for the movie purchase. Open CinemaTime Watch App/MovieDetailsView and add else like this:

if !ticketOffice.isPurchased(movie) {
  PurchaseTicketView(movie: movie)
} else {
  movie.qrCodeImage()
}

Build and rerun your app. Purchase a movie from the Apple Watch. After some time, you’ll see the QR code at the bottom of the details screen if you navigate away from and back to the details view.

img

Key Points

  • There are many methods available for sending data. Be sure you choose one based on how quickly you need the data to arrive.
  • If you send an interactive message from your watchOS app, the corresponding iOS app will wake up in the background and become reachable.
  • If you send an interactive message from your iOS app while the watchOS app is not in the foreground, the message will fail.
  • Bundle messages together whenever possible to limit battery consumption.
  • Do not supply a reply handler if you aren’t going to reply.

Where to Go From Here?

In this chapter, you set up the Watch Connectivity framework, learned about the different ways to transfer data between counterpart iOS and watchOS apps, and successfully implemented the application context transfer method.

Keeping your iOS and watchOS apps in sync through data transfer is important. That way, users can use both devices indistinctly. In the next chapter, you will learn how to provide users a quick view of the current state of your app. By using Snapshots, you produce apps that feel responsive and up to date.