fix(reporting): auto-prompt to submit a crash only on first re-open, for a legitimate <24h crash #263

Merged
JMR-dev merged 2 commits from fix-255-crash-prompt-gating into main 2026-07-03 20:47:05 +00:00
JMR-dev commented 2026-07-03 20:18:54 +00:00 (Migrated from github.com)

What changed

The auto-submit crash prompt over-triggered: StartupReportViewModel.pendingCrash
re-surfaced the newest saved CRASH report on every launch with no age bound,
so a pre-update crash kept popping "LibreMail closed unexpectedly" long after the
crash was fixed. This gates the prompt so it fires at most once, only for a
legitimate recent crash.

Production changes (minimal, focused on the three criteria):

  • DebugReport — added an additive surfaced: Boolean = false flag. It is
    persisted in the storage JSON only (via optBoolean("surfaced", false), so older
    stored reports read as not surfaced) and deliberately kept out of
    toSubmissionPayload() — it is internal bookkeeping, never part of what the user
    reviews or submits.
  • ReportStore — added markSurfaced(id) (additive/minimal; no changes to
    existing methods, so #242's purgeOlderThan rebases cleanly). It rewrites the
    report with surfaced = true and stays a no-op for a missing/already-surfaced id.
    The report is not deleted — only delete(id) removes it.
  • StartupReportViewModel — injects a now: () -> Long clock (via a secondary
    @Inject constructor, matching the ImapClient pattern) so the age gate is
    unit-testable. pendingCrash now only surfaces a CRASH report that is
    !surfaced and createdAtMillis >= now() - 24h. dismiss(id) persists the
    surfaced marker instead of the old in-memory-only hide; discard(id) still deletes.
  • LibreMailApp — extracted the crash dialog into an internal StartupCrashPrompt
    composable so the real dialog + gating is E2E-testable; identical behavior. "Review"
    and "Not now" both mark surfaced; "Discard" deletes.

How each acceptance criterion is met

  1. First re-open only — dismiss() calls ReportStore.markSurfaced(id), which
    persists surfaced = true. On the next launch a fresh ReportStore reads the flag
    and pendingCrash filters the report out, so it is auto-offered at most once. The
    report remains in the store and is still listed under Problem Reports for manual
    review; only discard() deletes it.
  2. < 24h only — pendingCrash filters to createdAtMillis >= now() - 24h, with
    now injected for deterministic testing (24h boundary covered).
  3. Legitimate crash only — reports are created solely by CrashReporter's
    uncaught-exception handler, so an app update (killDueToPackageUpdate), a
    force-stop, or a user swipe-away/task-removal create no report and never pop
    the prompt. Made explicit and test-covered.

Tests

  • Unit (all green):
    • StartupReportViewModelTest (7): fresh <24h unseen crash shown; ≥24h not shown;
      24h boundary shown; non-CRASH ignored; newest-eligible chosen while skipping
      surfaced/stale; dismiss persists surfaced and the crash does not reappear on a
      simulated relaunch; discard deletes.
    • DebugReportTest (10): surfaced round-trips in storage json, is absent from the
      submission payload, and a legacy report without the key reads as not-surfaced.
    • ReportStoreTest (7): markSurfaced flags + persists across a fresh instance;
      no-op for a missing report.
    • CrashReporterInstallTest (2): only a genuine uncaught exception creates a report
      — update/force-stop/swipe-away do not (criterion 3).
  • E2E/instrumented: StartupCrashPromptTest drives the real StartupCrashPrompt
    over a real file-backed ReportStore: a recent crash pops the dialog once and not
    again after a simulated relaunch reads the persisted flag; a stale (>24h) crash never
    pops; discard deletes. It compiles and passes ktlint.

Local verification

assembleDebug, testDebugUnitTest, compileDebugAndroidTestKotlin, lintDebug,
ktlintCheck, and detekt all pass locally (JDK 21).

⚠️ The local api36DebugAndroidTest emulator run could not be executed: a stray
Gradle managed-device emulator (dev36_google_apis_x86_64_Pixel_2, a hung
-check-snapshot-loadable probe left over from an earlier run, not registered with
adb) held the emulator and blocked the managed-device boot on this shared machine.
CI's E2E matrix (which includes api36) is the authoritative validation for the new
instrumented test.

Closes #255

🤖 Generated with Claude Code

## What changed The auto-submit crash prompt over-triggered: `StartupReportViewModel.pendingCrash` re-surfaced the newest saved `CRASH` report on **every** launch with no age bound, so a pre-update crash kept popping "LibreMail closed unexpectedly" long after the crash was fixed. This gates the prompt so it fires at most once, only for a legitimate recent crash. Production changes (minimal, focused on the three criteria): - **`DebugReport`** — added an additive `surfaced: Boolean = false` flag. It is persisted in the storage JSON only (via `optBoolean("surfaced", false)`, so older stored reports read as *not surfaced*) and deliberately kept **out** of `toSubmissionPayload()` — it is internal bookkeeping, never part of what the user reviews or submits. - **`ReportStore`** — added `markSurfaced(id)` (additive/minimal; no changes to existing methods, so #242's `purgeOlderThan` rebases cleanly). It rewrites the report with `surfaced = true` and stays a no-op for a missing/already-surfaced id. The report is **not** deleted — only `delete(id)` removes it. - **`StartupReportViewModel`** — injects a `now: () -> Long` clock (via a secondary `@Inject` constructor, matching the `ImapClient` pattern) so the age gate is unit-testable. `pendingCrash` now only surfaces a `CRASH` report that is `!surfaced` **and** `createdAtMillis >= now() - 24h`. `dismiss(id)` persists the surfaced marker instead of the old in-memory-only hide; `discard(id)` still deletes. - **`LibreMailApp`** — extracted the crash dialog into an `internal StartupCrashPrompt` composable so the real dialog + gating is E2E-testable; identical behavior. "Review" and "Not now" both mark surfaced; "Discard" deletes. ## How each acceptance criterion is met 1. **First re-open only** — `dismiss()` calls `ReportStore.markSurfaced(id)`, which persists `surfaced = true`. On the next launch a fresh `ReportStore` reads the flag and `pendingCrash` filters the report out, so it is auto-offered at most once. The report remains in the store and is still listed under Problem Reports for manual review; only `discard()` deletes it. 2. **< 24h only** — `pendingCrash` filters to `createdAtMillis >= now() - 24h`, with `now` injected for deterministic testing (24h boundary covered). 3. **Legitimate crash only** — reports are created solely by `CrashReporter`'s uncaught-exception handler, so an app update (`killDueToPackageUpdate`), a force-stop, or a user swipe-away/task-removal create **no** report and never pop the prompt. Made explicit and test-covered. ## Tests - **Unit (all green):** - `StartupReportViewModelTest` (7): fresh <24h unseen crash shown; ≥24h not shown; 24h boundary shown; non-`CRASH` ignored; newest-eligible chosen while skipping surfaced/stale; `dismiss` persists surfaced and the crash does not reappear on a simulated relaunch; `discard` deletes. - `DebugReportTest` (10): `surfaced` round-trips in storage json, is absent from the submission payload, and a legacy report without the key reads as not-surfaced. - `ReportStoreTest` (7): `markSurfaced` flags + persists across a fresh instance; no-op for a missing report. - `CrashReporterInstallTest` (2): only a genuine uncaught exception creates a report — update/force-stop/swipe-away do not (criterion 3). - **E2E/instrumented:** `StartupCrashPromptTest` drives the real `StartupCrashPrompt` over a real file-backed `ReportStore`: a recent crash pops the dialog once and not again after a simulated relaunch reads the persisted flag; a stale (>24h) crash never pops; discard deletes. It compiles and passes ktlint. ### Local verification `assembleDebug`, `testDebugUnitTest`, `compileDebugAndroidTestKotlin`, `lintDebug`, `ktlintCheck`, and `detekt` all pass locally (JDK 21). > ⚠️ The local `api36DebugAndroidTest` emulator run could **not** be executed: a stray > Gradle managed-device emulator (`dev36_google_apis_x86_64_Pixel_2`, a hung > `-check-snapshot-loadable` probe left over from an earlier run, not registered with > adb) held the emulator and blocked the managed-device boot on this shared machine. > **CI's E2E matrix (which includes api36) is the authoritative validation for the new > instrumented test.** Closes #255 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.