Engineering

A deep link is four platform integrations wearing one URL

Verified, registered and delivered differently; parsing is the easy part.

MobileByteSensei 10 min read
kmpplatformnavigation

TL;DR

  • One shared URL parser, four platform integrations, and a share link that opened the app's home screen instead of the shared note on iOS for eleven days — because the Universal Links association file was served with the wrong content type.
  • Parsing the URL is the shared part and it is 40 lines. Verification, registration and delivery are per platform and they are where every failure was.
  • The deliveries differ in kind: Android hands you an Intent that may arrive before your navigation graph exists, iOS hands you an NSUserActivity on a cold start and a different callback on a warm one, and the web hands you a route that was already rendered.
  • What reading on buys: the four delivery shapes, the verification each platform performs, and the one check that would have caught the content-type mistake in CI.

The case this post is about

A share link that should open a note:

https://notes.example.com/n/7f2a1c9e

The shared parser, which is the easy and portable part:

// commonMain/kotlin/deeplink/DeepLink.kt
sealed interface DeepLink {
    data class Note(val id: String) : DeepLink
    data class Folder(val id: String) : DeepLink
    data object Settings : DeepLink
}

fun parseDeepLink(url: String): DeepLink? {
    val u = Url(url)
    if (u.host != "notes.example.com") return null
    return when (u.segments.firstOrNull()) {
        "n"        -> u.segments.getOrNull(1)?.let { DeepLink.Note(it) }
        "f"        -> u.segments.getOrNull(1)?.let { DeepLink.Folder(it) }
        "settings" -> DeepLink.Settings
        else       -> null
    }
}

And four platform entry points, each a handful of lines that hand the URL to that function.

The symptom

Three reports over eleven days, all from iOS, all the same shape:

"I tapped a link my colleague sent and it just opened the app's main screen."

Which is not a parse failure — a failed parse returns null and we show a "link not recognised" screen. The app was opening normally, as though the link had never been a link.

And it had not been. iOS was not treating the URL as a Universal Link at all, so Safari opened the web page, and tapping through from there launched the app cold with no link attached:

$ # the association file, which iOS fetches to decide whether to route the domain
$ curl -sI https://notes.example.com/.well-known/apple-app-site-association
HTTP/2 200
content-type: application/octet-stream        ← must be application/json

A content type. Served correctly for Android's equivalent file, wrong for iOS's, and the consequence is total: iOS silently declines to associate the domain, and there is no error anywhere on the device.

The second symptom, found while fixing the first, was on Android and intermittent:

java.lang.IllegalStateException: no NavHostController available
  at com.example.app.MainActivity.handleIntent(MainActivity.kt:48)

A cold start from a link, where onNewIntent ran before the Compose navigation graph existed.

The association file served with the wrong content type while its body was correct, so one operating system silently declined the domain for eleven days with no error on the device or the server

Why the obvious fix failed

The first fix was to add a custom scheme as a fallback, which does work and is worse:

<data android:scheme="notesapp" android:host="n" />
notesapp://n/7f2a1c9e

A custom scheme has no verification — any app can register notesapp://, and a link in that form sent to someone without the app installed does nothing at all rather than opening a web page. We kept it for one internal tool and it is not a fallback for a share link.

The second fix was to test the association file by eye, which is how it had been wrong for eleven days:

$ curl https://notes.example.com/.well-known/apple-app-site-association
{"applinks":{"details":[{"appIDs":["TEAMID.com.example.notes"],"components":[…]}]}}

The content is correct. Reading the body tells you nothing about the header, and the header was the fault.

The third fix was Apple's own validator, which reported success:

$ # the AASA validator, via the developer tools
  notes.example.com — association file found and valid

Found and valid. The validator fetches the file and parses it; it does not enforce the content type that iOS itself requires. Two tools agreed the file was fine and the device disagreed, which is the state that cost the eleven days.

The actual mechanism

Four integrations, and the only shared part is parsing.

Android App Links are verified by the system against a JSON file (/.well-known/assetlinks.json) containing your signing certificate's fingerprint. Verification happens at install and periodically; the status is inspectable on the device. An unverified domain falls back to the chooser dialog rather than failing.

