I use universal links to link into my app, Underway. I’ll talk about iOS first, and then Android.
I use a lot of the same code for Universal Links and User Activities, but will not be covering User Activities in this note. NSUserActivity
is used for Continuity and hand-off between macOS and iOS.
For Apple platforms, I need an apple-app-site-association
file, an entitlement in our iOS app, and of course the code to handle the link in your app.
This is a JSON file without the JSON extension. I serve this file on from my webserver using Nginx at .well-known/apple-app-site-association
. This file served over HTTPS is good enough for Apple to trust that I own & control the domain.
I originally used Vapor to serve this as a route. I added that path to my route configuration, and set the Content-Type
to application/json
. Later I swapped to serving this as a static file from Nginx.
For some examples of how to form this file, we can look at YouTube’s apple-app-site-association
. We can see a whole bunch of "NOT /path/*"
followed by a "*"
at the end. This tells iOS to send the link to the YouTube app for all paths, except for the ones starting with NOT
iOS also needs an entitlement to associate my domain with my app. Just like other iOS entitlements, I added an entitlement by editing the property list directly. You could also edit this in Xcode.
For my app my Underway.entitlements
property list contains the following code:
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:www.underway.nyc</string>
</array>
My app’s main Info.plist
doesn’t need anything for Universal links, though I did register a custom URL scheme to force-link to Underway.
I can also make this change in Xcode’s editor by opening the project file, clicking on the Signing & Capabilities tab, clicking on the + Capability
, and scrolling down to the “Associated Domains” item. For further steps, please follow Apple’s documentation for adding an associated domain.
After the other two steps are done, it’s time to write some code! In my UIApplicationDelegate
subclass, there are three methods to override. These may be deprecated, but with all of the edges cases, I choose to simply leave these in my code.
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
return self.open(url: url)
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
if let url = launchOptions?[UIApplication.LaunchOptionsKey.url] as? URL {
return self.open(url: url)
} else {
return true
}
}
func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool {
return self.open(url: url)
}
And my open(url: URL) -> Bool
method parses the URL and pushes the requested screen onto the root navigation controller (i.e. the root UINavigationController
subclass.
Your app may want to do something different based on the URL, for example, open a modal window.
All my URLs are stored in a Swift package shared between my Vapor web server, my iOS app, and other targets, such as my Kubernetes YAML generator for configuring cluster ingress. This is a fantastic case of code-reuse.
I model my routes as one large Swift enumeration. Here’s a taste:
enum AppRoute {
case station(StationRoute)
struct StationRoute {
let stationID: UnderwayStation.ID
let action: Action
enum Action: String {
case arrivals, filter, serviceAlerts = "service-alerts"
static let `default`: Self = .arrivals
}
}
}
Then since add a computed variable on AppRoute
, then I can ask for the universal URL like this:
let timesSquare = AppRoute.station(.init(stationID: 25))
print(timesSquare.universalURL) // https://www.underway.nyc/station/25
And I also added a static parsing method to AppRoute
to go in the reverse direction, which is how I’ll know which screen to show when opening a Universal Link.
let timesSquare: AppRoute = AppRoute.parse(url: URL(string: "https://www.underway.nyc/station/25"))
switch
My root navigation controller has a massive switch
statement for opening every screen in my app, after parsing the AppRoute
switch appRoute {
case .station(let stationRoute):
switch stationRoute.action {
default:
self.push(StationArrivalsWireframe(id: stationRoute.stationID))
}
}
}
In SwiftUI I can add onOpenURL
to my top-level view. Inside of that call back, I can parse the URL as before, and append the parsed AppRoute
to my navigation model.
Since Underway is also available for Android, I also set up Universal links for Android.
assetlinks.json
on website!Android has it’s own version of the apple-app-site-association
file. It’s called asset links, and looks like this.
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "nyc.underway.underway",
"sha256_cert_fingerprints": [
"00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00"
]
}
}
]
AndroidManifest.xml
in Android StudioAndroid apps are already a large navigation stack, with the “Back” button built-in to the operating system, and even the hardware on older models! My navigation model for internal app navigation uses the same mechanism as the
Over in Android Studio, in the AndroidManifest.xml
, I registered each screen, and an intent on how to open it. I added the universal link, www.underway.nyc
along with an expression to match the path, with a wildcard where the station ID would be.
<activity
android:name=".screens.StationScreen"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="www.underway.nyc"
android:pathPattern="/station/..*/arrivals"
android:scheme="https" />
</intent-filter>
</activity>
Now that the configuration is up, in my Activity
subclass, I get the URL from the Activity
’s intent. Then I parse the URL to get the station ID and proceed to load the screen from there.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val route = AppRoute.parse(intent.data)
sealed class
Although Kotlin does have enum
’s, I use a sealed class
to model the AppRoute
for the Android app.
sealed class AppRoute() {
data class Station(val stationID: UnderwayStationID, val action: StationAction) :
AppRoute()
}
and
sealed class StationAction(val rawValue: String) {
object AllArrivals : StationAction("arrivals")
object ServiceStatus : StationAction("service-status")
}
when
Because Android handles the routing, I don’t need a massive when
statement like in iOS. I do use a when
statement to cast the route to the appropriate case like this:
when (route) {
is AppRoute.Station -> {
val stationID = route.stationID
// ...
}
else -> return null
}
I don’t expect the else
case to ever be executed, but it’s there in case Android’s routing or my parsing ever fails unexpectedly.
I think of Kotlin’s when
and Swift’s switch
as equivalents.
Universal links all set up for iOS and Android! Done!
Thank you to haIIux for contributing to this article.