DEV Community

Cover image for Where Did My Screen Go? Reading the Android Back Stack Like a Map
Android Expert
Android Expert

Posted on Originally published at Medium

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

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

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

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().


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

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

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:

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent) // keep getIntent() in sync for recreation
    handleOrderIntent(intent)
}
Enter fullscreen mode Exit fullscreen mode

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:

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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)
}
Enter fullscreen mode Exit fullscreen mode

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

The fix, warm path

Declare the screen so that arriving intents are reusable:

<activity
    android:name=".tracking.TrackingActivity"
    android:launchMode="singleTop"
    android:parentActivityName=".MainActivity"
    android:exported="false" />
Enter fullscreen mode Exit fullscreen mode

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

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,
    )
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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

  • 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

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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):

@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")) }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.


Originally published on Medium.

Top comments (0)