Merge main into feat-unicode-search-casefold
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user