Merge main into feat-328-logging-connsend

This commit is contained in:
Jason Ross
2026-07-04 21:51:58 -05:00
committed by GitHub
16 changed files with 699 additions and 15 deletions
@@ -10,6 +10,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.reporting.AppLog
/**
* Runs one bounded slice of the full-history backfill (issue #12). Cancellable (WorkManager stops it
@@ -31,7 +32,10 @@ class BackfillWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
// Can't open the encrypted DB without the user present — retry later rather than parking a
// WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await.
if (cacheGuard.isCacheLocked()) return Result.retry()
if (cacheGuard.isCacheLocked()) {
AppLog.i(TAG, "backfill deferred: cache locked")
return Result.retry()
}
return runCatching {
// Chain bounded slices back-to-back while history remains, so a large mailbox isn't limited
// to one slice per periodic run. runBackfill() returns true while any folder still has pages
@@ -39,8 +43,19 @@ class BackfillWorker @AssistedInject constructor(
val mailBackfiller = backfiller.get()
while (mailBackfiller.runBackfill() && !isStopped) { /* page the next slice */ }
}.fold(
onSuccess = { Result.success() },
onFailure = { error -> if (error is CancellationException) throw error else Result.retry() },
onSuccess = {
AppLog.i(TAG, "backfill worker: success")
Result.success()
},
onFailure = { error ->
if (error is CancellationException) throw error
AppLog.w(TAG, "backfill worker: retry", error)
Result.retry()
},
)
}
private companion object {
const val TAG = "BackfillWorker"
}
}
@@ -25,6 +25,8 @@ import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.repository.MailRepository
import org.libremail.mail.ImapClient
import org.libremail.power.BatteryStatusProvider
import org.libremail.reporting.AppLog
import org.libremail.reporting.accountLogRef
import javax.inject.Inject
import javax.inject.Singleton
@@ -65,13 +67,19 @@ class MailBackfiller @Inject constructor(
* count as more work — it is retried on a future scheduled run instead of spun on back-to-back.
*/
suspend fun runBackfill(maxBatches: Int = DEFAULT_MAX_BATCHES): Boolean = maintenanceGate.mutex.withLock {
AppLog.i(TAG, "backfill slice: maxBatches=$maxBatches")
var remaining = maxBatches
var moreWork = false
for (account in accountDao.getAll().map { it.toDomain() }) {
accounts@ for (account in accountDao.getAll().map { it.toDomain() }) {
val params = runCatching { connectionFactory.imapParamsFor(account) }.getOrNull() ?: continue
val policy = accountSettingsRepository.effectiveRetention(settingsRepository, account.id)
for (folder in messageDao.syncedFolders(account.id)) {
if (remaining <= 0) return@withLock true
if (remaining <= 0) {
// The budget ran out before every folder was visited, so there is very likely more
// work left even though nothing here reported it directly.
moreWork = true
break@accounts
}
// Per-folder failures (e.g. a transient server error) must not abort the whole slice.
val result = runCatching { backfillFolder(account, params, folder, policy, remaining) }
.getOrElse { FolderResult(batches = 0, moreWork = true) }
@@ -79,6 +87,7 @@ class MailBackfiller @Inject constructor(
if (result.moreWork) moreWork = true
}
}
AppLog.i(TAG, "backfill slice done: moreWork=$moreWork")
moreWork
}
@@ -153,6 +162,8 @@ class MailBackfiller @Inject constructor(
delay(BACKFILL_BATCH_DELAY_MS)
}
if (complete) markComplete(account.id, folder, beforeUid)
val folderLabel = logSafeFolderLabel(folder)
AppLog.d(TAG, "backfill ${accountLogRef(account.id)} folder=$folderLabel pages=$batches complete=$complete")
return FolderResult(batches, moreWork = !complete && !stalled)
}
@@ -226,6 +237,8 @@ class MailBackfiller @Inject constructor(
}
private companion object {
const val TAG = "MailBackfiller"
/** Headers fetched per server page. */
const val BACKFILL_BATCH_SIZE = 50
@@ -13,6 +13,7 @@ import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.RetentionPolicy
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.settings.effectiveRetention
import org.libremail.reporting.AppLog
import javax.inject.Inject
import javax.inject.Singleton
@@ -49,6 +50,7 @@ class MailPruner @Inject constructor(
if (policy.isUnlimited) continue
removed += pruneAccount(account.id, policy, nowMillis)
}
AppLog.i(TAG, "prune done: removed=$removed")
removed
}
@@ -82,6 +84,8 @@ class MailPruner @Inject constructor(
}
private companion object {
const val TAG = "MailPruner"
/** Ids per DELETE, kept under SQLite's 999-host-parameter limit on older Android. */
const val DELETE_CHUNK = 500
}
@@ -21,6 +21,8 @@ import org.libremail.domain.repository.MailRepository
import org.libremail.mail.ImapClient
import org.libremail.notifications.MailNotifier
import org.libremail.power.BatteryStatusProvider
import org.libremail.reporting.AppLog
import org.libremail.reporting.accountLogRef
import javax.inject.Inject
import javax.inject.Singleton
@@ -47,6 +49,7 @@ class MailSyncer @Inject constructor(
/** Syncs every account's inbox. Succeeds if at least one account synced (or there are none). */
override suspend fun syncAll(): Result<Int> {
val accounts = accountDao.getAll().map { it.toDomain() }
AppLog.i(TAG, "sync all: ${accounts.size} accounts")
if (accounts.isEmpty()) return Result.success(0)
val result = syncMutex.withLock {
@@ -64,6 +67,8 @@ class MailSyncer @Inject constructor(
}
if (anySuccess || firstError == null) Result.success(total) else Result.failure(firstError)
}
result.onSuccess { total -> AppLog.i(TAG, "sync all done: fetched=$total") }
.onFailure { error -> AppLog.w(TAG, "sync all failed", error) }
if (result.isSuccess) accounts.forEach { prefetchIfEnabled(it, INBOX) }
return result
}
@@ -147,6 +152,8 @@ class MailSyncer @Inject constructor(
notifier.notifyNewMail(account, newMessages.sortedByDescending { it.timestampMillis })
}
}
val folderLabel = logSafeFolderLabel(folder)
AppLog.d(TAG, "sync ${accountLogRef(account.id)} folder=$folderLabel fetched=${fetched.size}")
fetched.size
}
@@ -172,6 +179,7 @@ class MailSyncer @Inject constructor(
}
private companion object {
const val TAG = "MailSyncer"
const val INBOX = "INBOX"
/**
@@ -9,6 +9,7 @@ import dagger.Lazy
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.reporting.AppLog
/**
* Enforces device-only retention (issue #13) by running [MailPruner]. Purely local — it never
@@ -28,10 +29,23 @@ class PruneWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
// Can't open the encrypted DB without the user present — retry later rather than parking a
// WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await.
if (cacheGuard.isCacheLocked()) return Result.retry()
if (cacheGuard.isCacheLocked()) {
AppLog.i(TAG, "prune deferred: cache locked")
return Result.retry()
}
return runCatching { pruner.get().prune() }.fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
onSuccess = {
AppLog.i(TAG, "prune worker: success")
Result.success()
},
onFailure = { error ->
AppLog.w(TAG, "prune worker: retry", error)
Result.retry()
},
)
}
private companion object {
const val TAG = "PruneWorker"
}
}
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
/**
* The folder name safe to write to an [org.libremail.reporting.AppLog] breadcrumb (and so, in turn, a
* submitted [org.libremail.reporting.DebugReport]): the leaf name itself for a known **system** folder
* (INBOX, Sent, Drafts, Trash, Spam/Junk, Archive — including the alternate names real IMAP servers use
* for them), or a fixed placeholder for anything else. A user-created folder or label (e.g. a client
* name or project) can be PII-ish, so only this fixed, closed set of well-known names is ever logged
* verbatim; every other folder logs as the placeholder, regardless of nesting or the server's hierarchy
* delimiter. Matching is name-only — no server SPECIAL-USE attributes are available down here at the
* sync layer — so it is necessarily best-effort in the same way
* [org.libremail.domain.model.FolderRole.roleOf]'s display-name fallback is. That is the safe direction:
* a false negative just logs the placeholder, never a leaked name.
*/
internal fun logSafeFolderLabel(folder: String): String {
val leaf = folder.substringAfterLast('/').substringAfterLast('.').trim()
return if (leaf.lowercase() in SYSTEM_FOLDER_NAMES) leaf else FOLDER_PLACEHOLDER
}
private const val FOLDER_PLACEHOLDER = "<folder>"
/** Case-insensitive leaf names recognized as provider-supplied system folders, never user-created. */
private val SYSTEM_FOLDER_NAMES = setOf(
"inbox",
"sent",
"sent mail",
"sent items",
"sent messages",
"drafts",
"draft",
"junk",
"spam",
"junk e-mail",
"junk email",
"bulk mail",
"trash",
"deleted",
"deleted items",
"deleted messages",
"bin",
"archive",
"archives",
"all mail",
)
@@ -9,6 +9,7 @@ import dagger.Lazy
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.reporting.AppLog
@HiltWorker
class SyncWorker @AssistedInject constructor(
@@ -23,10 +24,23 @@ class SyncWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
// Can't open the encrypted DB without the user present — retry later rather than parking a
// WorkManager thread (which also wedges the shared serial executor) on an unsatisfiable await.
if (cacheGuard.isCacheLocked()) return Result.retry()
if (cacheGuard.isCacheLocked()) {
AppLog.i(TAG, "sync deferred: cache locked")
return Result.retry()
}
return mailSyncer.get().syncAll().fold(
onSuccess = { Result.success() },
onFailure = { Result.retry() },
onSuccess = {
AppLog.i(TAG, "sync worker: success")
Result.success()
},
onFailure = { error ->
AppLog.w(TAG, "sync worker: retry", error)
Result.retry()
},
)
}
private companion object {
const val TAG = "SyncWorker"
}
}
@@ -1,31 +1,59 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.util.Log
import androidx.work.ListenableWorker.Result
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [BackfillWorker] must defer while the encrypted cache is locked rather than park this WorkManager
* thread opening the DB. It gates on [EncryptedCacheGuard], resolving the (`Lazy`) [MailBackfiller]
* only once unlocked — the same pre-auth invariant `SyncWorker`/`SendWorker` enforce.
* only once unlocked — the same pre-auth invariant `SyncWorker`/`SendWorker` enforce. Also covers issue
* #329's AppLog breadcrumbs on the deferred/success/retry outcomes.
*/
class BackfillWorkerTest {
private val backfiller = mockk<MailBackfiller>()
private val lazyBackfiller = mockk<Lazy<MailBackfiller>> { every { get() } returns backfiller }
private val cacheGuard = mockk<EncryptedCacheGuard>()
private val logBuffer = RingLogBuffer()
private fun worker() = BackfillWorker(mockk(relaxed = true), mockk(relaxed = true), lazyBackfiller, cacheGuard)
@Before
fun setUp() {
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here,
// mirroring org.libremail.reporting.AppLogTest — doWork() now breadcrumbs through AppLog.
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.d(any(), any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
AppLog.install(logBuffer)
}
@After
fun tearDown() = unmockkAll()
@Test
fun `retries without resolving the backfiller when the cache is locked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
@@ -54,4 +82,44 @@ class BackfillWorkerTest {
assertEquals(Result.retry(), worker().doWork())
}
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
@Test
fun `logs a deferred breadcrumb when the cache is locked, without touching the backfiller`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('I', entry.level)
assertEquals("backfill deferred: cache locked", entry.message)
}
@Test
fun `logs a success breadcrumb once every chained slice completes`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { backfiller.runBackfill(any()) } returnsMany listOf(true, false)
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('I', entry.level)
assertEquals("backfill worker: success", entry.message)
}
@Test
fun `logs a scrubbed retry breadcrumb when backfilling throws`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { backfiller.runBackfill(any()) } throws
IllegalStateException("auth failed for a@example.org")
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('W', entry.level)
assertTrue(entry.message.startsWith("backfill worker: retry"), entry.message)
assertTrue(entry.message.contains("IllegalStateException"), entry.message)
assertFalse(entry.message.contains("a@example.org"), entry.message)
}
}
@@ -2,12 +2,15 @@
package org.libremail.data.sync
import android.content.Context
import android.util.Log
import com.icegreen.greenmail.util.GreenMail
import com.icegreen.greenmail.util.ServerSetupTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Session
@@ -38,6 +41,8 @@ import org.libremail.mail.FetchedMessage
import org.libremail.mail.ImapClient
import org.libremail.power.BatteryStatus
import org.libremail.power.BatteryStatusProvider
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import java.util.Date
import java.util.Properties
import kotlin.test.assertEquals
@@ -74,15 +79,33 @@ class MailBackfillerTest {
// though `cached` would silently absorb it. Isolates the "no message fetched twice" guarantee.
private var totalOffered = 0
// issue #329: AppLog breadcrumbs — a fresh buffer per test (a new instance per @Test, per JUnit4).
private val logBuffer = RingLogBuffer()
@Before
fun setUp() {
greenMail = GreenMail(ServerSetupTest.SMTP_IMAP)
greenMail.start()
greenMail.setUser("alice@example.org", "secret")
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here
// for the whole class, mirroring org.libremail.reporting.AppLogTest — every test now exercises
// real backfill code, which breadcrumbs through AppLog.
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.d(any(), any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
AppLog.install(logBuffer)
}
@After
fun tearDown() = greenMail.stop()
fun tearDown() {
greenMail.stop()
unmockkAll()
}
private fun params() = ImapConnectionParams(
host = "127.0.0.1",
@@ -353,6 +376,68 @@ class MailBackfillerTest {
}
}
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
@Test
fun `runBackfill logs a start breadcrumb, a per-folder breadcrumb, and a done breadcrumb`() = runTest {
appendMessages(TOTAL)
seedForegroundWindow()
backfiller(AccountSettings("acct")).runBackfill(maxBatches = 3)
val snapshot = logBuffer.snapshot()
val start = snapshot.first { it.message.startsWith("backfill slice:") }
val perFolder = snapshot.first { it.message.startsWith("backfill acct:") }
val done = snapshot.first { it.message.startsWith("backfill slice done:") }
assertEquals('I', start.level)
assertEquals("backfill slice: maxBatches=3", start.message)
assertEquals('D', perFolder.level)
assertTrue(perFolder.message.contains("folder=INBOX"), perFolder.message)
assertTrue(perFolder.message.contains("pages="), perFolder.message)
assertTrue(perFolder.message.contains("complete="), perFolder.message)
assertEquals('I', done.level)
assertTrue(done.message.contains("moreWork="), done.message)
snapshot.forEach { assertNoPii(it.message) }
}
@Test
fun `an already-complete folder logs no redundant per-folder breadcrumb on the next slice`() = runTest {
appendMessages(60)
seedForegroundWindow()
val backfiller = backfiller(AccountSettings("acct"))
var guard = 0
while (backfiller.runBackfill() && guard++ < 10) { /* drive to completion */ }
logBuffer.clear()
// The folder is already complete; this slice does no work for it.
backfiller.runBackfill()
assertTrue(
logBuffer.snapshot().none { it.message.startsWith("backfill acct:") },
"an already-complete folder must not spam a breadcrumb on every subsequent slice",
)
}
@Test
fun `no slice of a full backfill ever logs the account's email, host, or a message address`() = runTest {
appendMessages(TOTAL)
seedForegroundWindow()
val backfiller = backfiller(AccountSettings("acct"))
var guard = 0
while (backfiller.runBackfill() && guard++ < 10) { /* drive to completion */ }
val snapshot = logBuffer.snapshot()
assertTrue(snapshot.isNotEmpty(), "the run must have logged something to be a meaningful check")
snapshot.forEach { assertNoPii(it.message) }
}
/** No test fixture's email address or host may ever reach a log line — the hard PII rule. */
private fun assertNoPii(message: String) {
assertFalse(message.contains("@example.org"), message) // covers the account and every sender
assertFalse(message.contains("127.0.0.1"), message) // the GreenMail host
}
private fun fetchedMessage(uid: String) = FetchedMessage(
uid = uid,
sender = "Sender",
@@ -1,9 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.util.Log
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -13,6 +16,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.yield
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
@@ -28,6 +33,8 @@ import org.libremail.mail.FetchedMessage
import org.libremail.mail.ImapClient
import org.libremail.power.BatteryStatus
import org.libremail.power.BatteryStatusProvider
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import java.util.Collections
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertEquals
@@ -52,6 +59,25 @@ class MailMaintenanceGateTest {
smtp = ServerConfigEmbedded("127.0.0.1", 465, "NONE"),
)
// issue #329: MailBackfiller/MailPruner now breadcrumb through AppLog, whose Logcat forwarding
// (`android.util.Log`) is a no-op stub under plain JVM unit tests — mock it statically, mirroring
// org.libremail.reporting.AppLogTest. The breadcrumbs themselves are asserted in
// MailBackfillerTest/MailPrunerTest; this class only needs to not crash.
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.d(any(), any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
AppLog.install(RingLogBuffer())
}
@After
fun tearDown() = unmockkAll()
/**
* The gate's contract: everyone who acquires the *same* gate takes the *same* exclusive lock, so no
* two critical sections overlap. Guards against a regression that hands out a fresh lock per access
@@ -2,15 +2,20 @@
package org.libremail.data.sync
import android.content.Context
import android.util.Log
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
@@ -20,8 +25,11 @@ import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.domain.model.AccountSettings
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
@@ -40,6 +48,27 @@ class MailPrunerTest {
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
)
// issue #329: AppLog breadcrumbs — a fresh buffer per test (a new instance per @Test, per JUnit4).
private val logBuffer = RingLogBuffer()
@Before
fun setUp() {
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here
// for the whole class, mirroring org.libremail.reporting.AppLogTest — every test now exercises
// real prune code, which breadcrumbs through AppLog.
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.d(any(), any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
AppLog.install(logBuffer)
}
@After
fun tearDown() = unmockkAll()
private fun pruner(global: AppSettings, accountSettings: AccountSettings, messageDao: MessageDao): MailPruner {
val accountDao = mockk<AccountDao>()
coEvery { accountDao.getAll() } returns listOf(account)
@@ -136,4 +165,61 @@ class MailPrunerTest {
coVerify(exactly = 0) { messageDao.syncedIdsOlderThan(any(), any()) }
coVerify(exactly = 0) { messageDao.syncedIdsBeyondCountInFolder(any(), any(), any()) }
}
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
@Test
fun `prune logs a done breadcrumb with the removed count`() = runTest {
val messageDao = mockk<MessageDao>(relaxed = true)
coEvery { messageDao.syncedFolders("acct") } returns listOf("INBOX", "Archive")
coEvery { messageDao.syncedIdsBeyondCountInFolder("acct", "INBOX", 2) } returns
listOf("acct:INBOX:1", "acct:INBOX:2")
coEvery { messageDao.syncedIdsBeyondCountInFolder("acct", "Archive", 2) } returns listOf("acct:Archive:9")
coEvery { messageDao.deleteByIds(any()) } just Runs
pruner(
global = AppSettings(retentionCount = 2, retentionMonths = 0),
accountSettings = AccountSettings("acct"),
messageDao = messageDao,
).prune()
val entry = logBuffer.snapshot().single()
assertEquals('I', entry.level)
assertEquals("prune done: removed=3", entry.message)
}
@Test
fun `prune logs a done breadcrumb even when nothing is removed`() = runTest {
val messageDao = mockk<MessageDao>(relaxed = true)
pruner(
global = AppSettings(retentionCount = 0, retentionMonths = 0),
accountSettings = AccountSettings("acct"),
messageDao = messageDao,
).prune()
val entry = logBuffer.snapshot().single()
assertEquals('I', entry.level)
assertEquals("prune done: removed=0", entry.message)
}
@Test
fun `prune never logs the account email or host, even while removing rows by age and count`() = runTest {
val messageDao = mockk<MessageDao>(relaxed = true)
coEvery { messageDao.syncedFolders("acct") } returns listOf("INBOX")
coEvery { messageDao.syncedIdsOlderThan(any(), any()) } returns listOf("acct:INBOX:1")
coEvery { messageDao.syncedIdsBeyondCountInFolder("acct", "INBOX", 2) } returns listOf("acct:INBOX:2")
coEvery { messageDao.deleteByIds(any()) } just Runs
pruner(
global = AppSettings(retentionCount = 2, retentionMonths = 6),
accountSettings = AccountSettings("acct"),
messageDao = messageDao,
).prune()
logBuffer.snapshot().forEach { entry ->
assertFalse(entry.message.contains("a@example.org"), entry.message)
assertFalse(entry.message.contains("example.org"), entry.message)
}
}
}
@@ -2,14 +2,19 @@
package org.libremail.data.sync
import android.content.Context
import android.util.Log
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.BackfillProgressDao
@@ -28,6 +33,8 @@ import org.libremail.mail.FetchedMessage
import org.libremail.mail.ImapClient
import org.libremail.power.BatteryStatus
import org.libremail.power.BatteryStatusProvider
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertEquals
@@ -74,6 +81,25 @@ class MailSyncConcurrencyTest {
useXoauth2 = false,
)
// issue #329: MailSyncer/MailBackfiller/MailPruner now breadcrumb through AppLog, whose Logcat
// forwarding (`android.util.Log`) is a no-op stub under plain JVM unit tests — mock it statically,
// mirroring org.libremail.reporting.AppLogTest. The breadcrumbs themselves are asserted in
// MailSyncerTest/MailBackfillerTest/MailPrunerTest; this class only needs to not crash.
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.d(any(), any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
AppLog.install(RingLogBuffer())
}
@After
fun tearDown() = unmockkAll()
// --- sync ↔ backfill -----------------------------------------------------------------------
/**
@@ -5,12 +5,17 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.util.Log
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.MessageDao
@@ -28,12 +33,24 @@ import org.libremail.mail.ImapClient
import org.libremail.notifications.MailNotifier
import org.libremail.power.BatteryStatus
import org.libremail.power.BatteryStatusProvider
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Also covers issue #329's net-new [AppLog] breadcrumbs: every test exercises real sync code, so
* `android.util.Log` (a no-op stub under plain JVM unit tests) is statically mocked here for the whole
* class, mirroring [org.libremail.reporting.AppLogTest]. [logBuffer] is fresh per test (a new
* [MailSyncerTest] instance per `@Test`, per JUnit4) and installed before every test so the dedicated
* breadcrumb tests below can assert against it directly.
*/
class MailSyncerTest {
private val logBuffer = RingLogBuffer()
private val account = AccountEntity(
id = "acct",
email = "a@example.org",
@@ -43,6 +60,21 @@ class MailSyncerTest {
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
)
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.d(any(), any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
AppLog.install(logBuffer)
}
@After
fun tearDown() = unmockkAll()
/** The IMAP client of the most recently built [syncer], for verifying the fetch window size. */
private lateinit var lastImapClient: ImapClient
@@ -382,4 +414,61 @@ class MailSyncerTest {
every { it.getSystemService(ConnectivityManager::class.java) } returns manager
}
}
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
@Test
fun `syncFolder records a debug breadcrumb with the account ref, folder, and fetched count`() = runTest {
val repo = mockk<MailRepository>(relaxed = true)
val fetched = listOf(
FetchedMessage("1", "Ada", "ada@example.org", "Secret subject", 1_000L, isRead = true, isFlagged = false),
)
syncer(FetchPolicy.ON_DEMAND, repo, fetched = fetched).syncFolder("acct", "INBOX")
val entry = logBuffer.snapshot().single()
assertEquals('D', entry.level)
assertTrue(entry.message.contains("folder=INBOX"), entry.message)
assertTrue(entry.message.contains("fetched=1"), entry.message)
assertNoPii(entry.message)
}
@Test
fun `syncAll logs a start breadcrumb and a done breadcrumb with the fetched total`() = runTest {
val syncer = syncAllSyncer(accounts = listOf(accountEntity("one"), accountEntity("two")))
syncer.syncAll()
val snapshot = logBuffer.snapshot()
val start = snapshot.first { it.message.startsWith("sync all:") }
val done = snapshot.first { it.message.startsWith("sync all done:") }
assertEquals('I', start.level)
assertEquals("sync all: 2 accounts", start.message)
assertEquals('I', done.level)
assertEquals("sync all done: fetched=2", done.message)
snapshot.forEach { assertNoPii(it.message) }
}
@Test
fun `syncAll logs a warning breadcrumb with the scrubbed failure when every account fails`() = runTest {
val syncer = syncAllSyncer(
accounts = listOf(accountEntity("bad1"), accountEntity("bad2")),
failingIds = setOf("bad1", "bad2"),
)
syncer.syncAll()
val entry = logBuffer.snapshot().first { it.message.startsWith("sync all failed") }
assertEquals('W', entry.level)
// The throwable's class survives the scrub; its free-text message does not (issue #325).
assertTrue(entry.message.contains("IOException"), entry.message)
assertFalse(entry.message.contains("no network"), entry.message)
logBuffer.snapshot().forEach { assertNoPii(it.message) }
}
/** No test fixture's email address or host may ever reach a log line — the hard PII rule. */
private fun assertNoPii(message: String) {
assertFalse(message.contains("@example.org"), message)
assertFalse(message.contains("example.org"), message)
}
}
@@ -1,32 +1,60 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.util.Log
import androidx.work.ListenableWorker.Result
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [PruneWorker] must not touch the database while the encrypted cache is locked: opening it would park
* this WorkManager thread on an unsatisfiable passphrase await (and wedge the shared serial executor).
* It gates on [EncryptedCacheGuard] and only resolves the (`Lazy`) [MailPruner] once unlocked — the
* invariant every pre-auth DB entry point shares with `SyncWorker`/`SendWorker`.
* invariant every pre-auth DB entry point shares with `SyncWorker`/`SendWorker`. Also covers issue
* #329's AppLog breadcrumbs on the deferred/success/retry outcomes.
*/
class PruneWorkerTest {
private val pruner = mockk<MailPruner>()
private val lazyPruner = mockk<Lazy<MailPruner>> { every { get() } returns pruner }
private val cacheGuard = mockk<EncryptedCacheGuard>()
private val logBuffer = RingLogBuffer()
private fun worker() = PruneWorker(mockk(relaxed = true), mockk(relaxed = true), lazyPruner, cacheGuard)
@Before
fun setUp() {
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here,
// mirroring org.libremail.reporting.AppLogTest — doWork() now breadcrumbs through AppLog.
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.d(any(), any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
AppLog.install(logBuffer)
}
@After
fun tearDown() = unmockkAll()
@Test
fun `retries without resolving the pruner when the cache is locked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
@@ -55,4 +83,43 @@ class PruneWorkerTest {
assertEquals(Result.retry(), worker().doWork())
}
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
@Test
fun `logs a deferred breadcrumb when the cache is locked, without touching the pruner`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('I', entry.level)
assertEquals("prune deferred: cache locked", entry.message)
}
@Test
fun `logs a success breadcrumb when pruning succeeds`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { pruner.prune(any()) } returns 5
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('I', entry.level)
assertEquals("prune worker: success", entry.message)
}
@Test
fun `logs a scrubbed retry breadcrumb when pruning throws`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { pruner.prune(any()) } throws IllegalStateException("auth failed for a@example.org")
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('W', entry.level)
assertTrue(entry.message.startsWith("prune worker: retry"), entry.message)
assertTrue(entry.message.contains("IllegalStateException"), entry.message)
assertFalse(entry.message.contains("a@example.org"), entry.message)
}
}
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import org.junit.Test
import kotlin.test.assertEquals
/**
* [logSafeFolderLabel] is the PII gate between a raw IMAP folder path and an [org.libremail.reporting.AppLog]
* breadcrumb (issue #329's folder-name caveat): a known system folder logs by name, anything else logs
* as the fixed placeholder — never the real name, regardless of nesting or delimiter.
*/
class SyncLoggingTest {
@Test
fun `a top-level system folder logs verbatim`() {
assertEquals("INBOX", logSafeFolderLabel("INBOX"))
assertEquals("Sent", logSafeFolderLabel("Sent"))
assertEquals("Drafts", logSafeFolderLabel("Drafts"))
assertEquals("Trash", logSafeFolderLabel("Trash"))
assertEquals("Archive", logSafeFolderLabel("Archive"))
}
@Test
fun `matching is case-insensitive but preserves the server's own casing`() {
assertEquals("JUNK", logSafeFolderLabel("JUNK"))
assertEquals("sent items", logSafeFolderLabel("sent items"))
}
@Test
fun `a system folder nested under a Gmail-style slash path logs only its leaf name`() {
assertEquals("Sent Mail", logSafeFolderLabel("[Gmail]/Sent Mail"))
assertEquals("All Mail", logSafeFolderLabel("[Gmail]/All Mail"))
}
@Test
fun `a system folder nested under a Dovecot-style dot path logs only its leaf name`() {
assertEquals("Trash", logSafeFolderLabel("INBOX.Trash"))
}
@Test
fun `a user-created top-level folder never logs its real name`() {
assertEquals("<folder>", logSafeFolderLabel("Projects"))
assertEquals("<folder>", logSafeFolderLabel("Invoice from Acme Corp"))
}
@Test
fun `a user-created nested folder logs the placeholder, never its name or its parent`() {
assertEquals("<folder>", logSafeFolderLabel("Work/Reports"))
assertEquals("<folder>", logSafeFolderLabel("Clients/Acme Corp"))
}
@Test
fun `a folder that merely shares a system folder's leaf name still logs only that leaf`() {
// Even a custom parent is never exposed — only the recognized leaf is ever logged, by design.
assertEquals("Archive", logSafeFolderLabel("Clients/Archive"))
}
}
@@ -1,31 +1,59 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.util.Log
import androidx.work.ListenableWorker
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.reporting.AppLog
import org.libremail.reporting.RingLogBuffer
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [SyncWorker] already gates on [EncryptedCacheGuard]; this locks that invariant in as regression
* cover for the class of bug fixed in PruneWorker/BackfillWorker — while the cache is locked it must
* defer without resolving the (`Lazy`) [MailSyncer] (whose construction opens the Room DB).
* defer without resolving the (`Lazy`) [MailSyncer] (whose construction opens the Room DB). Also covers
* issue #329's AppLog breadcrumbs on the deferred/success/retry outcomes.
*/
class SyncWorkerTest {
private val mailSyncer = mockk<MailSyncer>()
private val lazySyncer = mockk<Lazy<MailSyncer>> { every { get() } returns mailSyncer }
private val cacheGuard = mockk<EncryptedCacheGuard>()
private val logBuffer = RingLogBuffer()
private fun worker() = SyncWorker(mockk(relaxed = true), mockk(relaxed = true), lazySyncer, cacheGuard)
@Before
fun setUp() {
// `android.util.Log` is a no-op stub under plain JVM unit tests, so it is statically mocked here,
// mirroring org.libremail.reporting.AppLogTest — doWork() now breadcrumbs through AppLog.
mockkStatic(Log::class)
every { Log.d(any(), any()) } returns 0
every { Log.d(any(), any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
AppLog.install(logBuffer)
}
@After
fun tearDown() = unmockkAll()
@Test
fun `retries without resolving the syncer when the cache is locked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
@@ -53,4 +81,43 @@ class SyncWorkerTest {
assertEquals(ListenableWorker.Result.retry(), worker().doWork())
}
// --- issue #329: AppLog breadcrumbs ---------------------------------------------------------
@Test
fun `logs a deferred breadcrumb when the cache is locked, without touching the syncer`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('I', entry.level)
assertEquals("sync deferred: cache locked", entry.message)
}
@Test
fun `logs a success breadcrumb when syncing succeeds`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { mailSyncer.syncAll() } returns Result.success(3)
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('I', entry.level)
assertEquals("sync worker: success", entry.message)
}
@Test
fun `logs a scrubbed retry breadcrumb when syncing fails`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
coEvery { mailSyncer.syncAll() } returns Result.failure(IllegalStateException("auth failed for a@example.org"))
worker().doWork()
val entry = logBuffer.snapshot().single()
assertEquals('W', entry.level)
assertTrue(entry.message.startsWith("sync worker: retry"), entry.message)
assertTrue(entry.message.contains("IllegalStateException"), entry.message)
assertFalse(entry.message.contains("a@example.org"), entry.message)
}
}