MainActivity.onCreate only parsed the incoming intent (pendingCompose / pendingOpenMessageId) when savedInstanceState == null, on the assumption that a
non-null value always means a config-change recreation (e.g. rotation) — where
Android redelivers the same, already-handled intent, so re-parsing would just
navigate to a duplicate destination on top of what the NavHost already restored.
That assumption is wrong: Android also passes a restored, non-null savedInstanceState when it recreates the Activity after the process was killed
in the background and is then relaunched — e.g. by tapping a notification. In that
case intent is the new tap, not a replay, but the old guard swallowed it exactly
like a rotation: pendingOpenMessageId was never set, so the tap silently landed
wherever the restored back stack was (typically the mailbox), never the message.
That's the regression reported in #157 against the original fix in a6ec00d (#56). pendingCompose (mailto:/share intents) went through the identical guard and had
the same latent bug.
onNewIntent (used when the process is warm and the Activity is reused via FLAG_ACTIVITY_SINGLE_TOP/FLAG_ACTIVITY_CLEAR_TOP) had no such guard and was
already correct — consistent with the bug reading as an intermittent/"used to work"
regression rather than total breakage.
Fix
Replaced the savedInstanceState == null branch with IntentHandledMarker, which
marks the Intent instance itself (via a private boolean extra) the first time
it's parsed, instead of trying to infer "already handled" from savedInstanceState:
A config-change recreation redelivers the very same Intent instance this
Activity already marked → recognized and skipped (no duplicate navigation, no
regression on the case the original guard protected).
A genuinely new intent — a fresh notification tap or mailto:/share intent,
whether delivered warm via onNewIntent or cold via onCreate after a
process-death relaunch — is never marked yet → always (re)parsed, regardless of savedInstanceState.
This dedupes on the intent's own identity rather than an unreliable proxy for it, so
the two recreation paths (config change vs. process-death relaunch) can no longer be
confused for each other. Both onCreate and onNewIntent now funnel through the
same handleIntent() helper, so pendingCompose gets the identical fix.
Testing
Added IntentHandledMarkerTest (instrumented, androidTest), covering the marking
contract directly: unhandled on first look, recognized as handled when the same
instance is redelivered (the config-change case), and still unhandled on a
freshly constructed but content-equal instance (the process-death case).
Not independently verified on-device. The full regression — tap while process
is alive, tap after adb shell am kill, both with app-lock on/off — needs a real
or emulated device exercising the actual Activity recreation lifecycle, which
isn't available in this environment. IntentHandledMarkerTest locks in the core
dedup logic, but there's no ActivityScenario/Hilt-instrumented-Activity test
harness in this repo yet to drive MainActivity itself through a simulated
process-death recreation, so CI's existing suites don't exercise this path
end-to-end either.
## Root cause
`MainActivity.onCreate` only parsed the incoming intent (`pendingCompose` /
`pendingOpenMessageId`) when `savedInstanceState == null`, on the assumption that a
non-null value always means a **config-change recreation** (e.g. rotation) — where
Android redelivers the same, already-handled intent, so re-parsing would just
navigate to a duplicate destination on top of what the NavHost already restored.
That assumption is wrong: Android also passes a restored, non-null
`savedInstanceState` when it recreates the Activity **after the process was killed
in the background and is then relaunched** — e.g. by tapping a notification. In that
case `intent` is the *new* tap, not a replay, but the old guard swallowed it exactly
like a rotation: `pendingOpenMessageId` was never set, so the tap silently landed
wherever the restored back stack was (typically the mailbox), never the message.
That's the regression reported in #157 against the original fix in a6ec00d (#56).
`pendingCompose` (`mailto:`/share intents) went through the identical guard and had
the same latent bug.
`onNewIntent` (used when the process is warm and the Activity is reused via
`FLAG_ACTIVITY_SINGLE_TOP`/`FLAG_ACTIVITY_CLEAR_TOP`) had no such guard and was
already correct — consistent with the bug reading as an intermittent/"used to work"
regression rather than total breakage.
## Fix
Replaced the `savedInstanceState == null` branch with `IntentHandledMarker`, which
marks the **`Intent` instance itself** (via a private boolean extra) the first time
it's parsed, instead of trying to infer "already handled" from `savedInstanceState`:
- A config-change recreation redelivers the very same `Intent` instance this
Activity already marked → recognized and skipped (no duplicate navigation, no
regression on the case the original guard protected).
- A genuinely new intent — a fresh notification tap or `mailto:`/share intent,
whether delivered warm via `onNewIntent` or cold via `onCreate` after a
process-death relaunch — is never marked yet → always (re)parsed, regardless of
`savedInstanceState`.
This dedupes on the intent's own identity rather than an unreliable proxy for it, so
the two recreation paths (config change vs. process-death relaunch) can no longer be
confused for each other. Both `onCreate` and `onNewIntent` now funnel through the
same `handleIntent()` helper, so `pendingCompose` gets the identical fix.
## Testing
- Added `IntentHandledMarkerTest` (instrumented, `androidTest`), covering the marking
contract directly: unhandled on first look, recognized as handled when the *same*
instance is redelivered (the config-change case), and still unhandled on a
freshly constructed but content-equal instance (the process-death case).
- Local gate green: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`,
`detekt`, `compileDebugAndroidTestKotlin` (JDK 21).
- **Not independently verified on-device.** The full regression — tap while process
is alive, tap after `adb shell am kill`, both with app-lock on/off — needs a real
or emulated device exercising the actual Activity recreation lifecycle, which
isn't available in this environment. `IntentHandledMarkerTest` locks in the core
dedup logic, but there's no `ActivityScenario`/Hilt-instrumented-Activity test
harness in this repo yet to drive `MainActivity` itself through a simulated
process-death recreation, so CI's existing suites don't exercise this path
end-to-end either.
Closes #157
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Root cause
MainActivity.onCreateonly parsed the incoming intent (pendingCompose/pendingOpenMessageId) whensavedInstanceState == null, on the assumption that anon-null value always means a config-change recreation (e.g. rotation) — where
Android redelivers the same, already-handled intent, so re-parsing would just
navigate to a duplicate destination on top of what the NavHost already restored.
That assumption is wrong: Android also passes a restored, non-null
savedInstanceStatewhen it recreates the Activity after the process was killedin the background and is then relaunched — e.g. by tapping a notification. In that
case
intentis the new tap, not a replay, but the old guard swallowed it exactlylike a rotation:
pendingOpenMessageIdwas never set, so the tap silently landedwherever the restored back stack was (typically the mailbox), never the message.
That's the regression reported in #157 against the original fix in
a6ec00d(#56).pendingCompose(mailto:/share intents) went through the identical guard and hadthe same latent bug.
onNewIntent(used when the process is warm and the Activity is reused viaFLAG_ACTIVITY_SINGLE_TOP/FLAG_ACTIVITY_CLEAR_TOP) had no such guard and wasalready correct — consistent with the bug reading as an intermittent/"used to work"
regression rather than total breakage.
Fix
Replaced the
savedInstanceState == nullbranch withIntentHandledMarker, whichmarks the
Intentinstance itself (via a private boolean extra) the first timeit's parsed, instead of trying to infer "already handled" from
savedInstanceState:Intentinstance thisActivity already marked → recognized and skipped (no duplicate navigation, no
regression on the case the original guard protected).
mailto:/share intent,whether delivered warm via
onNewIntentor cold viaonCreateafter aprocess-death relaunch — is never marked yet → always (re)parsed, regardless of
savedInstanceState.This dedupes on the intent's own identity rather than an unreliable proxy for it, so
the two recreation paths (config change vs. process-death relaunch) can no longer be
confused for each other. Both
onCreateandonNewIntentnow funnel through thesame
handleIntent()helper, sopendingComposegets the identical fix.Testing
IntentHandledMarkerTest(instrumented,androidTest), covering the markingcontract directly: unhandled on first look, recognized as handled when the same
instance is redelivered (the config-change case), and still unhandled on a
freshly constructed but content-equal instance (the process-death case).
assembleDebug,testDebugUnitTest,lintDebug,ktlintCheck,detekt,compileDebugAndroidTestKotlin(JDK 21).is alive, tap after
adb shell am kill, both with app-lock on/off — needs a realor emulated device exercising the actual Activity recreation lifecycle, which
isn't available in this environment.
IntentHandledMarkerTestlocks in the corededup logic, but there's no
ActivityScenario/Hilt-instrumented-Activity testharness in this repo yet to drive
MainActivityitself through a simulatedprocess-death recreation, so CI's existing suites don't exercise this path
end-to-end either.
Closes #157
🤖 Generated with Claude Code