iOS Universal Links are verified against apple-app-site-association, fetched over HTTPS at install and cached by Apple's CDN — so an association change is not immediate, and a wrong file means the OS never offers your app. There is no on-device status to inspect without a profile, and no error surface at all.

A custom scheme is not verified by anything. It is a registration, first-come on Android and last-registered-wins behaviour that varies on iOS.

And a web route is not a deep link at all. The URL is the application state; there is no hand-off, no verification, and the "integration" is that your router already handles it.

Then the deliveries differ in shape, which is the second half nobody plans for:

  android, cold start     an Intent on the Activity, possibly before your UI exists
  android, warm           onNewIntent, at any point in the lifecycle
  ios, cold start         an NSUserActivity in the launch options
  ios, warm               continue userActivity: — a different callback entirely
  web                     the initial route, already rendered
  desktop                 an OS open-url event, or argv on first launch

Six delivery points for four platforms, each arriving at a different moment relative to your navigation being ready. Which explains the Android crash: the link arrived and the code tried to navigate immediately, and "immediately" is before the graph on a cold start.

What each platform verifies and where it delivers the link, with six delivery points across four platforms arriving at different moments relative to navigation being ready

The fix

Parse in shared code, hold the result until navigation is ready, and verify the platform registration in CI rather than by eye.

// commonMain/kotlin/deeplink/PendingDeepLink.kt
//
// A one-slot holder, because the link can arrive before anything can act on it. The original
// code navigated on receipt, which works warm and crashes cold — and "cold" is the case a
// shared link is almost always in.
class PendingDeepLink {

    private val pending = MutableStateFlow<DeepLink?>(null)

    /** Called from whichever platform entry point received it, at any time. */
    fun offer(url: String) {
        val link = parseDeepLink(url)
        if (link == null) {
            // An unparseable link is a REPORTABLE event, not a silent drop: it usually means
            // a link format shipped that this app version does not know, which is a
            // compatibility fact worth having.
            analytics.log("deeplink.unparsed", "url" to url)
            pending.value = DeepLink.Unrecognised
            return
        }
        pending.value = link
    }

    /**
     * Consumed ONCE, by the navigator, when it is ready. A StateFlow rather than a Channel
     * deliberately: a cold start can deliver the link before any collector exists, and a
     * Channel's buffered-then-conflated semantics made that ordering a coin flip in testing.
     */
    @Composable
    fun ConsumeWhenReady(navigate: (DeepLink) -> Unit) {
        val link by pending.collectAsState()
        LaunchedEffect(link) {
            val l = link ?: return@LaunchedEffect
            navigate(l)
            pending.value = null          // consumed: a rotation must not re-navigate
        }
    }
}
// androidMain — both entry points, feeding the same holder
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        intent?.data?.let { pendingDeepLink.offer(it.toString()) }   // cold
        setContent { AppRoot() }                                      // graph exists after this
    }
    override fun onNewIntent(intent: Intent) {                        // warm
        super.onNewIntent(intent)
        intent.data?.let { pendingDeepLink.offer(it.toString()) }
    }
}

And the CI check, which is the part that addresses the eleven days rather than the crash:

#!/usr/bin/env bash
# tools/verify-deeplink-registration.sh
#
# Checks the association files as the OPERATING SYSTEMS do, not as a human reading the body
# does. The content type is what broke us: the file's contents were correct, Apple's own
# validator reported success, and iOS declined the domain because of a header.
set -euo pipefail
HOST="${1:-notes.example.com}"
fail=0

# ── iOS ────────────────────────────────────────────────────────────────────────────────
AASA="https://$HOST/.well-known/apple-app-site-association"
ct=$(curl -sI "$AASA" | tr -d '\r' | awk -F': ' 'tolower($1)=="content-type"{print $2}')
code=$(curl -s -o /dev/null -w '%{http_code}' "$AASA")

[ "$code" = "200" ] || { echo "FAIL ios: $AASA returned $code"; fail=1; }

# The requirement iOS enforces and no validator checks. Charset suffixes are accepted, so
# the match is on the type rather than the whole header.
case "${ct%%;*}" in
  application/json) ;;
  *) echo "FAIL ios: content-type is '${ct:-<none>}', must be application/json."
     echo "     iOS silently declines the association — no device error, no log line."
     fail=1 ;;
