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"))
switchMy 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 classAlthough 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")
}
whenBecause 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.