fix(notifications): reliably open the tapped message from a new-mail notification #181

Merged
JMR-dev merged 3 commits from fix-157-notification-open-message into main 2026-07-03 00:32:42 +00:00
JMR-dev commented 2026-07-02 23:43:21 +00:00 (Migrated from github.com)

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

## 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)
Sign in to join this conversation.