RizTech Academy logo
RizTech Academy
Permissions and Device FeaturesLesson 4 of 430 min

Intents, sharing and deep links

Kavita sees onion at Lasalgaon close at ₹2,720 and wants her brother, who is loading the truck, to know before he leaves. He does not have Mandi Bhav; he has WhatsApp. And module 10 left a promise: a price-alert notification opens today's price through two string extras that only Mandi Bhav's own notifications can send. This lesson is about the messages Android apps send each other, called intents: Mandi Bhav sending a price to whichever app Kavita chooses, opening a market in whichever maps app the phone has, and accepting a link, from its own notifications or from anywhere else, that opens one price. That last one is where an app first takes input from strangers, and it is treated that way.

Explicit and implicit

An intent names either a component or a job.

Explicit Implicit
Names One class: Intent(context, MainActivity::class.java) An action and data: ACTION_SEND with text, ACTION_VIEW with a geo: Uri
Who handles it That class, in that app Any app whose manifest says it can; the user may choose
Use it for Your own screens, your own notifications Anything another app does better: sharing, maps, dialling, the browser

Implicit intents are why Mandi Bhav needs no map, no WhatsApp integration and no permission for either.

Sharing a price

The detail screen has a "Share" button in its top bar:

fun Context.shareText(text: String, title: String) {
    val send = Intent(Intent.ACTION_SEND)
        .setType("text/plain")
        .putExtra(Intent.EXTRA_TEXT, text)
        .putExtra(Intent.EXTRA_TITLE, title)
    startActivity(Intent.createChooser(send, null))
}

and the text is written for someone without the app, on a phone where the ₹ sign is the only formatting that survives:

Onion at Pune (Gultekdi) APMC, 8 Nov 2026: ₹2,300 a quintal (modal price). Min ₹1,100, max ₹2,850. From Mandi Bhav.

shareText(price) is a pure function with a test, because a wrong date in a shared price is a wrong decision on a truck. On the emulator the share sheet opened titled "Sharing text", previewed exactly that line with a "Copy text" button, and offered Quick Share, Chrome, Drive, Copy to clipboard and Messages.

createChooser is not decoration. It makes Android show its share sheet every time, with the preview and the people and apps Kavita shares with most, rather than leaving the choice of what to show to however the phone is set up. There is no link in the text, and that is deliberate: the next section explains why a Mandi Bhav link would mean nothing in a chat today.

Opening a market on a map

Each market screen has "Show on map", built from the market's coordinates:

fun Context.openOnMap(name: String, at: GeoPoint): Boolean {
    val uri = "geo:${at.latitude},${at.longitude}?q=${at.latitude},${at.longitude}(${Uri.encode(name)})".toUri()
    return try {
        startActivity(Intent(Intent.ACTION_VIEW, uri))
        true
    } catch (e: ActivityNotFoundException) {
        Toast.makeText(this, "No maps app on this phone can show $name.", Toast.LENGTH_LONG).show()
        false
    }
}

q= with the name in brackets puts a labelled pin on the market instead of a bare point. On the emulator:

START u0 {act=android.intent.action.VIEW dat=geo: xflg=0x4 cmp=com.google.android.apps.maps/com.google.android.maps.MapsActivity} …

Google Maps opened on Lasalgaon APMC; Back returned to the market.

The trap: asking first. Older code, and many answers you will find, checks before starting:

if (intent.resolveActivity(packageManager) != null) startActivity(intent)

A scratch build did that, on the same emulator with Google Maps installed:

I M11Map  : resolveActivity=null

and the button did nothing. Since Android 11, an app sees only the apps its manifest declares an interest in, through a <queries> element, and resolveActivity answers from what the app can see. Maps was there; Mandi Bhav could not see it. With this in the scratch build's manifest:

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

the same call returned ComponentInfo{com.google.android.apps.maps/com.google.android.maps.MapsActivity}. Start the intent and catch ActivityNotFoundException; that works on every version and needs no declaration. Use <queries> only when you must know beforehand, to hide a button, say, and declare the narrowest intent that answers it, never the permission to see every app, which Play restricts.

A deep link is a Uri that opens a particular screen in an app. Mandi Bhav has two:

mandibhav://price?market=Lasalgaon%20APMC&commodity=Onion
mandibhav://alerts

There are two kinds of deep link, and the difference decides who can use them:

