# Where Did My Screen Go? Reading the Android Back Stack Like a Map

Every Android team has a ticket that reads something like: *“Tapping the notification opens a second copy of the screen, and now Back takes me through the same page three times.”* The bug is rarely in your UI code. It lives in the contract between three things most of us configured once, years ago, and never looked at again: the manifest `launchMode`, the flags on the `Intent`, and the `PendingIntent` your notification carries.

This post is a field guide to that contract. We’ll use a fictional delivery app called **Parcelo**, with a `TrackingActivity` that shows where a courier is right now, and we'll follow one notification, "Your courier is two stops away", through every configuration that can go wrong.

## 1\. What a task and back stack actually are

A **task** is a collection of activities the user perceives as one journey. The **back stack** is the ordered list of activities inside that task. The activity at the top is the one on screen, and pressing Back pops it.

Two details matter more than they look:

- A task belongs to the *system*, not to your process. Your process can die while the task, with its stack of activity records, stays in Recents.
- Activities from different apps can share a task. If your screen is launched from a messaging app without special flags, it joins the messaging app’s task.

![task-anatomy](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/fb8fce0b-06a0-49cd-8709-880c2342bb63.png align="center")

Keep that picture in mind. Every launch mode below answers a single question: *when an intent arrives, do I push a new entry, reuse one, or rearrange the stack?*

## 2\. The four launch modes, step by step

## `standard`: always push

This is the default. Each intent creates a new instance, even if an identical screen is already on top.

![launch-standard](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/730dd3d9-0b1b-428f-9f0c-a485bc9c85e4.png align="center")

For most screens this is correct. A product page opened from a product page *should* stack. It becomes a bug the moment an external entry point (notification, widget, shortcut) can fire the same intent repeatedly.

## `singleTop`: reuse only when already on top

If an instance of the activity sits at the top of the target task, the system delivers the intent to it through `onNewIntent()`. Otherwise it behaves like `standard`.

![launch-singletop](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/e7b2750d-acc1-4556-9e20-af4413eb2334.png align="center")

The common misreading is “singleTop means only one instance.” It doesn’t. It means *no back-to-back duplicates*. The third panel is the trap: once the user wanders to a details screen, the same notification creates a second tracker.

## `singleTask`: one instance, trim everything above it

If the activity already exists in the matching task, the system brings that task forward, finishes every activity above it, and calls `onNewIntent()`.

![](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/ef095857-9848-4cca-952e-b394c660bf10.png align="center")

*launch-singletask*

Two consequences deserve respect. First, the finished activities lose their state. If the user was typing in a chat screen above the tracker, that draft is gone. Second, the instance may or may not be at the root of a task depending on affinity (section 6), so don’t assume “single task” means “root of the app.”

On Android 12 and later there is one more behavior to know: pressing Back on a *root launcher* activity moves the task to the background rather than finishing the activity. If your `singleTask` screen happens to be the root, users can return to it with stale state far more often than they used to.

## `singleInstance`: a task of one

The activity lives alone in its own task. Anything it starts is placed in a different task.

![launch-singleinstance](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/61e54b2d-75af-4474-8452-041a57e88fc7.png align="center")

This is almost never right for an ordinary screen. It exists for things like a call-handling UI that must be separate from everything else. Expect a second card in Recents and a Back order that surprises people. If what you want is “one instance per task”, newer platform versions offer `singleInstancePerTask`, which is a different and more flexible tool.

## 3\. `onNewIntent()`: when it fires and what it doesn't do

![onnewintent-flow](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/430da490-9e84-407b-a775-bf435a203dc4.png align="center")

`onNewIntent()` runs when the system routes an intent to an **existing** instance rather than creating one. It runs before `onResume()`, and if the activity was stopped, `onRestart()` and `onStart()` come first.

The failure mode people hit most often: `getIntent()` **does not change.** It still returns the intent that originally created the activity. If you later rotate the device, the recreated activity reads the *old* intent and quietly shows the previous order.

The classic override fixes it with `setIntent`:

```kotlin
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent) // keep getIntent() in sync for recreation
    handleOrderIntent(intent)
}
```

In an `androidx.activity.ComponentActivity` you can also register a listener, which composes better with Compose-era code and with libraries that need to see the same events:

