From 7f623167e7b09379956a52d89975b8a151449603 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 12:27:24 -0500 Subject: [PATCH] feat(reporting): purge crash/problem reports older than a month while charging Adds ReportPurgeWorker, deleting locally-stored crash/problem reports older than 30 days via new ReportStore.purgeOlderThan(cutoffMillis). Scheduled as a periodic WorkManager job with a charging constraint (SyncScheduler.schedulePeriodicReportPurge, enqueued at startup alongside sync/backfill/prune) so it never costs battery. Reports are file-backed (no DB), so the worker needs no cache-lock gate. Also discloses the auto-deletion: the problem-reports list and the submission review screen state reports are deleted from the device after 1 month. Tests: ReportStore.purgeOlderThan cutoff (boundary kept); ReportPurgeWorker computes a ~30-day cutoff and retries on failure. Closes #239 Co-Authored-By: Claude Opus 4.8 --- .../org/libremail/LibreMailApplication.kt | 2 + .../org/libremail/data/sync/SyncScheduler.kt | 16 +++++++ .../libremail/reporting/ReportPurgeWorker.kt | 35 +++++++++++++++ .../org/libremail/reporting/ReportStore.kt | 11 +++++ .../ui/reporting/ProblemReportsScreen.kt | 6 +++ .../ui/reporting/ReportReviewScreen.kt | 6 +++ app/src/main/res/values/strings.xml | 1 + .../reporting/ReportPurgeWorkerTest.kt | 45 +++++++++++++++++++ .../libremail/reporting/ReportStoreTest.kt | 15 +++++++ 9 files changed, 137 insertions(+) create mode 100644 app/src/main/kotlin/org/libremail/reporting/ReportPurgeWorker.kt create mode 100644 app/src/test/kotlin/org/libremail/reporting/ReportPurgeWorkerTest.kt diff --git a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt index d97c2a9..7828b5c 100644 --- a/app/src/main/kotlin/org/libremail/LibreMailApplication.kt +++ b/app/src/main/kotlin/org/libremail/LibreMailApplication.kt @@ -79,6 +79,8 @@ class LibreMailApplication : // resumable background jobs so they never block foreground sync / pull-to-refresh. syncScheduler.schedulePeriodicBackfill() syncScheduler.schedulePeriodicPrune() + // Delete local crash/problem reports older than a month, only while charging (issue #239). + syncScheduler.schedulePeriodicReportPurge() // Run the IMAP IDLE push service only while it has something to do: the push setting is on // AND at least one account exists. This starts it when the first account is added and stops // it when the last is removed, reactively. diff --git a/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt b/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt index fcaa4d0..828f254 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SyncScheduler.kt @@ -10,6 +10,7 @@ import androidx.work.Operation import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import org.libremail.reporting.ReportPurgeWorker import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Provider @@ -41,6 +42,12 @@ class SyncScheduler @Inject constructor( .setRequiresBatteryNotLow(true) .build() + // Report purge is low-priority housekeeping (issue #239): run it only while charging so it never + // costs the user battery, and it needs no network (reports are local files). + private val chargingConstraint = Constraints.Builder() + .setRequiresCharging(true) + .build() + /** Periodic background sync (WorkManager's 15-minute floor). */ fun schedulePeriodicSync() { val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) @@ -98,6 +105,14 @@ class SyncScheduler @Inject constructor( workManager.enqueueUniqueWork(ONESHOT_PRUNE, ExistingWorkPolicy.REPLACE, request) } + /** Periodic purge of crash/problem reports older than a month, only while charging (issue #239). */ + fun schedulePeriodicReportPurge() { + val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + .setConstraints(chargingConstraint) + .build() + workManager.enqueueUniquePeriodicWork(PERIODIC_REPORT_PURGE, PERIODIC_POLICY, request) + } + private companion object { const val PERIODIC_WORK = "libremail_periodic_sync" const val ONESHOT_WORK = "libremail_oneshot_sync" @@ -105,6 +120,7 @@ class SyncScheduler @Inject constructor( const val ONESHOT_BACKFILL = "libremail_oneshot_backfill" const val PERIODIC_PRUNE = "libremail_periodic_prune" const val ONESHOT_PRUNE = "libremail_oneshot_prune" + const val PERIODIC_REPORT_PURGE = "libremail_periodic_report_purge" // UPDATE, not KEEP (issue #96). These periodic jobs are re-enqueued at every app start, so KEEP // pinned an already-installed device to the interval/constraints from the version that first diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportPurgeWorker.kt b/app/src/main/kotlin/org/libremail/reporting/ReportPurgeWorker.kt new file mode 100644 index 0000000..db7ca53 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/reporting/ReportPurgeWorker.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit + +/** + * Housekeeping: deletes locally-stored crash/problem reports older than [RETENTION_DAYS] so they don't + * accumulate on the device forever (issue #239). Scheduled to run only while charging (see + * [org.libremail.data.sync.SyncScheduler.schedulePeriodicReportPurge]). Purely local file cleanup — + * no network and no database, so it never blocks on the encrypted cache. + */ +@HiltWorker +class ReportPurgeWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val store: ReportStore, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result = runCatching { + store.purgeOlderThan(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(RETENTION_DAYS)) + }.fold( + onSuccess = { Result.success() }, + onFailure = { Result.retry() }, + ) + + private companion object { + const val RETENTION_DAYS = 30L + } +} diff --git a/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt index 247b1ed..4969a29 100644 --- a/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt +++ b/app/src/main/kotlin/org/libremail/reporting/ReportStore.kt @@ -34,6 +34,17 @@ class ReportStore(private val directory: File) { } } + /** + * Deletes stored reports created before [cutoffMillis], returning how many were purged. Drives the + * charging-time housekeeping job (issue #239) so old crash/problem records don't pile up on-device. + */ + fun purgeOlderThan(cutoffMillis: Long): Int = synchronized(lock) { + val stale = _reports.value.filter { it.createdAtMillis < cutoffMillis } + stale.forEach { File(directory, fileName(it.id)).delete() } + if (stale.isNotEmpty()) _reports.value = scan() + stale.size + } + private fun scan(): List { val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) } ?: return emptyList() diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt index 0425b11..ce12a1f 100644 --- a/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ProblemReportsScreen.kt @@ -77,6 +77,12 @@ fun ProblemReportsScreen( ) { Text(stringResource(R.string.reports_create)) } + Text( + stringResource(R.string.report_auto_delete_notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) if (reports.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt index 56f45b2..ac32080 100644 --- a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt @@ -121,6 +121,12 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi .padding(16.dp), ) { PiiDisclaimer() + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.report_auto_delete_notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) Spacer(Modifier.height(16.dp)) OutlinedTextField( value = state.comment, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3a647d..de2b295 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -362,6 +362,7 @@ Create a report Crash report Manual report + Reports are automatically deleted from this device after 1 month. Review report May contain personal information This report can include email addresses, server names, and other details from your device. Read the whole thing below before sending. Nothing is sent unless you tap Submit. diff --git a/app/src/test/kotlin/org/libremail/reporting/ReportPurgeWorkerTest.kt b/app/src/test/kotlin/org/libremail/reporting/ReportPurgeWorkerTest.kt new file mode 100644 index 0000000..163ae4d --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/ReportPurgeWorkerTest.kt @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import androidx.work.ListenableWorker +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * [ReportPurgeWorker] deletes locally-stored reports older than 30 days (issue #239). Pins the cutoff + * it computes and that a failure retries rather than silently dropping the housekeeping. + */ +class ReportPurgeWorkerTest { + + private val store = mockk() + + private fun worker() = ReportPurgeWorker(mockk(relaxed = true), mockk(relaxed = true), store) + + @Test + fun `purges reports older than 30 days and succeeds`() = runTest { + val cutoff = slot() + every { store.purgeOlderThan(capture(cutoff)) } returns 3 + + assertEquals(ListenableWorker.Result.success(), worker().doWork()) + + val expected = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30) + assertTrue( + abs(cutoff.captured - expected) < TimeUnit.MINUTES.toMillis(1), + "cutoff should be ~30 days before now", + ) + } + + @Test + fun `retries when the purge fails`() = runTest { + every { store.purgeOlderThan(any()) } throws IllegalStateException("io error") + + assertEquals(ListenableWorker.Result.retry(), worker().doWork()) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt index 02e7072..c15ed11 100644 --- a/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt +++ b/app/src/test/kotlin/org/libremail/reporting/ReportStoreTest.kt @@ -78,4 +78,19 @@ class ReportStoreTest { assertEquals(listOf("valid"), store.reports.value.map { it.id }) } + + @Test + fun `purgeOlderThan deletes reports strictly older than the cutoff`() { + val store = ReportStore(tempFolder.root) + store.save(report("old", createdAt = 1_000L)) + store.save(report("boundary", createdAt = 3_000L)) + store.save(report("recent", createdAt = 5_000L)) + + val purged = store.purgeOlderThan(cutoffMillis = 3_000L) + + assertEquals(1, purged) + assertNull(store.find("old")) + // A report exactly at the cutoff is kept (strictly-older purge); list stays newest-first. + assertEquals(listOf("recent", "boundary"), store.reports.value.map { it.id }) + } }