Accounts, credentials, per-account settings and signatures lived in the
same libremail.db that SQLCipher encrypts under the auth-bound passphrase
when app-lock + encrypted-cache are on. A genuine key invalidation
(biometric re-enrollment or lock removal/re-add) made that file
undecryptable, and the "clear + re-sync" recovery wiped the accounts and
stored credentials along with the mail cache, dropping the user into
onboarding (issue #111).
Move those four tables into a new plaintext AccountDatabase
(libremail-accounts.db) that is never bound to the auth key. Credentials
stay AES-GCM sealed at the column level by the surviving non-auth
KeystoreCrypto master key, so the only secret never touches disk in the
clear. A cache-key invalidation now wipes only libremail.db; the user
stays signed in.
- AccountDatabase (v1) + AccountDatabaseModule; the cache DB drops to v15
via MIGRATION_14_15. DAOs are unchanged and re-provided from the new DB,
so no injection site changes.
- AccountDataMigrator performs the one-time cross-DB copy at startup,
before Room opens either database. It attaches the cache (with its
resolved passphrase, so an encrypted source is handled) and copies with
INSERT OR IGNORE. It is crash-safe and idempotent: the source is dropped
only by MIGRATION_14_15 after the copy, a re-run never duplicates or
overwrites, and it runs after the clear-pending wipe so an unrecoverable
cache degrades to "nothing to move" instead of blocking.
- Exported schemas for both databases; MigrationTest asserts the account
rows/backfills survive to v14 then are dropped at v15, plus a dedicated
14->15 test. AccountDataMigratorTest covers the plaintext + encrypted
copy, idempotency, a DDL-vs-Room drift guard, and end-to-end survival of
a simulated cache wipe.
Closes#111
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mailbox list observed the entire `messages` table (observeSummaries, no
WHERE/LIMIT), mapped every cached row to a domain Message, and filtered down to
the visible account+folder in MailboxViewModel — so its cost scaled with the
whole cache and re-ran on every write to `messages` (IDLE delivery, a flag
toggle, a backfill page, any folder sync). On a 20k-row cache that is ~125 ms of
work per unrelated write.
Push the account/folder filter into SQL (observeFolderSummaries /
observeUnifiedFolderSummaries, exposed via observeFolderMessages /
observeUnifiedFolderMessages) and flatMapLatest the ViewModel over the selected
account+folder. The only remaining client-side pass separates the normal list
from an active search over the small folder-scoped set.
Validated on an emulator against 1k/5k/20k-row caches (docs/perf/issue-86-
profiling.md): the account-scoped query is ~1.5 ms flat (~80x faster at 20k) and
is already served by the existing (accountId, folder, uid) index — so NO
composite index and NO schema migration are added. The ticket's proposed
(accountId, folder, inInbox, timestampMillis) index changes timing only within
noise and isn't even preferred by SQLite's planner. The unified "All inboxes"
view stays an O(N) folder scan (still 5.6x better) and is a follow-up for paging;
IMAP latency on folder open is a separate, unmeasured concern.
Closes#86
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parentOf() re-inferred the IMAP hierarchy separator from each folder's name,
relying on an unenforced invariant (displayName == fullName.substringAfterLast(
separator)) established three layers from where ImapClient reads the
authoritative JavaMail folder.separator and then discards it.
Carry that separator through FetchedFolder -> FolderEntity -> Folder and split a
folder's parent on it. Fall back to the old name inference only for legacy rows
whose delimiter is null, until the next folder refresh (delete-then-insert)
backfills the real value.
Adds a nullable folders.hierarchyDelimiter column via a Room v14 -> v15 migration
with the exported v15 schema, and registers MIGRATION_14_15 in DatabaseModule so
existing v14 installs actually upgrade (provideDatabase configures no destructive
fallback, so an unregistered migration would crash every upgrading user).
Tests: JVM unit tests for parentOf() (persisted vs. null delimiter, incl. the
case where inference cannot locate the parent) and the FetchedFolder->entity
round-trip; an instrumented v14->v15 migration test plus the chain-replay
assertion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Behavior-preserving cleanup of the app-lock UI plumbing:
- SettingsScreen: replace the app's only Toast with the canonical
SnackbarHostState + Scaffold(snackbarHost) + consume pattern for the
app-lock rejection message (matches MailboxScreen); the ViewModel keeps
the @StringRes id, resolved via LocalResources at the display boundary.
- AppLockGateHost: replace the hand-rolled DisposableEffect +
LifecycleEventObserver with LifecycleEventEffect, and the ContextWrapper
findFragmentActivity() walk with LocalActivity; remember the derived
activity and the authenticate lambda.
- AppLockManager: delete the dead availability() API and the four-value
AppLockAvailability enum (no production caller; the
BIOMETRIC_STRONG or DEVICE_CREDENTIAL canAuthenticate combo is
unsupported on minSdk 29). Keep isDeviceSecure() and AUTHENTICATORS.
- AppLockViewModel: derive the gated uiState from the injected gate via a
single publish() helper instead of hand-mirroring gate.state at each
auth site; the transient Checking cover and app-lock-off unlocked states
stay explicit (settings/lifecycle-driven, not session-gate-driven).
Extend SettingsScreenTest with a Compose test for the rejection snackbar
(now visible to Compose semantics) and drop availability() from its fake.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three code-quality cleanups from the PR #54 review, all in the folder-label
plumbing so they ship as one change (adapted to the post-#108/#117 code):
#69 providerLabel: consolidate provider-brand host matching. Host->brand
knowledge now lives solely in MailProvider: forImapHost matches an entry's
imapHost plus new hostAliases (Gmail gains legacy imap.googlemail.com), and a
new companion brandFor(account) is the single seam that also recognizes
Outlook (by OAuth auth type or a precise office365.com / outlook.office.com
host, not any substring). MailProvider stays the app-password preset registry
(Outlook is not an entry). providerLabel() drops its ad-hoc host substrings.
#68 i18n: move folder-label disambiguation patterns into strings.xml. The
"base - provider", "base (parent)", and "base [path]" grammars become
folder_label_with_provider/parent/path resources, threaded into the pure
resolver as a LabelPatterns bundle whose defaults match the old literals; the
composable resolves the localized strings and passes them down.
#67 FolderDrawer: memoize label resolution, resolver early-return, fail-fast
lookups. resolvedFolderLabels hoists the role->string and pattern lookups out
of a remember() so the resolved map is rebuilt only when folders/accounts/
strings change (not every recomposition of the idle drawer). resolveDrawerLabels
returns baseLabels unchanged when nothing collides, and both map lookups use
getValue so a key miss fails loudly instead of silently un-deduplicating.
Behavior is unchanged: existing FolderLabelsTest and FolderDrawerTest
assertions (from #60/#61/#64/#108) stay green. Adds unit tests for host->brand
matching and its over-match guard, pattern-driven formatting, the early-return
identity, and fail-fast on a missing base label.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two lifecycle-consistency fixes from PR #45's review (issue #101).
1. Grace across Back/recreation. The AppLockGate state machine was a field of
the Activity-scoped AppLockViewModel, so Back on the task root (which finishes
the Activity and clears its ViewModelStore on API 29/30) dropped the grace
marker and re-armed a fresh LOCKED gate, demanding full re-auth on return —
unlike leaving via Home. Provide AppLockGate as an application-scoped @Singleton
(SecurityModule) and inject it into the ViewModel, so the same instance is
reused across recreation and the 30s grace behaves identically for Back and
Home. A genuine cold start (process death) still constructs a fresh, LOCKED gate.
2. PassphraseSession eviction. The KDoc promised the passphrase is "cleared on
lock, timeout," but nothing re-locked it on grace expiry and full eviction is
not achievable without a DB close/reopen (provideDatabase runs once per process;
owned by #93 / #111). Correct the KDoc to state the process-lifetime limitation
explicitly and add a code comment at the timeout re-lock deferring full eviction
to #93 / #111. We deliberately do NOT call session.lock() on timeout: it is the
only separately-held copy but also drives EncryptedCacheGuard, so clearing it
while merely locked (not exited) would stall background sync/push even though the
DB stays open — not a correct partial eviction. No DatabaseModule changes.
Tests (JVM): extend AppLockGateTest to cover grace surviving a reused-instance
recreation within and beyond the window, and a fresh gate starting LOCKED; add
AppLockViewModelTest asserting the gate is an injected dependency the ViewModel
delegates to (onBackground/onAuthError).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the two hand-maintained RFC 6154 tables in FolderRole's companion
-- roleOf's attribute when-ladder and the separate SPECIAL_USE_ATTRIBUTES set,
which had already drifted (\All and \Flagged were special-use but had no role
branch) -- with a single ordered ATTRIBUTE_ROLES map from a lowercase
SPECIAL-USE attribute to the FolderRole it implies (null = server-special but
role-less). roleOf returns the first role-bearing entry the folder advertises
(insertion order preserves the old ladder's precedence); isServerSpecial treats
every key as special-use. One source of truth, so the two can no longer diverge.
Also adds \Important (RFC 8457) as a role-less special-use key, so Gmail's
[Gmail]/Important is recognized as server-provisioned and the drawer de-dup
renders "Important - Gmail" instead of leaking the raw "Important ([Gmail])"
namespace form (#62). Purely additive: no role/specialUse mapping changed for
any existing attribute, and specialUse stays a plain Boolean column re-derived
on the next folder refresh -- no Room migration needed.
Tests: extends the #64 fidelity fixtures (FolderRoleTest, FolderMapperTest) with
the \Important case, and adds table-order precedence and role-less-fallback
guards pinning the refactor behavior-for-behavior.
Closes#65Closes#62
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a live unread-count signal shared by two navigation-drawer indicators:
- #83: each folder row shows a trailing unread-count badge (capped at
"99+"), hidden when zero. Screen readers announce the exact count via a
plurals content description.
- #84: accounts with unread mail render their email in bold in the drawer
account switcher and the mailbox account-filter chips.
Both derive from one efficient Room aggregate, MessageDao.observeUnreadCounts():
a COUNT(*) ... GROUP BY accountId, folder over folder-synced rows
(inInbox = 1 AND isRead = 0) that pulls no message rows into memory. Its
GROUP BY is served by the existing (accountId, folder, uid) index, so no
schema change or migration is needed. MailboxViewModel derives
folderUnreadCounts (drawer account, per folder) and accountsWithUnread
(any folder) from the one shared flow.
Unread scope is folder-synced mail in any folder, kept consistent across
both features: an account reads as bold exactly when one of its folders
shows a badge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Periodic sync, backfill, and prune were enqueued with
ExistingPeriodicWorkPolicy.KEEP, so a newer app version's interval or
constraint change never reached already-installed devices: KEEP pins the job
to the spec from whichever version first scheduled it.
Switch the three periodic schedulers to UPDATE (WorkManager 2.8+; 2.11.2 in
use), which re-applies the current spec on each app-start re-enqueue while
preserving the running period's progress. An unchanged spec is effectively a
no-op, so this never resets the schedule on launch the way REPLACE (cancel +
re-enqueue) would. The one-shot kicks (syncNow/backfillNow/pruneNow) keep
their existing policies -- they are a separate concern from #96.
SyncScheduler now injects Provider<WorkManager> (via a new WorkManagerModule)
instead of calling the WorkManager.getInstance() static directly, so the
policy is unit-testable with MockK; the Provider keeps resolution lazy to
preserve the previous initialization timing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes the two test gaps from PR #54's review (issue #64).
1. The single production link between a server LIST response and the folder
feature -- FetchedFolder.toEntity deriving role (FolderRole.roleOf) and
specialUse (FolderRole.isServerSpecial) from IMAP attributes, plus
FolderEntity.toDomain's specialUse pass-through -- had zero coverage; every
listFolders stub returned emptyList and other tests hand-set specialUse.
Adds FolderMapperTest pinning each RFC 6154 attribute to its expected
(role, specialUse): \Sent/\Drafts/\Junk/\Trash/\Archive drive a role and
mark the folder special, while \All/\Flagged mark it special but drive no
role of their own. Adds a MailRepositoryImplTest refreshFolders case that
slot-captures replaceForAccount and asserts persisted specialUse == [true,
false], and extends the observeFolders test to assert the toDomain leg.
2. baseLabelsOf's doc comment claimed it builds labels "the way the drawer
does", but it is a hand-copied literal stand-in for folderDisplayLabel
(which is @Composable and unreachable from a JVM test). Rewords it to state
it is an independent literal fixture that pins the de-dup logic, not the
role-to-wording mapping, and gives FolderDrawerTest an ARCHIVE fixture whose
server name ("All Mail") differs from its friendly label so it can actually
discriminate role-to-label drift.
The mapping itself was correct, only uncovered -- no production change; the
attribute-to-role table refactor is #65.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two code-review-derived backfill-correctness bugs (from the PR #46
review). Both govern where MailBackfiller stops and resumes paging a
folder, so they are fixed together.
(MIN(timestampMillis)), but paging descends by UID. One high-UID
message with an old Date header (moved/imported mail) dragged the
cached minimum below the cutoff and marked the folder complete while
lower-UID within-retention messages were still unfetched — a silent,
permanent gap (completion is sticky). The age floor is now decided from
each page actually fetched: only a page ENTIRELY older than the cutoff
(or folder exhaustion) ends paging, and such a prune-fodder page is not
persisted. The count floor keeps its cheap cache check — it orders by
UID like paging, so inversions can't bite it. oldestSyncedTimestamp had
no remaining caller and is removed.
migrated before the uid column existed, or a UIDFolder.getUID -1
fetch); fetchOlderThan treats beforeUid <= 1 as "nothing older", so the
folder was falsely marked fully backfilled. lowestSyncedUid now ignores
uid <= 0 rows (matching MailSyncer's minWindowUid guard), a stale
persisted boundary <= 0 is discarded on resume, and the per-page
descent takes min over positive UIDs only. A page of entirely
unresolved UIDs stalls the folder — it stays incomplete (a future
scheduled run retries) but reports no immediate more-work, so
BackfillWorker's slice-chaining loop can't busy-spin on it.
Together: #95 guarantees paging always descends with a real positive
UID boundary, and #94 makes the stop decision independent of cached
aggregates, so a placeholder or old-Dated row can no longer end
backfill early through either path. Completion stays sticky and is
declared only on positive evidence, preserving the #12/#13
backfill/pruner non-interference.
Tests (JVM, GreenMail + the existing in-memory DAO-fake harness; all
four fail against the pre-fix code): a high-UID/old-Date message must
not gap within-retention history (#94); an entirely-old page ends
paging without persisting prune-fodder (#94); a uid=0 row must not
poison the boundary (#95); a page of unresolvable UIDs stalls instead
of falsely completing (#95). MessageDaoRetentionTest pins the uid > 0
SQL guard against real SQLite and drops the removed oldestSyncedTimestamp
probe.
Closes#94Closes#95
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gmail's app-passwords page rejects accounts that don't have 2-Step
Verification enabled, and the setup screen's intro text names that
prerequisite without giving the user any way to act on it. Add a
nullable MailProvider.twoFactorHelpUrl (set only for Gmail, to
Google's "Turn on 2-Step Verification" article) and surface it as a
second outlined button under the existing app-password link, reusing
the same UriHandler + snackbar failure plumbing. Yahoo and iCloud
screens are unchanged.
Closes#98
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three display bugs from the PR #54 code review, all in the shared
label-resolution/presentation path:
- #59: resolveDrawerLabels was wired only into the drawer, so the
move-to picker and the app-bar title still rendered the bare
folderDisplayLabel — two identical "Drafts" rows in the picker could
move mail to different folders. Both surfaces now consume the same
resolution via a shared resolvedFolderLabels helper; picker rows
resolve against the unfiltered target list so a row keeps its
disambiguation even when its colliding twin is filtered out.
- #60: two top-level folders sharing a role-derived base label (e.g.
"Sent" and "Sent Items" both classifying SENT on servers without
SPECIAL-USE) fell through to the full-path safety net as a
self-referential "Sent [Sent]". Colliding top-level user folders now
tie-break on the display name: the folder actually named like the
base keeps it, the others show their real server name. Corrected the
resolver KDoc's overclaimed uniqueness sketch.
- #61: the drawer derived the de-dup provider suffix from drawerAccount,
which updates before the lagging folders StateFlow during an account
switch, so stale Gmail folders briefly rendered as "Drafts - Outlook".
The suffix now derives from the rendered folder list's own accountId
(providerLabelFor), keeping a stale list under its own account's brand.
Tests: FolderLabelsTest covers the role tie-break and providerLabelFor;
MailboxViewModelTest pins the switch gap with Turbine; MailboxScreenTest
drives the disambiguated move picker (moving via "Drafts - Gmail" lands
in [Gmail]/Drafts) and the app-bar title; FolderDrawerTest renders the
transient switch frame.
Closes#59, closes#60, closes#61.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shared core: BatteryStatusProvider (BatteryManager one-shot +
ACTION_BATTERY_CHANGED flow) feeds SyncResourcePolicy, a pure,
unit-tested decision object; all gates are runtime-only and
self-reverting - no setting is ever mutated.
- #88: FetchPolicy now defaults to WIFI_ONLY in both the AppSettings
default and the DataStore-read fallback, so fresh installs and
never-touched existing installs stop bulk-downloading full content
over cellular. An explicitly chosen policy is unaffected.
- #89: the aggressive body/attachment prefetch pauses for every
FetchPolicy at <=20% battery in BOTH content-prefetch paths -
MailSyncer's recent-window prefetch and MailBackfiller's
full-history prefetch (#12) - resuming on the next sync once above
the threshold; charging exempts. Header sync and backfill header
paging (new-mail detection, notifications, history) are untouched.
- #90: IdleService watches battery and proactively closes its IDLE
connections at <=20%, flipping the foreground notification to say
mail is checked every 15 minutes (the always-scheduled periodic
sync, re-asserted on entry); IDLE resumes at >=25% or on charger
(hysteresis prevents threshold flapping) and catches up missed mail
via idle()'s on-connect sync.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
snippetOf() stripped only tag delimiters with a single regex on every
body, HTML or not: <style>/<script> text leaked into HTML snippets,
entities stayed encoded, and plain-text bodies had literal <...> text
eaten as if it were markup.
Replace it with Snippet.of(body, isHtml), which finally consults the
isHtml flag both call sites already had: HTML bodies go through
HtmlToText (script/style content dropped, tags stripped, entities
decoded), plain text gets no markup handling at all; both paths keep
the whitespace collapsing and the 140-char cap. HtmlToText's entity
decoding is now a single-pass decoder that also handles decimal/hex
numeric character references and never re-decodes produced characters.
Snippets are persisted when a body is first fetched and never
re-derived, so existing rows would keep their broken snippets forever;
a data-only v13->v14 migration re-derives every cached row's snippet
with the corrected logic (schema unchanged relative to v13, exported
14.json committed).
Closes#85
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>