fix(reporting): auto-prompt to submit a crash only on first re-open, for a legitimate <24h crash #263
@@ -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<ComponentActivity>()
|
||||
|
||||
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<StartupReportViewModel>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReportSummary?> =
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<ReportStore>(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<ReportStore>(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<ReportStore>(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<ReportStore>(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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user