skip to content

A Flutter ticketing app's shared event link opens the browser instead of the app; how do you diagnose it on Android and iOS?

level: seniorimportance: should knowfreq 42%

answer

  1. split app side from site side
  2. fingerprint from the key that signs installs
  3. hosted file path, HTTPS, no redirect
  4. Apple's CDN caches the association file
  5. iOS throws unhandled links back to Safari

basics

~20 s

A direct adb or simctl launch tests the app side; only a real tap tests verification. Usual causes: a wrong signing fingerprint, missing autoVerify, an unreachable or redirected association file, Apple's CDN lag, or a link Flutter left unhandled.

solid answer

~50 s

I start by splitting the problem. `adb shell am start -a android.intent.action.VIEW ... -d "https://tickets.example.com/events/42"` or `xcrun simctl openurl booted ...` exercise the app side; if the event page opens, the manifest, entitlement and router are fine and verification is the suspect. On Android the usual culprits are a fingerprint in `assetlinks.json` from the upload key while Play App Signing signs installs, a filter without `android:autoVerify="true"`, or a file not reachable over HTTPS at `/.well-known/assetlinks.json` without a redirect; a local build may also need Supported web addresses enabled by hand. On iOS: a wrong `<team id>.<bundle id>`, `components` that exclude the path, a missing `applinks:` entitlement, or Apple's CDN not having fetched the file yet, which can take up to 24 hours. Flutter itself matters on iOS: if the framework reports the link unhandled, the engine reopens it in Safari. DevTools' Deep Links tab checks most of this.

code

bash · 11 lines
bash
# App side only: delivers the intent directly, verification is skipped.
adb shell 'am start -a android.intent.action.VIEW \
    -c android.intent.category.BROWSABLE \
    -d "https://tickets.example.com/events/42"' \
    com.example.tickets

# Android 12+: show the verification state of each declared domain.
adb shell pm get-app-links com.example.tickets

# iOS Simulator: open the link as the system would.
xcrun simctl openurl booted https://tickets.example.com/events/42

go deeper

for a junior

Recall the two halves of a verified link, the app declaration and the hosted file, and the adb and simctl commands that test the app side.

for a middle

Explain why direct launches skip verification, which fields in assetlinks.json and apple-app-site-association must match the build, and how Flutter reports unhandled links.

for a senior

Diagnose methodically across signing keys, flavors, hosting, CDN caching and router handling, using DevTools and cold and warm tests to confirm each fix.

for a principal

Own link reliability as a cross-team contract between web hosting, release signing and the app router, with monitoring when any of them changes.

