Add a preset-size dropdown (10/12/14/18/24pt, plus Default to clear) to the
compose FormattingToolbar via a new FontSizePicker composable, applying
RichStyle.FontSize over the selection through the existing generalized
applyStyle/clearStyle toggle path (no font-size-specific branching needed).
The anchor button shows the selection's current size, or "Default" when
unset/mixed. The rich-text foundation already provided RichStyle.FontSize,
its pt/px-tolerant HTML round-trip, and pt->sp mapping for in-editor
rendering; this ticket wires up the missing UI control.
Closes#73
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MailMaintenanceGate serializes only the backfill↔prune pair. The other two
pairs — sync↔backfill and sync↔prune — are deliberately ungated (foreground
sync uses its own syncMutex to stay UI-responsive) and rely on a disjoint-by-UID
argument for safety, with no test covering it (issue #53).
Add MailSyncConcurrencyTest: a real MailSyncer and a real MailBackfiller/
MailPruner wired to one shared in-memory message store, with CompletableDeferred
gates (mirroring MailMaintenanceGateTest) that park one actor mid-critical-
section while the other's whole critical section runs. Each interleaving is
driven to the boundary (window edge / count floor) where an overlap would
surface as a lost, duplicated, or wrongly-deleted row:
- sync's windowed reconcile runs while a backfill is parked mid-paging just
below the window (tightest edge: lowestSyncedUid == minWindowUid);
- a backfill's below-window page lands after a concurrent full sync of the
window (stale-boundary ordering);
- a count-retention prune runs while a foreground sync is parked in its fetch;
- a full foreground sync runs while a prune is parked inside its critical
section, before it touches the message table.
All four pass: the disjointness invariant holds unlocked. No production code
changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds MailProvider.AOL as a guided app-password provider (after iCloud, before
Other), closing the coupled pair of #156 (app-password help content) and #154
(IMAP/SMTP presets):
- appPasswordHelpUrl points at AOL's "Create and manage 3rd-party app
passwords" article. No twoFactorHelpUrl: verified against both AOL's
app-password article and its separate two-step-verification article that
neither treats 2FA as a prerequisite for generating an app password (AOL
mirrors Yahoo here, not Gmail/iCloud).
- IMAP imap.aol.com:993 (SSL/TLS); SMTP smtp.aol.com:465 (SSL/TLS) — AOL's
official docs and the Thunderbird ISPDB autoconfig only document implicit
TLS on 465 for submission, with no STARTTLS/587 alternative, so this
follows Yahoo's rationale rather than Gmail/iCloud's STARTTLS preset.
AppPasswordSetupScreen's two exhaustive `when` blocks (providerIntro,
twoFactorHelpLabel) gain an AOL branch; AccountPickerScreen already lists
providers generically via MailProvider.entries. Test coverage mirrors the
Yahoo/iCloud assertions: presets, help URLs, no twoFactorHelpUrl, fromKey,
forImapHost, and brandFor resolving a manually-configured imap.aol.com
account to the AOL brand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the previously-unused ColorSwatchRow into the compose FormattingToolbar
with two new controls: a font-color button applying RichStyle.FontColor and a
highlight button applying RichStyle.Highlight over the selection. Each opens a
ColorPickerDialog built on the shared ColorSwatchRow (~8 font colors; yellow /
green / cyan / pink highlighter markers), with a "no color"/"none" entry that
clears the style outright via a new clearStyle op. Buttons reflect the current
selection's color and carry accessible onClickLabels; swatches carry their own
contentDescriptions.
Closes#74Closes#75
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to #148: opening an already-cached message was still slow. Three fixes
on the cached-open critical path (issue #186).
Fix 1 - WebView renders once. The reader resolved cid: inline images AFTER the
first render, so the AndroidView update key (which included inlineImages.keys)
changed and reloaded the whole document a second time for any inline-image email.
ReaderViewModel now resolves inline images and folds them into the SAME state
update as the body, and HtmlBody drops inline images from the reload key, so the
WebView loads exactly once and a late inline-image change never reloads. The
WebView is also destroyed onRelease so it (and its Context) is not leaked.
Pool/pre-warm is left as a TODO (leak-prone; single-render is the dominant win).
Fix 2 - openMessage does no wasted work for a cached, already-read message. Added
a body-less MessageRouting projection (mirrors MessageSummary, no migration);
the routing/flag callers (openMessage's first read, downloadAttachment, setStarred,
deleteMessage, expunge, moveByRole/moveToFolder, buildReplyDraft, prefetchMessage)
route on it, and getById (SELECT *) is reserved for the single read that returns
the body. imapParamsFor (Keystore decrypt + DataStore read) is resolved lazily,
only in the fetch / SEEN-push branches; the cached+read path also skips the
account lookup. De-duped the inlineImages attachment N+1 via a shared
ensureAttachmentFile helper that takes the already-resolved account/folder.
Fix 3 - repository IO off the main thread. openMessage, inlineImages,
downloadedAttachmentParts, and downloadAttachment now run in
withContext(Dispatchers.IO), so their DB/file/crypto work no longer runs on the
Main.immediate viewModelScope during the open animation.
Reader behavior (content, read/SEEN semantics) is unchanged; does not touch the
async SEEN network push handled separately by #170.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Yahoo's guided app-password setup pointed appPasswordHelpUrl at the
generic account-security sign-in page, which assumes the user already
knows to hunt for "Create app password" once there. Point it instead at
Yahoo's own step-by-step "Generate and manage 3rd-party app passwords"
article, mirroring what #153 did for iCloud.
Yahoo does NOT gate app-password creation behind two-step verification
(verified against Yahoo's live help docs, which never list it as a
prerequisite), so — unlike Gmail and iCloud — it keeps twoFactorHelpUrl
null and shows no 2FA button. AppPasswordSetupScreen already renders the
help buttons generically from these provider fields, so no screen change
is needed; the existing PR #152 ordering (2FA link before the
app-password link) is preserved for the providers that have both.
Add a MailProviderTest assertion pinning Yahoo's new app-password URL
(mirroring the iCloud test) and extend the onboarding
yahooSetup_hasNoTwoFactorHelpLink coverage note for #155.
Closes#155
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds friction to the "Report a Problem" form: a required email field (basic
local-part@domain.tld validation), a required consent notice about being
contacted at that address, and a 200-character minimum on the comment field
with a live "x/200" counter that turns red (with the field outline) until the
threshold is met. Submit stays disabled until both the comment and email are
valid, mirroring and extending the existing SUBMITTING gate. The email rides
along on DebugReport (userEmail) so it round-trips through the storage JSON
and the exact payload that's previewed, copied, saved, and POSTed. The new
ViewModel-level guard on submit() also fully integrates with the #161
success-confirmation dialog: invalid attempts never reach SUBMITTING/SUCCEEDED,
so the dialog flow is unaffected.
Closes#159
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iCloud's guided setup screen previously linked only a generic Apple ID
sign-in page and had no two-factor help link, unlike Gmail. Apple also
requires two-factor authentication before it will issue an
app-specific password, so:
- MailProvider.ICLOUD.appPasswordHelpUrl now points at Apple's actual
app-specific-password instructions (support.apple.com/en-us/102654)
instead of the generic appleid.apple.com landing page.
- MailProvider.ICLOUD.twoFactorHelpUrl now points at Apple's dedicated
two-factor-authentication article (support.apple.com/en-us/102660),
so the existing generic 2FA-help button in AppPasswordSetupScreen
picks it up automatically, positioned the same as Gmail's (#152).
- The 2FA button now reads "How to turn on Two-Factor Authentication"
for iCloud instead of Google's "2-Step Verification" wording, via a
new app_password_2fa_help_icloud string.
Closes#153
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the #36 test-coverage gap left after the rich-text editor shipped:
- RichTextEditorTest.kt (new JVM unit test, 20 cases): exercises the
Compose-editor glue in RichTextEditor.kt that had no direct coverage -
applyStyle/applyBlock/applyLink, the AnnotatedString.toRichContent() <->
RichTextContent.toAnnotatedString() round trip across every span/link/
alignment/image/baseStyle channel, and the isStyled/hasBlock predicate
FormattingToolbar uses to light up its buttons. All plain TextFieldValue/
AnnotatedString/Color types, so it runs on the JVM with no emulator.
applyBlock/applyLink go from private to internal so the test can reach
them directly, mirroring applyStyle's existing internal visibility.
- ComposeScreenTest.kt (androidTest, following this file's existing
createAndroidComposeRule + fake-repository harness): one case taps the
bullet-list toolbar button and asserts the sent message carries the
<ul><li> HTML (block markers apply to the caret's line, so no fragile
on-device range selection is needed); another asserts every toolbar
button's click-action label matches its string resource, verifying the
accessibility claim (the labels are onClickLabel, not contentDescription).
Headings remain deliberately out of scope per the ticket - no model/
toolbar changes here.
Closes#36
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Persist a defaultAccountId preference (SettingsRepository/AppSettings,
following the existing key/field/setter pattern), add a "Default account"
switch to AccountSettingsScreen, and prefer it in ComposeViewModel's
from-account fallback (fromAccountId -> valid default -> first account).
Deleting the default account clears the preference (SettingsRepository
.clearDefaultAccountId), and a stale/foreign id is validated against the
current account list before use so it can never crash or point at a
missing account.
Closes#163
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
selectFolder() kicked off its background syncFolder() fetch fire-and-forget
with no loading flag, so opening a per-account folder with no cached
messages yet flashed "No messages to display" for the whole IMAP fetch
instead of a spinner. Adds isSyncingFolder, a StateFlow set for the
duration of that sync (mirroring isRefreshing) and cleared via try/finally
regardless of outcome. A private latestFolderSelection token guards the
clear so a stale sync from a folder no longer selected can't hide the
spinner for whichever folder is actually selected now.
MailboxScreen's non-paged empty branch now holds NoMessagesState back
while isSyncingFolder is true, showing a CircularProgressIndicator
instead — mirroring how the unified-inbox paged branch already gates on
loadState.refresh.
Closes#149
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Spiked #150 against the AOSP Settings source (not just the reference docs): no
public, non-hidden Settings action opens the "Unrestricted/Optimized/Restricted"
screen directly for a specific package. ACTION_VIEW_ADVANCED_POWER_USAGE_DETAIL
would, but it's @hide/non-SDK; the other public battery action,
ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, isn't package-scoped and is a worse
landing for one known app. So ACTION_APPLICATION_DETAILS_SETTINGS (one tap from
the target via "Battery" on stock/Pixel/AOSP) stays the primary target.
BatteryOptimizationManager.settingsIntent() is restructured into a verified,
never-dead-end fallback chain: try app-details, and if it doesn't resolve on
some device, fall back to the battery-optimization list rather than nothing.
The ordering/selection logic is extracted into a small Android-free helper so
it's directly unit-testable; a new instrumented test checks the real candidate
intents/order against a real PackageManager.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
openMessage() was running a live imapClient.setFlag(SEEN) IMAP round trip
(connection + STORE) before returning whenever a message's body was already
cached but unread — purely to mark it read on the server. That network call
sat on the reader's critical path even though nothing needed for rendering
(body/attachments) required it, making "open an already-downloaded message"
feel slow (#148).
The local isRead flag is now set immediately (optimistic, local-only) and
openMessage returns without awaiting the SEEN push. The push itself runs on
a new application-lifetime backgroundScope (same CoroutineScope(SupervisorJob()
+ Dispatchers.IO) pattern already used by LibreMailApplication.appScope and
IdleService.scope), with a bounded retry (3 attempts, short backoff) since
today's folder sync deliberately never overwrites local read/star flags with
server state (see MessageDao.updateHeaderContent) and therefore would not
otherwise re-drive a push that never reached the server.
Closes#148
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract the AES-256-GCM Android Keystore plumbing that `KeystoreCrypto` and
`DatabaseKeyCipher` copy-pasted (~60 lines) into a shared alias-parameterized
base, `AesGcmKeystoreCipher`: the encrypt/decrypt bodies, existing-key lookup /
get-or-create under a lock, key deletion, and the 5 identical GCM constants now
live in ONE place. Each cipher keeps only its delta — the `KeyGenParameterSpec`
(via `keySpecBuilder()`) and, for the auth-bound key, the invalidation handling.
Preserve — deliberately — the two ciphers' different missing-key-on-decrypt
behavior via a `generateKeyOnDecrypt` policy parameter, documented on the base:
- master key (`KeystoreCrypto`, true): auto-generates on a missing alias, correct
for a first-run key with nothing sealed yet.
- auth-bound cache key (`DatabaseKeyCipher`, false): fails fast, because a missing
auth-bound key means it was INVALIDATED and silently regenerating it would
re-arm the lock against a cache that can no longer be decrypted.
Also map the opaque `AEADBadTagException` (thrown when the master path generates a
fresh key then can't decrypt old data) to a clear `GeneralSecurityException`,
while leaving `KeyPermanentlyInvalidatedException` to propagate unwrapped.
Unify the accepted-authenticator policy behind one source of truth,
`AuthenticatorPolicy.ACCEPTED`, mapped into each API's vocabulary
(`AppLockManager.AUTHENTICATORS` for BiometricManager / BiometricPrompt,
`DatabaseKeyCipher.keySpec` for KeyProperties / KeyGenParameterSpec) so the two
can no longer drift — a drift that yields a prompt that succeeds but a key that
throws `UserNotAuthenticatedException` at use.
Add JVM tests for the shared base (both `generateKeyOnDecrypt` modes + the AES-GCM
error mapping) and for the authenticator mapping. #100's seal-exchange and
policy-table safety net stays green.
Closes#102
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Close the test-coverage gap on the app-lock security core (#100): the
branching that decides when to WIPE user data or drop the lock, which
shipped largely untested.
- AppLockViewModelTest: pin the onAuthenticated unlock/arm classification
(OK / UNRECOVERABLE / RETRY) and the onForeground LockAction dispatch --
DISABLE_APP_LOCK persists the setting, CLEAR_* set the pending flag and
drop the gate BEFORE the awaited re-sync enqueue and process restart,
and CLEAR_AND_REQUIRE_AUTH clears + restarts but keeps app-lock on.
- KeyInvalidationPolicyTest: make the exhaustive 16-row decision table a
test, with a completeness guard so no row can be dropped. The common
(appLock on, encrypt off, secure, valid) -> REQUIRE_AUTH row is now
pinned, so a mutation to PROCEED (a silent lock bypass) fails.
- DatabaseKeyStoreTest: new JVM tests for the dual-seal exchange
(sealWithAuth dropping SEALED_MASTER, sealWithMaster, resetSealedPassphrase,
unlockWithAuth, clear-pending) pinning the "never both seals at once" and
"not recoverable without auth" invariants.
- SettingsViewModelTest: setAppLock reject / reseal / disable branches.
To make the device-only DatabaseKeyStore crypto JVM-testable, add a
minimal @VisibleForTesting DataStore seam (mirroring AppLockViewModel's
injectable dispatcher); production still uses the real per-app DataStore.
No crypto plumbing is refactored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Inline images in rich HTML emails (embedded via Content-ID and
<img src="cid:...">, e.g. USPS Informed Delivery digests) were listed
under Attachments with a download button and never rendered in the body.
Two bugs combined; both are fixed here.
1. Misclassification: ImapClient classified any part with a filename as
an attachment, sweeping inline images (which carry a filename AND a
Content-ID under Content-Disposition: inline) into the list. A part is
now a downloadable attachment only when its disposition is attachment,
or it has a filename but no Content-ID; an inline image is collected
separately and excluded from the displayed list (AttachmentDao filters
contentId IS NULL). The Content-ID is read via MimePart.getContentID()
so it resolves from IMAP BODYSTRUCTURE rather than a per-part header
fetch that Angus leaves unpopulated.
2. No rendering path: HtmlBody's WebViewClient now overrides
shouldInterceptRequest to resolve cid:<id> to the matching part's
bytes (backing the CSP's existing cid: allowance). Content-ID is
threaded end-to-end through AttachmentPart, Attachment,
AttachmentEntity, and MailRepository.inlineImages(); ReaderViewModel
surfaces the cid->bytes map to the WebView.
Schema: adds attachments.contentId (v16 -> v17, MIGRATION_16_17).
Tests: MIME-part classification (inline+cid excluded, real/disposition/
filename-only kept), a GreenMail multipart/related round-trip, the
cid->bytes resolver, repository inlineImages(), the DAO display filter,
and the v16->v17 migration.
Closes#133
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The key-invalidation recovery restart was unreliable in two ways, both in
AppLockViewModel:
1. Same-process self-restart race: restartProcess() did
context.startActivity(...) immediately followed by Runtime.exit(0) in the
same process, so ActivityManager could schedule the relaunch into the
process being killed and drop it — the app just closed, recovering only on
the next manual launch. Fixed with a ProcessPhoenix-style separate-process
trampoline (RestartActivity in a distinct ":restart" process, driven by
ProcessRestarter): it kills the original process by PID and only then
relaunches, so the relaunch is issued from a process that survives the kill.
No new dependency; LibreMailApplication early-returns in the ":restart"
process so it runs no normal startup work.
2. Lost syncNow() enqueue: clearCacheAndRestart() enqueued the post-wipe
re-sync fire-and-forget, but WorkManager persists the WorkSpec
asynchronously on its serial task executor, so exiting raced that insert and
could drop the re-sync (now user-visible after #118: an empty mailbox until
the next periodic sync). syncNow() now returns its enqueue Operation, and
clearCacheAndRestart awaits it (bounded by a 5s timeout) before restarting,
so the WorkSpec is durably persisted first.
CLEAR_PENDING recovery-flag semantics are preserved; the cache wipe still
happens at cold start in DatabaseModule (unchanged).
Tests: JVM unit tests assert the enqueue Operation is awaited before the
restart is triggered (order) and that a timed-out enqueue still restarts;
SyncSchedulerTest pins syncNow() returning the enqueue Operation. The
separate-process kill/relaunch is device-only and noted for on-device
wipe+resync verification.
Closes#99
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>