It was late afternoon, with two test phones on the desk, when I noticed it. The app was installed, but tapping a link to my own site from an email opened the browser. Calling Linking.openURL from inside the app landed on exactly the right screen. Only links arriving from outside walked straight past the app.
As an indie developer running several apps at once, this kind of one-directional bug costs me the most time. Because one path still works, there is very little to grab onto when looking for the broken one.
Let me give you the ending first. In my case the location of assetlinks.json was fine and the JSON structure was fine. What was wrong was the fingerprint I had written inside it. I had pasted the fingerprint of the key I build with locally, which is not the key that signs the build users actually receive.
Since Android 12, unverified links do not open your app
Android App Links work by placing the same statement on two sides and matching them: a file on your site saying "this app may handle these URLs", and an intent filter in your app marked with autoVerify.
The part worth committing to memory is the default behavior from Android 12 (API level 31) onward. If verification has not succeeded, the link will not open your app unless the user turns it on by hand in system settings. It goes to the browser instead.
Which means a failed verification produces no error at all. Nothing crashes, no red line appears in the logs, and all you are left with is "I tapped a link and the browser opened." I lost half a day suspecting my own routing code first. When an entry point from outside breaks, I now look at the space between the app and the site before I look inside the app.
There are three steps, and the third is where people get stuck
In a Rork or Expo project, the app side goes in app.json (or app.config.ts).
{
"expo": {
"android": {
"package": "net.example.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{ "scheme": "https", "host": "example.com", "pathPrefix": "/app" }
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}With autoVerify set to true, the device fetches https://example.com/.well-known/assetlinks.json at install time and checks whether your signature's fingerprint is listed there.
The file on the site side looks like this.
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "net.example.myapp",
"sha256_cert_fingerprints": ["your fingerprint here"]
}
}
]The serving requirements are almost boringly simple: HTTPS, no redirects, and a application/json content type. I run my Lab sites on Cloudflare and my reading blogs on WordPress, and while the former serves a static file without complaint, the latter can swallow requests to .well-known through its permalink rules. Opening the URL directly in a browser before you go further will save you an hour.
Then comes the third step, the fingerprint. That is the real subject here.
Paste what keytool prints, exactly as printed
The fingerprint comes from the certificate of your signing key. For a local keystore, keytool will read it. Here is the actual output from a sample keystore I generated.
$ keytool -list -v -keystore upload.jks -storepass changeit
Alias name: upload
Certificate fingerprints:
SHA256: 6E:66:8F:80:8A:86:71:93:42:09:66:DB:3B:7F:AB:AE:4B:52:35:EC:E2:C8:76:EA:D9:F8:C5:9B:E8:E6:51:F7
Signature algorithm name: SHA256withRSATake the string to the right of SHA256: and put it into sha256_cert_fingerprints with the colons intact and the letters uppercase. Counting the one above, it is 95 characters with colons and 64 hex digits without them. There is no need to tidy it by stripping colons or lowercasing. Leaving it untouched is safer.
If you only have openssl at hand, the same value comes out this way.
$ openssl x509 -in cert.pem -noout -fingerprint -sha256
sha256 Fingerprint=AF:B8:01:5B:D8:47:87:2A:59:A5:3F:F1:ED:61:2C:E3:14:D8:EE:AF:1F:BA:68:ED:A8:9A:31:FF:3C:67:7B:8DYour local build and the shipped build carry different signatures
This is where I got stuck. When Play App Signing is enabled, the key you hold is the upload key, while the signature that actually rides on the APK reaching a user's device comes from the app signing key that Play keeps. Two different certificates, so two different fingerprints.
I generated two sample keystores and put their fingerprints side by side.
upload = 6E:66:8F:80:8A:86:71:93:42:09:66:DB:3B:7F:AB:AE:4B:52:35:EC:E2:C8:76:EA:D9:F8:C5:9B:E8:E6:51:F7
appsigning = AE:4E:BC:69:E7:8E:51:0F:DC:BA:C2:38:CC:7A:2E:37:0A:E7:57:2C:5C:11:9E:67:35:AB:04:43:E7:11:07:4B
match = no
That explains the symptom where links open on a locally installed build but not on the one downloaded from the store. The local build is signed with the upload key, so it matches an assetlinks.json that lists only the upload fingerprint. The shipped build carries a different key, so it does not.
The fix is small: list both fingerprints. sha256_cert_fingerprints is an array and takes more than one entry. You will find the app signing key's fingerprint on the app integrity (app signing) page in Play Console.
| Key | Where it is used | Where to read the fingerprint |
|---|---|---|
| Upload key | Local builds, submissions to Play | keytool -list -v on your keystore |
| App signing key | The build users install | Play Console, app signing page |
If the submission itself is being rejected over key mismatch, Fixing the 'Signed With the Wrong Key' Error When Uploading a Rork App to Google Play covers that closer.
Hand the comparison to a script instead of your eyes
Comparing 64 hex digits by eye was beyond me. I keep a short script that reads the file and does the matching mechanically.
import json, re, sys
REL = "delegate_permission/common.handle_all_urls"
FP_RE = re.compile(r"^(?:[0-9A-F]{2}:){31}[0-9A-F]{2}$")
def check(path, package, expected):
problems = []
try:
doc = json.loads(open(path, encoding="utf-8").read())
except json.JSONDecodeError as e:
return [f"not valid JSON: {e}"]
if not isinstance(doc, list):
problems.append("top level is not an array (wrap even a single statement in [])")
doc = [doc]
hit = False
for i, st in enumerate(doc):
tgt = st.get("target", {})
if tgt.get("namespace") != "android_app":
continue
if tgt.get("package_name") != package:
problems.append(f"[{i}] package_name is {tgt.get('package_name')!r}")
continue
hit = True
if REL not in st.get("relation", []):
problems.append(f"[{i}] relation is missing {REL}")
fps = tgt.get("sha256_cert_fingerprints", [])
for fp in fps:
if not FP_RE.match(fp):
problems.append(f"[{i}] fingerprint format looks off: {fp[:20]}...")
# ignore colons and case only when comparing
norm = {fp.replace(":", "").upper() for fp in fps}
if expected.replace(":", "").upper() not in norm:
problems.append(f"[{i}] the key you asked about is not listed")
if not hit:
problems.append(f"no android_app statement targets {package}")
return problems
if __name__ == "__main__":
path, package, expected = sys.argv[1], sys.argv[2], sys.argv[3]
found = check(path, package, expected)
print(f"--- {path}")
print(" nothing found" if not found else "")
for p in found:
print(f" NG: {p}")Here is the real output across four files, each broken in a different way.
--- samples/ok.json
nothing found
--- samples/obj.json
NG: top level is not an array (wrap even a single statement in [])
--- samples/nocolon.json
NG: [0] fingerprint format looks off: 6E668F808A8671934209...
--- samples/badrel.json
NG: [0] relation is missing delegate_permission/common.handle_all_urls
And here is the mismatch itself, reproduced. A file listing only the upload fingerprint, checked against the shipped build's app signing key.
--- samples/uploadonly.json
NG: [0] the key you asked about is not listed
--- samples/both.json
nothing found
The file with both fingerprints passed against the app signing key and against the upload key. Running those two checks before you deploy means a fingerprint will no longer send your users to the browser without saying a word.
One note on intent: the format check is strict about uppercase and colons, while the matching step ignores both. I want what I write into the file to follow the documented example, and at the same time I do not want a formatting difference to hide a real mismatch during comparison.
Ask the device what it decided
Even with a correct file, a link will not open until the device has finished verifying. You can read that state on a real phone.
# check the current verification state
adb shell pm get-app-links net.example.myapp
# ask the device to verify again
adb shell pm verify-app-links --re-verify net.example.myappEach domain in the output carries a state, and verified is the one you want. If it stays none or legacy_failure, either the file is not being served correctly or the fingerprint still does not line up. The device fetches the file once at install time, so after fixing it you need to reinstall the app or run the --re-verify command above.
If you want to think about where the user lands after that link, Landing Users on the Right Screen Right After Install — Deferred Deep Links for Rork Apps approaches the same journey from a different side.
One thing to do next
Open your assetlinks.json and look at whether sha256_cert_fingerprints holds a single entry. If it does, find out whether that one belongs to your local keystore or to the key Play holds. In most cases, adding one more line is the whole fix.
I did not catch it either until I tested the shipped build. If this shortens someone else's detour a little, I am glad.