Commit Graph
108 Commits
Author SHA1 Message Date
Jason Ross 9782a28c57 Merge branch 'main' into refactor-folder-label-resolver 2026-07-02 04:03:30 -05:00
JMR-devandClaude Fable 5 9484c2a25b refactor(folders): consolidate, externalize, and memoize folder-label resolution
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>
2026-07-02 04:01:27 -05:00
Jason Ross 18f52e1cb9 Merge branch 'main' into fix-applock-lifecycle 2026-07-02 03:40:26 -05:00
Jason Ross 9c73654723 Merge branch 'main' into refactor-folder-role-table 2026-07-02 03:29:37 -05:00
JMR-devandClaude Fable 5 c39f803c97 fix(security): app-lock grace survives activity recreation; clarify passphrase eviction
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>
2026-07-02 03:28:52 -05:00
Jason Ross cb4ce08b4f Merge branch 'main' into feat-onboarding-2fa-link 2026-07-02 03:18:38 -05:00
Jason Ross 791615da06 Merge branch 'main' into fix-workmanager-update-policy 2026-07-02 03:07:05 -05:00
Jason Ross 76574b66b5 Merge branch 'main' into fix-backfill-gaps 2026-07-02 02:50:56 -05:00
JMR-devandClaude Fable 5 10203d8ee9 refactor(folders): derive roleOf and isServerSpecial from one attribute table
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 #65
Closes #62

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:49:16 -05:00
Jason Ross 1f57d36292 Merge branch 'main' into fix-backfill-gaps 2026-07-02 02:37:33 -05:00
JMR-devandClaude Fable 5 9b970af2d1 feat(drawer): show per-folder unread counts and bold accounts with unread mail
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>
2026-07-02 02:37:24 -05:00
JMR-devandClaude Fable 5 e8c4fc1ea1 fix(sync): re-enqueue periodic work with UPDATE so upgrades re-apply the schedule
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>
2026-07-02 02:27:33 -05:00
JMR-devandClaude Fable 5 d5550cc1d9 test(folders): cover attributes-to-specialUse wiring; fix FolderLabelsTest fidelity claim
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>
2026-07-02 02:25:26 -05:00
JMR-devandClaude Fable 5 bce82452d0 fix(sync): make backfill age floor robust to out-of-order dates and guard lowestSyncedUid
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 #94
Closes #95

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 02:20:31 -05:00
JMR-devandClaude Fable 5 a547c01ff7 feat(onboarding): link Google 2FA help from Gmail app-password step
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>
2026-07-02 02:14:32 -05:00
JMR-dev 21a97222d6 Merge branch 'main' into feat-screen-unlock 2026-07-02 00:07:50 -05:00
JMR-dev 57126f2db0 Merge branch 'main' into feat-screen-unlock
# Conflicts:
#	app/src/main/kotlin/org/libremail/push/IdleService.kt
2026-07-01 23:58:21 -05:00
Jason Ross d6ffd10396 Merge branch 'main' into fix-folder-label-display 2026-07-01 23:53:22 -05:00
JMR-devandClaude Fable 5 7bddc2eb58 fix(folders): apply label disambiguation to picker and app bar; fix self-referential and transient labels
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>
2026-07-01 23:45:15 -05:00
JMR-dev bb9b7f39c4 Merge branch 'main' into feat-screen-unlock 2026-07-01 23:40:23 -05:00
Jason Ross c9d98b4ca2 Merge branch 'main' into feat-sync-battery-network-policy 2026-07-01 23:39:47 -05:00
JMR-dev 9ca53de3da Merge branch 'main' into feat-screen-unlock 2026-07-01 23:31:14 -05:00
Jason Ross c18ab42c6c Merge branch 'main' into fix-html-preview-snippets 2026-07-01 23:29:44 -05:00
JMR-devandClaude Fable 5 26f9127d84 feat(sync): gate full-content fetch on Wi-Fi/battery; IDLE polls at low battery
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>
2026-07-01 23:18:18 -05:00
Jason Ross 657a3aefea Merge branch 'main' into feat-room-migration-tests 2026-07-01 23:17:11 -05:00
JMR-devandClaude Fable 5 c06a387b3c fix(mailbox): derive plain-text preview snippets from HTML bodies
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>
2026-07-01 23:13:16 -05:00
JMR-dev 63b553ab8e Merge branch 'main' into feat-screen-unlock
# Conflicts:
#	app/src/main/kotlin/org/libremail/di/DatabaseModule.kt
#	app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
2026-07-01 23:12:56 -05:00
JMR-dev 2feaa0bb30 Merge branch 'main' into feat-screen-unlock
# Conflicts:
#	app/src/main/kotlin/org/libremail/MainActivity.kt
2026-07-01 22:57:23 -05:00
JMR-dev 4e9e21e847 Merge remote-tracking branch 'origin/main' into feat-fetch-all-retention 2026-07-01 22:46:08 -05:00
Jason Ross 0350572c9f Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:30:52 -05:00
JMR-devandClaude Fable 5 c5c2da8e68 test(db): add Room migration tests with MigrationTestHelper
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>
2026-07-01 22:30:14 -05:00
Jason Ross 4d26a91f59 Merge branch 'main' into feat-message-options-top-bar 2026-07-01 22:29:50 -05:00
JMR-devandClaude Fable 5 f66ec3d7fb fix(security): harden app-lock + encrypted-cache flows (PR #45 review)
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>
2026-07-01 22:29:48 -05:00
JMR-devandClaude Opus 4.8 5283d29d5d Merge branch 'main' into feat-fetch-all-retention
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>
2026-07-01 22:18:50 -05:00
JMR-devandClaude Fable 5 1d796e3c41 feat(message): move message actions from dropdown to top-bar icons
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>
2026-07-01 22:13:25 -05:00
Jason Ross bb3dcd8217 Merge branch 'main' into fix-move-by-role-specialuse 2026-07-01 22:07:18 -05:00
JMR-devandClaude Opus 4.8 6ea02f588d fix(sync): resolve code-review findings on fetch-all history + retention
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>
2026-07-01 21:53:43 -05:00
JMR-devandClaude Fable 5 a6436719b1 fix(mail): prefer special-use folder when resolving move-by-role destination
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>
2026-07-01 21:48:46 -05:00
JMR-devandClaude Fable 5 db96134492 chore(fdroid): add F-Droid metadata, license audit, and anti-feature docs
Prepare for F-Droid publication (issue #16):

- docs/fdroid-compliance.md: full dependency license audit (release
  runtime classpath + buildscript classpath — all FOSS, no Play
  Services/Firebase, no non-free Gradle plugins), an anti-feature
  review of actual app behavior (none to declare: debug reporting is
  opt-in/local-only with no endpoint by default, Android Backup is
  gated off by default, Outlook OAuth is optional per-account with a
  public client id), a complete network-surface inventory, and the
  clean-room build verification (assembleRelease succeeds with no
  secrets.properties).
- app/build.gradle.kts: stop embedding AGP's dependency-info block (a
  Google-Play-encrypted dependency list in the APK signing block) in
  APKs/bundles — a known F-Droid inclusion/reproducibility blocker.
- fastlane/metadata/android/en-US/: store listing (title, short/full
  description, changelog for versionCode 1) that F-Droid reads from
  the repo; listing .txt files deliberately carry no license headers.
- docs/fdroid/org.libremail.app.yml: commented template + instructions
  for the eventual fdroiddata build recipe (submission out of scope).
- README.md: F-Droid section pointing at the above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:43:58 -05:00
JMR-dev d687f11518 Merge remote-tracking branch 'origin/main' into feat-compose-attachment-reminder
# Conflicts:
#	app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
#	app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt
2026-07-01 18:04:59 -05:00
Jason Ross eeeb838658 Merge branch 'main' into feat-compose-collapsible-cc-bcc 2026-07-01 17:18:15 -05:00
Jason Ross bce597b03c Merge branch 'main' into feat-compose-collapsible-cc-bcc 2026-07-01 17:12:43 -05:00
JMR-devandClaude Fable 5 4782b24453 fix(richtext): preserve blank lines between aligned paragraphs; share span merging
Code-review fixes: an all-empty paragraph group (a blank line isolated by an
alignment split) emitted <p></p>, which the parser collapses — it now emits one
<br> per line so blank lines round-trip. The identical span-merge helper that
existed in both the parser and RichTextEditing is now a single shared
mergeSameValueSpans() in RichText.kt, and the private applyBlock/applyLink drop
their never-used default font resolver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 17:05:14 -05:00
JMR-devandClaude Fable 5 671fca99a2 feat(compose): prompt before sending when a mentioned attachment is missing
Send now scans the subject and body for "attach" and its variants
(word-bounded, case-insensitive). When the text mentions one but the
message carries no attachment, an AlertDialog asks "Need to attach
anything?" — Yes returns to composing and pulses the attach button,
No sends the message as-is, and dismissing cancels the send. (#79)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 17:04:15 -05:00
JMR-devandClaude Fable 5 2cefc4751b feat(richtext): parameterized styles, alignment/image/base-style channels, HTML round-trip
RichStyle becomes a sealed interface (Bold/Italic/Underline/Strikethrough +
FontFamily/FontSize/FontColor/Highlight); RichTextContent gains alignments,
images, and baseStyle channels. The HTML serializer emits merged <span style>
runs, text-align on <p>/<li> (splitting merged paragraphs at alignment
boundaries), <img src="cid:…"> over the visible [image: name] token, and a
single outer <div style> for the base style. The parser is a faithful inverse
and additionally tolerates <del>/<strike>, px font sizes, #rgb colors, and
start/end alignment synonyms; unknown CSS is ignored without dropping text.

hasFormatting() covers every new channel so ComposeViewModel.normalizedHtml()
never silently drops serialized formatting. The editor carries parameterized
style identity via string annotations (libremail:style / libremail:image), maps
alignment onto ParagraphStyle ranges, holds baseStyle in separate field state,
and RichTextEditing.toggleStyle now replaces a different value of the same kind
while styleAt() answers "current value over the selection" for pickers.
ColorSwatchRow is added for the upcoming color/highlight dialogs. No UI change.

Closes #70

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 16:54:25 -05:00
JMR-devandClaude Fable 5 99ece7b6d7 feat(compose): collapse Cc/Bcc into expandable links under the To field
The Cc and Bcc fields now start collapsed into small left-aligned link
buttons under the To box, freeing about two field heights of vertical
space for the message body. Tapping a link expands it into the regular
input field and focuses it; a field also expands on its own when it
already carries recipients (reply-all/mailto prefill, resumed drafts)
and never re-collapses once shown, so it cannot vanish mid-edit. The
expansion state lives in the UI via rememberSaveable and survives
rotation. Moving the Bcc field also gives it the medium shape every
sibling field already had.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 16:20:23 -05:00
JMR-devandClaude Fable 5 f99c2df85f fix(compose): restore Bcc recipients when resuming a draft
saveOrDeleteDraft persists the Bcc line, but the init-block restore
never copied it back, so reopening a draft silently dropped its Bcc
recipients (and re-saving then lost them for good).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 16:20:07 -05:00
JMR-devandClaude Fable 5 a6ec00d203 fix(notifications): open the tapped message from a new-mail notification
Tapping a new-mail notification only brought the app to the foreground:
the content PendingIntent was a bare launch intent shared by every
notification, and nothing on the activity side handled a message target.

Per-message notifications now carry an explicit open-message intent —
action + id extra + a per-message data URI, so each message keeps its
own PendingIntent under filterEquals instead of all collapsing onto one
FLAG_UPDATE_CURRENT entry. MainActivity parses the id on fresh launch
and in onNewIntent and hands it to the NavHost as pending state (the
pendingCompose handoff pattern) to navigate to the reader. The group
summary keeps the plain open-the-app intent.

Fixes #56

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 15:51:16 -05:00
Jason Ross 4a139faf61 Merge branch 'main' into feat-screen-unlock 2026-07-01 15:39:48 -05:00
JMR-devandClaude Opus 4.8 77f837e67a Merge main into feat-fetch-all-retention
Resolve the Room schema-version collision: main's PR #54 added MIGRATION_11_12 (folders.specialUse), colliding with this branch's v11->v12 uid/retention/backfill migration. Renumbered ours to MIGRATION_12_13 — the two migrations touch disjoint tables, so ours stacks cleanly on top — bumped the DB to version 13, kept main's 12.json as the v12 schema and regenerated 13.json, and renamed Migration11To12Test -> Migration12To13Test.

Verified locally: assembleDebug, testDebugUnitTest, lintDebug, ktlint, detekt, compileDebugAndroidTestKotlin, and Migration12To13Test on an API 37 emulator all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:34:47 -05:00