```kotlin
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels

class TrackingActivity : ComponentActivity() {

    private val viewModel: TrackingViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Cold start: only act on a *fresh* creation, not on rotation.
        if (savedInstanceState == null) {
            viewModel.showOrder(intent.orderIdOrNull())
        }

        // Warm path: delivered by singleTop / singleTask / CLEAR_TOP reuse.
        // The listener lives on this activity instance, so it goes away when the
        // instance is destroyed (rotation creates a new instance and a new listener,
        // not an accumulating pile). Unregister manually only if you register from
        // something that outlives the activity.
        addOnNewIntentListener { newIntent ->
            setIntent(newIntent)
            viewModel.showOrder(newIntent.orderIdOrNull())
        }

        setContent { TrackingScreen(viewModel) }
    }

    private fun Intent.orderIdOrNull(): String? = getStringExtra(EXTRA_ORDER_ID)

    companion object {
        const val EXTRA_ORDER_ID = "parcelo.order_id"
    }
}
```

Note the `savedInstanceState == null` guard. Without it, a configuration change replays the launch intent and can reset a screen the user has already moved on from. For process-death safety, keep the "current order" in a `SavedStateHandle` inside the ViewModel rather than re-deriving it from the intent.

## 4\. Intent flags vs. manifest launch modes

![flags-vs-manifest](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/3a0b96cc-08a8-48b2-8b83-d45ab1fa682e.png align="center")

Both mechanisms steer the same system logic, but they differ in who decides:

- A **manifest** `launchMode` is declared by the *target* and applies to every launch of that activity.
- An **intent flag** is chosen by the *caller* for one specific launch.

When they disagree, the caller’s flags generally win for the behaviors they express (`SINGLE_TOP`, `CLEAR_TOP`, `NEW_TASK`), but this is not a blanket override. The system resolves the two together, and some manifest modes are still enforced: an activity declared `singleInstance` cannot be forced into an ordinary shared stack just because a caller omitted or added a flag. That interplay is why an activity declared `singleTop` can still be duplicated by a caller that adds the wrong flag, and why changing the manifest alone sometimes appears to do nothing.

The flags you will actually use:

- `FLAG_ACTIVITY_NEW_TASK` starts the activity in a task matching its affinity, creating the task if needed. It is required when launching from a non-activity `Context`.
- `FLAG_ACTIVITY_SINGLE_TOP` mirrors `singleTop` for this launch.
- `FLAG_ACTIVITY_CLEAR_TOP` pops everything above an existing instance. **On a** `standard` **activity, the existing instance is itself finished and recreated** unless you also add `SINGLE_TOP`. This is the second most common source of "why did my screen reset?"
- `FLAG_ACTIVITY_CLEAR_TASK` (with `NEW_TASK`) wipes the whole task first. Powerful, and destructive.

The two mappings in the graphic marked “close, not identical” are exactly that. `NEW_TASK | CLEAR_TOP` approximates `singleTask`, but affinity and root-of-task rules differ, so treat flags as an adjustment tool and not as a re-implementation.

## 5\. Case study: the notification should open the existing screen

**Scenario.** The user is already watching order 4812 in `TrackingActivity`. A push arrives: *courier is two stops away*. They tap it. We want the same screen, updated. We do not want a second copy.

## The naive version

```kotlin
fun buildPendingIntent(context: Context, orderId: String): PendingIntent {
    val intent = Intent(context, TrackingActivity::class.java)
        .putExtra(TrackingActivity.EXTRA_ORDER_ID, orderId)
    return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
```

Four independent bugs hide in these five lines.

1. **Duplicate screen.** With `standard` mode, each tap pushes another tracker.
2. **Stale extras.** `PendingIntent` identity ignores extras. Two notifications with request code `0` and the same target `Intent` collapse into one `PendingIntent`, and without `FLAG_UPDATE_CURRENT` the *first* extras win. Users tap the second order's notification and see the first order.
3. **Mutability on newer Android.** Targeting Android 12 or later, you must declare `FLAG_IMMUTABLE` or `FLAG_MUTABLE` explicitly. Omitting it throws.
4. **No parent.** If the app was not running, the tracker becomes the root of a fresh task. Back leaves the app instead of going Home.

![notification-case-study](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/3822ed5d-c4e1-45d6-979a-c5b7daefb6b7.png align="center")

## The fix, warm path

Declare the screen so that arriving intents are reusable:

