Flutter Android deep links rarely fail for just one reason. The browser might open the app, but the system still shows a chooser. The app might launch, but your route parser lands on the home page instead of the product screen. A staging build might work while the release bundle fails because the signing fingerprint changed after Play App Signing. This draft focuses on one evergreen engineering problem: how to troubleshoot Flutter Android deep links in a way that isolates App Links verification, website association, and in-app routing instead of treating them as one blurry issue.

When to use this workflow

Use this workflow when you already ship a Flutter Android app, control the website domain, and need https:// links to open a specific screen such as a product detail page, campaign page, or message thread. The steps below are aligned with the current Flutter documentation track that reflects Flutter 3.44.0. Android 6 and above can perform automatic verification, and Android 12 and above can also re-run domain verification manually from the command line.

This is not a guide for third-party attribution links or domains you do not own. If marketing links terminate on a provider-managed domain, you can still parse the final URI inside Flutter, but you should not expect Android App Links verification to behave like a first-party domain setup.

For a production codebase, keep deep link ownership visible in one small directory slice:

android/app/src/main/AndroidManifest.xml
lib/app/linking/app_link_handler.dart
web/.well-known/assetlinks.json

If your team already uses flavors, split production and staging hosts before you touch deep links. Mixing every host into one manifest usually makes verification harder to debug. The related environment split is the same discipline described in Flutter Android multi-environment configuration: flavor, dart-define-from-file, and CI checks.

Separate the problem into four layers first

Do not start with the widget tree. A reliable troubleshooting flow breaks the problem into four layers:

  1. Manifest declaration: can Android match the URL pattern to your activity?
  2. Website association: does assetlinks.json match the application ID and signing certificate?
  3. System approval: has Android actually verified the host and assigned your app as the handler?
  4. App routing: after Flutter receives the URI, does your parser send the user to the expected screen?

That ordering matters. If layer 2 is broken, changing Dart code does nothing. If layer 4 is broken, re-uploading assetlinks.json wastes time. Pick one concrete URL and keep it fixed for the entire review, for example:

https://app.example.com/product/42?from=campaign

Using one stable link gives you consistent log output, consistent verification results, and a clear answer about which layer failed.

Keep the Android configuration narrow

The Android App Links configuration should be explicit and narrow. Official Android guidance points out that multiple <data> elements within the same intent filter are merged together. That means a broad filter can accidentally allow combinations you never intended to support.

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask">

    <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:scheme="https"
            android:host="app.example.com"
            android:pathPrefix="/product" />
    </intent-filter>

    <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:scheme="https"
            android:host="app.example.com"
            android:pathPrefix="/campaign" />
    </intent-filter>
</activity>

Three review rules help here. First, put android:autoVerify="true" on the intent filters that represent real website links. Second, if you use multiple subdomains such as www.example.com and app.example.com, treat them as separate verification targets. Third, remember the Android 11-and-lower behavior: if you list multiple hosts and one of them does not verify, the system might not establish the default handler relationship at all.

Once Android passes the URL into your app, the most common Flutter-side bug is duplicate handling. One code path comes from Flutter's built-in deep link support, another comes from a plugin or custom listener, and the result looks like a random race: the home page flashes first, query parameters disappear, or a detail page opens twice.

The safer pattern is to keep one parser and one handoff object:

import 'package:flutter/foundation.dart';

sealed class LinkTarget {
  const LinkTarget();
}

class ProductTarget extends LinkTarget {
  const ProductTarget(this.id, this.source);
  final String id;
  final String? source;
}

class HomeTarget extends LinkTarget {
  const HomeTarget();
}

LinkTarget parseIncomingUri(Uri uri) {
  debugPrint('[deep_link] uri=$uri path=${uri.path} query=${uri.queryParameters}');

  final segments = uri.pathSegments;
  if (segments.length >= 2 && segments.first == 'product') {
    return ProductTarget(segments[1], uri.queryParameters['from']);
  }
  return const HomeTarget();
}

This code does not force a specific router package. The point is that every route decision, fallback, and debug log comes from one function. That gives you a trustworthy test surface. If a link opens the wrong page, you can compare the URI, parser output, and final navigation result instead of guessing which listener handled it.

