Files
LibreMail/app/src/androidTest/kotlin/org/libremail/debug/FetchGateReceiverInstrumentedTest.kt
T
JMR-devandClaude Opus 4.8 8f8608a430 feat(debug): dev-only pause/halt mail-fetch hook for test harnesses (#393)
The on-device perf harness cannot force a genuinely uncached body fetch: proactive
backfill (#12) and post-sync body prefetch (#88/#89) warm the cache before a test can
open a message. Add a debug-only, adb-reachable hook to pause proactive fetch so a real
uncached open can be measured.

Components:
- DebugFetchGate (src/main): thread-safe in-memory holder of paused FetchScopes
  (BACKFILL, PREFETCH; `all` alias). Defaults to not-paused; HEADER_SYNC and on-demand
  OPEN are never gateable.
- FetchGateReceiver (src/debug only): BroadcastReceiver registered in the debug manifest,
  driven by `adb shell am broadcast -a org.libremail.debug.FETCH_GATE -n .../FetchGateReceiver
  --es action <pause|resume|query> --es scope <backfill,prefetch|all>`. Returns the state as
  ordered-broadcast result data (paused=[...]) for a synchronous read-back.

Enforcement (each read guarded by BuildConfig.DEBUG so R8 strips it from release):
- BackfillWorker.doWork() entry -> skip-and-reschedule when BACKFILL is paused, mirroring
  the existing cache-lock deferral (covers periodic + backfillNow()).
- MailSyncer/MailBackfiller.prefetchIfEnabled -> early-return when PREFETCH is paused.
  openMessage / fetchBodyMarkingSeen / fetchAttachment are deliberately NOT gated.

Debug-only: receiver + <receiver> live wholly in src/debug; every gate read in main is
behind BuildConfig.DEBUG. Verified on assembleRelease that R8 strips DebugFetchGate /
FetchScope / FetchGateReceiver and the log strings from the release APK, and the merged
release manifest has no FETCH_GATE receiver.

PII-free AppLog breadcrumbs on pause/resume/query and on each gate-triggered defer/skip
(scope names only).

Tests: DebugFetchGateTest, BackfillWorkerTest / MailSyncerTest / MailBackfillerTest
enforcement cases, and FetchGateReceiverInstrumentedTest (ordered-broadcast -> gate ->
read-back; gated worker defers while an un-gated path runs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:13:22 -05:00

183 lines
7.5 KiB
Kotlin

// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.debug
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.work.ListenableWorker.Result
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.data.security.PassphraseSession
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.BackfillWorker
import org.libremail.data.sync.DebugFetchGate
import org.libremail.data.sync.FetchScope
import org.libremail.data.sync.MailBackfiller
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* On-device proof of the debug-only fetch gate (issue #393): the adb-reachable [FetchGateReceiver]
* updates [DebugFetchGate] and returns the resulting state as ordered-broadcast result data (exactly
* what `adb shell am broadcast ... FETCH_GATE` prints back to the harness), and a gated proactive path
* ([BackfillWorker]) genuinely defers while an un-gated path keeps running. The broadcast is sent
* ordered — the same delivery mode `am broadcast` uses — so [BroadcastReceiver.getResultData] on the
* final receiver reads back what the gate set, with no logcat race.
*
* The worker-deferral cases reuse `WorkerCacheLockDeferralInstrumentedTest`'s approach: build a
* [BackfillWorker] with a real, never-unlocked-or-off [EncryptedCacheGuard] and a `Lazy` [MailBackfiller]
* whose resolution is observable, so "the gate deferred before touching the DB" is proven by the `Lazy`
* never being resolved.
*/
@RunWith(AndroidJUnit4::class)
class FetchGateReceiverInstrumentedTest {
private val context: Context = ApplicationProvider.getApplicationContext()
// A fresh, real, never-unlocked session per test — so the real guard reports UNLOCKED only because
// app-lock is off (see [unlockedGuard]), never because of leftover auth state.
private val session = PassphraseSession()
@Before
@After
fun resetGate() {
DebugFetchGate.reset()
unmockkAll()
}
@Test
fun pauseUpdatesTheGateAndReturnsTheReadBack() {
val data = sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill,prefetch")
assertEquals("paused=[backfill,prefetch]", data)
assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL))
assertTrue(DebugFetchGate.isPaused(FetchScope.PREFETCH))
}
@Test
fun resumeAllClearsTheGateAndReturnsAnEmptyReadBack() {
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "all")
val data = sendGateBroadcast(FetchGateReceiver.ACTION_RESUME, "all")
assertEquals("paused=[]", data)
assertFalse(DebugFetchGate.isPaused(FetchScope.BACKFILL))
assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH))
}
@Test
fun queryReadsBackTheStateWithoutMutatingIt() {
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill")
val data = sendGateBroadcast(FetchGateReceiver.ACTION_QUERY, scope = null)
assertEquals("paused=[backfill]", data)
assertTrue(DebugFetchGate.isPaused(FetchScope.BACKFILL))
assertFalse(DebugFetchGate.isPaused(FetchScope.PREFETCH))
}
@Test
fun aBackfillPausedGateDefersTheBackfillWorkerWithoutResolvingTheBackfiller() = runBlocking<Unit> {
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "backfill")
val lazyBackfiller = mockk<Lazy<MailBackfiller>>()
val worker = TestListenableWorkerBuilder<BackfillWorker>(context)
.setWorkerFactory(backfillWorkerFactory(lazyBackfiller, unlockedGuard()))
.build()
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
assertEquals(Result.retry(), result)
// The gate deferred BEFORE any DB-backed work — the Lazy was never resolved.
verify(exactly = 0) { lazyBackfiller.get() }
}
@Test
fun aPrefetchOnlyPauseLeavesTheBackfillWorkerRunning() = runBlocking<Unit> {
// The worker gate honours BACKFILL only; pausing PREFETCH must NOT defer history paging — the
// on-device analogue of "on-demand open and header sync stay live while prefetch is paused".
sendGateBroadcast(FetchGateReceiver.ACTION_PAUSE, "prefetch")
val backfiller = mockk<MailBackfiller> { coEvery { runBackfill(any()) } returns false }
val lazyBackfiller = mockk<Lazy<MailBackfiller>> { every { get() } returns backfiller }
val worker = TestListenableWorkerBuilder<BackfillWorker>(context)
.setWorkerFactory(backfillWorkerFactory(lazyBackfiller, unlockedGuard()))
.build()
val result = withTimeout(TIMEOUT_MS) { worker.doWork() }
assertEquals(Result.success(), result)
verify { lazyBackfiller.get() }
}
/**
* Sends the [FetchGateReceiver.ACTION] broadcast to the receiver by explicit component (mirroring
* `am broadcast -n`), ordered, and returns the result data the receiver set (the harness read-back).
*/
private fun sendGateBroadcast(action: String, scope: String?): String {
val latch = CountDownLatch(1)
val readBack = arrayOfNulls<String>(1)
val intent = Intent(FetchGateReceiver.ACTION).apply {
component = ComponentName(context, FetchGateReceiver::class.java)
putExtra(FetchGateReceiver.EXTRA_ACTION, action)
if (scope != null) putExtra(FetchGateReceiver.EXTRA_SCOPE, scope)
}
context.sendOrderedBroadcast(
intent,
null,
object : BroadcastReceiver() {
override fun onReceive(c: Context, i: Intent) {
readBack[0] = resultData
latch.countDown()
}
},
null,
0,
null,
null,
)
assertTrue("gate broadcast timed out", latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS))
return requireNotNull(readBack[0]) { "receiver set no result data" }
}
/** A real [EncryptedCacheGuard] reporting UNLOCKED (app-lock off) — so only the gate can defer. */
private fun unlockedGuard(): EncryptedCacheGuard {
val settingsRepository = mockk<SettingsRepository>()
every { settingsRepository.settings } returns flowOf(AppSettings(appLock = false, encryptCache = true))
return EncryptedCacheGuard(settingsRepository, session)
}
private fun backfillWorkerFactory(lazyBackfiller: Lazy<MailBackfiller>, cacheGuard: EncryptedCacheGuard) =
object : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
) = BackfillWorker(appContext, workerParameters, lazyBackfiller, cacheGuard)
}
private companion object {
const val TIMEOUT_MS = 5_000L
}
}