In this guide
Android Deep Links and App Links: Making URLs Open Your App
You built an e-commerce app for your project. A user shares a product link on WhatsApp. Someone taps it — and the link opens in Chrome, on your mobile website, instead of in your app. The user has your app installed, logged in, with their cart ready. But Android had no idea your app wanted that URL, so it sent the tap to the browser like any other link.
Deep links fix exactly this: they teach Android that certain URLs belong to your app, so tapping one opens the right screen directly — the product page, the shared note, the payment confirmation — with the app's full context. App Links are the stricter, verified version: your website cryptographically vouches for your app, and Android opens your app without even asking.
This guide covers both, the way you would actually implement them in a student project: the intent filter that claims the URL, the Kotlin code that receives it, the verification file that turns a deep link into an App Link, and the adb commands that prove it works before your demo.
What a deep link actually is
On Android, everything that opens something is an intent — a message object describing an action to perform. When a user taps a link, Android creates an intent with the action ACTION_VIEW and the link as its data URI, then asks: which installed app can handle this?
Apps answer that question in advance through intent filters declared in AndroidManifest.xml. An intent filter says: "I handle VIEW actions for https URLs on this host with this path pattern." When the tap happens, Android matches the intent against every installed app's filters:
- No match: the browser handles it (browsers claim all http/https URLs by default).
- One match: your app opens.
- Multiple matches: Android shows the disambiguation dialog — the "Open with" chooser listing your app and the browser.
A deep link is simply a URI your app has claimed this way. The classic form uses a custom scheme like myapp://product/42, but the modern, recommended form uses ordinary https:// URLs — because an https deep link degrades gracefully: if your app isn't installed, the same URL opens your website instead of failing.
Two categories in the intent filter do the heavy lifting:
android.intent.category.DEFAULT— lets the activity receive implicit intents.android.intent.category.BROWSABLE— required for links tapped in a browser. Without it, taps from Chrome never reach your app, which is the single most common reason "my deep link doesn't work."
Deep links vs App Links: the one table that matters
| Deep link | App Link | |
|---|---|---|
| URL form | Custom scheme (myapp://...) or any https URL |
https URL only |
| Verification | None — any app can claim any URL | Your site hosts a file proving the app is yours |
| User experience | Chooser dialog if the browser also claims the URL (always, for https) | Opens directly in your app, no dialog |
| Android 12+ behaviour | Unverified https links open in the browser by default | Verified links open in the app automatically |
| If app not installed | Custom scheme fails; https falls back to the website | Falls back to the website |
The practical rule: use https URLs with App Link verification for anything user-facing (shared links, QR codes, marketing). Use custom schemes only for app-internal callbacks where no website exists — for example, an OAuth redirect back into the app from an identity provider that doesn't support https redirects cleanly.
Note the Android 12 change, because it bites students on demo day: from API 31, the system only auto-opens verified links in your app. An unverified https intent filter still works, but the user gets the chooser (or the browser, depending on their defaults). If your deep link "stopped working" after targeting newer SDKs, missing verification is almost always why.
Step 1: declare the intent filter
In AndroidManifest.xml, add an intent filter to the activity that should receive the link. This example claims all https://shop.example.com/product/... URLs for ProductActivity:
<activity
android:name=".ProductActivity"
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="shop.example.com"
android:pathPrefix="/product" />
</intent-filter>
</activity>
Three details students routinely get wrong:
android:exported="true"is mandatory for any activity with an intent filter on API 31+. The app won't install without it — the error message is cryptic, so memorise this one.android:autoVerify="true"on the intent filter tells Android to verify this as an App Link at install time (covered in Step 3). Without it, you have a plain deep link even if the verification file exists.android:launchMode="singleTask"means a tap while your app is already open reuses the existing activity instead of stacking a second copy. You'll handle the new intent inonNewIntent()(Step 2). Without it, repeated taps pile up duplicate screens and the back button behaves strangely.
Path matching options on the data tag: android:path matches exactly, android:pathPrefix matches a prefix, and android:pathPattern supports simple wildcards (* matches a sequence, .* is the full wildcard). Prefer pathPrefix for sections like /product, /order, /profile.
Step 2: handle the incoming intent in Kotlin
Declaring the filter only gets the tap to your activity. You still have to read the URL and navigate. Handle both entry paths — a cold start (app was closed, intent arrives in onCreate) and a warm relaunch (app was open, intent arrives in onNewIntent):
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_product)
handleDeepLink(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleDeepLink(intent)
}
private fun handleDeepLink(intent: Intent) {
val data: Uri? = intent.data
if (intent.action == Intent.ACTION_VIEW && data != null) {
val productId = data.lastPathSegment
if (productId != null) {
openProductScreen(productId)
} else {
openHomeScreen()
}
}
}
The setIntent(intent) call in onNewIntent matters: without it, a later getIntent() still returns the original intent, and any code reading the intent later sees stale data. This is a classic bug in projects that "work on first tap but not the second."
Parse defensively. Links in the wild are messy — users edit them, QR scanners mangle them, marketing adds tracking parameters. Extract what you need with Uri methods (lastPathSegment, getQueryParameter("ref")), validate it, and fall back to a sensible screen instead of crashing. Never trust a deep link parameter the way you'd trust your own code: validate IDs against your backend before acting on them.
Step 3: turn it into an App Link with Digital Asset Links
Verification is a handshake between your app and your website. Your website publishes a JSON file at a fixed, well-known path; Android fetches it at install time and checks that it names your app's package and signing certificate. If they match, the link is verified.
3a. Get your SHA-256 certificate fingerprint. For a debug build:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android
Copy the SHA256: line. For your release build, use your real keystore — the fingerprint in the file must match the certificate that signed the installed APK. Debug and release fingerprints differ, which is why "it worked in debug but not the release build" is such a common complaint.
3b. Host assetlinks.json. Serve it at exactly https://shop.example.com/.well-known/assetlinks.json, with content type application/json, no redirects, reachable over HTTPS:
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.shop",
"sha256_cert_fingerprints": ["PASTE_YOUR_RELEASE_SHA256_FINGERPRINT_HERE"]
}
}]
Use a clearly-marked placeholder while developing — never paste a real fingerprint into sample code you share, and never commit the file with a production fingerprint to a public repo.
3c. Keep android:autoVerify="true" on the intent filter (from Step 1). At install time, Android fetches the file and verifies. Verification can take ~20 seconds and only runs on install or update — reinstall the app after changing the file.
Check verification status on a connected device:
adb shell pm get-app-links --user 0 com.example.shop
The output lists each host with its state: verified, approved, denied, or ask. If yours says ask or denied, the file wasn't found or didn't match. Force a re-check with:
adb shell pm verify-app-links --re-verify com.example.shop
Then query the status again. The usual culprits: the file 404s (wrong path — it must be /.well-known/assetlinks.json exactly), wrong content type, an HTTP→HTTPS redirect in the way, a fingerprint from the wrong keystore, or testing a debug-signed APK against a file that lists the release fingerprint.
Step 4: test the full flow with adb
Don't test by tapping links in WhatsApp on demo day. Simulate the tap deterministically:
adb shell am start -a android.intent.action.VIEW -d "https://shop.example.com/product/42"
This fires exactly the intent a link tap would fire. Check: does your activity open? Does it show product 42? Now test the relaunch path: open the app normally, press home, fire the adb command again — does onNewIntent route correctly, or do you get a stacked duplicate? Finally, test the fallback: uninstall the app and open the same URL in Chrome — you should land on the product's web page, not an error.
For App Links specifically, test on a device running Android 12 or newer, because that's where the verified/unverified behaviour actually differs. An emulator with a Google APIs image works if you can reach your assetlinks.json over the network.
Jetpack Navigation deep links (the modern shortcut)
If your app uses the Navigation component, you can declare deep links on destinations instead of hand-writing manifest filters for every screen. In the navigation graph:
<fragment
android:id="@+id/productFragment"
android:name="com.example.shop.ProductFragment">
<deepLink app:uri="https://shop.example.com/product/{productId}" />
</fragment>
The Navigation library generates the manifest intent filter for you (including verification support) and parses {productId} into a navigation argument automatically. For links that must work from notifications or widgets — where there is no tap intent — build a PendingIntent with NavDeepLinkBuilder, which constructs the correct back stack so the back button returns to your app's home instead of exiting to the launcher.
This doesn't replace understanding Steps 1–3: the generated filter still needs autoVerify, the assetlinks file, and correct handling. It just removes boilerplate.
What happens when the app isn't installed
This is where https App Links earn their keep over custom schemes. A custom-scheme link (myapp://product/42) on a phone without your app does nothing useful — the browser shows an error. An https App Link opens your website, where you control the experience:
- Show the product page with a prominent "Open in app" / "Get the app" banner.
- Pass the context through the install: after the user installs from the Play Store, read the original URL (via the Play Install Referrer or Firebase Dynamic Links-style deferred deep linking) and land them on the product, not your home screen. Losing the context at install is the difference between a shared link converting and bouncing.
- Keep the web page genuinely useful on its own. Examiners and users both notice when the "website" is just a download nag.
Debugging checklist: when it doesn't work
Work through this in order — it resolves nearly every student deep-link bug:
- Missing BROWSABLE? Taps from Chrome/browsers need it. Taps from adb don't, which is why "it works with adb but not from WhatsApp" points here.
- Missing
exported="true"? Install fails on API 31+. - Wrong host or scheme? The filter's
hostmust match the URL exactly (nowwwmismatch). Remember the filter matches the URL, not your intentions. - Handling only in
onCreate? Second tap while open goes toonNewIntent— handle both. - App Link not verified? Run
pm get-app-linksand check the state; verify the file's path, content type, and fingerprint. - Testing with the wrong signature? Debug APK vs release fingerprint mismatch.
- Link opens the chooser every time? Expected for unverified https links when the browser also claims them — that's the system working as designed, not a bug. Verify (Step 3) to skip the chooser.
- Cached verification? Verification runs at install; after fixing
assetlinks.json, reinstall the app (don't just re-deploy over it) and re-check.
Watch logcat during install for the verification pass — the system's verifier logs exactly which check failed, which is faster than guessing.
Security notes worth knowing
- Any app can declare any deep link. Custom schemes and unverified https filters are claims, not proof — a malicious app could claim your scheme. App Link verification exists precisely to make https URLs un-spoofable. Don't route anything sensitive (password resets, payment confirmations) through unverified links.
- Deep link URIs leak. They appear in browser history, server logs, and sometimes analytics. Never put tokens, session IDs, or personal data in the URL path — use short-lived, single-use codes validated server-side.
- Validate before acting. A crafted link can carry any product ID, user ID, or action. Check server-side that the current user is allowed to see or do what the link requests.
Common mistakes
- Only handling the cold start. Forgetting
onNewIntentbreaks every tap made while the app is already open — the most-tested path in a demo. - Forgetting
setIntent(intent)inonNewIntent, then reading stale data later. - Putting the release fingerprint's file live but testing a debug build (or vice versa).
- Serving
assetlinks.jsonwith a redirect (http→https or www-stripping) — the verifier follows no redirects; it must be served directly. - Using
pathwhen you meantpathPrefix— one product works, the rest 404 inside your own app. - Assuming the chooser is a bug. On unverified links it's the designed behaviour; the fix is verification, not filter tweaking.
- No web fallback. Custom scheme with no website means dead links for everyone without the app.
FAQ
Do I need a website to use deep links?
Not for custom-scheme deep links (myapp://...) — those work with no website at all. But you need an https website for App Links verification, and you should have one anyway for the not-installed fallback.
Can one URL open different screens based on login state?
Yes — route every link through one entry activity, check the session there, and forward to login (remembering the original destination) or straight to the target screen. Don't scatter auth checks across deep-link handlers.
Do deep links work from QR codes?
Yes. A QR code just encodes the URL; the scanner fires a VIEW intent like any other tap. This makes https App Links ideal for posters, packaging, and event check-ins — the same QR works whether or not the app is installed.
Why does Android show my app and Chrome for the same link?
Because your link is unverified and Chrome claims all https URLs. Either verify it as an App Link (recommended) or accept the chooser. There is no manifest trick that hides Chrome from an unverified https URL.
Can I test App Links without a public website?
Verification needs a publicly reachable https URL at install time. For local development, test the routing with plain deep links and adb, and verify against a staging domain (or a free static host serving the JSON with the right content type) before pointing at production.
Does the Play Store listing affect App Links?
No. Verification is purely between your APK's signing certificate and your website's assetlinks.json. The store listing is irrelevant to the handshake.
Limitations
- Verification depends on your website being up and serving the file correctly at install time; a misconfigured server silently downgrades every link to chooser behaviour.
- Users can override per-app link handling in system settings ("Open supported links"), so even verified links aren't guaranteed — respect the user's choice.
- Custom schemes have no fallback and no verification; treat them as internal plumbing, not user-facing links.
- iOS Universal Links are a separate mechanism with their own verification file — this guide covers Android only.
- Enterprise devices with restricted browsers or work profiles may route links through the work browser regardless of your filters.
Where to go from here
- ADB Commands for Debugging Android Apps — the adb testing workflow this guide relies on.
- FCM Push Notifications in Android — combine notification taps with deep links to land users on the right screen.
- MVVM Architecture for Android, Explained — structure the screens your links open.
- How to Publish Your App on the Play Store — release signing, which your App Link fingerprints must match.
- More mobile development in the Android branch hub.