Setting up Universal links for iOS & Android

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.

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.

Apple app site association on my website

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

Entitlement in iOS

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.

Handling the URL in iOS

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.

Parsing URLs

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"))

The big 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))
    }
  }
}

SwiftUI

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.

Android

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 Studio

Android 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>

Handling the URL in Android Studio

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)

A big 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")
}

A tiny 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.

And there you have it

Universal links all set up for iOS and Android! Done!

Thank you to haIIux for contributing to this article.