Yahoo/AOL trip an automated ~1-hour service lockout after too many rapid or
failed authentication attempts. An unguarded login repeater (notably the IDLE
reconnect loop, which starts at a 5s backoff) could fire several failed LOGINs
within the first minute and lock out a real user for an hour. Add a proactive,
Yahoo/AOL-scoped auth circuit-breaker that spaces out login attempts so we never
reach the lockout, building on the #360 reactive throttle framework rather than
reinventing it.
New (org.libremail.mail), all host-keyed so only Yahoo/AOL are gated:
- ProviderAuthPolicy: per-host AuthCadencePolicy (Yahoo/AOL enabled, everything
else DISABLED). Also exposes the documented 5-connection and 10k-folder caps.
- AuthBackoff: pure schedule — exponential equal-jitter ramp (base 60s -> 30s
floor after jitter, cap 15m) up to a 4-failure threshold, then a fixed 30m
open-circuit window. Every wait stays well under the ~1h lockout.
- AuthThrottleGate (@Singleton): per-account state; onAuthFailure / onAuthSuccess
/ remainingAuthBlockMillis, PII-free logging.
Enforcement:
- ImapClient guards every real LOGIN (connect-per-op, reuse connect/reconnect,
and the IDLE connection): skips the login (AuthBackoffException) while backing
off, arms the gate on an AuthenticationFailedException, clears it on success.
A transient (non-auth) connect error never arms the backoff.
- MailBackfiller skips an auth-blocked account exactly as it skips a reactively
throttled one (#360), so BackfillPacer (#356) never spins a cooldown on it.
The 5-connection cap is already satisfied by connection reuse (#125/#357: ~1 warm
socket + 1 IDLE per account); the 10k folder truncation is respected for free by
backfill stopping when the server returns nothing older.
Tests: pure-schedule, policy-resolution, gate (virtual time, incl. composition
with #360), GreenMail enforcement (auth-fail arms / transient does not / blocked
skips), and an on-device instrumented gate test.
Closes#362
Add a throttle-aware Microsoft Graph HTTP layer (org.libremail.mail.graph) and route
the live me/sendMail path through it, composing with #360's AccountThrottleGate:
- GraphHttpClient: the single Graph HTTP transport seam; preserves the send path's
may-have-sent distinction (GraphTransportException) and parses Retry-After.
- GraphThrottle: caps Graph concurrency at 4, honors a 429/503 Retry-After via the
shared per-account backoff gate (retry after the honored wait, bounded), clears it
on a 2xx. Because the gate is shared+account-keyed, a Graph 429 also cools that
account's IMAP background work down.
- GraphBatch: multiplexes ops via $batch (<=20/call), collapsing N calls to
ceil(N/20); feeds per-op 429s back into the gate.
- GraphUploadSession: createUploadSession + chunked Content-Range PUTs for content
over Graph's ~4 MB one-shot ceiling (320 KiB-multiple chunks).
- GraphSender.send now honors a Graph 429 once (Retry-After) before falling back to
SMTP, instead of failing over on the first throttle.
Outlook mail is read over IMAP and the Graph token is Mail.Send-scoped, so $batch
reads and draft-based chunked attachment upload have no live call site yet (they need
the Mail.ReadWrite scope, a re-consent-forcing change kept out of this perf ticket);
both ship as fully-tested capabilities of the Graph layer.
Unit tests (MockK the HTTP client, coroutines-test virtual time, no real sleeps) cover
429+Retry-After backoff, $batch call-count reduction, and chunked upload; an
instrumented test exercises the toolkit under the Android runtime. PII-free AppLog
(accountLogRef) throughout; SPDX on every file.
Closes#364
Add Gmail's documented IMAP caps (15 max simultaneous connections, 2,500 MB/day
download, 500 MB/day upload, 10,000 messages/labels per limit) as provider-scoped
config/policy that feeds the existing #360/#356 pacing machinery instead of
reinventing it:
- GmailSyncLimits: pure constants + `appliesTo(account)` provider detection via the
existing MailProvider.forImapHost lookup (no changes to MailProvider itself).
- GmailBandwidthTracker: a new, per-account/per-day download-byte tracker (mirrors
AccountThrottleGate's shape: ConcurrentHashMap state, injectable clock, PII-free
once-per-crossing AppLog breadcrumb). Proactive and orthogonal to the #360
AccountThrottleGate (which only reacts to a provider-issued throttle) and #356's
BackfillPacer (which paces slice cadence, not bytes) - same relationship
InteractiveImapGate already documents having to AccountThrottleGate.
Wiring: MailRepositoryImpl.prefetchMessage (the single funnel both MailBackfiller
and MailSyncer's background prefetch already share) records bytes actually pulled
over the network for Gmail accounts; MailBackfiller/MailSyncer's prefetchIfEnabled
consult isOverDailyBudget once per account before starting a batch and defer
body/attachment prefetch for the rest of the day once Gmail's budget is reached -
header paging/sync is never gated, and interactive fetches (open, attachment tap,
inline images) are never gated either, matching the existing interactive-priority
principle (#355/#360).
The 15-connection cap is already satisfied by the existing architecture
(ImapConnectionCache keeps one reused connection per account plus one dedicated
IDLE connection - 2 total, well under the cap); GmailSyncLimitsTest pins that
invariant against the documented ceiling so a future change that grows per-account
concurrency trips a test before it could approach Gmail's real limit. The
10k-messages-per-label figure is captured as a documented constant only - it is
deliberately NOT wired into a backfill stop condition, since issue #12's full
history backfill is intentional and a large real mailbox can exceed 10k messages.
AccountThrottleGate, BackfillPacer, ThrottleClassifier/ThrottleSignal/ThrottleBackoff,
InteractiveImapGate, and MailProvider are all untouched.
Closes#361
The outbound-link E2E tests verified the opened page with Espresso-Intents
(intending(ACTION_VIEW).respondWith(...) + intended(...)). intended() runs an
onView(isRoot()).check(...) whose RootViewPicker waits up to 10s for a
window-focused root. On the CI matrix emulator the activity window
intermittently reports has-window-focus=false, so the assertion flakes with
RootViewPicker$RootViewWithoutFocusException, failing the whole E2E leg and
forcing a 9-min retry. The intent stubs were already present and do NOT fix
this: no external activity launches, the focus loss is environmental (the same
run failed 8 unrelated RootViewPicker-based tests at once).
Verify these ACTION_VIEW/browser-open link taps by injecting a recording
LocalUriHandler and asserting the exact URL the screen opens. That keeps the
tests entirely on Compose interactions, which do not depend on window focus
(280+ Compose-only tests passed in the same failing run), so they are
deterministic without weakening the assertion (still asserts the provider
page / host).
Converted (ACTION_VIEW / UriHandler "browser-open" shape):
- AppPasswordSetupScreenTest.tappingCreateAppPasswordPage_launchesBrowserIntentToHelpUrl
- AppPasswordSetupScreenTest.imapDisabledFailure_showsThePrompt_andHelpLinkOpensTheProviderPage
- OutlookImapNoticeScreenTest.tappingImapHelpLink_opensTheMicrosoftArticle
Real-intent tests (hasComponent/Settings action, no UriHandler seam) keep
Espresso-Intents and are out of scope here.
Backfill chained bounded slices back-to-back with no gap, and on a large
mailbox `moreWork` never clears, so a single BackfillWorker run paged
flat-out for its whole session and kept the account's IMAP connection
saturated -- the background load that starves interactive message-opens
(#355) and worsens provider throttling (#360).
Add BackfillPacer, a small in-process primitive that bounds one run:
- inter-slice cooldown: a fixed 30s idle between chained slices;
- per-run slice cap: at most 4 slices per run, then defer to the 30-min
periodic cadence so one run cannot monopolise the account.
Composes with the two sibling mechanisms instead of duplicating them:
- #355 (InteractiveImapGate): the cooldown is SKIPPED while an interactive
fetch is active -- the next slice already parks at its per-page yield
point, so a fixed delay on top would only double the idle (no pathological
double-delay);
- #360 (AccountThrottleGate): a slice whose only outstanding work is a
throttled account returns moreWork=false, so the loop stops and no
cooldown is spent spinning on a backed-off provider.
The cooldown is a cancellable delay and the loop rechecks !isStopped before
each slice, so a WorkManager stop / teardown ends a run promptly. All
breadcrumbs are PII-free (durations/counts only).
Tests: BackfillPacerTest (JVM, virtual time) covers cooldown timing, cap,
cancellation, and the #355/#360 composition; BackfillWorkerTest asserts the
worker caps a flat-out run; BackfillPacerInstrumentedTest proves forward
progress across paced runs, the interactive skip, and prompt cancellation on
the real Android runtime.
Closes#356
Opening an uncached message stalled ~35-74s (avg 48s) behind the reader
spinner because the on-demand IMAP body fetch has no priority over the
continuous full-history backfill (#12) and loses the race for the
account's IMAP throughput (connect-per-op client, no shared lock).
Introduce InteractiveImapGate (@Singleton), a process-wide priority
signal mirroring MailMaintenanceGate/AccountThrottleGate (#360):
- MailRepositoryImpl wraps the user-facing IMAP paths (openMessage,
inlineImages, downloadAttachment, buildReplyDraft) in withInteractive
{}, which raises an in-flight counter for the duration and always
lowers it in a finally, so a failed fetch can never strand it.
- MailBackfiller parks (awaitInteractiveIdle) at its per-page yield
point while the counter is non-zero, resuming the instant it clears.
This is also the slice's first yield point, so a slice never begins a
page while a user is waiting on a body.
- Backfill's own content prefetch bypasses the gate (calls
ensureAttachmentFile directly) so it never yields to itself.
A counter, not a mutex, is used so overlapping interactive fetches run
concurrently and backfill waits for all to clear. PII-free AppLog
park/resume breadcrumbs (accountLogRef) at the backfill yield points.
Builds on #360's throttle framework (orthogonal: that backs off after a
provider rejects background work; this yields to a foreground fetch).
Tests: InteractiveImapGateTest (counter/park/resume/error-release/
concurrency + Turbine), MailBackfillerTest park+resume+no-deadlock,
MailRepositoryImplTest gate-held-during-open, and
InteractiveImapGateInstrumentedTest (on-device pause/resume across the
CI API matrix).
Closes#355
Compose BOM 2026.06.00 deprecates the v1 test-rule factories in
androidx.compose.ui.test.junit4 in favour of the ...junit4.v2 package
(v2 composes on StandardTestDispatcher instead of UnconfinedTestDispatcher).
Swap the import in all 30 androidTest classes from
androidx.compose.ui.test.junit4.createAndroidComposeRule to
androidx.compose.ui.test.junit4.v2.createAndroidComposeRule.
v2's createAndroidComposeRule<A>() returns the same
AndroidComposeTestRule type, so the call sites are unchanged. The tests
already wait on async state via waitUntil/waitForIdle rather than
assuming eager effect execution, so no test semantics needed adjusting
for the StandardTestDispatcher change. The JVM (test) source set already
used the v2 createComposeRule from PR #375.
Closes#385
Show a lightweight, dependency-free looping illustration of the
"tap Battery -> choose Unrestricted" path on BatteryOptimizationScreen,
before the user is sent to system settings (complements the #150 deep
link, which cannot guarantee the exact per-OEM screen).
- BatteryGuideAnimation: a stylized Compose illustration driven by
rememberInfiniteTransition (no Lottie / AnimatedVectorDrawable, no new
dependency). A highlight moves from a "Battery" row to an
"Unrestricted" option while a tap dot pulses.
- Reduced motion: rememberReducedMotion() reads ANIMATOR_DURATION_SCALE;
when animations are off it renders the same card at rest (no motion).
- TalkBack: the illustration exposes a single contentDescription
mirroring the retained onboarding_battery_guidance text (additive, not
a replacement). Screen made scrollable so the actions stay reachable.
- PII-free AppLog breadcrumbs: shown / opening-settings / skipped.
- Robolectric JVM tests (animated + static + reduced-motion decision)
and the instrumented step test exercise the guide; the step test
disables device animations so the static path renders on-device.
Addresses the below-cut data-core review nits from #313:
- SignatureRepository.delete: wrap delete + default-promotion in one SignatureDao
@Transaction (deletePromotingDefault) so a crash between them can't leave an
account with signatures but no default; log the promotion (PII-free).
- SignatureRepository.create: move the count-then-default check-then-act into a
SignatureDao @Transaction (insertMakingFirstDefault) so two concurrent
first-creates can't both become default.
- AccountSettingsRepository.update: route the read-modify-write through an
AccountSettingsDao @Transaction (readModifyWrite) so concurrent per-field
setters can't clobber each other.
- MailRepositoryImpl expunge/move/move-by-role: chunk the unbounded
getRoutingByIds/deleteByIds IN(:ids) queries (500/chunk) like MailPruner,
removing the latent SQLITE_MAX_VARIABLE_NUMBER crash.
- MessageDao.observeSummaries: remove the dead whole-table projection (superseded
by Paging #124/#214); migrate test/debug-probe callers to getById or the paged
query (which now guards the #51 CursorWindow regression).
- AccountDataMigrator: fix stale KDoc (schema is v2 with sortOrder, not v1).
- DatabaseEncryption.migrate: also sweep the stale -journal sidecar (journal_mode
= DELETE), matching AccountDataMigrator's sweep.
Unit tests updated for the repository delegations; instrumented DAO tests cover
the new @Transaction behaviour; MailRepositoryImplTest covers the chunk split;
DatabaseEncryptionTest covers the -journal sweep.
Closes#313
Six of the seven LOW findings collected in #308; the seventh is
deliberately skipped (see below).
- SettingsViewModel: run the app-lock disable-path Keystore/DataStore
reseal off the main dispatcher (withContext(Dispatchers.Default)),
matching AppLockViewModel's threading policy - no Keystore crypto on
Main.
- AppLockGateHost: clear the covered app content out of the semantics
tree while locked so TalkBack can't traverse the occluded mailbox/
compose nodes behind the opaque cover; content stays composed so its
state still survives a re-lock.
- ReaderViewModel.toggleStar: reconcile the optimistic star on a failed
persist - roll it back and surface a one-shot StarFailed event instead
of leaving the star stuck in a state the store rejected.
- OnboardingViewModel: persist firstAddedAccountId in SavedStateHandle so
a process kill mid-onboarding still finishes onto the first account's
inbox rather than the unfiltered mailbox (preserves #30).
- RichTextEditor: memoize the formatting toolbar's parse + selection
scans with remember(value) so the per-keystroke hot path isn't
re-derived on every recomposition.
- AccountSetupViewModel.onOutlookResult: treat a normal OAuth cancel
(null result) as a no-op instead of surfacing an error snackbar.
Skipped: MailboxViewModel per-keystroke search re-paging - the finding
is documented-intentional and only a "could". The local pager narrows
cached results instantly as you type while the expensive server search
is already debounced (400ms); debouncing the local pager would add lag
for no clear win, and correct scoping (query only, not account/folder)
adds risk to a hot, well-tested path.
Each behavioral change ships a JVM/Robolectric test; the toolbar
memoization (a pure refactor) adds a toolbarStateOf test. PII-free
AppLog breadcrumbs added on the new fallback/state-change paths.
Closes#308
Adds the reactive throttle layer for #360: when a provider rate-limits or
locks us (IMAP `[THROTTLED]`/"too many connections" NO, HTTP 429, auth
lockout), the app now backs off exponentially and pauses the offending
activity instead of hammering the server (which the perf drilldown proved
makes throttling worse — `docs/perf/issue-125-*`).
- ThrottleClassifier: message-text (IMAP/SMTP) + HTTP-status classification of
throttle vs lockout, distinct from ordinary transient errors; conservative
so a wrong password or the #390 "IMAP disabled" state is never misread.
- ThrottleBackoff: pure exponential schedule with equal jitter, a bounded cap,
and Retry-After honoring; a longer window for a lockout (sized to Yahoo's ~1h).
- AccountThrottleGate: per-account backoff state (@Singleton), so one throttled
account never stalls the others; PII-free AppLog breadcrumbs on throttle/clear.
- MailBackfiller: skips an account inside its backoff window and stops paging one
that throttles mid-slice (a degradation, not a moreWork spin) — resumes on a
later slice once the window elapses. Reset on the next successful page.
- MailSyncer: foreground sync feeds the gate but is never blocked by it, so
interactive work keeps priority over background backfill.
Integrates with the existing WorkManager retry (#403) and connection reuse
(#357) rather than duplicating them. Unit tests cover classification (positive
+ negative), the backoff schedule, and the degrade-not-tight-loop behaviour
(virtual time for the timing).
Closes#360
Address the Phase-3 review nits collected in #298:
- perf(HtmlToText): hoist the 4 per-call Regex literals in convert() to
private vals so each compiles once, not once per fetched HTML body.
- fix(ReportStore): write reports via temp-file + atomic rename so a crash
mid-write can't truncate a .json that scan() then silently drops. Temp uses
a non-.json suffix so it is never scanned.
- fix(RichTextEditing): applyLink now splits partially-overlapping links
(keeping the non-overlapping remainder) instead of un-linking it whole,
mirroring subtractRange.
- perf(GraphSender): guard attachment size before readBytes() so an oversized
file can't OOM or blow Graph sendMail's ~4 MB request cap; fails
mayHaveSent=false so the outbox falls back to SMTP (which streams).
- perf(RichText): mergeSameValueSpans is O(n) via a last-run-per-style map
instead of O(n^2) indexOfLast; output is identical.
- fix(DiagnosticsCollector): bucket provider labels by DNS-label boundary, not
raw substring, so mail.notgmail.example no longer reads as Gmail.
Adds/updates unit tests for each behavioural change; pure-perf nits keep their
existing green coverage plus a direct mergeSameValueSpans equivalence test.
MainActivity is exported (launcher / mailto: / share), so although the
per-message ACTION_OPEN_MESSAGE intent is explicit and carries no manifest
intent-filter, any app could still craft an explicit intent at the exported
component and drive the reader to an arbitrary cached message id (#307,
domain/platform review nit 1).
Trust only this app's own notification taps: openMessage now attaches an
unforgeable sender-token PendingIntent (its creator package is stamped by the
system and cannot be forged), and messageId yields the id only when that token
was minted by us. A foreign caller carries no token, or one attributed to its
own package, so its intent is ignored and logged PII-free via AppLog.
NotificationIntentsTest gains a case proving a token-less ACTION_OPEN_MESSAGE
intent is rejected while the genuine one still resolves; existing cases move to
the new messageId(context, intent) signature.
When account setup obtains a valid credential but the IMAP AUTHENTICATE step is
rejected because IMAP access is switched off for the mailbox, surface an
actionable "turn on IMAP" dialog (with the provider's enable-IMAP help link)
instead of the opaque generic auth error (#390).
- ImapAuthError.isImapDisabled classifies the failure on two signals: explicit
provider "IMAP is disabled/not enabled" server text (e.g. Gmail's "not enabled
for IMAP use"), and -- for the Outlook XOAUTH2 path -- a valid-token
AUTHENTICATE rejection, which outlook.office365.com reports only as a generic
"AUTHENTICATE failed". Ordinary wrong-password / expired-token / network errors
are deliberately not matched, so they keep the generic error.
- imapDisabledPromptFor resolves a provider-aware prompt (brand via
MailProvider.brandFor; Outlook + Gmail enable-IMAP help URLs, generic
otherwise).
- Shared ImapDisabledDialog reused by the Outlook picker, the app-password form,
and manual setup -- the three points where the auth failure surfaces. The
reactive complement to the pre-auth Outlook notice (#411/#426).
- PII-free AppLog breadcrumbs at the classification/prompt points (accountLogRef
only; never the email/host/token).
Tests: ImapAuthErrorTest (provider text + OAuth inference + a real GreenMail
wrong-password negative), ImapDisabledPromptTest (brand/URL resolution),
ImapDisabledDialogJvmTest (Robolectric), per-view-model + per-screen wiring
tests, and an instrumented AppPasswordSetupScreenTest case driving the failure
end to end.
Closes#390
persistBatch refreshed each pre-existing backfilled header with a per-row
updateHeaderContent in its own implicit transaction — the same N-commits-per-page
anti-pattern #310 fixed in MailSyncer. Route the whole pre-existing subset through
the batched MessageDao.updateHeaderContents(List) @Transaction so a page costs one
commit instead of one fsync per message (amplified on the encrypted cache).
Semantics are unchanged: updateHeaderContents applies updateHeaderContent to each
row in list order, so the same rows get the same values (and the same casefold
columns); the isNotEmpty guard still skips an empty refresh batch; brand-new rows
stay insert-only. No schema change.
Adds a PII-free, counts-only AppLog breadcrumb at the persist point.
Tests:
- MailBackfillerTest: the refresh routes through the batched update and never the
per-row one; a partial page refreshes only its pre-existing subset in one batched
call; an all-new page skips the batch entirely (empty boundary); breadcrumb counts.
- MessageDaoTest (real Room): the batch writes byte-for-byte the same row as the
per-row path; an empty batch is a no-op.
Closes#322
The targeted UID EXPUNGE (IMAPFolder.expunge(Message[])) from #295/#318 throws
"UID EXPUNGE not supported" on a server without the UIDPLUS extension, which
broke delete/move entirely on rare self-hosted/legacy IMAP servers (#319).
expungeTargeted now probes UIDPLUS from the folder's own already-open protocol
(via IMAPFolder.doCommand, so it never opens a second connection + LOGIN and
keeps the one-connection-per-batch invariant of #125/#295). With UIDPLUS it
still uses the targeted UID EXPUNGE. Without it, it falls back to a plain,
untargeted EXPUNGE only when provably safe: the messages we just flagged are the
only \Deleted ones in the folder. When unrelated \Deleted mail is present a plain
EXPUNGE would destroy it, and there is no UIDPLUS-free way to expunge a single
UID, so we refuse and fail loud, preserving the #295 "never touch unrelated
\Deleted mail" invariant.
PII-free AppLog breadcrumbs record the fallback decision. Covered by GreenMail
unit tests for both branches (with UIDPLUS via the default probe; without via an
injected capability seam, since GreenMail always advertises UIDPLUS).
Closes#319
At account-add both LibreMailApplication's push collector and
IdleService.reconcileWatchers react to the accounts table. The account row was
inserted before its credential was saved, so a watcher could observe the new
account and call MailConnectionFactory.resolveSecret before the secret existed,
logging "No stored credentials" on the first IDLE attempt (it self-healed on
retry, but fired a failed IDLE + log noise on every add).
Primary fix: reorder the writes so the credential is committed before the account
row (the credentials table has no FK to accounts; account_settings does, so its
ensureDefaults still follows the insert). Any reactive observer of the account row
is then guaranteed to see the credential.
Defense-in-depth: resolveSecret now throws a typed MissingCredentialsException and
the IDLE watcher treats it as a transient miss, deferring quietly (short flat
re-check, PII-free info log) instead of the warn + exponential backoff a real
connection drop gets. Genuinely-absent credentials keep deferring without noise.
Tests: AccountRepositoryImplTest pins the credential-before-row order
(coVerifyOrder); MailConnectionFactoryTest asserts the typed exception; a new
instrumented test drives the real repository add path against a real
AccountDatabase + Keystore-backed CredentialStore and proves the secret is
resolvable the instant the account row becomes observable.
New personal outlook.com accounts ship with IMAP OFF by default, so
Microsoft OAuth succeeds while the later IMAP AUTHENTICATE step fails — a
confusing dead-end (#390 handles this reactively). This adds a proactive
interstitial shown when the user taps Outlook during onboarding, before
the OAuth browser launches.
The screen asks whether IMAP is enabled (with a short why-we-need-it
explanation), links Microsoft's canonical "POP, IMAP, and SMTP settings
for Outlook.com" help article and the Outlook.com IMAP settings page
(opened via UriHandler/Custom Tab), and puts a "Sign in" button at the
bottom that continues the existing Outlook OAuth flow unchanged.
- New OutlookImapNoticeScreen (onboarding package) reusing the picker's
exact AppAuth launch wiring via AccountSetupViewModel.
- AccountPickerScreen gains an optional onPickOutlook callback: onboarding
routes the Outlook tap to the notice; the standalone "Add account" entry
still launches auth inline (unchanged).
- New ONBOARDING_OUTLOOK_IMAP route; onboarding setup-form destinations
extracted into onboardingSetupDestinations() for readability.
- PII-free AppLog breadcrumbs: notice shown, help/settings link tapped,
sign-in continued.
- Robolectric JVM Compose test (render, both help links, sign-in
continuation, done/error/busy states) + instrumented E2E (Espresso-
Intents for the help ACTION_VIEW and the AppAuth sign-in launch) +
OnboardingFlowTest coverage of picker -> notice navigation.
Closes#411
Preserve the in-flight #359 crash-loop fix recovered from an orphaned agent
worktree (host crashed before it committed). Widens the fail-closed
LinkageError handling to the keyed opens that previously escaped it
(AccountDataMigrator, deferred Room open, headless workers/IdleService via a
new EncryptedCacheWorkerGuard), plus unit + instrumented regression tests.
Not yet validated end-to-end; gate + E2E run to follow.
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.
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>
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>