## Split the problem first A verified link has two halves, and they fail differently: - The **app side**: the Android intent filter or the iOS Associated Domains entitlement, Flutter's deep-linking flag, and a router that can handle the path. - The **site side**: the association file on the domain, which the platform fetches to decide whether the domain trusts the app. A direct launch tests only the first half. The Flutter cookbook states this for Android: the `adb shell am start` command launches the app even if the web files are not present. So: 1. Run `adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://tickets.example.com/events/42" <package>`, or on a Simulator `xcrun simctl openurl booted https://tickets.example.com/events/42`. 2. If the event page does **not** appear, fix the app side first: filter, entitlement, flag or router. 3. If it **does** appear, the app side is fine and the failure is verification; test by tapping the link in another app, such as a note or a chat message, because that is the path users take. ## Android causes | Symptom | Likely cause | |---|---| | store installs open the browser, local builds work | `sha256_cert_fingerprints` holds the upload key; Play App Signing signs installs with Google's app signing key | | no build ever verifies | intent filter lacks `android:autoVerify="true"`, or the `<data>` host differs from the domain | | verification fails intermittently or everywhere | `assetlinks.json` is not served over HTTPS at `/.well-known/assetlinks.json`, or the request is redirected | | only a debug build on one device fails | the build is signed with a debug key not listed, or Supported web addresses must be enabled by hand for a sideloaded build | On Android 12 and later, `adb shell pm get-app-links <package>` shows the verification state per domain, which turns guessing into a lookup. ## iOS causes - **Wrong app identifier**: `appIDs` must be `<team id>.<bundle id>`; a flavor with a different bundle ID needs its own entry. - **Path excluded**: `components` must match `/events/42`; a pattern for `/event/*` does not. - **Entitlement missing or wrong**: `applinks:tickets.example.com` must be in the Associated Domains capability of the signed build; personal teams cannot use it. - **Apple's CDN**: iOS fetches the file through Apple's content delivery network, and it can take up to 24 hours before a new or changed file is picked up. Apple's alternate developer mode bypasses the CDN during development. - **File details**: named `apple-app-site-association` with no `.json` extension, served over HTTPS from `/.well-known/`. ## When Flutter itself sends the link back The iOS embedding forwards an https link to the framework and asks whether it was handled. The framework offers the route information to every `WidgetsBindingObserver`: a `Router`'s route information provider, such as go_router's, or `WidgetsApp`'s navigator handling. If none of them accepts it, the engine **reopens the URL with the system**, which means Safari. Two related checks: - Is anything in the Dart app able to accept pushed route information, a `Router` or a `WidgetsApp` navigator? - Is the deep-linking flag still enabled? With `FlutterDeepLinkingEnabled` set to `NO`, the engine does not forward the link to Dart at all, so only a plugin can handle it. So an iOS link that briefly opens the app and then lands in Safari points at the Flutter side, not at the association file. On Android, the equivalent symptom is the app opening on its home screen or on the router's error page, because the intent was delivered but the path matched nothing. ## A worked example A ticketing app's event links worked for the QA team but opened the browser for everyone who installed from the store. The QA builds were signed locally with the upload key, whose fingerprint was the only one in `assetlinks.json`. Store installs are re-signed by Play App Signing with Google's key, so Android found no matching fingerprint and left the links to the browser. Adding the app signing key's SHA-256 from the Play Console fixed store installs without breaking QA builds. ## Tools that shorten the search - **DevTools > Deep Links** validates the Android manifest and `assetlinks.json` and, since Flutter 3.27, the iOS entitlements and `apple-app-site-association`, with instructions for each error. - **go_router's `debugLogDiagnostics: true`** prints the location the router received, which settles whether the path reached Dart intact. - **A cold and a warm test**: launch the link with the app killed and again with it in the background, because the two travel different code paths.

  • With a Flutter app on Android, why does adb shell am start opening the event page not prove the App Link works?
    `am start` with an explicit package delivers the intent straight to the app and skips verification, so it launches even if `assetlinks.json` is missing. It proves the manifest filter, Flutter's flag and the router; only a tap on the link from another app exercises verification.
  • With iOS Universal Links, why might a corrected apple-app-site-association file still not work the same day?
    iOS obtains the file through Apple's CDN, which can take up to 24 hours to request a new or changed file from your domain. During development, Apple's alternate developer mode for associated domains bypasses the CDN.
  • In a Flutter iOS app, why can a universal link open the app and then continue in Safari?
    The engine sends the URL to the framework and, if the framework reports it unhandled, reopens it with the system. That happens when no WidgetsBindingObserver, such as a Router's route information provider or WidgetsApp's navigator, accepts the pushed route information.

A verified link is a signed letter of introduction: the app claims to represent the domain, and the domain's well-known file is the countersignature. adb is you walking straight into the office yourself; it proves the office is open, not that the reception desk accepts the letter.

saying these in an interview costs you the question

  • Treats a successful adb am start as proof that App Link verification works.
  • Uses the upload key fingerprint although Play App Signing signs installs.
  • Expects a changed apple-app-site-association file to take effect instantly.
  • Serves assetlinks.json through a redirect to another host.
  • Assumes the Flutter side cannot cause an iOS link to reopen in Safari.