Glossary/Implementation artifacts
Intent Filter
Definition
An intent filter is an element in an Android app's manifest that declares which intents an activity can handle, including the URL patterns that should open it.
It is the Android equivalent of registering a URL with the system, and it covers both custom URI schemes and HTTPS App Links — the difference between the two is a handful of attributes in the same block of XML. Most Android deep links that never fire are not broken code; they are an intent filter missing one category, or a <data> element that means something other than what it looks like.
What the declaration looks like
One activity, two filters: an HTTPS filter for App Links and a custom-scheme filter as a fallback for contexts where an HTTPS link cannot reach the app. They are separate filters on purpose — the reason is in the next section.
<activity
android:name=".DeepLinkActivity"
android:exported="true"
android:launchMode="singleTask">
<!-- Android App Links: verified HTTPS URLs -->
<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="example.com"
android:pathPrefix="/products" />
<data android:scheme="https"
android:host="www.example.com"
android:pathPrefix="/products" />
</intent-filter>
<!-- Custom scheme: no verification, no host ownership -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="shopapp" android:host="products" />
</intent-filter>
</activity>android:exported="true" is mandatory from Android 12 onwards for any component with an intent filter. Omit it and the app fails to install with InstallFailedException, which at least announces itself — unlike everything else on this page.
Every element, and what it actually does
| Element | Value | Why it is there |
|---|---|---|
<action> | android.intent.action.VIEW | The generic "display this to the user" intent every link tap sends |
<category> DEFAULT | android.intent.category.DEFAULT | Required to be resolvable by an implicit intent at all |
<category> BROWSABLE | android.intent.category.BROWSABLE | Required to be reachable from a browser. Missing it is the classic "works from adb, dead from Chrome" bug |
<data> | scheme, host, port, path attributes | The URL pattern to match. At minimum a scheme |
android:autoVerify | true | Asks the system to verify domain ownership via assetlinks.json at install time |
android:launchMode | singleTask | Routes the link into the existing task instead of stacking a second copy of the activity |
Never put autoVerify on a custom-scheme filter
autoVerify applies to the whole filter. If a filter contains a shopapp:// scheme alongside HTTPS, verification of the entire filter fails — including the HTTPS hosts in it — because a custom scheme has no domain to verify. Keep verified HTTPS in its own filter, as above. This single mistake accounts for a large share of App Links that verify as legacy_failure.
Path matching, and the cross-product trap
Path attributes are mutually exclusive within one <data> element, and their matching rules are not the glob syntax most people assume.
| Attribute | Matches | API level |
|---|---|---|
android:path | The path exactly: /products/shoes and nothing else | 1 |
android:pathPrefix | Any path starting with the value: /products matches /products/42 | 1 |
android:pathPattern | A limited glob where * means "zero or more of the previous character" and .* means any sequence | 1 |
android:pathSuffix | Any path ending with the value: .pdf | 31 |
android:pathAdvancedPattern | A real regex subset, including [a-z] ranges and + | 31 |
In pathPattern, a literal dot must be escaped twice — once for XML, once for the pattern parser — so matching /orders/anything.json is /orders/.*\\.json. When a pattern refuses to match, this is usually why.
Attributes combine across every <data> element in the filter
Android merges the attributes of all <data> elements in one filter and matches the cross product, not each element as a unit. Two elements — one https://example.com and one myapp://open — declare four combinations, including myapp://example.com and https://open. If you need two distinct URLs matched exactly, use two intent filters.
# Fire the intent a browser would fire
adb shell am start -W -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://example.com/products/42" com.example.shop
# List everything that resolves for a URL, across all apps
adb shell pm query-activities -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://example.com/products/42"
# Dump the filters the system parsed out of your manifest
adb shell dumpsys package com.example.shop | grep -A 30 "Activity Resolver"Include -c android.intent.category.BROWSABLE in every test. Without it, am start succeeds against a filter that a real browser tap would never reach, which is exactly how a missing BROWSABLE category survives testing.
Reading the link in the activity
A matched filter gets you the intent; the URL is on it, and with singleTask it arrives in two different places depending on whether the activity was already running.
class DeepLinkActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
route(intent) // cold start
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // singleTask: keep getIntent() current
route(intent) // already running
}
private fun route(intent: Intent) {
val uri = intent.data ?: return
when (uri.pathSegments.firstOrNull()) {
"products" -> openProduct(uri.pathSegments.getOrNull(1))
"orders" -> openOrder(uri.getQueryParameter("id"))
else -> openHome()
}
}
}Forgetting onNewIntent is the reason a link works from a cold start and silently does nothing when the app is already open — the system delivers it, and nothing reads it.
Deep link debugger
Check whether a domain is actually configured for Android App Links: it fetches /.well-known/assetlinks.json, validates its structure, package name and fingerprints, and reports which step fails. Pair it with the adb commands above — the debugger covers the domain side, adb covers the manifest side.
Frequently asked questions
- Why does my Android deep link work with adb but not from a browser?
- The intent filter is almost certainly missing android.intent.category.BROWSABLE. That category is what marks an activity as safe to launch from web content, so an adb command without it succeeds while every real tap in Chrome, Gmail or a webview falls through to the browser. Add BROWSABLE alongside DEFAULT and retest with -c android.intent.category.BROWSABLE.
- Can one intent filter contain both an HTTPS host and a custom scheme?
- It can, but it should not. Android merges the data attributes across the whole filter and matches every combination of them, which creates URL patterns you never intended. More importantly, android:autoVerify applies to the whole filter, so a custom scheme inside it makes App Links verification fail for the HTTPS hosts too. Use one filter per scheme.
- What is the difference between pathPrefix and pathPattern?
- pathPrefix matches any path beginning with the given string, which covers most routing needs. pathPattern uses a limited glob where an asterisk means zero or more repetitions of the preceding character rather than any sequence, so any-sequence matching requires .* and a literal dot must be written as a doubly escaped sequence. On API 31 and later, pathAdvancedPattern supports a genuine regex subset.
- Does an intent filter need autoVerify to work?
- No. Without autoVerify the link still resolves, but as an unverified web intent — and on Android 12 and later the system sends unverified HTTPS links straight to the browser rather than showing a chooser. autoVerify plus a valid assetlinks.json is what makes an HTTPS URL open your app directly with no disambiguation.
- Why does my deep link only work when the app is closed?
- The activity uses singleTask or singleTop launch mode, so a link arriving while it is already running is delivered to onNewIntent rather than onCreate. If only onCreate reads intent.data, the second link is received and ignored. Override onNewIntent, call setIntent with the new intent so getIntent stays current, and route from there as well.
Related terms
- assetlinks.json — assetlinks.json is a Digital Asset Links statement file hosted at a domain's /.well-known/ path that authorises a named Android app, identified by package name and signing certificate fingerprint, to handle that domain's URLs.
- Android App Link Verification — Android App Link verification is the process by which Android confirms, at install time, that a domain named in an app's intent filter publishes an assetlinks.json file authorising that app to handle its URLs.
- Custom URI Scheme — A custom URI scheme is a non-standard URL protocol, such as myapp://, that an app registers with the operating system so that URLs beginning with it open that app.
- Android App Links not working — Android App Links fail when the system cannot verify that a domain and an app belong to the same owner, at which point tapped links open in the browser instead of the app with no error shown to the user.