esac

# A REDIRECT is also fatal and also invisible: iOS does not follow one for this file.
redirects=$(curl -s -o /dev/null -w '%{num_redirects}' -L "$AASA")
[ "$redirects" = "0" ] || { echo "FAIL ios: $redirects redirect(s); iOS does not follow them"; fail=1; }

# The app ID must be present, and must be the RELEASE one — a debug bundle id here is a
# file that validates and associates the wrong app.
grep -q "$EXPECTED_APP_ID" <(curl -s "$AASA") \
  || { echo "FAIL ios: $EXPECTED_APP_ID absent from the association file"; fail=1; }

# ── Android ───────────────────────────────────────────────────────────────────────────
AL="https://$HOST/.well-known/assetlinks.json"
curl -s "$AL" | grep -q "$RELEASE_SHA256" \
  || { echo "FAIL android: release signing fingerprint absent from assetlinks.json"; fail=1; }

exit $fail

And the output it produced, run against production at the time:

$ ./tools/verify-deeplink-registration.sh notes.example.com

FAIL ios: content-type is 'application/octet-stream', must be application/json.
     iOS silently declines the association — no device error, no log line.

exit 1

One line, the exact fault, from a check that takes 400 ms.

How we proved it

That the content type alone caused it — changed in isolation, with the body byte-identical:

  content-type                     iOS associates the domain?   link opens the app?
  application/octet-stream                   no                        no
  application/json                           yes                       yes
  application/json; charset=utf-8            yes                       yes

Same file, same bytes, one header. And the association is CDN-cached, so the fix took around 24 hours to reach devices that had already fetched the bad file — which is its own reason to check in CI rather than after a report.

That two tools said it was fine. Worth recording, because "the validator passes" was the reason nobody looked at the header:

  Apple's AASA validator           association file found and valid
  curl, reading the body            valid JSON, correct app ID
  curl -I, reading the header       application/octet-stream        ← the fault

That the Android crash was an ordering problem and not a navigation bug. The measurement separates cold from warm, which the stack trace does not:

  launch path                            crashed   navigated correctly
  warm, onNewIntent                      0 / 50          50 / 50
  cold, from a link                     47 / 50           3 / 50
  cold, with PendingDeepLink            0 / 50          50 / 50

Forty-seven of fifty on a cold start, three surviving by scheduling luck — which is why it had reached production looking intermittent.

That a Channel was the wrong holder. We tried it first and the ordering made it unreliable:

                      link delivered before any collector exists
  Channel(RENDEZVOUS)         dropped                 0 / 50 navigated
  Channel(CONFLATED)          delivered sometimes    31 / 50
  StateFlow                   delivered always       50 / 50

A cold start always delivers the link before a collector exists, so the holder must retain rather than buffer.

That consuming the link matters. The fixture is a rotation after a deep-link navigation, which without the pending.value = null re-navigates and loses whatever the user did next:

$ ./gradlew :app:connectedAndroidTest --tests '*RotationDoesNotRenavigate*'
  without consume:  navigated to Note(7f2a1c9e) twice; user's scroll position lost
  with consume:     navigated once

What I cannot tell you is how many users hit the iOS failure. Three reported it; the web page opened and worked, so most people who tapped a link got a usable experience and no reason to complain. That is the uncomfortable part of this class — the fallback was good enough to hide the defect, and the only honest statement is that the rate is unknown and was not zero for eleven days.

And a gap the check does not close. The script verifies the files are served correctly. It cannot verify that iOS has fetched the new one, because that is CDN-cached on Apple's side with no inspectable status. After an association change there is a window — we measured roughly a day — in which a device that cached the old file still declines, and nothing on the device or the server shows it.

Three measurements isolating the header from the body, separating the cold-start crash from a navigation bug, and showing that a channel drops a link delivered before any collector exists

What this generalizes to

A URL that works everywhere looks like one feature and is four registrations with four verification mechanisms, three of which can fail without producing an error anywhere. The shared part — parsing — is the part that is easy to test and the part that was never broken. For the rest, the useful move is to check the platform's own requirement mechanically rather than trusting a validator or a reading of the file, because a validator checks what it was written to check and the OS checks something slightly different. And whatever receives the link, assume it arrives before anything can act on it.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS