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>
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>
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>
Closes the gap where app/schemas was exported but never validated (#63):
- androidx.room:room-testing (androidTest) + ship the exported schemas as
androidTest assets so MigrationTestHelper can build old-version databases.
- MigrationTest: 11->12 asserts folders.specialUse arrives defaulting to 0
with existing rows intact; a chain-integrity test requires exactly one
migration per version step up to the newest exported schema; a full
v7->latest replay validates every step against its exported JSON and
asserts seeded v7 data and each migration's backfills survive. The
migration list is discovered from Migrations.kt and the target version
from the exported schemas, so a future migration is covered by just
committing its schema JSON.
- Pin kotlinx-serialization to 1.8.1 via its BOM: androidx.savedstate pins
1.7.3 transitively (shared with androidTest by AGP 9 consistent
resolution), and Room 2.8's schema-bundle serializers need >= 1.8.0 or
MigrationTestHelper throws AbstractMethodError parsing the schema JSON.
Identical to the pin already proven on the feat-fetch-all-retention
branch, so the two merge cleanly in either order.
- Refresh comments that described migration tests as future work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses 14 of the 15 confirmed findings from the max-effort review of the
screen-lock app gate. The remaining one (accounts/credentials share the
auth-bound cache DB) needs a device-tested Room migration and is filed
separately; its blast radius is reduced here by eliminating the spurious wipes.
- Cold-start deadlock: LibreMailApplication injects AccountRepository lazily so
the Room DB is never built on the main thread before unlock.
- Passphrase source of truth: DatabaseKeyStore.resolvePassphrase() keys off
which seal exists, not the app-lock setting; passphrase() refuses to mint a
master key while an auth seal exists.
- Toggle-order strand: disabling app-lock reseals under the master key whenever
an auth seal exists (not gated on the encryptCache setting).
- Crash-safe clear protocol: wipe + reset seals, then clear the flag last; set
clear-pending before flipping app-lock off.
- isInvalidated(): treats a lapsed auth window (UserNotAuthenticated) as valid,
and onForeground short-circuits when app-lock is off.
- unwrapSealedPassphrase: classifies all decrypt failures — no crash after a
successful auth.
- Headless entry points: SyncWorker/SendWorker/IdleService fail fast via
EncryptedCacheGuard instead of blocking DB construction while locked.
- sealWithMaster: deletes the orphaned auth key (no spurious later wipe).
- Lock-bypass race: AppLockGate ignores a background recorded after a foreground
pass began; the ViewModel captures the foreground timestamp synchronously.
- FLAG_SECURE: set while app-lock is on (recents/screenshot protection).
- Resume + re-lock: the gate covers content with an opaque overlay instead of
removing it, so no stale frame renders and in-progress state (nav, drafts)
survives re-lock.
- Retry feedback: lock emissions carry a nonce so a retry updates the UI.
Tests: AppLockGate stale-foreground race cases + an exhaustive
KeyInvalidationPolicy table. Fast gate green + androidTest compiles.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
32b91a1 merged an older main: it dropped main's F-Droid content (docs/fdroid-compliance.md,
fastlane metadata, the build.gradle.kts dependenciesInfo block) and its CI failed only on an
E2E (30) infra flake (~110s in 'Run E2E tests'). This side merges the LATEST main, restores that
content, resolves build.gradle.kts keeping both additions, and is fast-gate + androidTest-compile
green. Supersede 32b91a1 via -s ours.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the build.gradle.kts conflict by keeping both additions: the
androidTest Room-schema srcDir (this branch) and main's F-Droid
dependenciesInfo block. Full fast gate + androidTest compile green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The multi-select contextual action bar buried Archive, Spam, Move,
Select all, and the single-selection Reply/Reply All/Forward behind one
MoreVert dropdown; only Close and Delete were direct. Promote the
common actions to direct IconButtons, matching the reader app bar's
icons-not-menus pattern: Archive (Done glyph - material-icons-core has
no archive icon, so this leans on the "done = archive" mail idiom),
Spam (Warning), and Delete, each with a contentDescription for
accessibility.
The overflow keeps only the long tail: Move (no usable core glyph, per
the ticket it stays text-labeled), Select all, and the
single-selection reply actions. All conditional visibility is
preserved: Archive/Spam still hide while viewing their own role
folder, Move still requires a single-account selection, and the reply
actions still require exactly one selected message. Four 48dp actions
plus Close still fit a 320dp-wide bar; the count title just truncates
earlier.
UI tests: the direct Archive icon archives without opening the
overflow, the direct Spam icon still confirms before reporting, the
Archive icon hides inside the archive folder, and the overflow test
now keys on Select all instead of the promoted Archive.
Closes#87
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewrite the manual-dispatch release.yml into the issue-#19 pipeline:
v* tag push (or dispatch with dry-run/re-release inputs) runs the fast
CI gate, builds bundleRelease + assembleRelease signed from base64
keystore secrets (falling back to *-unsigned artifacts when unset),
generates a Conventional-Commit changelog and SHA-256 checksums, then
creates the GitHub release and fans out to secret-gated Google Play
publish (staged rollout supported), a documented Galaxy Store manual
stub, and an S3-compatible archive under releases/<tag>/. Every
credentialed stage skips with a clear notice while the store accounts
(#16/#17/#18) don't exist yet; no secret lives in the repo and
app/build.gradle.kts is unchanged. docs/release.md documents the
secrets, flows, and per-store manual fallbacks.
Part of #19
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Addresses the review of PR #46 (#12/#13):
- Age-retention backfill/prune loop: mark a folder complete at the
retention floor and resume from the persisted nextBeforeUid low-water
mark; loosening resumes via AccountRepository.resetBackfillProgress.
- Guard the windowed reconcile bound to the lowest positive UID so a
getUID==-1 message can't collapse it and wipe backfilled history.
- Order count-based retention by uid DESC to match the fetch window,
ending the re-fetch/re-prune churn for high-UID/old-Date messages.
- BackfillWorker chains slices while work remains.
- Extract shared effectiveRetention / isActiveNetworkUnmetered /
attachmentCacheDir helpers; remove dead deleteSyncedNotIn/getForAccount;
refresh only pre-existing rows in persistBatch; add composite index
(accountId, folder, uid) with migration + regenerated 13.json.
Adds an age-floor prune regression test. Fast gate + androidTest compile
green on JDK 21.
Follow-ups filed for below-the-cut findings: #93, #94, #95, #96.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Repo-actionable deliverables for the Google Play compliance work (issue #17),
every claim verified against the code and the built release artifacts:
- PRIVACY.md: user-facing privacy policy (device-local mail cache, optional
SQLCipher encryption, traffic only to the user's own mail provider,
on-device-only contacts autocomplete, strictly local opt-in debug reports,
no ads/analytics/tracking SDKs).
- docs/play-data-safety.md: Play Data safety questionnaire mapping -- answer
'no data collected/shared' with per-category code evidence, the policy
exemptions relied on, a dependency audit, and a conservative fallback.
- docs/play-permissions.md: merged-manifest permission audit (incl. the
WorkManager-injected WAKE_LOCK / RECEIVE_BOOT_COMPLETED) with paste-ready
Console justifications for READ_CONTACTS, POST_NOTIFICATIONS, and the
FOREGROUND_SERVICE_DATA_SYNC declaration + demo-video script.
- docs/play-compliance.md: verified targetSdk 37 (requirement: 35+), 16 KB
page-size compliance (all packaged .so PT_LOAD p_align=0x4000, incl.
sqlcipher-android 4.16.0), bundleRelease AAB check, the Gmail-app-password /
no-CASA OAuth note, the console-steps checklist with drafted content-rating
and listing answers, and repo findings (push-mail default vs docs, README
minSdk/app-lock drift, debug-key release fallback).
- README.md: link PRIVACY.md and note the no-Google-OAuth/no-CASA status
(fuller README pass stays issue #20).
Part of #17.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveRoleFolder().pick() chose the destination for archive/reportSpam/
trash as the first selectable folder with the matching role, in server
LIST order. A provider's built-in folder (role via an RFC 6154 attribute,
e.g. [Gmail]/Spam via \Junk) and a same-named user folder (role via
roleFromDisplayName) can share a role, so the winner depended on which
one the server happened to LIST first — silently misrouting mail past
the provider's junk training, retention, and auto-purge.
Prefer the server-advertised special-use folder among same-role matches:
maxByOrNull { it.specialUse } picks a specialUse=true folder over
name-derived ones, and, because maxByOrNull returns the first max, keeps
the existing LIST-order behavior when no special-use folder exists.
Closes#58
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>