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 !surfacedandcreatedAtMillis >= 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
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.
< 24h only — pendingCrash filters to createdAtMillis >= now() - 24h, with now injected for deterministic testing (24h boundary covered).
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.
## 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)
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.
What changed
The auto-submit crash prompt over-triggered:
StartupReportViewModel.pendingCrashre-surfaced the newest saved
CRASHreport 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 additivesurfaced: Boolean = falseflag. It ispersisted in the storage JSON only (via
optBoolean("surfaced", false), so olderstored reports read as not surfaced) and deliberately kept out of
toSubmissionPayload()— it is internal bookkeeping, never part of what the userreviews or submits.
ReportStore— addedmarkSurfaced(id)(additive/minimal; no changes toexisting methods, so #242's
purgeOlderThanrebases cleanly). It rewrites thereport with
surfaced = trueand stays a no-op for a missing/already-surfaced id.The report is not deleted — only
delete(id)removes it.StartupReportViewModel— injects anow: () -> Longclock (via a secondary@Injectconstructor, matching theImapClientpattern) so the age gate isunit-testable.
pendingCrashnow only surfaces aCRASHreport that is!surfacedandcreatedAtMillis >= now() - 24h.dismiss(id)persists thesurfaced marker instead of the old in-memory-only hide;
discard(id)still deletes.LibreMailApp— extracted the crash dialog into aninternal StartupCrashPromptcomposable 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
dismiss()callsReportStore.markSurfaced(id), whichpersists
surfaced = true. On the next launch a freshReportStorereads the flagand
pendingCrashfilters the report out, so it is auto-offered at most once. Thereport remains in the store and is still listed under Problem Reports for manual
review; only
discard()deletes it.pendingCrashfilters tocreatedAtMillis >= now() - 24h, withnowinjected for deterministic testing (24h boundary covered).CrashReporter'suncaught-exception handler, so an app update (
killDueToPackageUpdate), aforce-stop, or a user swipe-away/task-removal create no report and never pop
the prompt. Made explicit and test-covered.
Tests
StartupReportViewModelTest(7): fresh <24h unseen crash shown; ≥24h not shown;24h boundary shown; non-
CRASHignored; newest-eligible chosen while skippingsurfaced/stale;
dismisspersists surfaced and the crash does not reappear on asimulated relaunch;
discarddeletes.DebugReportTest(10):surfacedround-trips in storage json, is absent from thesubmission payload, and a legacy report without the key reads as not-surfaced.
ReportStoreTest(7):markSurfacedflags + 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).
StartupCrashPromptTestdrives the realStartupCrashPromptover a real file-backed
ReportStore: a recent crash pops the dialog once and notagain 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, anddetektall pass locally (JDK 21).Closes #255
🤖 Generated with Claude Code