Custom scheme (mandibhav://) Android App Link (https://… with autoVerify)
Needs An intent filter An intent filter and a website you control, serving /.well-known/assetlinks.json with your app's signing certificate
Who opens it Any app that registers the scheme; Android asks if several do Only your app, verified by Android against the website
Without the app installed Nothing happens It opens the web page instead
In WhatsApp or SMS Often not tappable A normal, tappable link

App Links are what you want for links people share. They need a website, owned by you, that says "this app may open my links", and Mandi Bhav has none: its API is files on GitHub, and GitHub's domain is not ours to vouch for. So Mandi Bhav uses a custom scheme, for its own notifications and for testing, and the share text carries no link. The cost of a custom scheme is that it proves nothing about who receives it, so it must never carry anything private, and the app must treat every link as written by a stranger. Both halves of that were measured.

The manifest filter, on MainActivity:

<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="mandibhav" />
    <data android:host="price" />
    <data android:host="alerts" />
</intent-filter>

DEFAULT is needed for any implicit intent to reach an activity. BROWSABLE lets a link in a web page open it, which is useful and is also why the parsing below trusts nothing. The <data> elements in one filter combine, so this accepts mandibhav://price and mandibhav://alerts and nothing else: mandibhav://settings was refused by Android before the app saw it ("unable to resolve Intent").

Parsing it as untrusted input

navigation/DeepLinks.kt builds and reads the links in one place, in plain Kotlin with java.net.URI, so the tests run on the JVM:

fun parseDeepLink(link: String): NavKey? {
    val uri = try {
        URI(link)
    } catch (e: URISyntaxException) {
        return null
    }
    if (!uri.scheme.equals(DEEP_LINK_SCHEME, ignoreCase = true)) return null
    if (!uri.rawPath.isNullOrEmpty() && uri.rawPath != "/") return null
    return when (uri.host?.lowercase()) {
        PRICE_HOST -> {
            val query = queryOf(uri.rawQuery) ?: return null
            val market = query["market"]?.takeIf(::isSaneName) ?: return null
            val commodity = query["commodity"]?.takeIf(::isSaneName) ?: return null
            LatestPrice(market = market, commodity = commodity)
        }
        ALERTS_HOST -> Alerts
        else -> null
    }
}

private fun isSaneName(name: String): Boolean =
    name.isNotBlank() && name.length <= MAX_NAME_LENGTH && name.none { it.isISOControl() } && name.trim() == name

A name has to be present, at most 80 characters, free of control characters such as a newline, and without stray spaces. A link that fails any of that is not Mandi Bhav's, and the app opens as if launched normally. The encoding uses the String overloads of URLEncoder and URLDecoder, because the Charset overloads arrived only in Android 13 and the app runs from Android 8.

MainActivity reads the link instead of module 10's extras, in the same two places, so a link works whether the app was closed or already open:

if (savedInstanceState == null) linkTarget = intent.linkTarget()
addOnNewIntentListener { newIntent -> newIntent.linkTarget()?.let { linkTarget = it } }

private fun Intent.linkTarget(): NavKey? =
    if (action == Intent.ACTION_VIEW) data?.toString()?.let(::parseDeepLink) else null

and openFromLink (module 10's openFromNotification, renamed) puts the price on the Prices tab over its first screen, so Back goes to today's prices.

The trap: reading it the quick way. A scratch build read the link the way most examples do:

LatestPrice(uri.getQueryParameter("market")!!, uri.getQueryParameter("commodity")!!)

and was sent a link with the commodity missing:

adb shell am start -a android.intent.action.VIEW -d "mandibhav://price?market=Nashik%20APMC" com.riztechacademy.mandibhav.m11
E AndroidRuntime: FATAL EXCEPTION: main
E AndroidRuntime: Process: com.riztechacademy.mandibhav.m11, PID: 16346
E AndroidRuntime: java.lang.NullPointerException
E AndroidRuntime:   at com.riztechacademy.mandibhav.MainActivity.linkTarget(MainActivity.kt:53)

Any web page, any app and any typo in a message can crash an app that does this, every time. The real app, sent the same links one after another while open on the Markets tab, kept the same process throughout (pid 15014):

Link What opened
mandibhav://price?market=Vashi%20APMC,%20Navi%20Mumbai&commodity=Wheat Vashi, 8 Nov 2026, ₹3,010 per quintal
mandibhav://alerts The Alerts tab
mandibhav://price?market=Atlantis%20APMC&commodity=Onion "This price is no longer available."
Market name containing %0A (a newline) Nothing changed; still the Markets tab
Market name 5,000 characters long Nothing changed
market=%zz (a broken escape) Nothing changed
mandibhav://price with no parameters Nothing changed
mandibhav://settings Refused by Android: no app handles it

DeepLinksTest has these cases and a few more, such as a + for a space, a repeated parameter, and a path after the host.

Two apps, one scheme

With the scratch build installed beside the real app, both declare mandibhav://. Sent without naming a package:

adb shell am start -a android.intent.action.VIEW -d "mandibhav://price?market=Lasalgaon%20APMC&commodity=Onion"

Android opened its "Open with" chooser, listing "Mandi Bhav" twice, with "Just once" and "Always". Nothing stops another app, called anything at all, from registering the same scheme. If a link carried a sign-in token or an order number, a lookalike could receive it. That is the reason Mandi Bhav's links carry only a market and a commodity, which anyone can read on the Prices screen, and the reason its notifications use an explicit intent.

Module 10's notifications now build the same link, delivered to MainActivity only:

private fun openInApp(link: String): Intent =
    Intent(Intent.ACTION_VIEW, link.toUri(), context, MainActivity::class.java)

A Uri and a class together: implicit in what it says, explicit in where it goes, so the chooser above never appears for a notification. The extras and their constants are gone, and a tap and a link from anywhere else now take one path through the app.

There is one change in behaviour to know about. Two PendingIntents are the same to Android when their intents match on action, data, type, class and categories; extras are ignored. Module 10's intents differed only in extras, which is why each alert needed its own request code. These now differ in their data whenever the market or commodity differs, but two alerts on the same price (above ₹2,500 and above ₹2,700 for Lasalgaon onion) would share one, so the per-alert request codes stay.

Measured end to end, with an alert for Lasalgaon onion above ₹2,650, the local server serving a new day at ₹2,720 and the worker run from adb: the shade showed "Onion at Lasalgaon APMC: ₹2,720"; with the app open on the Markets tab, a tap gave

START u0 {act=android.intent.action.VIEW dat=mandibhav://price/... xflg=0x4 cmp=com.riztechacademy.mandibhav/.MainActivity} with LAUNCH_SINGLE_TOP … result code=3

delivered to the activity already on top, opening "Lasalgaon APMC, 9 Nov 2026, ₹2,720 per quintal"; Back went to the Prices list. With the app closed, am start with the link launched it cold in 1,098 ms straight onto the price.

Command Tests
adb shell am start -W -a android.intent.action.VIEW -d "<link>" <package> The link, delivered to your app only; -W waits and prints the launch time
The same without <package> What a link from a web page does, including the chooser
adb shell am force-stop <package>, then the link A cold start from a link
adb shell dumpsys package <package> Under "Activity Resolver Table", the filters Android actually registered

Quote the link: an unquoted & ends the shell command there, and the commodity silently goes missing.

Check your work

Why the share text has no link. A custom-scheme link means nothing to someone without the app and is usually not tappable in chats; an App Link, which would work, needs a website Mandi Bhav does not have.

Why resolveActivity is not used before showing the map. Since Android 11 it only sees apps declared in <queries>; on the emulator it returned null with Google Maps installed. Starting the intent and catching ActivityNotFoundException works everywhere.

What a link with the commodity missing did to the quick parser. Crashed the app with a NullPointerException; parseDeepLink returns null and the app opens normally.

Why notifications use an explicit intent even with a deep link. Any app can register mandibhav://; with two installed, Android asked which one to use. Naming MainActivity sends the tap only to Mandi Bhav.

Why the per-alert request codes are kept. PendingIntents compare action, data, type, class and categories but not extras; two alerts on the same market and commodity have the same link.

Practice

  1. Add shareText with its test and the Share button; share a price to Messages on the emulator and read what arrives.
  2. Add "Show on map". Then, in a scratch copy, guard it with resolveActivity, log what it returns, and add and remove the <queries> element above.
  3. Add the intent filter, DeepLinks.kt with its tests, and the change to MainActivity and the notifications. Fire each link in the table above with adb.
  4. Break it on purpose: use getQueryParameter(…)!! in a scratch copy and send a link with one parameter missing. Read the stack trace.
  5. Install a scratch copy with a different applicationIdSuffix and send a link with no package. Choose "Always", then work out how to undo that choice in Settings.
  6. Harder: add mandibhav://market?name=…, opening one market's screen on the Markets tab over its list. Write the parser tests first, including a name that is not a market, and decide what the screen should say for it.

Official documentation

Next: module 12, Meeting XML Layouts.


That is module 11. You can now:

  • tell install-time, runtime and special permissions apart, read them with dumpsys package, and ask for a runtime permission from the button that needs it, with an explanation before the second request;
  • handle refused once, refused twice and dismissed, knowing what shouldShowRequestPermissionRationale says at each step and what it cannot say, and survive a permission revoked while the app is in the background;
  • get a photo with no permission at all, from the photo picker or another app's camera through a FileProvider, keep it safely, and explain when CAMERA and READ_MEDIA_IMAGES are needed and what Play asks of apps that use them;
  • ask for approximate location, get one fix from the framework without Play services, handle location off and no fix, and keep the promise of "once, on this phone";
  • share through the system's share sheet, open other apps with implicit intents, and accept deep links as untrusted input.

Mandi Bhav now sorts the markets by how far Arjun is from them, opens the nearest one in his maps app, keeps Priya's sale slips beside the prices they belong to, sends a price to Kavita's brother on whatever app he uses, and opens today's price from a notification through a real link. Every one of those works with the smallest permission that does the job, and three of them with none. Everything in the app is Compose; module 12, Meeting XML Layouts, turns to the code you will meet on your first day on most existing apps: screens built from XML layouts, Fragments and RecyclerViews, how to read them, and how to add Compose to them without breaking them.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship