Intents & Deep Linking Explained

ANDROID › Components

Every Android component talks to another one through an **Intent**, whether that's one Activity starting another, a Service being bound, or a browser handing a URL to your app. Before any of the other rules in this topic make sense, you need the one split everything else builds on: an intent is either explicit or implicit.

An explicit intent names its target directly, by class or ComponentName, so the system delivers it straight there. An implicit intent skips naming a target and instead describes what it wants done: an action, maybe some data, maybe a category. The system then goes looking through every installed app's manifest for a component whose intent filter matches that description.

// Explicit: names the exact component
val explicit = Intent(context, DetailsActivity::class.java)

// Implicit: describes an action, lets Android resolve a handler
val implicit = Intent(Intent.ACTION_SEND).apply {
    type = "text/plain"
    putExtra(Intent.EXTRA_TEXT, "hello")
}

Interviewers ask about this split because it's the fork in the road for everything downstream: filter matching, security rules, and deep linking all only apply on the implicit side.

An intent is really a small bundle of fields, and knowing all of them matters because interview questions probe the edge cases between them. Beyond the action you already met, an intent carries: **data**, a URI plus an optional MIME type; **category**, extra hints about the kind of component that should handle it; **extras**, a key-value bundle of payload; and for explicit intents, the target **component**. It can also carry **flags** that change how the intent is delivered or how the receiving task behaves.

Two of these fields fight each other in a way that catches people out. setData() and setType() each clear the other one when you call them, because a plain Intent can't hold an independently-set URI and MIME type at the same time:

val intent = Intent(Intent.ACTION_VIEW)
intent.setData(Uri.parse("content://com.example/file"))
intent.setType("image/png")   // wipes the data URI that setData() just set

To set both together, call setDataAndType() instead. Flags matter too: if you call startActivity() from a Service or the application Context rather than from an Activity, there is no existing task for the new screen to attach to, and the system throws unless you add Intent.FLAG_ACTIVITY_NEW_TASK to the intent first.

When an intent is implicit, Android runs it through three tests against every component's <intent-filter>, and all three have to pass before that component counts as a match.

The **action test**: the intent's action has to equal one of the actions listed in the filter. The **category test**: every category on the intent has to appear somewhere in the filter, though the filter is free to declare additional categories the intent doesn't carry. The **data test**: the intent's URI scheme, host, and path, along with its MIME type, has to match one of the filter's <data> elements.

The category test hides a classic gotcha. startActivity() automatically stamps CATEGORY_DEFAULT onto whatever implicit intent you hand it, so if an activity's filter never declares CATEGORY_DEFAULT, it will never receive an implicit launch through startActivity(), no matter how well the action matches.

Matching a filter is only half the story. Since Android 11 (API 30), your app can't even see most other apps' components by default, a change called package visibility. Call packageManager.resolveActivity() or queryIntentActivities() for an implicit intent, and you can get back null or an empty list even though a real app on the device could handle it perfectly.

To see a category of components, you declare a <queries> element in your manifest describing the kind of intent you plan to resolve:

<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" />
    </intent>
</queries>

Without a matching <queries> entry, or the broad QUERY_ALL_PACKAGES permission, other apps stay invisible to your process even when they would otherwise match. This is a common cause of bugs that only show up after targeting a newer SDK version.

Two related rules tighten who is allowed to reach a component, and interviewers like to pair them. Since Android 5.0 (API 21), you cannot bindService() or startService() with an **implicit** intent, doing so throws a SecurityException. Services must always be addressed with an explicit intent naming the class:

val intent = Intent(context, SyncService::class.java)
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)

Since Android 12 (API 31), the rule tightens further and spreads beyond services: any activity, service, or broadcast receiver that declares an <intent-filter> must explicitly set android:exported in the manifest. Leaving it out used to silently default to true; now it's a build and install failure, precisely because that implicit default made it too easy to expose a component you never meant to.

A third tightening targets broadcast receivers specifically. Since Android 8.0 (Oreo, API 26), most implicit broadcasts can no longer start a receiver that's only declared in the manifest. Background execution limits cut that path off, because a manifest-declared receiver used to wake your whole process for broadcasts you might not even care about.

The fix is to register the receiver at runtime instead, scoped to a lifecycle:

class MyActivity : AppCompatActivity() {
    private val receiver = MyBroadcastReceiver()

    override fun onStart() {
        super.onStart()
        registerReceiver(receiver, IntentFilter(Intent.ACTION_LOCALE_CHANGED))
    }

    override fun onStop() {
        super.onStop()
        unregisterReceiver(receiver)
    }
}

Explicit broadcasts, and a short allowlist of exempt system actions, still reach manifest-declared receivers. But for most implicit broadcasts, runtime registration is now the only path that works.

A **PendingIntent** wraps an Intent as a token you hand to something outside your process, a notification, AlarmManager, or a launcher shortcut, so that it can fire your intent later with your app's identity and permissions, as if your own code had called it.

val intent = Intent(context, MainActivity::class.java)
val pi = PendingIntent.getActivity(
    context, requestCode, intent,
    PendingIntent.FLAG_IMMUTABLE
)

