From 6333dd451158bf2dda6c364dda24a3358e2e9dda Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 15:15:10 -0500 Subject: [PATCH] fix(reporting): gate startup crash prompt to a legitimate <24h crash, first re-open only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-submit crash prompt over-triggered: it re-surfaced the newest saved crash report on every launch, with no age bound, so a pre-update crash kept popping "LibreMail crashed" long after the crash was fixed (#255). Gate StartupReportViewModel.pendingCrash so a crash is auto-offered: - first re-open only — dismiss() now persists a "surfaced" marker instead of an in-memory-only hide, so a report is offered at most once across launches; it stays in the store (still listed in Problem Reports) and only discard() deletes. - < 24h only — inject a clock provider and filter to createdAtMillis within 24h. - legitimate crash only — reports come solely from CrashReporter's uncaught- exception handler, so update / force-stop / user-close create none; made explicit and covered by a test. The marker is a minimal additive `surfaced` flag on DebugReport (persisted in storage JSON, kept out of the submission payload; a missing flag = not surfaced) plus ReportStore.markSurfaced(id). Extracted StartupCrashPrompt from LibreMailApp so the real dialog + gating is E2E-testable. Co-Authored-By: Claude Opus 4.8 --- .../ui/reporting/StartupCrashPromptTest.kt | 145 ++++++++++++++++++ .../org/libremail/reporting/DebugReport.kt | 12 +- .../org/libremail/reporting/ReportStore.kt | 14 ++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 23 ++- .../ui/reporting/StartupReportViewModel.kt | 47 ++++-- .../reporting/CrashReporterInstallTest.kt | 23 +++ .../libremail/reporting/DebugReportTest.kt | 28 ++++ .../libremail/reporting/ReportStoreTest.kt | 21 +++ .../reporting/StartupReportViewModelTest.kt | 137 ++++++++++++----- 9 files changed, 387 insertions(+), 63 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/reporting/StartupCrashPromptTest.kt diff --git a/app/src/androidTest/kotlin/org/libremail/ui/reporting/StartupCrashPromptTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/reporting/StartupCrashPromptTest.kt new file mode 100644 index 0000000..5f4413b --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/reporting/StartupCrashPromptTest.kt @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.reporting.DebugReport +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import org.libremail.ui.StartupCrashPrompt +import org.libremail.ui.theme.LibreMailTheme +import java.io.File + +/** + * E2E for the #255 startup-crash-prompt gating, driving the real [StartupCrashPrompt] composable over a + * real file-backed [ReportStore]: a legitimate recent crash pops the dialog exactly once (and never + * again after a simulated relaunch reads the persisted `surfaced` flag), while a stale (> 24h) crash + * never pops it. A fixed clock keeps the age gate independent of the device wall clock. + */ +@RunWith(AndroidJUnit4::class) +class StartupCrashPromptTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val now = 1_000_000_000_000L + private val dayMs = 24L * 60 * 60 * 1000 + private lateinit var dir: File + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + dir = File(context.cacheDir, "startup_crash_prompt_test_${System.nanoTime()}") + dir.deleteRecursively() + dir.mkdirs() + } + + @After + fun tearDown() { + dir.deleteRecursively() + } + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun store() = ReportStore(dir) + + private fun crash(id: String, createdAt: Long) = DebugReport( + id = id, + createdAtMillis = createdAt, + kind = ReportKind.CRASH, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = null, + settings = emptyMap(), + logs = emptyList(), + ) + + private fun viewModel(store: ReportStore) = StartupReportViewModel(store, now = { now }) + + /** Renders the prompt against [vmState]; swapping its value simulates a fresh process on relaunch. */ + private fun render(vmState: MutableState) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + StartupCrashPrompt(viewModel = vmState.value, onReview = {}) + } + } + } + + private fun awaitDialogShown() = composeTestRule.waitUntil(WAIT_MS) { + composeTestRule.onAllNodesWithText(string(R.string.crash_prompt_title)).fetchSemanticsNodes().isNotEmpty() + } + + private fun awaitDialogGone() = composeTestRule.waitUntil(WAIT_MS) { + composeTestRule.onAllNodesWithText(string(R.string.crash_prompt_title)).fetchSemanticsNodes().isEmpty() + } + + @Test + fun recentCrash_popsDialogOnce_andNotAgainOnRelaunch() { + val store = store() + store.save(crash("c", createdAt = now - 60_000L)) + val vmState = mutableStateOf(viewModel(store)) + render(vmState) + + // First re-open after the crash: the prompt is offered. + awaitDialogShown() + composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertIsDisplayed() + + // "Not now" hides it and persistently marks it surfaced (the report itself stays saved). + composeTestRule.onNodeWithText(string(R.string.crash_prompt_later)).performClick() + awaitDialogGone() + assertNotNull(store().find("c")) + + // Relaunch: a fresh store + VM over the same dir reads the persisted flag → no re-nag. + composeTestRule.runOnUiThread { vmState.value = viewModel(store()) } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertDoesNotExist() + } + + @Test + fun staleCrash_doesNotPopDialog() { + val store = store() + store.save(crash("old", createdAt = now - dayMs - 60_000L)) + render(mutableStateOf(viewModel(store))) + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertDoesNotExist() + } + + @Test + fun discard_deletesTheReport() { + val store = store() + store.save(crash("c", createdAt = now - 60_000L)) + render(mutableStateOf(viewModel(store))) + + awaitDialogShown() + composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.crash_prompt_discard)).performClick() + awaitDialogGone() + + assertNull(store().find("c")) + } + + private companion object { + const val WAIT_MS = 5_000L + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt b/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt index 2c782f7..8f12a52 100644 --- a/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt +++ b/app/src/main/kotlin/org/libremail/reporting/DebugReport.kt @@ -31,12 +31,19 @@ data class DebugReport( val userComment: String = "", /** Reply-to address the user supplied when submitting (see #159); required for online submit. */ val userEmail: String = "", + /** + * Whether the startup crash prompt has already auto-offered this report (see #255). Internal + * bookkeeping only: persisted with the report but deliberately kept out of [toSubmissionPayload] + * so it never leaks into what the user reviews or submits. A missing flag (older stored reports) + * reads as `false` — not yet surfaced. + */ + val surfaced: Boolean = false, ) { /** The exact text shown for review, copied, saved to a file, and POSTed on submit. */ fun toSubmissionPayload(): String = toJson().toString(JSON_INDENT) - /** Compact form used for on-disk persistence. */ - fun toStorageJson(): String = toJson().toString() + /** Compact form used for on-disk persistence; adds the internal [surfaced] bookkeeping flag. */ + fun toStorageJson(): String = toJson().put("surfaced", surfaced).toString() private fun toJson(): JSONObject { val app = JSONObject() @@ -92,6 +99,7 @@ data class DebugReport( logs = logs, userComment = json.optString("userComment", ""), userEmail = json.optString("userEmail", ""), + surfaced = json.optBoolean("surfaced", false), ) } } diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt index 247b1ed..ffef53b 100644 --- a/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt +++ b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt @@ -27,6 +27,20 @@ class ReportStore(private val directory: File) { fun find(id: String): DebugReport? = _reports.value.firstOrNull { it.id == id } + /** + * Persistently marks a report as auto-surfaced so the startup crash prompt offers it at most once + * across launches (see #255). The report itself stays in the store (still listed under Problem + * Reports); only [delete] removes it. No-op if the report is missing or already surfaced. + */ + fun markSurfaced(id: String) { + synchronized(lock) { + val report = _reports.value.firstOrNull { it.id == id } ?: return + if (report.surfaced) return + File(directory, fileName(id)).writeText(report.copy(surfaced = true).toStorageJson()) + _reports.value = scan() + } + } + fun delete(id: String) { synchronized(lock) { File(directory, fileName(id)).delete() diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index c81f587..9ee1125 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -69,7 +69,6 @@ fun LibreMailApp( val start = startDestination ?: return val licenseAlreadyAccepted = licenseAccepted ?: return val navController = rememberNavController() - val pendingCrash by startupViewModel.pendingCrash.collectAsStateWithLifecycle() // A mailto:/share intent opens compose on top of the mailbox, pre-filled. Keyed on the request so // it fires once per intent (and again for a new intent delivered while the app is alive). @@ -256,14 +255,28 @@ fun LibreMailApp( } // On launch, offer any saved crash report for review — never sent without the user's action. + StartupCrashPrompt( + viewModel = startupViewModel, + onReview = { reportId -> navController.navigate(Routes.reportReview(reportId)) }, + ) +} + +/** + * Offers any pending crash report for review on launch (see #255). [StartupReportViewModel] gates this + * to a legitimate crash from the last 24h, shown at most once; this only renders its decision. "Review" + * and "Not now" both mark the report surfaced so it never re-nags; only "Discard" deletes it. + */ +@Composable +internal fun StartupCrashPrompt(viewModel: StartupReportViewModel, onReview: (String) -> Unit) { + val pendingCrash by viewModel.pendingCrash.collectAsStateWithLifecycle() pendingCrash?.let { crash -> CrashReportDialog( onReview = { - startupViewModel.dismiss() - navController.navigate(Routes.reportReview(crash.id)) + viewModel.dismiss(crash.id) + onReview(crash.id) }, - onLater = startupViewModel::dismiss, - onDiscard = { startupViewModel.discard(crash.id) }, + onLater = { viewModel.dismiss(crash.id) }, + onDiscard = { viewModel.discard(crash.id) }, ) } } diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt b/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt index 89cfdec..b70c1ac 100644 --- a/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/reporting/StartupReportViewModel.kt @@ -4,43 +4,58 @@ package org.libremail.ui.reporting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.libremail.reporting.ReportKind import org.libremail.reporting.ReportStore import javax.inject.Inject -/** Surfaces a pending crash report (if any) so the app can offer it for review on launch. */ +/** + * Surfaces a pending crash report (if any) so the app can offer it for review on launch. The prompt is + * gated (see #255) so it fires at most once, only for a legitimate recent crash: + * + * - **First re-open only:** a report is auto-offered once, then persistently marked surfaced; it never + * re-nags on later launches. It stays in the store (still listed under Problem Reports for manual + * review); only [discard] deletes it. + * - **< 24h only:** older crashes are never auto-surfaced (they may still be reviewed manually). + * - **Legitimate crash only:** only `CrashReporter`'s uncaught-exception handler ever creates a + * [ReportKind.CRASH] report, so an app update, a user-initiated close, or a force-stop create no + * report and therefore never pop this prompt. + * + * @param now clock provider, injected so the age gate is unit-testable. + */ @HiltViewModel -class StartupReportViewModel @Inject constructor(private val store: ReportStore) : ViewModel() { +class StartupReportViewModel(private val store: ReportStore, private val now: () -> Long) : ViewModel() { - private val dismissed = MutableStateFlow(false) + @Inject + constructor(store: ReportStore) : this(store, { System.currentTimeMillis() }) val pendingCrash: StateFlow = - combine(store.reports, dismissed) { reports, isDismissed -> - if (isDismissed) { - null - } else { - reports.firstOrNull { it.kind == ReportKind.CRASH } - ?.let { ReportSummary(it.id, it.kind, it.createdAtMillis) } - } + store.reports.map { reports -> + reports.firstOrNull { report -> + report.kind == ReportKind.CRASH && + !report.surfaced && + report.createdAtMillis >= now() - CRASH_MAX_AGE_MS + }?.let { ReportSummary(it.id, it.kind, it.createdAtMillis) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), null) - /** Hides the prompt for this launch; the report stays saved and is offered again next launch. */ - fun dismiss() { - dismissed.value = true + /** + * "Not now" / "Review": persistently marks the crash surfaced so it is auto-offered at most once + * across launches. The report stays saved (still shown in Problem Reports); only [discard] deletes. + */ + fun dismiss(id: String) { + viewModelScope.launch { store.markSurfaced(id) } } fun discard(id: String) { - dismissed.value = true viewModelScope.launch { store.delete(id) } } private companion object { const val SUBSCRIBE_MS = 5_000L + const val CRASH_MAX_AGE_MS = 24L * 60 * 60 * 1000 } } diff --git a/app/src/test/kotlin/org/libremail/reporting/CrashReporterInstallTest.kt b/app/src/test/kotlin/org/libremail/reporting/CrashReporterInstallTest.kt index 32354a7..d72c745 100644 --- a/app/src/test/kotlin/org/libremail/reporting/CrashReporterInstallTest.kt +++ b/app/src/test/kotlin/org/libremail/reporting/CrashReporterInstallTest.kt @@ -11,6 +11,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import org.libremail.data.settings.SettingsRepository import kotlin.test.assertEquals +import kotlin.test.assertTrue /** * Covers [CrashReporter.install]: the installed handler must persist the crash locally AND still chain @@ -63,4 +64,26 @@ class CrashReporterInstallTest { // ...and the OS's original handler still ran, so the system crash still surfaces. verify { previous.uncaughtException(thread, crash) } } + + @Test + fun `only a genuine uncaught exception creates a crash report - update, force-stop, swipe-away do not`() { + Thread.setDefaultUncaughtExceptionHandler(mockk(relaxed = true)) + val store = ReportStore(tempFolder.root) + val buffer = RingLogBuffer() + val collector = DiagnosticsCollector(appVersion, settingsRepository, buffer) + val reporter = CrashReporter(collector, store, buffer) + reporter.install() + val installed = requireNotNull(Thread.getDefaultUncaughtExceptionHandler()) + + // An app update (killDueToPackageUpdate), a force-stop, and a user swipe-away/task-removal all + // end the process WITHOUT delivering an uncaught throwable to this handler, so none of them + // creates a report and the startup prompt stays silent (#255 criterion 3). Reports come solely + // from a genuine uncaught crash routing through the installed handler. + assertTrue(store.reports.value.isEmpty()) + + installed.uncaughtException(Thread.currentThread(), IllegalStateException("real crash")) + + assertEquals(1, store.reports.value.size) + assertEquals(ReportKind.CRASH, store.reports.value.single().kind) + } } diff --git a/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt b/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt index 4cf670b..0ea96a3 100644 --- a/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt +++ b/app/src/test/kotlin/org/libremail/reporting/DebugReportTest.kt @@ -1,8 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.reporting +import org.json.JSONObject import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -93,4 +95,30 @@ class DebugReportTest { assertTrue(payload.contains("reporter@example.com")) } + + @Test + fun `surfaced flag round-trips through storage json`() { + val original = sample().copy(surfaced = true) + + val restored = DebugReport.fromStorageJson(original.toStorageJson()) + + assertTrue(restored.surfaced) + assertEquals(original, restored) + } + + @Test + fun `a legacy stored report without the surfaced flag reads as not surfaced`() { + val legacy = JSONObject(sample().toStorageJson()).apply { remove("surfaced") }.toString() + + val restored = DebugReport.fromStorageJson(legacy) + + assertFalse(restored.surfaced) + } + + @Test + fun `the internal surfaced flag never appears in the submission payload`() { + val payload = sample().copy(surfaced = true).toSubmissionPayload() + + assertFalse(payload.contains("surfaced")) + } } diff --git a/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt index 02e7072..eda881e 100644 --- a/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt +++ b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt @@ -69,6 +69,27 @@ class ReportStoreTest { assertEquals("persisted", reopened.find("persisted")?.id) } + @Test + fun `markSurfaced flags the report and persists across a fresh instance`() { + val store = ReportStore(tempFolder.root) + store.save(report("a")) + + store.markSurfaced("a") + + assertTrue(store.find("a")!!.surfaced) + // Survives a fresh instance over the same directory (the next launch reads it as surfaced). + assertTrue(ReportStore(tempFolder.root).find("a")!!.surfaced) + } + + @Test + fun `markSurfaced is a no-op for a missing report`() { + val store = ReportStore(tempFolder.root) + + store.markSurfaced("missing") + + assertTrue(store.reports.value.isEmpty()) + } + @Test fun `ignores unparseable files`() { File(tempFolder.root, "garbage.json").writeText("not json at all") diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt index e10e73b..83f6901 100644 --- a/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt @@ -1,15 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.reporting -import io.mockk.Runs -import io.mockk.every -import io.mockk.just -import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -18,25 +13,41 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.libremail.reporting.DebugReport import org.libremail.reporting.ReportKind import org.libremail.reporting.ReportStore import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue +/** + * Verifies the #255 gating: the startup crash prompt surfaces a [ReportKind.CRASH] report only when it + * is fresh (< 24h), unseen (first re-open only, persisted across relaunches), and a real crash. Uses a + * real file-backed [ReportStore] over a temp dir so the persisted `surfaced` flag round-trips exactly + * as it would across a process restart, and a fixed clock so the age gate is deterministic. + */ @OptIn(ExperimentalCoroutinesApi::class) class StartupReportViewModelTest { + @get:Rule + val tempFolder = TemporaryFolder() + private val dispatcher = UnconfinedTestDispatcher() + private val now = 1_000_000_000_000L + private val dayMs = 24L * 60 * 60 * 1000 + @Before fun setUp() = Dispatchers.setMain(dispatcher) @After fun tearDown() = Dispatchers.resetMain() - private fun report(id: String, kind: ReportKind, createdAt: Long = 1L) = DebugReport( + private fun report(id: String, kind: ReportKind, createdAt: Long) = DebugReport( id = id, createdAtMillis = createdAt, kind = kind, @@ -51,59 +62,105 @@ class StartupReportViewModelTest { logs = emptyList(), ) - @Test - fun `pendingCrash surfaces the first crash report`() = runTest(dispatcher) { - val store = mockk(relaxed = true) - every { store.reports } returns - MutableStateFlow(listOf(report("m", ReportKind.MANUAL), report("c", ReportKind.CRASH, createdAt = 9L))) - val vm = StartupReportViewModel(store) + private fun crash(id: String, createdAt: Long) = report(id, ReportKind.CRASH, createdAt) + private fun store() = ReportStore(tempFolder.root) + + private fun viewModel(store: ReportStore) = StartupReportViewModel(store, now = { now }) + + /** Subscribes to [StartupReportViewModel.pendingCrash] so the `WhileSubscribed` flow starts. */ + private fun TestScope.subscribe(vm: StartupReportViewModel) { backgroundScope.launch { vm.pendingCrash.collect {} } runCurrent() - - assertEquals(ReportSummary("c", ReportKind.CRASH, 9L), vm.pendingCrash.value) } @Test - fun `pendingCrash is null when only manual reports exist`() = runTest(dispatcher) { - val store = mockk(relaxed = true) - every { store.reports } returns MutableStateFlow(listOf(report("m", ReportKind.MANUAL))) - val vm = StartupReportViewModel(store) + fun `surfaces a fresh unseen crash`() = runTest(dispatcher) { + val store = store() + store.save(crash("c", createdAt = now - 1_000L)) + val vm = viewModel(store) + subscribe(vm) - backgroundScope.launch { vm.pendingCrash.collect {} } - runCurrent() + assertEquals(ReportSummary("c", ReportKind.CRASH, now - 1_000L), vm.pendingCrash.value) + } + + @Test + fun `does not surface a crash older than 24h`() = runTest(dispatcher) { + val store = store() + store.save(crash("old", createdAt = now - dayMs - 1)) + val vm = viewModel(store) + subscribe(vm) assertNull(vm.pendingCrash.value) } @Test - fun `dismiss hides the prompt for this launch without deleting the report`() = runTest(dispatcher) { - val store = mockk(relaxed = true) - every { store.reports } returns MutableStateFlow(listOf(report("c", ReportKind.CRASH))) - val vm = StartupReportViewModel(store) + fun `surfaces a crash exactly at the 24h boundary`() = runTest(dispatcher) { + val store = store() + store.save(crash("edge", createdAt = now - dayMs)) + val vm = viewModel(store) + subscribe(vm) - backgroundScope.launch { vm.pendingCrash.collect {} } - runCurrent() - vm.dismiss() - runCurrent() - - assertNull(vm.pendingCrash.value) - verify(exactly = 0) { store.delete(any()) } + assertEquals("edge", vm.pendingCrash.value?.id) } @Test - fun `discard hides the prompt and deletes the report`() = runTest(dispatcher) { - val store = mockk(relaxed = true) - every { store.reports } returns MutableStateFlow(listOf(report("c", ReportKind.CRASH))) - every { store.delete(any()) } just Runs - val vm = StartupReportViewModel(store) + fun `ignores non-crash reports`() = runTest(dispatcher) { + val store = store() + store.save(report("m", ReportKind.MANUAL, createdAt = now)) + val vm = viewModel(store) + subscribe(vm) + + assertNull(vm.pendingCrash.value) + } + + @Test + fun `surfaces the newest eligible crash, skipping surfaced and stale ones`() = runTest(dispatcher) { + val store = store() + store.save(crash("stale", createdAt = now - dayMs - 1)) + store.save(crash("fresh", createdAt = now - 2_000L)) + store.save(crash("seen", createdAt = now - 1_000L)) + store.markSurfaced("seen") + val vm = viewModel(store) + subscribe(vm) + + assertEquals("fresh", vm.pendingCrash.value?.id) + } + + @Test + fun `dismiss marks the crash surfaced so it does not reappear on relaunch`() = runTest(dispatcher) { + val store = store() + store.save(crash("c", createdAt = now)) + val vm = viewModel(store) + subscribe(vm) + assertNotNull(vm.pendingCrash.value) + + vm.dismiss("c") + advanceUntilIdle() + + // Hidden this launch, still stored (not deleted), and now flagged surfaced. + assertNull(vm.pendingCrash.value) + assertNotNull(store.find("c")) + assertTrue(store.find("c")!!.surfaced) + + // A fresh store + VM (a new process) reads the persisted flag → never re-nags. + val relaunchStore = store() + val relaunchVm = viewModel(relaunchStore) + subscribe(relaunchVm) + assertNull(relaunchVm.pendingCrash.value) + } + + @Test + fun `discard deletes the report`() = runTest(dispatcher) { + val store = store() + store.save(crash("c", createdAt = now)) + val vm = viewModel(store) + subscribe(vm) - backgroundScope.launch { vm.pendingCrash.collect {} } - runCurrent() vm.discard("c") advanceUntilIdle() assertNull(vm.pendingCrash.value) - verify(exactly = 1) { store.delete("c") } + assertNull(store.find("c")) } } -- 2.47.3