Skip to content

Chore: isowntask fixes#3011

Merged
StylianosGakis merged 8 commits into
developfrom
chore/isOwnTask-fixes
Jun 29, 2026
Merged

Chore: isowntask fixes#3011
StylianosGakis merged 8 commits into
developfrom
chore/isOwnTask-fixes

Conversation

@StylianosGakis

Copy link
Copy Markdown
Member

Stop relying purely on isTaskRoot to determine if we are in a deep link to show the up arrow. This also happens when launching from Android Studio or from clicking a notification and then going home and clicking on the launched icon.

More discussion here: https://hedviginsurance.slack.com/archives/C03HT2JRDPG/p1782462903853589


AI-generated PR description


Summary

Members occasionally saw a "deep-link stack" on Home (a back arrow and no bottom nav bar) after launching from the icon and logging in, without doing anything deep-link related. This fixes that, plus a couple of related navigation-robustness issues found along the way.

Root cause

BackstackController.loneDeepLinkChrome chose between the nav bar and the lone-deep-link Up bar partly from isTaskRoot. But isTaskRoot is false in two different situations:

  1. A genuinely foreign-hosted deep link (launched into another app's task) — here we do want the Up/escape affordance.
  2. A non-root second MainActivity stacked in our own task — MainActivity is launchMode="standard", so a launcher relaunch (or a notification that fronts our existing task) can stack a second instance. Here we do not.

Case 2 rendered the back arrow on a normal Home. It only reproduces when the task was first established by a non-launcher entry (notification, deep link, Android Studio) and the user then taps the icon, which is why it was intermittent.

Fix

Decide foreignness from the launch flags, not isTaskRoot:

isHostedInForeignTask(isTaskRoot, launchFlags) =
!isTaskRoot && (launchFlags and FLAG_ACTIVITY_NEW_TASK) == 0

We're foreign-hosted only when we're not the task root and were launched without NEW_TASK (Android's default of joining the caller's task). NEW_TASK being set means the system gave us our own task (fresh or brought-to-front), so launcher relaunches and notification taps (both carry NEW_TASK) are correctly own-task even when not root. The chrome uses isOwnTask = !isHostedInForeignTask(...), refreshed in onCreate and onResume.

Also included

  • isOwnTask is now snapshot-backed (by mutableStateOf) and seeded from isTaskRoot at construction (via NavRetainedViewModel), so the chrome recomposes when it changes and never starts from a guessed default.
  • Time-bounded pendingDeepLink restore (30 min): a link stashed while logged out survives process death for the mid-login window but is dropped on restore if older, so an abandoned pending link can't bleed into an unrelated later login.
  • One per-launch breadcrumb log in MainActivity.onCreate (tag DeepLinkStacktack diagnosis; all other investigation logging removed.

Testing

Unit tests: IsHostedInForeignTaskTest, plus BackstackControllerTest / Navigs (snapshot-backed isOwnTask, pending-timestamp lifecycle, 30-min boundary).Manually verified on-device across the full launch matrix:

┌─────────────────────────────────────────────────┬───────────────────┬──────────────┬──────────┬─────────────────────────┐
│ Launch │ NEW_TASK? │ is result │
├─────────────────────────────────────────────────┼───────────────────┼──────────────┼──────────┼─────────────────────────┤
│ Launcher cold / relaunch (dup) │ yes │ trur ✓ │
├─────────────────────────────────────────────────┼───────────────────┼──────────────┼──────────┼─────────────────────────┤
│ Notification cold / warm │ yes │ trur / lone screen ✓ │
├─────────────────────────────────────────────────┼───────────────────┼──────────────┼──────────┼─────────────────────────┤
│ Foreign deep link (another app) │ no │ fal → escape ✓ │
├─────────────────────────────────────────────────┼───────────────────┼──────────────┼──────────┼─────────────────────────┤
│ Android Studio │ yes │ trur ✓ │
├─────────────────────────────────────────────────┼───────────────────┼──────────────┼──────────┼─────────────────────────┤
│ escape-to-own-task relaunch │ yes (+CLEAR_TASK) │ trur ✓ │
├─────────────────────────────────────────────────┼───────────────────┼──────────────┼──────────┼─────────────────────────┤
│ Config change / Recents / process-death restore │ yes │ trur ✓ │
└─────────────────────────────────────────────────┴───────────────────┴──────────────┴──────────┴─────────────────────────┘

Known limitation (out of scope)

The duplicate MainActivity itself still exists when the task was established by a non-launcher entry (two back stacks; Back from the duplicate's Home reveals the older instance). Pre-existing (launchMode="standard"); the chrome now renders correctly regae would mean launchMode="singleTask"/singleTop, a larger change to deep-linkrouting, the escapeToOwnTask mechanism, and the multiple-back-stack model — raising separately as an "is this WAI?" question first.

The 'Up arrow tracking logs' commit added logcat calls in BackstackController
that run during BackstackControllerTest and DeepLinkNavigationTest, where the
default NoLog logger throws. Install TestLogcatLoggingRule in both so the suite
stays green.
Two defensive fixes for the report of a 'deep-link stack' (Home shown with a
back arrow instead of the nav bar) appearing after a plain icon launch + login,
with no deep-link action by the user.

1. Snapshot-back BackstackController.isOwnTask. The lone-deep-link chrome derives
   the back-arrow-vs-nav-bar decision partly from isTaskRoot, previously read via
   a non-observable () -> Boolean lambda. A normal lone Home then renders as a
   deep-link stack whenever isTaskRoot is false, and could latch that stale
   reading. isOwnTask is now a mutableStateOf-backed Boolean, refreshed from
   isTaskRoot in onCreate and on every onResume, so the chrome recomposes and
   self-corrects.

2. Time-bound the persisted pendingDeepLink. It survives process death (for the
   mid-OTP case) but was never age-bounded, so a link stashed long ago could
   bleed into an unrelated later login as a lone stack. It now carries a stash
   timestamp; on restore a link older than 30 minutes (or with no timestamp, or a
   backwards clock) is dropped.

Adds regression tests for the isOwnTask toggle, the pending-timestamp lifecycle,
and the 30-minute freshness boundary.
Follow-up cleanups on the two defensive fixes:

- Seed BackstackController.isOwnTask from the Activity's isTaskRoot at first
  construction (threaded through NavRetainedViewModel) instead of relying on the
  true default and the onCreate wiring running before any read. onResume remains
  the authoritative refresh; the default stays only for unit tests.
- Collapse the isOwnTask backing field + explicit accessors into a single
  'by mutableStateOf' delegate.
- In NavigationStateBridge.restoreAndPersist, sample the clock once (was twice,
  letting the logged age disagree with the freshness decision) and use takeIf.
- Express the pending-deep-link window as 30.minutes (kotlin.time.Duration), the
  repo idiom, instead of raw millis arithmetic.
Verbose logs are skipped in datadog and firebase, only shown locally
The lone-deep-link chrome was driven by isTaskRoot, which is false both for a
genuinely foreign-hosted deep link (where we want the Up/escape affordance) and
for a non-root second MainActivity stacked in our own task (a launcher relaunch
or a notification that fronted our existing task), where we do not. The latter
rendered a back arrow and no nav bar on a normal Home.

Discriminate on the launch flags instead: FLAG_ACTIVITY_NEW_TASK means the system
placed us in our own task (new or brought-to-front), so we are own-task even when
not its root; only a launch without NEW_TASK joins the caller's task, which with
not-task-root is the one genuinely foreign-hosted case. MainActivity now feeds
isOwnTaskForLaunch(isTaskRoot, intent.flags) into the controller in onCreate and
onResume.

Verified across the full launch matrix (Android Studio, launcher cold/relaunch,
notification cold/warm, foreign deep link, escape-to-own-task, config change,
recents and process-death restore).

Keeps a single per-launch breadcrumb log in onCreate (DeepLinkStackDebug) for
future task/back-stack diagnosis; removes the rest of the investigation logging.
Adds IsOwnTaskForLaunchTest for the discriminator.
Rename isOwnTaskForLaunch -> isHostedInForeignTask and flip it to name the one
exceptional case directly: !isTaskRoot && no NEW_TASK flag (i.e. we joined the
caller's task). Both clauses now read as the facts that define foreignness, which
is clearer than a positive catch-all built from an OR. Call site reads
isOwnTask = !isHostedInForeignTask(...). Behaviour is identical.

Renames the test and the onCreate breadcrumb field to match.
@StylianosGakis StylianosGakis requested a review from a team as a code owner June 29, 2026 14:22
@StylianosGakis StylianosGakis merged commit b4d45f6 into develop Jun 29, 2026
4 checks passed
@StylianosGakis StylianosGakis deleted the chore/isOwnTask-fixes branch June 29, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants