A while ago I talked with a friend about an app we wanted to develop for the Apple Watch, where people in a team / department would receive notifications about their specific work at that given moment. Normally, this isn’t that hard: everybody carries an iPhone and a connected Watch and from a server you just send a notification to that person.
Now, in the context that we would like to use the app, everybody carrying an iPhone is not an option. For starters, an Apple Watch SE starts at €299, but combining this with an iPhone would at least put another €489 on top for an iPhone SE. Second, people would carry only that iPhone for usage of the Watch app, which would defy the idea of the standalone apps on the Watch. Instead, what we would like to do is have multiple Watches paired to one single iPhone and operate those Watches independently from one another.
It is possible to pair multiple Apple Watches to one single phone. However, this is not the same as being connected to that device. Only one Apple Watch can be connected to an iPhone at the same time.
The problem is that we didn’t know if only the connected Apple Watch would be able to receive notifications or that all paired Watches would still be able to receive them, regardless of their connectivity to the iPhone.
The hypothesis is that it should be possible to receive notifications on non-connected Watches:
- If you turn off your iPhone, Apple Watch is still able to receive iMessages
- Family setup in WatchOS 7 (which is only available on cellular models) sort of uses the same setup we would like to achieve
- On this Apple Developer page, Apple states:
Specifically, an independent watchOS app must send push notifications, including complication pushes, directly to the watch.
So in order to test our hypothesis, we decided to develop a simple proof-of-concept standalone Apple Watch app.
Devices and Frameworks
The devices we used in this experiment were all able to run the latest versions of their operating systems:
- iPhone 12 Pro Max, running iOS 14.3
- Apple Watch S5, running WatchOS 7.2
- Apple Watch SE, running WatchOS 7.2
As client side application we made a standalone Apple Watch app without an iPhone companion app. This is important, because otherwise notifications might get delivered to the iPhone only.
We used SwiftUI 2 together with the SwiftUI App LifeCycle setup for the Apple Watch app.
For the server we used Vapor 4, a Server Side Swift framework that has all the nice goodies from Swift, plus some extra frameworks and tools:
- Leaf, a templating framework to generate a little front end
- Fluent, an ORM framework
- SQLite, to store the push tokens from the Watch
- Vapor-APNS, an extension for Vapor to send Push Notifications via APNs
Vapor 4 is relatively new, at the time of writing it is 9 months old and a lot of the documentation and tutorials you’ll find online are about earlier versions of the framework. Luckily there are some tutorials and starter projects that describe using the newer versions:
- Sending Push notifications with Vapor on Ray Wenderlich
- Routing in Vapor 4 by Christian Weinberger on Medium
The start projects on Ray Wenderlich’s page are helpful and contain some extensions that make it easier to build support for APNs.
You need a paid Apple Developer account in order to support push notifications.
I would advice you to read the Install and Getting Started page on Vapor docs first, on order to get your machine ready.
As Vapor uses Swift Package Manager (SPM), packages are configured via the Package.swift file.
For the dependencies add:
* There is one file that is shared between the server app and the iOS app in the project, which contains some structs. Set the path to the place where that shared file is located, if you’re planning on reusing the code from the tutorial.
And for the target dependencies add:
Close Xcode and run
vapor build on the directory. To open the project again, run
vapor xcode -y.
With that done, we need to configure these frameworks in the configure.swift file:
To configure Leaf, add:
To configure Fluent:
app.databases.use(.sqlite(.memory), as: DatabaseID.sqlite)app.migrations.add(CreateDevice(), to: .sqlite)try app.autoMigrate().wait()
We chose an in-memory database, so every time you restart your whole stack, everything is clean. In the two lines after that we create a migration of CreateDevice and auto migrate it. The CreateDevice object defines the migration and the fields in the database. See the sample project from Ray Wenderlich for the definition of this struct.
To configure push:
This method is declared in the Application+APNS.swift file, see the tutorial from Ray.
To support push notifications in your Apple Watch app, you need to do some extra stuff, compared to an iOS app. First, you’ll need to get a push notifications key. In the Ray Wenderlich article, it is explained how you can do this. You can only download your .p8 file once, so be careful with it!
Create the Apple Watch Application without the iOS companion app:
Choose for interface: SwiftUI and for Life Cycle: SwiftUI App. In the project settings, add support for push notifications to the Watchkit Extension. In order to do this, click the extension in the target section, then click +capability and add Push Notifications.
At this point, unfortunately the app does not support push notifications yet. We need to do some extra configurations on the developer portal. Click Certificates, IDs & Profiles in the left column, Choose Identifiers, press the + button and choose App IDs. In the next screen you choose app. When you click continue to go next, give your app a description, add its bundle ID and add .watchkitapp to it:
com.myCompany.myApp.watchkitapp. Check push notifications and click save.
Nice! Your app supports push notifications! In a later section we’ll ask the user for permission and retrieve the push token.
Go back to the server application and open the Application+APNS.swift file again. Add the private key to appleECP8PrivateKey constant:
let appleECP8PrivateKey ="""-----BEGIN PRIVATE KEY-----<KEY GOES HERE>-----END PRIVATE KEY-----"""
You might want to store this key in an environment variable, but for now we’ll just put it here. To add the configuration to apns:
In the Watch app project, we will now try to retrieve the push token and ask the user for permission:
The delegate method we will use to retrieve the token is
didRegisterForRemoteNotifications(withDeviceToken:) . We need a little ‘old school’ code for this, so add this to the file where your
@main is declared:
This code retrieves the token and converts it into a normal string that we can send to and use on the server. The save function send the token to the server. The example project also saves the token to disk, but somehow this didn’t work in our project, so it is disabled for brevity.
To ask for permission (and show some placeholder text when the app is launched):
We need and endpoint to receive the token on the server and to show which devices are registered. We need some way to send the push notifications to APNs. We will use a controller for that, which is a collection of routes. This controller will have three functions:
- list, which returns a list of all registered devices
- put, which saves the token to the in-memory database
- sendTestPush, which sends a push notification based to a specified device
First, we group these functions in the boot function:
The list function just returns a list of registered devices:
What happens here, is that it queries the database and retrieves the devices from it. With render function on view uses the hello.leaf where we will write our leaf code. We pass it our collection of devices.
The put functions adds the device with its push token to the database:
This code checks if the token is already in the database. If it is this is the case, it updates its information. When the device is not present yet, we add it to our database.
At last, we have our send push notification function:
In the next section, we place a button next to the device on the webpage which will call this function with the Id of that device.
In the routes.swift file we need to register our controller:
let apiRoutes = app.grouped("api")try apiRoutes.grouped("push").register(collection: DeviceController())
This will result in the path …/api/push/… but it is up to you how you’d like to order your routes.
Great! We’re almost done!
A simple webpage
To display the registered devices and the buttons we make a very simple webpage, using leaf:
The fancy stuff happens at the # hashtags. We loop trough the devices that we sent earlier by using
#for(a in b). In each row we then display the id and the token and a button that sends the id to the API. This can be done a lot cleaner, but it will work for now.
Wonderful! The only thing we need to do now, is to let the Watch know where to find our server. If you’re using your local machine, you could use ngrok to expose your application to the internet. Don’t forget to use that url in your watch app, so it knows where to find your server.
With everything configured, we should now be able to register your Apple Watches to the server and send push notifications to it. Both watches are paired to the iPhone beforehand and the apps are loaded onto the devices with Xcode. They need to at least be launched once in order to accept push notifications. Take a close look to the console, because sometimes the registration of the token might fail the first time. Usually when you force close the app on the watch and open it again, it should work fine.
When both watches contain the app, accepted push notifications and sent their tokens to the server, the fun can begin.
If everything went well, your webpage at http://127.0.0.1:8080/api/push/devices/list should display both watches with their Id, their token and a button:
Put both watches on your wrists and push any of the buttons to send a notification the the corresponding watch. If everything went fine, this should be your result:
And that’s it! Our hypothesis that sending notifications to Watches that are paired to the same iPhone can receive notifications anyway, is proven to be true. One small thing to keep in mind is that you need to check if your Watch is connected to WiFi (or cellular), otherwise it won’t receive anything.
Thank you for reading this post! If you have any questions, please leave a comment. I also would like to thank Gianpiero for his advice!