You add an intent filter for your domain, install the build, tap a link to your own site, and Chrome opens anyway. Nothing crashes and nothing is logged. In almost every case the cause is the same: Android could not verify that your app is allowed to handle links for that domain, and the proof it wanted is a small JSON file called assetlinks.json on your web server.
What makes this hard is that the problem is spread across three places. Part of it lives in your manifest, part on your web host, and part in your signing setup, and no single tool owns all three. This guide goes through each piece in the order I check them, with Google's App Links documentation as the reference for every rule.
Why do Android App Links open in the browser instead of the app?
Android App Links open in the browser when the system has not verified your app for the link's domain. Since Android 12, a web link only opens an app that is approved for that exact domain, and anything else goes to the default browser. Approval normally comes from a valid assetlinks.json file that matches the app's signing certificate.
The reasoning is simple. Anyone can write an intent filter that claims links to yourdomain.com, so a claim in a manifest proves nothing. Android App Links, available since Android 6.0 (API level 23), close that gap with a two-way handshake called Digital Asset Links. The app says it handles a domain, and the domain publishes a statement naming the app's package and signing certificate. When both sides agree, a tap goes directly to your app with no chooser dialog.
The Android 12 behavior changes made the unverified case stricter. From API level 31, a generic web intent resolves to your activity only if the app is approved for that domain, and otherwise it resolves to the user's default browser. The only other route to approval is the user associating your app with the domain by hand in system settings, which you cannot count on.
One thing my original short version of this post got wrong: Android does not fetch the file when the link is tapped. Verification runs ahead of time, the result is stored on the device, and the tap only consults that stored result. That detail explains most "I fixed the file and it still opens Chrome" reports, and it gets its own section below.
The intent filter Android needs to see
Start with the manifest, because verification never begins without it. This is the shape Google's App Links setup guide asks for:
<activity
android:name=".MainActivity"
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:scheme="http" />
<data android:scheme="https" />
<data android:host="www.example.com" />
</intent-filter>
</activity>
Four details matter here:
android:autoVerify="true"is what tells the system to verify the hosts when the app is installed. Leave it off and no check ever happens.- The action and both categories are required:
VIEW,DEFAULTandBROWSABLE. - Keep custom schemes out of this filter. Google's example declares
httpandhttpsand warns that adding other schemes to the same filter prevents verification. Putmyapp://links in a separate filter. - Every host is verified separately.
example.comandwww.example.comare two hosts, and each needs its own file. A wildcard such as*.example.comis verified against the file at the root hostname,example.com.
What does assetlinks.json need to contain, and where does it go?
assetlinks.json is a JSON array naming your app's package and the SHA-256 fingerprint of its signing certificate, under the relation delegate_permission/common.handle_all_urls. It must be served at /.well-known/assetlinks.json on every host in your intent filter, over HTTPS, with content type application/json, and without any redirect.
A minimal file for one app looks like this:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": [
"3F:9A:5C:1E:77:B2:D4:08:6B:E1:42:9D:AC:50:13:F8:27:6E:B9:04:D1:8A:35:C7:60:FE:92:4B:1D:A6:73:0C"
]
}
}]
The hosting rules come from Google's Digital Asset Links configuration page, and each one is a way to fail without seeing an error:
- Exact path.
https://www.example.com/.well-known/assetlinks.json. Not the site root, and not a subfolder. - HTTPS always. The file is fetched over HTTPS even if your intent filter also declares
http. - Content type
application/json. Check the response header instead of assuming your host sets it. - No redirects. A 301 or 302 counts as a failure. Google's troubleshooting page calls out the two classic cases:
httptohttps, andexample.comtowww.example.com. If your apex domain redirects to www, either serve this one path on the apex without redirecting, or list only the host that answers directly. - Publicly reachable. A file that is only visible on a VPN or a staging network cannot be verified.
- Uppercase fingerprint. The same troubleshooting page lists a lowercase signature as an error.
A header check from a terminal catches most of these in seconds:
curl -sI https://www.example.com/.well-known/assetlinks.json
You want a 200 status, a content-type of application/json, and no location header.
Which SHA-256 fingerprint goes in assetlinks.json with Play App Signing?
With Play App Signing, use the SHA-256 fingerprint of the app signing key certificate that Google holds, not the upload key in your local keystore. Google re-signs your app with the app signing key before it reaches users, so that is the certificate Android compares against. Play Console lists the fingerprint on its Play app signing page.
This is the part that costs people an afternoon. Under Play App Signing you hold an upload key and Google holds the app signing key. Running keytool -list -v -keystore my-release-key.keystore prints the fingerprint of whatever is in your keystore, which is the upload key, and Google's docs say that value will usually not match the one on users' devices. A file built from it looks perfect and fails for every user who installs from the store.
The right value is in Play Console. Play Console Help currently gives the path as Protected with Play, then Play Store distribution, then Go to Play app signing, with the fingerprint in the App signing key section. The Android docs still show an older path (Release, Setup, App signing), so expect the menu to move. According to the Android docs, that page also provides a ready-made Digital Asset Links JSON snippet with the correct fingerprint filled in.
The sha256_cert_fingerprints field is an array for a reason. Add your debug certificate's fingerprint next to the Play one and local debug builds verify too. The same two-key split causes other surprises, which I cover in why a release build works locally but fails from Google Play, and comparing both fingerprints is one of the Play Console checks I run before publishing.
How do you test App Links verification with adb?
Install the build on a device running Android 12 or higher, wait at least 20 seconds, then run adb shell pm get-app-links followed by your package name. Each host from your manifest is listed with a state, and verified means links to it will open your app. To force a fresh check, run adb shell pm verify-app-links --re-verify.
The full sequence from Google's verification guide resets the stored state, triggers verification, and prints the result:
adb shell pm set-app-links --package com.example.app 0 all
adb shell pm verify-app-links --re-verify com.example.app
# wait a few minutes for the verification agent, then:
adb shell pm get-app-links com.example.app
The output has one line per host. These are the states you are likely to see:
| State | Meaning |
|---|---|
verified | The host passed. Links to it open your app. |
none | Nothing is recorded yet. Wait, then run the check again. |
legacy_failure | Rejected by the legacy verifier, with no specific reason given. |
1024 or higher | An error code specific to the device's verifier. Check the network connection and re-verify. |
approved or denied | Forced by a shell command, usually your own earlier testing. |
Then fire a real link and see where it lands:
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://www.example.com/some/path"
You can check the web side without a device too. Google's Digital Asset Links API returns the statements it can read for a host:
https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://www.example.com&relation=delegate_permission/common.handle_all_urls
Google also hosts a Statement List Generator and Tester that builds a statement file or tests an existing one. In Play Console, the Deep links page (Grow users, then Deep links) has a Domains table showing the status of every domain in your manifest, and hovering over a status shows which checks failed. Android Studio offers a similar view under Tools, App Links Assistant.
How long does a fixed assetlinks.json take to work?
It depends on the Android version. On Android 14 and lower, an updated file is typically only picked up when the app is installed or updated. Android 15 and higher re-verify domains periodically in the background, and Google says changes can take up to seven days to reach all devices. Reinstalling the app forces a new check.
This follows from verification happening at install time. Correcting the file on your server changes nothing on a phone that already stored a failed result. Google's verification guide adds that server-side caches can delay delivery of the updated file even after a reinstall, and that the delay usually clears if you try again a few hours later.
The Android version also changes how strict the check is. On Android 11 and lower, your app becomes the default handler only if a matching file is found for every host in the manifest, so one broken subdomain breaks all of them. On Android 12 and higher, hosts are verified one by one. Play Console Help notes that fixing a domain does not guarantee that all existing users get working links, and that on versions before Android 12 you need to release a new app version for old installs to start receiving them.
Test with a build that carries the real signature. A build installed from a Play testing track is signed with the app signing key. A build shared through internal app sharing is not: Play Console Help says those are re-signed with a separate internal app sharing key, so they carry a different fingerprint. The closed testing period with 12 testers is a good moment to confirm links on devices you do not own.
Where IOn Emit fits
Nothing above needs more than adb, curl and a text editor. The file lives on your server and the check happens on the device. Where IOn Emit helps is the Play Console half of release day. It is a Windows desktop app for publishing to Google Play, with a 9-step pre-flight wizard that explains each Play Console question, pre-publish validation, and a quick push flow where you drop in the AAB and pick the track and rollout percentage. The publishing suite is free.
The App Links checklist
- The intent filter has
android:autoVerify="true", theVIEWaction, and theDEFAULTandBROWSABLEcategories. - The filter contains only
httpandhttpsschemes and lists every host you want to claim. - Each host serves
/.well-known/assetlinks.jsonover HTTPS with a 200 status, content typeapplication/json, and no redirect. - The package name in the file matches your application ID exactly.
- The fingerprint is the app signing key SHA-256 from Play Console, in uppercase, not the upload key from your keystore.
- The Digital Asset Links API returns your statement for each host.
- On an Android 12 or higher device,
pm get-app-linksshowsverifiedfor every host. - You tested a build installed from a Play testing track, on a fresh install.
Run the list again whenever you add a host or change anything about signing, and keep it next to the rest of your pre-launch checklist.
This post is part of our app publishing guides for indie Android developers.