```xml
<activity
    android:name=".tracking.TrackingActivity"
    android:launchMode="singleTop"
    android:parentActivityName=".MainActivity"
    android:exported="false" />
```

Then build the `PendingIntent` with a distinct request code, matching flags, and the update flag:

```kotlin
fun trackingPendingIntent(context: Context, orderId: String): PendingIntent {
    val intent = Intent(context, TrackingActivity::class.java).apply {
        putExtra(TrackingActivity.EXTRA_ORDER_ID, orderId)
        flags = Intent.FLAG_ACTIVITY_NEW_TASK or
                Intent.FLAG_ACTIVITY_CLEAR_TOP or
                Intent.FLAG_ACTIVITY_SINGLE_TOP
    }
    return PendingIntent.getActivity(
        context,
        orderId.hashCode(), // one PendingIntent per order, not one per app
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
    )
}
```

Why this works: `NEW_TASK` finds Parcelo's existing task, `CLEAR_TOP` removes any details or chat screens stacked above the tracker, and `SINGLE_TOP` makes the surviving instance receive `onNewIntent()` instead of being destroyed and rebuilt.

## The fix, cold path

If the process is gone and no task exists, the tracker becomes the root. Give Back somewhere sensible to go:

```kotlin
// Registered in onCreate, before setContent. Dispatcher callbacks run
// last-registered-first, so a Compose BackHandler added later still wins.
onBackPressedDispatcher.addCallback(this) {
    if (isTaskRoot) {
        // Evaluated at press time, not once at registration.
        startActivity(Intent(this@TrackingActivity, MainActivity::class.java))
        finish()
    } else {
        // Not the root: step aside and let default back handling run.
        isEnabled = false
        onBackPressedDispatcher.onBackPressed()
    }
}
```

Checking `isTaskRoot` inside the callback matters: passing it as the `enabled` argument would freeze its value at registration time. The `else` branch disables the callback and re-dispatches, which is safe here because default handling finishes the activity; if you keep the activity alive after Back, re-enable the callback afterwards. Confirm the dispatcher call against the `androidx.activity` version you ship.

## Alternative: rebuild the task

`TaskStackBuilder.addNextIntentWithParentStack(...)` constructs a synthetic Home-then-Tracking stack, which fixes "Back exits the app." Be aware the resulting `PendingIntent` starts the first activity with task-clearing flags, so tapping it **recreates** the task and loses whatever the user was doing. That is correct for "open this order from nowhere" and wrong for "refocus the screen I'm already on." Choose deliberately.

One more platform restriction worth remembering: for apps targeting Android 12 (API 31) or higher, a notification tap can no longer route through a `Service` or `BroadcastReceiver` that then starts an activity (a "notification trampoline"); the start is blocked. Apps targeting older SDKs are not subject to it yet, but you should not build new code on that. The `PendingIntent` should target the activity directly.

## 6\. Deep links and task affinity

**Task affinity** is a string on each activity naming the task it prefers to live in. By default it is your package name, so all your activities share one. A task also takes its affinity from its root activity, which is how the system decides which existing task a new launch belongs to, and that in turn shapes how tasks appear grouped in Recents. Affinity is *actively consulted* mainly when a launch uses `NEW_TASK`, when an activity is `singleTask`, or when `allowTaskReparenting` is set, so those are the cases where a surprising affinity bites hardest.

Deep links make affinity visible, because the *caller* is another app:

![deeplink-affinity](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/5e049110-9c5e-4a73-b961-1ffc83deb4cc.png align="center")

- If the caller doesn’t use `NEW_TASK`, your screen is pushed into the caller's task. Back returns to the caller, and your app has no Home screen underneath. Some users find that natural; others think the app "has no way in."
- If the caller uses `NEW_TASK` and the affinity matches, the system targets your existing task. You then fall back into the launch-mode rules above.

The panels in the graphic are illustrative. Browsers, messengers and launchers choose their own flags, so **test your deep links from at least one browser, one messenger and the launcher**, not only from `adb shell am start`, which behaves differently.

Practical guidance:

- Don’t set a custom `taskAffinity` unless you *want* a separate Recents entry. Accidentally giving one activity a different affinity is a well-known way to create a "ghost" second app card.
- If a deep-linked screen is meant to be a standalone entry point (a share target, say), make sure there is a defined “up” destination. Back and Up don’t have to behave the same, but neither should dump the user in the wrong app.
- Treat `exported="true"` activities as public API: whatever launch mode they have is now a promise to other apps.

