← Back to writing

How I Made Shared Links Open the Right Screen, Even After Installing the App

A student taps "join my class", installs the app, and lands on the home screen. How I replaced scattered link handling with one router for 57 links that survives install and login.

  • Android
  • Navigation
  • Deep links
  • Architecture

The question

The Teachmint app is opened from everywhere: push notifications, links shared in WhatsApp groups, emails, QR codes, and Play Store campaigns. By early 2025, each of those entry points had grown its own handling. One link opened a class, another a test, another the fee screen, each parsed and navigated in a different place.

Two kinds of link didn’t work at all:

  • A link tapped before the app was installed. A teacher shares “join my class”. The student installs the app from Play, opens it, and lands on the home screen with the invitation gone.
  • A link tapped while logged out. The app routes to login, and after login the student is home again, not in the class.

In May 2025 I rebuilt it around one parser and one navigation stream.

My initial mental model

Deep links are an intent-filter problem. Declare the URLs in the manifest, read intent.data in the activity, and navigate. Every new link is a new branch in a when.

That model is how we got per-feature handling. It also has no answer for links that arrive before the app exists, or before there’s a user to navigate for.

What actually happens

A link is really three questions:

  1. What is it? Parse the URL into a typed destination and its arguments.
  2. Can we go there now? Is there a user? Is the right screen ready?
  3. How do we go there? Issue a navigation command the UI can act on exactly once.

Separating those three is the whole architecture:

notification / app link / QR / install referrer
              │
              ▼
   Uri ──► ParsedDeepLink(type: DeepLink, args)        parse: pure, testable
              │
              ▼
   NavigationViewModel                                 decide: user? state?
     ├─ no user  ──► hold as PostLogin(uri) ──► replay after login
     ├─ unknown  ──► fallback screen + message
     └─ ok       ──► NavCommand ──► Channel ──► UI navigates once

Let’s look at the implementation

A sealed type per destination

Every link the app understands is one object in a sealed class, keyed by its path:

sealed class DeepLink(val path: String) {
    object HomeScreen   : DeepLink("homescreen")
    object JoinInstitute: DeepLink("join_institute")
    object CreateTest   : DeepLink("create_test")
    object TFile        : DeepLink("tfile")
    // … 57 in all
}

data class ParsedDeepLink(
    val type: DeepLink,
    val args: List<String>,
    val deepLink: String,
)

Parsing is a lookup from the URL’s first path segment, with the remaining segments as arguments. Adding a link means adding an object and a handler, not threading a new if through an activity. Over the month, around twenty link types moved onto it one by one: classrooms, tests, assessments, live classes, fees, announcements, enrolment, the library, files, feedback and more.

One-shot navigation through a channel

Navigation is an event, not state. If it lives in a StateFlow, a configuration change replays it and the user is navigated twice. Commands go through a buffered Channel, received as a flow, so each command is delivered once:

private val navChannel = Channel<NavCommand>(Channel.BUFFERED)
val navCommands = navChannel.receiveAsFlow()

NavCommand is a small sealed set, covering a destination with arguments, navigate-only-if-not-already-there, back, launch an activity, show a fallback, and hold until login. The UI collects it in one place.

When a link arrives with no signed-in user, the view model stores it as a PostLogin command and sends the user to login. After login, the stored link is replayed through the same path as any other. The student lands in the class they were invited to.

The hardest case is the first one in the list: a link tapped by someone who doesn’t have the app yet.

On Android, the standard way through is the Play Install Referrer API. The link sends the user to the Play Store with a referrer string attached. Play keeps it, and after install the app can read it, once, through InstallReferrerClient. We put the deep link’s path into the campaign parameter. On first launch, the app reads the referrer, rebuilds an ordinary link from it, and feeds it to the same parser as every other link:

// Simplified. The campaign carries the path, e.g. "join_institute/<code>".
val path = referrer.replace("referrer=", "").replace("utm_campaign=", "")
val uri = Uri.parse("https://teachmint.com/$path")

From there it’s a normal link: typed, gated on login, and navigated once. Deferred links didn’t need their own navigation code, only a different source.

Three details were not obvious at first:

  • Organic installs have a referrer too. An install that didn’t come from a campaign still returns a referrer, utm_source=google-play&utm_medium=organic. Early builds treated it like a missing deferred link and showed a toast on organic installs. Organic referrers are now filtered out before parsing.
  • Read it once, and remember that you did. The referrer belongs to the install, not the launch. The app stores the result and whether it was handled, so later launches don’t replay the invitation. The payload, including click and install timestamps, also goes to the backend for attribution.
  • First open is its own case. On a fresh install, the deferred link arrives while the app is still setting itself up for the first time. Opening a shared file from a deferred link failed on that first open, and needed its own fix in the study-material screen.

Practical consequences

  • Firebase Dynamic Links shut down in August 2025. Many apps used it for exactly this install-then-open case. The Install Referrer route only depends on Play itself.
  • Every source becomes a URL. Notifications, QR codes and campaign referrers all reduce to one Uri before anything else runs, so there’s one place to log, test and debug a link.
  • A link has a lifecycle. It can be parsed, held for login, delivered and consumed. Modelling those states explicitly is what makes “after login, take them where they were going” work.

One feature-specific link, opening the AI tutor from a link, was reverted the day after it merged and re-landed later. Deep links touch every feature, so even with one router, shipping a new destination is a release decision, not just a code change.

What I’d change: the navigation view model grew to over 2,000 lines, because “decide where to go” became the place every feature added its routing. The parser and the command stream are the right shape. The decisions per destination belong in small handlers per feature, registered with the router, and each one testable on its own.

What I would remember

  1. Separate parse, decide and navigate. Each answers a different question and changes for different reasons.
  2. Navigation is an event. Send it through a channel, not state, or configuration changes replay it.
  3. A link without a user isn’t an error. Hold it, and replay it after login.
  4. A deferred link is just another source. Rebuild it as a URL and reuse everything.
  5. Filter organic referrers, and read the referrer once.

Further reading