Merge main into feat-unicode-search-casefold

This commit is contained in:
Jason Ross
2026-07-03 17:38:56 -05:00
committed by GitHub
9 changed files with 137 additions and 0 deletions
@@ -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.
@@ -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<SyncWorker>(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<ReportPurgeWorker>(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
@@ -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
}
}
@@ -48,6 +48,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<DebugReport> {
val files = directory.listFiles { file -> file.isFile && file.name.endsWith(SUFFIX) }
?: return emptyList()
@@ -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(
@@ -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,
+1
View File
@@ -362,6 +362,7 @@
<string name="reports_create">Create a report</string>
<string name="report_kind_crash">Crash report</string>
<string name="report_kind_manual">Manual report</string>
<string name="report_auto_delete_notice">Reports are automatically deleted from this device after 1 month.</string>
<string name="report_review_title">Review report</string>
<string name="report_pii_disclaimer_title">May contain personal information</string>
<string name="report_pii_disclaimer">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.</string>
@@ -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<ReportStore>()
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<Long>()
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())
}
}
@@ -99,4 +99,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 })
}
}