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.
The prior fix matched merge_conditions to auto_merge_conditions, but Mergify's
ruleset-compatibility check kept flagging "Configuration not compatible with
required_status_checks ruleset rule". The actual in-place-checks requirement is
that queue_conditions == merge_conditions, and this config had no
queue_conditions block at all, which Mergify reads as a two-step-CI mismatch.
Add a queue_conditions block to queue_rules.default identical (same conditions,
same order) to merge_conditions:
- base = main
- -draft
- -conflict
- label != broken
- check-success = CI passed
Mergify runs three condition sets sequentially: auto_merge_conditions triggers
queueing, queue_conditions validates queue entry, merge_conditions validates the
merge. All three are now identical. With batch_size 1 and max_parallel_checks 1,
this makes Mergify validate PRs in place on the real branch, keeping the strict
require-up-to-date ruleset enabled (hard invariant). No other settings changed.
Mergify flagged the strict require_status_checks ruleset
(require-branches-up-to-date) as incompatible with speculative draft-PR
checks. Per Mergify, staying compatible requires in-place checks: this repo
already has merge_queue.max_parallel_checks: 1 and queue_rules batch_size: 1,
but queue_rules.default.merge_conditions was missing `base = main` and thus
did not match merge_protections_settings.auto_merge_conditions.
Add `base = main` to merge_conditions and order both lists identically so
Mergify validates PRs in place instead of via a speculative draft PR.
require-up-to-date stays enabled; batch_size, merge_method, and
max_parallel_checks are unchanged.
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.
The `pull_request_rules` `queue` action no longer auto-queues PRs in current
Mergify: a green, matching PR just reported "Merge queue is ready - use
`@Mergifyio queue`" and sat there, never merging. Automatic queueing now lives
in `auto_merge_conditions` under `merge_protections_settings`; the old
`autoqueue`/queue-action auto path is deprecated and stops working 2026-07-16.
Replace the non-functional `pull_request_rules` block with
`merge_protections_settings.auto_merge_conditions`, mirroring the exact same
gating conditions the old queue action used (base = main, -draft, -conflict,
label != broken, check-success = CI passed).
This changes ONLY the trigger (manual -> automatic). It does not touch the
queue's merge semantics: queue_rules (batch_size 1, merge_method merge,
merge_conditions), merge_queue (max_parallel_checks 1), and priority_rules
(P0-P9) are all unchanged. require-up-to-date stays ON - only batching
(batch_size > 1) would force that GitHub checkbox off, and we keep batch_size 1.
When a merge queue is configured, a matched PR is auto-queued (not merged
directly), so it still goes through the serial queue, is updated onto latest
main, re-runs CI, and merges on the real green "CI passed".
Structure verified against the live Mergify docs (file-format, merge-queue
rules/priority/lifecycle/batches, merge-protections auto-merge). YAML parses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>