You have configured your Intent Filters in AndroidManifest.xml, hosted your assetlinks.json, and implemented the go_router or uni_links logic in Flutter. Yet, when you tap a link on an Android 12 or 13 device, it bypasses your app entirely and opens directly in Chrome. No "Open with..." dialog, no error logs in Flutter—just the browser.
This behavior is not a bug in your routing code; it is a failure of the OS-level Domain Verification process introduced in Android 12 (API level 31). If the Android Domain Verification Agent cannot validate your site association with 100% certainty, it now defaults to the browser without prompting the user.
Here is the root cause analysis and the rigorous solution to fix App Links verification.
The Root Cause: API 31+ Verification Changes
Prior to Android 12, if verification failed, Android relied on the "disambiguation dialog" (the bottom sheet asking the user to choose between the Browser or the App).
With Android 12+, verified links are binary.
- Verified: The app opens immediately (Zero-click experience).
- Unverified: The browser opens immediately.
The OS runs an asynchronous verification process immediately upon app installation. It fetches the /.well-known/assetlinks.json file from the host declared in your manifest. If this handshake fails for any reason—network timeouts, bad JSON formatting, incorrect Content-Type headers, or a mismatching SHA-256 fingerprint—the intent filter is marked as "unverified."
In a local Flutter development environment, this frequently breaks because your local build is signed with a debug keystore, while your server's assetlinks.json usually lists the production/release keystore SHA-256.
The Fix
We will configure the manifest correctly, set up the server-side validation, and most importantly, use adb to force-verify the links during local development.
1. Strict Manifest Configuration
In your android/app/src/main/AndroidManifest.xml, ensure your intent filter is defined exactly as follows. Note that android:autoVerify="true" must be present on every intent filter that handles HTTP/HTTPS if you want them verified, or consolidate them into one.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.yourapp">
<application
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:label="Your App">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Standard Flutter Launch Intent -->
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!--
APP LINKS CONFIGURATION
1. autoVerify="true" is mandatory.
2. scheme must be https (http is allowed but https is required for verification).
3. host must match your domain exactly.
-->
<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" />
<!-- Handle both www and root domain -->
<data android:scheme="https" android:host="www.example.com" />
<data android:scheme="https" android:host="example.com" />
<!-- Optional: Restrict to specific paths to avoid intercepting all traffic -->
<data android:pathPrefix="/products" />
</intent-filter>
</activity>
</application>
</manifest>
2. Dual-Environment assetlinks.json
To support both local debugging and production, your server's assetlinks.json must contain two entries: one for your release key and one for your local debug key.
Path: https://example.com/.well-known/assetlinks.json Content-Type: application/json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.yourapp",
"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"
]
}
},
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.yourapp",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77"
]
}
}
]
How to get the Debug SHA-256: Run this command in your Flutter project root to get the fingerprint for the key used during flutter run:
cd android
./gradlew signingReport
Look for the SHA-256 under the Variant: debug or Task: :app:signingReport section. Add this fingerprint to your server's JSON file.
3. Validating and Force-Verifying (The "Real" Fix)
Even with the configuration above, the Android emulator or test device often caches previous verification failures. You must manually diagnose and reset the verification state using adb.
Step A: Check Current State Install the app (flutter run), then keep the app running. In your terminal:
adb shell pm get-app-links com.example.yourapp
Output Analysis:
- Legacy/incorrect output:
legacy_failure - Waiting for network:
resource_missorverification_pending - Success:
verified
If you see legacy_failure or undefined for your domain, the OS has rejected the link.
Step B: Force Verification Reset You can invoke the verification agent manually to retry the handshake without reinstalling the app:
# 1. Clear package state
adb shell pm set-app-links --package com.example.yourapp 0 all
# 2. Trigger verification (wait 10-15 seconds after this command)
adb shell pm verify-app-links --re-verify com.example.yourapp
Step C: Manual Override (For Development Only) If network propagation of your assetlinks.json is taking too long (or you are testing against localhost), you can force the OS to accept your app as the handler for that domain locally. This is the most effective way to unblock development.
adb shell pm set-app-links-user-selection --user cur --package com.example.yourapp true "example.com"
Note: Replace example.com with the exact host defined in your manifest.
After running the override command, run adb shell pm get-app-links com.example.yourapp again. You should see: example.com: verified
4. Testing the Intent
Do not test by typing the URL into Chrome's address bar. Chrome often keeps navigation internal if it started there. Test by triggering the intent from the OS level:
adb shell am start \
-W \
-a android.intent.action.VIEW \
-d "https://example.com/products/123" \
com.example.yourapp
If Flutter opens and your routing logic triggers, the pipeline is fixed.
Why This Works
The manual override (set-app-links-user-selection) bypasses the asynchronous network call to your server and directly modifies the system's internal database of verified links (Domain Verification Manager). By manually setting the state to verified, you mimic the result of a successful assetlinks.json handshake.
For production, adding the Debug SHA-256 to your server ensures that when other developers pull your code and run it, the automatic verification process (which occurs on install) succeeds naturally, provided their debug keystore generates the fingerprint listed in your JSON.
Conclusion
Android 12's move to strict verification removes ambiguity but increases configuration complexity. The browser fallback behavior is intentional, not a bug. To ensure consistent deep linking:
- Include
autoVerify="true"in your Manifest. - Ensure your
assetlinks.jsonservesapplication/jsonand includes both Release and Debug fingerprints. - Use
adb shell pm get-app-linksto diagnose the actual OS status rather than guessing. - Use
adb shell pm set-app-links-user-selectionto unblock local development immediately.