If your app uses a third-party deep link plugin, Flutter's breaking change guidance for 3.27+ matters. Flutter's default deep linking flag became enabled by default, so plugin-based apps should explicitly turn it off to avoid double dispatch:

<meta-data
    android:name="flutter_deeplinking_enabled"
    android:value="false" />

That is not an instruction to avoid plugins. It is a boundary decision: either let Flutter own the event or let the plugin own it, but do not let both process the same URL.

Publish the correct assetlinks.json

Android App Links depend on Digital Asset Links. The system fetches:

https://your-domain/.well-known/assetlinks.json

and compares the contents with your app's package name and SHA256 certificate fingerprints. Start by generating the fingerprint you plan to use:

keytool -list -v -keystore upload-keystore.jks

Then publish a minimal statement like this:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": [
        "12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF"
      ]
    }
  }
]

Two pitfalls matter more than most teams expect. If you use Play App Signing, the production fingerprint on user devices is usually not the same as the one produced by your local keystore. In that case, use the Play Console App Signing fingerprint for production verification. Also, every subdomain is its own target. If your manifest supports both www.example.com and app.example.com, publish a valid file for each required host.

Validate with commands, tests, and metrics

Do not stop at “the file looks right in the browser.” Move the verification into repeatable command output:

curl -I https://app.example.com/.well-known/assetlinks.json

adb shell pm set-app-links --package com.example.app 0 all
adb shell pm verify-app-links --re-verify com.example.app
adb shell pm get-app-links --user cur com.example.app

adb shell am start -a android.intent.action.VIEW   -c android.intent.category.BROWSABLE   -d "https://app.example.com/product/42?from=campaign"   com.example.app

These commands give you four useful metrics:

  • The website file returns successfully.
  • Domain verification can be reset and re-run on demand.
  • The package shows the expected verified hosts.
  • The app receives the exact URI you tested.

After that, add the scenario to your regression test baseline. At minimum, cover cold start into a detail page, warm start while the app is already in the foreground, and a malformed or incomplete URI that should fall back safely. That fits naturally beside the device regression layers described in Flutter Android testing beyond flutter run: unit, widget, integration, and device checks. If you also want a published companion checklist that already exists on the site, keep this Chinese reference in the same release ticket: published Chinese device checklist.

If you already use Flutter DevTools, run the Deep Links validator before release. It is useful because it checks the project from a structure angle: website association, manifest declaration, and app configuration. It does not replace a device test, but it catches configuration drift earlier.

The most common pitfall patterns

  • The chooser still appears even though the app opens sometimes. That usually means the system has not verified the host, so inspect pm get-app-links before touching your navigation code.
  • Debug builds work but release builds fail. That is often a certificate mismatch, especially after enabling Play App Signing.
  • One host works while another host fails. Treat each subdomain as a separate verification target and review the website association file for each one.
  • The app opens but always lands on home. In most teams that points to an over-permissive parser, path segment assumptions, or duplicate handling between built-in and plugin deep link flows.
  • Android 11 behaves differently from Android 14. This is expected when multiple hosts are declared, because older Android versions are stricter about verification success across all listed hosts.

These are not random edge cases. They are stable failure classes, which means your review can also be stable if you keep the log output, command history, and known test URL together.

Release review checklist

Before publishing a release, I would run the following review:

  1. Confirm that each intent filter represents one deliberate URL pattern.
  2. Confirm that assetlinks.json contains the production package name and the correct SHA256 fingerprint.
  3. Validate that adb shell pm get-app-links --user cur com.example.app shows the intended host as verified.
  4. Test cold start, warm start, and malformed-link fallback on a real device.
  5. Review the Flutter log to confirm the incoming URI, route target, and fallback behavior all come from the same parser.

At that point, deep links stop being a fragile release-day trick and become a maintainable capability with configuration, code, command output, test coverage, and a clean review path. The next sensible progression is not another beginner deep link post, but a focused follow-up on WebView handoff, native plugin bridges, or hybrid Flutter/Android link ownership.