Since Android 12 (API 31), you must pass either FLAG_IMMUTABLE or FLAG_MUTABLE explicitly, omitting both is no longer tolerated. Default to FLAG_IMMUTABLE for security. Reach for FLAG_MUTABLE only when the receiver genuinely needs to fill in something you can't know ahead of time, like a direct-reply notification action, or an alarm that gets EXTRA_ALARM_COUNT attached by the system.

A PendingIntent isn't identified by the object reference you got back, it's identified by its requestCode plus Intent.filterEquals() on the wrapped intent, which compares action, data, type, category, and component, but ignores extras. Call getActivity() again with the same request code and a filter-equal intent, and with FLAG_UPDATE_CURRENT you get back the *same* token, just with its extras overwritten:

val intent1 = Intent(context, MyActivity::class.java).putExtra("id", 1)
val pi1 = PendingIntent.getActivity(
    context, 42, intent1,
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)

val intent2 = Intent(context, MyActivity::class.java).putExtra("id", 2)
val pi2 = PendingIntent.getActivity(
    context, 42, intent2,
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
// pi1 and pi2 are the same token; firing it now delivers id = 2

That identity rule is also why FLAG_IMMUTABLE matters for security, and why it's paired with an explicit inner intent rather than an implicit one. A mutable PendingIntent lets whoever holds it fill in blank fields on the wrapped intent, including its target component, and retarget it to something you never intended. An explicit inner intent has no component blank to fill in the first place, and FLAG_IMMUTABLE locks the rest.

A **deep link** is any URI, a custom scheme like myapp://item/42, or a plain https link, that an intent filter routes into a specific screen instead of your launcher activity. For a link to be reachable from a browser or another app's implicit VIEW intent, its filter needs three things together: action VIEW, category DEFAULT so it can match an implicit intent at all, and category BROWSABLE so a browser considers it a safe launch target.

<activity android:name=".DeepLinkActivity" android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.___" />
        <data android:scheme="https" android:host="example.com" />
    </intent-filter>
</activity>

Leave out that third category and the filter can still match other implicit VIEW intents your own app fires, but a browser will refuse to launch it from a clicked link.

A deep link filter like the one you just saw will happily fire for both https://example.com/... and, if you also declared it, myapp://.... But https links have a problem: any app can claim to handle example.com, so by default the system shows the user a disambiguation dialog to pick which app opens the link.

**Android App Links** fix that by proving domain ownership. Add android:autoVerify="true" to the http or https filter, and serve a Digital Asset Links file at https://example.com/.well-known/assetlinks.json listing the relation handle_all_urls, your package_name, and your signing certificate's sha256_cert_fingerprints. If verification succeeds, that link opens your app directly with no dialog at all.

That gives you three tiers to tell apart: a **verified App Link** is https with a host that passed asset-link verification. An **unverified web link** is https with a host that was never verified, or failed verification, so the system may still show the chooser or fall back to a browser. A **custom-scheme deep link**, myapp://..., was never eligible for verification in the first place, since Digital Asset Links only cover http and https.

Once your activity launches from a deep link, the triggering URI arrives as the intent's data, so you read it back with intent.data:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val uri: Uri? = intent.data
    val path = uri?.pathSegments             // e.g. ["product", "42"]
    val id = uri?.getQueryParameter("id")   // e.g. "42"
}

That works cleanly the first time an activity is created. But if the activity's launchMode is singleTop, and it's already sitting on top of the task when a second matching link arrives, onCreate does not run again. Instead the system calls onNewIntent(intent) with the new intent, while getIntent() keeps returning the *original* launching intent unless you explicitly call setIntent(intent) inside onNewIntent. Skip that call, and any code elsewhere in the activity that reads getIntent().data still sees the stale, first-launch URI, even though the screen visibly reacted to the new link.

One more piece worth knowing, since it replaces an API you'll still see in older code: getting a result back from a launched activity used to mean startActivityForResult() and an onActivityResult() callback keyed by an integer request code. Both are deprecated in favor of the **Activity Result API**.

val launcher = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult()
) { result: ActivityResult ->
    if (result.resultCode == Activity.RESULT_OK) {
        val data = result.data?.data // the returned Uri, if any
    }
}

// later, e.g. on a button click:
launcher.launch(Intent(this, PickerActivity::class.java))

registerForActivityResult() has to be called unconditionally, before the activity or fragment reaches STARTED, typically as a field initializer or early in onCreate. That's what lets it wire the callback into the lifecycle-aware ActivityResultRegistry instead of you tracking request codes by hand.

Step back and the whole topic is one idea wearing different clothes: intents are Android's messaging layer, and every rule you've just learned exists to control who gets to send one, and to what. Explicit intents remove ambiguity entirely. Implicit intents get resolved through action, category, and data tests, but only against components you're actually allowed to see, and only if the filter declares the categories the system silently adds. Services demand explicit intents. Exported components have to say so on purpose. PendingIntents need a mutability flag and, ideally, an explicit inner intent with no blanks left for a malicious holder to fill in.

If an interviewer asks why Android tightened all of this over time, the answer is the same each time: the old implicit defaults, exported by default, mutable by default, visible to every app by default, made it too easy for a component to be reached or hijacked by something it never opted into. Naming things explicitly, and declaring intent on purpose, is the thread that runs through every one of these rules.

Back to Intents & Deep Linking