When the opt-in cache encryption (encryptCache) is ON, persist crash and
"Report a problem" reports encrypted at rest, decrypting them on read; when
OFF they stay plaintext exactly as before.
- ReportStore gains a ReportEncryption collaborator (default None = plaintext,
so existing call sites are unchanged). On write it seals the storage JSON with
AES-256-GCM and tags it with a marker prefix; on read it sniffs the prefix, so
pre-toggle plaintext and post-toggle sealed reports coexist. Writes FAIL
CLOSED: a sealing failure drops the report rather than leaving plaintext on
disk. Decrypt failures are logged (PII-free) and skipped.
- KeystoreReportEncryption reuses the vetted KeystoreCrypto (non-auth master
key, so a crash while the app is locked can still seal), and mirrors the
encryptCache setting into a crash-safe in-memory flag warmed at startup (no
DataStore read on the crashing thread).
- PII-free AppLog logging at the enable/disable transition and both fallback
paths; never logs report contents.
Tests: JVM unit tests for the ReportStore branching (seal-on-write, plaintext
when off, crash persistence, fail-closed, mixed files, decrypt-failure skip,
markSurfaced re-seal) and for KeystoreReportEncryption; an instrumented test
proves real Keystore ciphertext on disk + round-trip on device.
Add SqlCipherOpenSpikeTest (androidTest), a focused on-device harness for #359
that exercises the encrypted-cache open path in three isolated stages so a
failure pinpoints the break site: Stage A loads libsqlcipher.so
(System.loadLibrary), Stage B reaches SQLiteConnection.nativeOpen via a keyed
open, Stage C opens the full Room encrypted cache through the production
SupportOpenHelperFactory. Each stage records the in-process page size
(Os.sysconf _SC_PAGESIZE) and re-raises the full UnsatisfiedLinkError (which
.so, cause chain, stacktrace) on failure. Investigation only; no app/src/main
crypto change.
On-device A/B finding (Pixel 8 Pro, husky, real SDK 37 / Android 17):
- 4 KB pages (PAGE_SIZE=4096): Stages A, B, C ALL PASS.
- 16 KB pages: NOT tested on-device — the Pixel "Boot with 16 KB page size"
toggle is gated behind an unlocked bootloader ("All user data and settings
will be wiped when activating 16 KB mode"), i.e. destructive + out of scope.
Static ELF proof (refutes the #359 root-cause hypothesis): every bundled
native library is already 16 KB-aligned (all PT_LOAD p_align = 0x4000),
including arm64-v8a libsqlcipher.so from sqlcipher-android 4.16.0 (unchanged
since the original encrypted-cache commit, so the crashing build shipped the
same aligned lib) plus libandroidx.graphics.path.so and
libdatastore_shared_counter.so. So the "unaligned .so" theory does not hold;
the nativeOpen UnsatisfiedLinkError needs a different root cause (library-load
ordering / a nativeOpen reached without a loaded lib, or an APK-delivery /
device-specific issue).
The `timeout -k 30s` wrapper + `capture_wedge()` added in 2f32657 for the
matrix `e2e` job (API 29-36) reproducibly wedges every leg, while the
manually-provisioned API 37 preview shard running the identical capture
logic passes. Revert the two matrix "Run E2E tests" steps' `script:`
blocks to main's plain script (just the backgrounded logcat stream +
`./gradlew connectedDebugAndroidTest`) and drop the now-dead "Upload wedge
diagnostics" step from the matrix job.
Kept untouched: the job-level `timeout-minutes: 50` backstop added in
27ede55, and the entire `e2e-preview` job (its own capture_wedge/watchdog
and wedge-diagnostics-api37-preview-shard* upload are unaffected).
The matrix E2E job (api-level 29-36) had no timeout-minutes, so a wedge
hangs until GitHub's 6-hour default instead of being force-killed. The
sibling e2e-preview job already sets timeout-minutes: 35. A normal
matrix run is ~15-20 min and a retry-inclusive run ~40 min, so set
timeout-minutes: 50 to give headroom above the in-step wedge-capture
timeout (1200s) while still bounding worst-case runtime.
E2E legs intermittently WEDGE (hang) with no fast-fail until the job force-kill,
and GitHub's post-force-kill step behavior is unreliable, so #388's diagnostics
don't reliably capture the wedge — and don't capture wedge-specific state anyway.
Wrap the `connectedDebugAndroidTest` run (both the `e2e` matrix first-attempt +
retry, and each `e2e-preview` shard) in an explicit `timeout -k 30s 1200`
(20 min) — comfortably above a normal run (~13-15 min), well below the hard cap —
so a wedge trips the wrapper (exit 124), NOT the force-kill, GUARANTEEING the
capture runs while the emulator is still alive. On 124, capture_wedge grabs the
smoking gun into a `wedge-diagnostics-api<level>` artifact: the running/last test
(logcat TestRunner), SIGQUIT (kill -3) thread dumps of the app + instrumentation
processes (ART -> logcat + /data/anr), dumpsys activity/window, `service list` +
`service check input/window/activity` (the boot-race crux), sys.boot_completed +
init.svc.* state, the snapshot cache-hit note, and accel/kvm/mem/disk. Then it
exits with the real status so #388's diagnostics + the existing retry still fire;
a normal run finishes before the wrapper and is unaffected.
EVIDENCE ONLY — no boot-readiness guard/fix (maintainer: prove the cause first).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bakes the proven 2026-07-06 cold-vs-warm pause-hook flow into
scripts/device-testing/ as a first-class, reproducible `cold-fetch-ab`
scenario, upstreaming the scratchpad driver.
- fetchgate.py: FETCH_GATE pause/resume/query helpers through the guarded
adb wrapper, with ordered-broadcast read-back parsing (paused=[...]).
- scenarios.cold_fetch_ab: pre-arm halt -> detect sign-in (sync all
breadcrumb) -> confirm halt (prefetch skipped) -> wait for header sync ->
measure cold opens -> resume -> measure warm opens. ALWAYS resumes on exit
(finally), even on error -- never leaves fetch paused.
- report.render_cold_fetch_ab: gate summary, cold/warm tables, cold-vs-warm
delta, connect=0ms reuse proof, throttle signature.
- Portability (subsumes #392): file-based uiautomator dump (not /dev/tty),
UTF-8 adb decode + PYTHONUTF8/console I/O, openMessage-breadcrumb readiness,
row-selection hardening (skip non-message rows).
The pause hook is debug-build-only (#393/#395), so the scenario needs a debug
APK. Automated validation: mocked unittest coverage (adb/breadcrumbs/gate) for
the helpers and the A/B scenario incl. restore-on-error, plus a --dry-run path
exercised end-to-end through perf_harness.main. A full on-device run is a
follow-up. Dev-tooling only; no app/src changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final step of the Robolectric Compose epic (#373): now that batches
#376-384 have all landed and proven stable, measure the new whole-app
JVM line-coverage baseline and raise the no-regression floor to match.
Measured 87.89% line (7994/9095), up from 80.21% (4838/6032) when the
floor was last set. Floor moves 0.79 -> 0.84, a deliberately wider
~3.9% headroom (vs. the usual ~0.5-1%) for this first post-epic
measurement; the maintainer can tighten it further in a follow-up PR.
Docs (CLAUDE.md, preflight SKILL.md) updated to match.
Batch 9/9 (final) of the Robolectric Compose JVM-test epic (#373).
- AppLockGateHostJvmTest: drives the app-lock gate host on the JVM via the
v2 createComposeRule under Robolectric, covering the Unlocked / Checking /
Locked render branches, the "content stays composed after re-lock" latch,
and the no-FragmentActivity auth-error path. AppLockViewModel is mocked.
Drops **/AppLockGateHost* from jacocoNonJvmTestableSurface.
- LibreMailAppJvmTest: covers the JVM-tractable parts of LibreMailApp.kt —
LibreMailBottomBar, StartupCrashPrompt (+ its dialog buttons), and
LibreMailApp's cold-start "hold until known" guards.
- LibreMailApp itself KEPT excluded (the acceptable exception noted in #384):
its NavHost start destinations call hiltViewModel() and the graph needs
owners a plain JVM compose rule can't surface, so graph-level nav stays on
the instrumented OnboardingFlowTest. Documented in the jacoco list.
Instrumented LibreMailBottomBarTest / StartupCrashPromptTest stay as the
on-device E2E. JaCoCo floor unchanged (0.79); scoped line coverage 0.8426.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert the Paging 3 mailbox list + folder drawer to Robolectric JVM Compose
tests (batch 8/9 of umbrella #373) and drop them from jacocoNonJvmTestableSurface.
- MailboxScreenJvmTest drives the real MailboxScreen + MailboxViewModel over
mocked repositories, feeding Paging via static PagingData.from flows (no real
Room/Paging source, mirroring MailboxViewModelTest). Covers the no-accounts
welcome fallback, populated list (sender/subject/snippet, offline badge,
unified per-account labels + filter chips, drafts/outbox entries), the
empty/loading/no-results states, search open/close, and the multi-select
contextual action bar (overflow, archive/spam/delete confirms, move picker,
archive-hidden-in-archive, disambiguated app-bar title).
- FolderDrawerJvmTest drives the callback-driven FolderDrawer: friendly role
names, duplicate-name provider disambiguation + account-switch gap, folder
taps, the multi-account switcher/dropdown, and the unread badge (incl. 99+ cap).
- Remove **/MailboxScreen* and **/FolderDrawer* from jacocoNonJvmTestableSurface;
overall JVM line coverage 84.69% (floor unchanged at 0.79).
The instrumented MailboxScreenTest/FolderDrawerTest stay as the on-device E2E.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert the settings screens/components to Robolectric JVM Compose tests
(umbrella #373, batch 5/9) and drop their globs from
`jacocoNonJvmTestableSurface`, so they count toward JaCoCo's JVM-testable
surface without an emulator.
New `src/test` Robolectric Compose tests (v2 createComposeRule, @Config sdk=36,
NATIVE graphics), mocking each ViewModel where needed:
- SettingsComponentsJvmTest (SectionHeader/SwitchRow/ClickRow/RadioRow/RetentionSection)
- SettingsScreenJvmTest (+ stateless ContactAutocompleteRow)
- AccountSettingsScreenJvmTest
- SignaturesScreenJvmTest
- SignatureEditScreenJvmTest
Line coverage of the newly-included files: SettingsComponents 100%,
SignatureEditScreen 100%, SignaturesScreen 97%, SettingsScreen 95%,
AccountSettingsScreen 84%. Overall scoped line coverage 86.2%. The instrumented
androidTest E2E stay; the JaCoCo floor is unchanged (re-ratchet is #386).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the instrumented ComposeScreenTest to a Robolectric JVM Compose
test (batch 7/9 of umbrella #373) so ComposeScreen's render + interaction
code counts toward JaCoCo's JVM-testable surface, and drop
`**/ComposeScreen*` from `jacocoNonJvmTestableSurface`.
ComposeViewModel is large (7 collaborators, several Context/Room-backed),
so it is mocked — mirroring AccountPickerScreenJvmTest / ManualSetup
ScreenJvmTest — with its state/accounts/finished flows stubbed so every
render/state branch is injectable. A RESUMED lifecycle owner (also the
back-press dispatcher owner) and a no-op ActivityResultRegistryOwner let
`collectAsStateWithLifecycle`, the BackHandler, and the attachment/inline
-image launchers compose on the JVM. The embedded RichTextBodyField renders
live; its toolbar accessibility labels and body-change plumbing are covered.
The instrumented ComposeScreenTest stays as the on-device E2E. JaCoCo floor
unchanged (0.79); overall line coverage 0.86.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the reader screen's chrome to a Robolectric JVM Compose test (umbrella
ReaderScreenJvmTest drives the real ReaderViewModel over a mocked
MailRepository/SettingsRepository via the v2 createComposeRule() — no emulator —
covering the top bar, star/delete/reply/reply-all/forward actions, the
attachment accordion + downloaded indicator, the attachment download-failure
snackbar, and the loading/plain-text/empty/error/remote-images-banner branches.
WebView caveat: the HTML body renders through HtmlBody, a hardened WebView that
Robolectric can only present as a non-rendering shadow, so the banner branch is
driven via an HTML message with a blank body (no HtmlBody call) and no
WebView-rendered HTML is asserted. HtmlBody.kt stays in scope, covered by its
existing HtmlBodyTest/InlineImageResolverTest. The instrumented ReaderScreenTest
stays as the on-device E2E. ReaderScreenKt lands at 94.3% line coverage; the
bundle rises to 82.7%, above the unchanged 0.79 floor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements issue #409: a serial Mergify merge queue that supersedes the
hand-rolled poor-man's queue (autoupdate.yml + ci-trigger.yml +
traffic-control.yml, all already disabled_manually).
- queue_rules "default": batch_size 1 (serial, no batching), merge_method
merge (merge commits, never squash/rebase), merge_conditions gate on
check-success = "CI passed" + -draft + -conflict + label != broken.
- merge_queue.max_parallel_checks 1 (true serial; unambiguously
require-up-to-date-compatible).
- priority_rules map P0..P9 labels (P0 = 10000 highest .. P9 = 1000).
- pull_request_rules queue action triggers auto-queueing (queue_conditions
alone do NOT auto-queue per Mergify lifecycle docs).
require-up-to-date STAYS ON (Phase 1 is the only trilemma combo that keeps
the checkbox literally enabled AND preserves merge commits). No batching
(that is Phase 2 / #410). Single required gate stays "CI passed".
Validated: YAML parses and conforms to Mergify's published JSON schema
(negative-control confirmed). Merging this activates Mergify, so NO
auto-merge — must be reviewed first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Investigate Mergify (free-for-OSS merge queue + batching + speculative checks)
as the right-way replacement for the hand-rolled traffic-controller
(autoupdate.yml + ci-trigger.yml + mothballed traffic-control.yml) and the
manual serial-bump grind, now that GitHub's native merge queue is org-only and
unavailable to a user account.
Proposal only — NO live .mergify.yml, nothing activates:
- docs/ci/mergify-integration-spec.md: how the queue coexists with the single
`CI passed` gate; the require-up-to-date x merge-commits x batching trilemma
and its resolution (Phase 1 serial keeps the rule literally; Phase 2 merge-batch
moves the up-to-date GUARANTEE into the queue); P0-P9 -> priority_rules mapping;
what it replaces; interaction with path-filter/sharding/wedge-diag; risks;
phased adopt recommendation.
- docs/ci/mergify.yml.proposed: annotated, NOT-active proposed config.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert the VM-driven mail list screens (DraftsScreen, OutboxScreen,
ProblemReportsScreen) to Robolectric JVM Compose tests in the `test`
source set, driving each real ViewModel over a mocked MailRepository /
ReportStore + DiagnosticsCollector via the v2 createComposeRule() — no
emulator. Each test covers the empty/populated render states, item
rendering (subject/recipient/body, queued-vs-failed status, crash/manual
kind labels), and the interactions (open, delete, cancel, retry, create).
Drop the three now-JVM-covered globs from jacocoNonJvmTestableSurface so
the screens count toward the JaCoCo denominator; measured coverage is
DraftsScreen 100%, OutboxScreen 100%, ProblemReportsScreen 97%, and the
bundle line ratio rises to ~82.7% (floor 0.79 unchanged). The instrumented
androidTest E2Es (DraftsScreenTest / OutboxScreenTest /
ProblemReportsScreenTest) stay as the on-device tests.
Part of the Robolectric Compose umbrella (#373); mirrors the #375/#376
pattern (AddAnotherAccountScreenJvmTest, format-control JVM tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a cheap `changes` job (dorny/paths-filter v4, pinned SHA) that sets
e2e_needed=false only when EVERY changed file is in a safe allow-list
(app/src/test/**, **/*.md, docs/**, scripts/**, .claude/**); anything
else -- or any non-pull_request event -- defaults to true (conservative,
"err toward running E2E").
Gate `e2e` and `e2e-preview` on needs.changes.outputs.e2e_needed so the
whole matrix runs or skips together, and rewrite the `ci-passed` gate:
it now BLOCKS on changes!=success, any of traffic-control-tests /
static-analysis / debug-build / unit-tests !=success, or e2e/e2e-preview
==failure|cancelled -- while TOLERATING an intentional e2e/e2e-preview
'skipped'. So test-only/docs PRs go green on the fast gate, a real E2E
failure/cancel still blocks, and a broken filter (changes!=success)
still blocks. Branch protection ("CI passed") context is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Robolectric JVM Compose tests (umbrella #373, batch 3/9) for the
account-setup screens and drop them from `jacocoNonJvmTestableSurface` so
their render/interaction code counts toward the JVM-testable coverage surface:
- AccountPickerScreen (98.9% line)
- AppPasswordSetupScreen (98.7% line)
- ManualSetupScreen (98.5% line)
Each test drives the real screen via the v2 `createComposeRule()` under
RobolectricTestRunner with a mocked ViewModel (their own logic stays covered by
the ViewModel unit tests), a RESUMED LifecycleOwner for
`collectAsStateWithLifecycle`, a no-op ActivityResultRegistry for the Outlook
launcher, and a recording UriHandler for the app-password help links — covering
render, per-provider chrome, field/submit wiring, and the enabled/busy/error/
done branches. The instrumented androidTest E2Es stay as the on-device coverage.
The JaCoCo floor (0.79) is unchanged — the re-ratchet is the final #373 step.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add Robolectric JVM Compose tests (umbrella #373, batch 2/9) for the
stateless onboarding + lock screens, and drop each from
jacocoNonJvmTestableSurface so its render/interaction code now counts as
JVM-testable surface:
- LockScreen: locked title/body, optional error, unlock callback.
- WelcomeContent + OnboardingWelcomeScreen: render + add-account; the
wrapper's NotificationPermissionEffect launcher is wired to a no-op
ActivityResultRegistry so no system dialog is surfaced on the JVM.
- LicenseScreen: real bundled GPL text renders, Agree gated on
scroll-to-end, Decline.
- ContactsAccessContent (skip/grant/request/rationale) plus the
ContactsAccessScreen wrapper, driven by a mocked OnboardingViewModel.
- BatteryOptimizationScreen: offered vs. done states; Take me there marks
the prompt handled and resolves the settings intent; Not now finishes.
Contacts/Battery use a tall @Config qualifier so their centered,
non-scrolling columns fit without the lower controls clipping. The
instrumented androidTest tests are kept (and remain the coverage for the
system back press, which the JVM compose rule cannot drive). JaCoCo floor
unchanged at 0.79 (the re-ratchet is the final #373 step).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Port the instrumented ColorSwatchRow / FontPicker / FontSizePicker /
ParagraphAlignmentControl tests to Robolectric JVM Compose tests (v2
createComposeRule, @GraphicsMode NATIVE, @Config sdk=36) in the `test`
source set, and drop their four globs from `jacocoNonJvmTestableSurface`
so they count toward the JVM coverage metric. The instrumented tests stay.
Also fix a latent gap in the #375 infra: the JaCoCo agent skips classes
with no code-source location, which is exactly how Robolectric loads the
classes-under-test through its sandbox classloader — so Robolectric-only
Compose coverage recorded as zero (the PoC AddAnotherAccountScreen
included). `isIncludeNoLocationClasses = true` on the Test tasks makes
that coverage register; scoped bundle line coverage rises ~0.80 -> ~0.82.
Floor left at 0.79 (#386 re-ratchets).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>