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

Merged
JMR-dev merged 1 commits from fix-notification-tap-opens-message into main 2026-07-01 21:13:47 +00:00
JMR-dev commented 2026-07-01 20:51:46 +00:00 (Migrated from github.com)

Fixes #56

What

Tapping a new-mail notification now opens that email's reader — warm (app alive, onNewIntent) or cold (fresh launch). Back from the reader lands in the mailbox, same as opening from the list.

Why it was broken

  • MailNotifier.contentIntent() built a bare launch intent with no message id, and every notification (messages + group summary) shared that single PendingIntent.
  • MainActivity only parsed compose (mailto:/share) intents; there was no "open message" path into the NavHost.

How

  • New NotificationIntents owns the deep-link contract: ACTION_OPEN_MESSAGE, the id extra, and a per-message data URI. The URI is load-bearing — PendingIntent identity ignores extras, so without it all messages would collapse onto one FLAG_UPDATE_CURRENT PendingIntent and always open the newest message. Explicit intent → no manifest change, no new exported surface.
  • MailNotifier: per-message notifications use the deep-link intent; the group summary keeps the plain open-the-app intent.
  • MainActivity → LibreMailApp: the parsed id flows as pendingOpenMessageId state into a LaunchedEffect that navigates to Routes.reader(id) — the exact pendingCompose handoff pattern. Fresh-launch-only parsing in onCreate (config-change recreation restores the reader via the NavHost), plus onNewIntent for warm taps.
  • Safe by construction: MailSyncer persists messages before notifying, so the row exists at tap time; a message deleted in the meantime hits the reader's existing error state.

Testing

  • New instrumented NotificationIntentsTest: id round-trip (incl. URI-hostile ids), null for launcher/mailto: intents, and filterEquals distinctness across message ids (the PendingIntent-uniqueness property).
  • Local fast gate green: assembleDebug, testDebugUnitTest, lintDebug, ktlintCheck, detekt, plus compileDebugAndroidTestKotlin; E2E matrix runs here in CI.

🤖 Generated with Claude Code

Fixes #56 ## What Tapping a new-mail notification now opens that email's reader — warm (app alive, `onNewIntent`) or cold (fresh launch). Back from the reader lands in the mailbox, same as opening from the list. ## Why it was broken - `MailNotifier.contentIntent()` built a bare launch intent with no message id, and every notification (messages + group summary) shared that single `PendingIntent`. - `MainActivity` only parsed compose (`mailto:`/share) intents; there was no "open message" path into the NavHost. ## How - **New `NotificationIntents`** owns the deep-link contract: `ACTION_OPEN_MESSAGE`, the id extra, and a per-message `data` URI. The URI is load-bearing — `PendingIntent` identity ignores extras, so without it all messages would collapse onto one `FLAG_UPDATE_CURRENT` `PendingIntent` and always open the newest message. Explicit intent → no manifest change, no new exported surface. - **`MailNotifier`**: per-message notifications use the deep-link intent; the group summary keeps the plain open-the-app intent. - **`MainActivity` → `LibreMailApp`**: the parsed id flows as `pendingOpenMessageId` state into a `LaunchedEffect` that navigates to `Routes.reader(id)` — the exact `pendingCompose` handoff pattern. Fresh-launch-only parsing in `onCreate` (config-change recreation restores the reader via the NavHost), plus `onNewIntent` for warm taps. - Safe by construction: `MailSyncer` persists messages before notifying, so the row exists at tap time; a message deleted in the meantime hits the reader's existing error state. ## Testing - New instrumented `NotificationIntentsTest`: id round-trip (incl. URI-hostile ids), `null` for launcher/`mailto:` intents, and `filterEquals` distinctness across message ids (the PendingIntent-uniqueness property). - Local fast gate green: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`, plus `compileDebugAndroidTestKotlin`; E2E matrix runs here in CI. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.