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
9 changed files with 387 additions and 63 deletions
@@ -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"))
}
}