## 7\. Single-activity apps and Navigation

In a single-activity architecture the picture changes shape. The system task holds one entry, and the real back stack lives in `NavController`.

![single-activity-nav](https://cdn.hashnode.com/uploads/posts/6ae6fd909af3dc0afb7a2ccf/a7569cec-7d6a-4644-807e-c87671535ad5.png align="center")

This is why launch mode matters *more* for your one activity, not less. Every external entry point funnels into it. With `standard`, a notification tap while the app is open can stack a second `MainActivity`, which creates a second `NavController` and a second copy of your whole UI.

A sound default for a single-activity app:

```xml
<activity
    android:name=".MainActivity"
    android:launchMode="singleTop"
    android:exported="true">
    <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="parcelo.example" android:pathPrefix="/track" />
    </intent-filter>
</activity>
```

Then make sure new intents reach navigation. Recent Navigation Compose releases already forward them for `ComponentActivity` hosts, so in the common case you need no extra code, and adding the listener below **on top of** the built-in behavior risks handling the same intent twice. Use the explicit version only when you own the controller wiring or have confirmed that the version you ship does not forward new intents (check by logging a deep link that arrives while the app is open):

```kotlin
@Composable
fun ParceloApp(activity: ComponentActivity) {
    val navController = rememberNavController()

    DisposableEffect(navController) {
        val listener = Consumer<Intent> { navController.handleDeepLink(it) }
        activity.addOnNewIntentListener(listener)
        onDispose { activity.removeOnNewIntentListener(listener) }
    }

    NavHost(navController, startDestination = "home") {
        composable("home") { HomeScreen() }
        composable(
            route = "tracking/{orderId}",
            deepLinks = listOf(navDeepLink { uriPattern = "https://parcelo.example/track/{orderId}" }),
        ) { entry -> TrackingScreen(entry.arguments?.getString("orderId")) }
    }
}
```

Failure modes specific to this setup:

- **Replay on recreation.** The controller records that it has handled a given deep-link intent, but reusing one intent object in your own code (re-handing it after rotation, say) can still navigate twice. Handle new intents once, from the callback.
- **Nav’s synthetic back stack vs. the activity’s.** `NavDeepLinkBuilder` produces a `PendingIntent` that rebuilds the task with parent destinations, using the same task-clearing machinery as `TaskStackBuilder`. It is great for cold opens and destructive for "refocus the current screen."
- `singleTask` **as a reflex.** It stops duplicates, but it also finishes any other activity above `MainActivity`, such as a camera or picker flow you launched as a separate activity. Prefer `singleTop` and handle navigation explicitly unless you have a concrete reason.

## Checklist you can paste into a code review

1. For every `exported` activity and every `PendingIntent`: *what happens if this fires twice? From a cold process? While the user is on a different screen of the same app?*
2. Is every `PendingIntent` given an explicit mutability flag, a unique request code per payload, and `FLAG_UPDATE_CURRENT` when extras change?
3. Does every activity that can be reused implement `onNewIntent` (or a listener), and does it call `setIntent`?
4. Does `CLEAR_TOP` appear without `SINGLE_TOP` anywhere? If so, is the recreation intentional?
5. Is there a custom `taskAffinity`? Can you name the Recents entry it produces?
6. Have you tested entry points from outside your app, not just from within it?

The back stack stops feeling like a haunted house once you treat each launch as a three-way negotiation between the target’s manifest, the caller’s flags and the existing stack. Draw the stack before and after, and the surprise nearly always shows up on paper first.

## 📘 Master Your Next Technical Interview

Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend **“Mastering Data Structures & Algorithms in Java”**. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.

- **E-book:** [**Available on Google Play**](https://play.google.com/store/books/details?id=biiSEQAAQBAJ)
- **Kindle Edition:** [**Available on Amazon**](https://www.amazon.com/dp/B0FXWL8XMB)
- **Topmate:** [**Available on Topmate**](https://topmate.io/sivavishnu_r/2248718)
- Also available in **Paperback & Hardcover.**

---

*Originally published on* [*Medium*](https://medium.com/@sivavishnu0705/where-did-my-screen-go-reading-the-android-back-stack-like-a-map-5b22b6beb084)*.*
