Commit Graph
100 Commits
Author SHA1 Message Date
JMR-devandClaude Fable 5 d0a5ccb10d ci: auto-update armed PRs; drop dead merge_group trigger
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 12:48:23 -05:00
JMR-devandClaude Fable 5 d424abc6d3 refactor(security): de-duplicate AES-GCM Keystore plumbing and unify authenticator policy
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>
2026-07-02 12:21:09 -05:00
JMR-devandClaude Fable 5 5777967375 test(security): cover the app-lock security core
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>
2026-07-02 11:41:40 -05:00
JMR-devandClaude Fable 5 3971e89d1e fix(reader): render inline cid: images in HTML emails
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>
2026-07-02 10:44:05 -05:00
JMR-devandClaude Fable 5 c84b0605cf ci: trigger on merge_group so the GitHub merge queue can gate PRs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:21:47 -05:00
JMR-devandClaude Fable 5 15c4cd9ae8 fix(security): harden app-lock recovery restart
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>
2026-07-02 10:09:21 -05:00
JMR-devandClaude Fable 5 ed9b9e2742 fix(di): defer database provisioning off the Hilt inject path
DatabaseModule.provideDatabase ran the whole startup sequence with
runBlocking while Hilt constructed the singleton database — a DataStore
read, a Keystore op, a possible SQLCipher re-key conversion, and (since
#111) the cross-database AccountDataMigrator — synchronously on whichever
thread first injected it, which can be the main thread (jank / ANR).

Move that work behind DatabaseProvisioner.prepareCache(): a memoized,
mutex-guarded suspend that runs the same sequence, in the same order, on
the IO dispatcher. Both databases' Room builders now open through a
DeferredOpenHelperFactory whose delegate — and therefore the gate — is
materialised only when Room first OPENS the database, on its background
query executor, never at inject time. AccountDatabase's open gates on the
same prepareCache(), preserving the #111 migrate-before-open ordering that
the old construction-time dependency on LibreMailDatabase enforced.

Behaviour, ordering, and crash-safety are unchanged — only where and when
the work runs moved off the (possibly main) inject thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:01:46 -05:00
JMR-devandClaude Fable 5 ae37823aec feat(reader): collapse extra attachments into an accordion
A message with several attachments used to render every AttachmentRow
stacked vertically, pushing the message body arbitrarily far down. Now
only the first attachment shows by default; when there is more than one,
the extras collapse behind a "See x more attachments" control that
expands and collapses with an animated, rotating chevron. A single
attachment renders exactly as before (no accordion).

The count uses a plurals resource (quantity one/other) so it reads
"See 1 more attachment" / "See 2 more attachments" correctly. The toggle
is one clickable Role.Button whose label and chevron contentDescription
expose the expanded state to screen readers. Download/open behavior of
each row is unchanged.

Refs #134

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:56:56 -05:00
JMR-devandClaude Fable 5 a7a7c323b5 refactor(security): derive backup exclusion set from DatabaseFiles
Make BackupPolicy.EXCLUDED_DATABASE_PATHS the true single source of truth
by deriving it from DatabaseFiles.NAME and DatabaseFiles.ACCOUNTS_NAME plus
their SQLite sidecars via a new DatabaseFiles.fileNames() helper, instead of
a hand-maintained list. This adds libremail-accounts.db (accounts + encrypted
credentials, split into their own DB by #118/#111) to the never-back-up set,
matching the field's stated intent, so a newly added database can never
silently fall out of the exclusions again.

Also fix DatabaseFiles.clear to wipe the cache DB via
context.deleteDatabase(NAME), which additionally removes the -mj*
master-journal temp files the hand-rolled suffix list missed. It still wipes
ONLY the cache DB (NAME) and never the accounts DB (ACCOUNTS_NAME), preserving
the sign-in-survives-cache-wipe separation from #111.

Update the backup XML comments (data_extraction_rules.xml, backup_rules.xml)
to note libremail-accounts.db is also kept off-device by the strict include-
allowlist, and extend the tests to assert the accounts DB is covered by the
exclusion SoT and that the derivation stays in lockstep with the XML resources.

There is no active backup leak today: the XML is a strict include-allowlist,
so the accounts DB was already excluded by omission. This closes the SoT drift
#118 introduced and the -mj* gap, so the security posture no longer depends on
the allowlist staying strict by luck.

Closes #103

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:45:54 -05:00
JMR-devandClaude Fable 5 d877f50fd9 feat(contacts): move contacts permission to onboarding + settings
Recipient autocomplete's READ_CONTACTS permission was requested lazily on
every compose-screen open (a LaunchedEffect(Unit)), re-prompting users who
had declined. Move the request to a dedicated, skippable onboarding step and
add a Settings entry to turn it on later, each with an in-context rationale.

- #127: new skippable ONBOARDING_CONTACTS step (mirrors the battery step),
  requested once. ComposeScreen no longer prompts; it only reads the current
  grant on resume, so a grant made later (e.g. from Settings) still takes
  effect the next time compose opens.
- #128: the onboarding step and the Settings request show a short rationale
  (contacts are used only for on-device autocomplete, never uploaded) and
  handle shouldShowRequestPermissionRationale so a re-request explains itself.
  docs/play-permissions.md updated to match.
- #129: Settings -> Contacts -> Recipient autocomplete reflects on / off /
  blocked-in-settings; requests in-app when grantable, deep-links to the app's
  system settings when permanently denied.

Graceful degradation is preserved: ContactsRepository.search still runCatch-es,
ComposeViewModel.searchContacts() still guards on contactsAllowed, and the
suggestion list still renders only when non-empty.

Adds a pure ContactPermissionDecision (JVM unit-tested), extends the onboarding
view-model tests, and adds Compose UI tests for the onboarding step
(skip / grant / deny / rationale) and the Settings row states.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:37:40 -05:00
JMR-devandClaude Fable 5 8bfc31f17c spike(imap): prototype flag-gated connection reuse for folder-open
Prototype the per-account connection reuse the #125 investigation recommended
and deferred, behind an OFF-by-default flag so it cannot destabilize `main`.

- ImapConnectionCache: keeps one authenticated Store alive per account, guarded
  by a per-account mutex, keyed by connection identity (not the rotating
  secret), with lazy catch-and-retry-once stale handling. No eviction policy
  yet beyond an explicit closeReusedConnections() hook.
- ImapClient gains a `reuseConnections` flag (default false via the @Inject
  no-arg constructor). With it off, withStore is byte-for-byte the previous
  connect + LOGOUT-per-call; with it on, calls borrow the kept-alive Store.
- ImapFolderOpenLatencyTest flips the flag on: the same real-IMAP operations
  that cost N connections / N LOGINs collapse to 1 connection / 1 LOGIN, with
  the necessary per-open EXAMINE unchanged (proven via CountingImapProxy +
  GreenMail; localhost is ~0 RTT so this proves structure, not wall-clock).
- docs/perf/issue-125-connection-reuse-spike.md: prototype design, the
  flag-off-vs-on proof, per-decision trade-offs, and the refined real-device
  validation plan. References #125; does not close it (needs device validation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:29:55 -05:00
JMR-devandClaude Fable 5 f8d03a4343 perf(mailbox): page the unified "All inboxes" list (#124)
The unified inbox query (WHERE folder = ?, no accountId) has no folder-leading
index, so it scans in timestamp order and materializes the whole unified inbox
(~4k rows at a 20k cache) into memory on every emission. Apply Paging 3 to the
unified browse path so query, mapping, and recomposition cost scale with the
visible window, not the total cache.

- MessageDao.pagingUnifiedFolderSummaries: a PagingSource over the folder's
  synced rows (inInbox = 1); unified search keeps the whole-folder query so it
  can still surface transient server-search hits.
- MailRepository.pagedUnifiedFolderMessages: a Pager (pageSize 40, initialLoad
  120, no placeholders) mapping summaries to domain.
- MailboxViewModel.pagedMessages: paged while browsing the unified inbox, else
  empty; the messages list flow stays empty in that state so the whole cache is
  never materialized. Selection captures each row's accountId at tap time, so
  "Move" still resolves the selection's account without an in-memory list.
- MailboxScreen renders the unified browse list via collectAsLazyPagingItems;
  per-account and search views render the flat list unchanged (issue #86 stays
  flat).

Profiling (docs/perf/issue-124-unified-inbox-paging.md) on an api29 emulator:
current whole-inbox first-emit ~24.6 ms at a 20k cache vs. the paged first page
~6.8 ms and flat regardless of cache size (~3.6x). EXPLAIN QUERY PLAN shows the
paged query still stops early on the existing timestamp index, so no
(folder, ...) index and no schema migration are added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:49:52 -05:00
JMR-devandClaude Fable 5 e53a553398 fix(security): copy account tables by shared columns, not SELECT *
Device upgrade testing surfaced a crash: on a cache last written before
v13, account_settings has 4 columns (accountId, signature,
signatureEnabled, notificationsEnabled) but the destination table has 6
(retentionCount/retentionMonths were added at v13). The migrator ran
`INSERT OR IGNORE INTO account_settings SELECT * FROM cache...`, which
supplied 4 values for 6 columns and threw SQLiteException — and because
the done-flag is only set after a successful copy, every launch re-ran
and re-crashed (crash loop).

AccountDataMigrator now copies each table by the column names present in
BOTH the freshly-created destination and the (possibly older) source, so
columns the source lacks take the destination's defaults instead of
overflowing the value list. Verified on-device: the upgrade migrates a
pre-v13 install cleanly and the account stays signed in (sync/backfill
workers run). Regression test seeds a v12 cache and asserts the copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:47:05 -05:00
JMR-devandClaude Fable 5 ec5e3088c0 chore(schema): export v16 cache schema after rebase on main
Main advanced to @Database v15 (the #66 folder hierarchyDelimiter
migration). Renumbered the account-tables-drop migration 14->15 to
15->16 and bumped the cache DB to v16; this exports the v16 schema
(main's v15 delimiter schema minus the moved account tables). Main's
own 15.json is kept unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:27:53 -05:00
JMR-devandClaude Fable 5 7529a8fa7d fix(test): correct AccountDataMigratorTest assertions
Two on-device assertion failures (green on JVM compile, red on the
emulator):

- migratorDdlMatchesExportedAccountDatabaseSchema built its expected DDL
  by substituting the schema's `${TABLE_NAME}` placeholder with a
  backtick-wrapped name, but the exported createSql already wraps the
  placeholder in backticks — producing a double-backticked identifier
  that never matched the (correct, single-backticked) migrator DDL.
  Substitute the bare name so the guard compares like-for-like.
- movesEveryAccountTableOutOfAPlaintextCache asserted signatureEnabled
  was false, but the seed row sets it to 1 (true). Assert the seeded
  values for both booleans so a true and a false each round-trip.

The production migrator DDL and drop logic were already correct; only
the tests were wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:25:26 -05:00
JMR-devandClaude Fable 5 d9d50f1903 fix(test): make instrumented migrator tests return Unit
`@Test fun x() = runBlocking { ... }` whose block ends in `.apply { }`
returns the DB (non-Unit), so JUnit4 rejects the whole class at runtime
with InvalidTestClassError ("method should be void") — which compiles
fine locally but fails every E2E job on the emulator. Use
`runBlocking<Unit>` (the existing DatabaseEncryptionTest idiom) so the
methods are void while keeping the expression body ktlint expects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:25:26 -05:00
JMR-devandClaude Fable 5 9d70bc2932 fix(security): move accounts/credentials to a non-auth-bound database
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>
2026-07-02 08:25:25 -05:00
JMR-devandClaude Fable 5 b0bb942a02 test(imap): measure folder-open round-trip structure (#125)
Investigate IMAP folder-open latency (follow-up to #86). Localhost GreenMail
has ~0 RTT, so real wall-clock latency can't be measured here; instead this
pins the folder-open round-trip STRUCTURE deterministically.

Finding: ImapClient.withStore wraps every operation in its own short-lived
Store, so each folder-open pays a full CONNECT + TLS + LOGIN + EXAMINE +
FETCH + LOGOUT. Only EXAMINE + FETCH is intrinsic to opening a folder; the
whole connection-setup group is avoidable on the 2nd+ operation if a
connection were reused. Optimistic render-from-cache already exists
(selectFolder renders cached rows; the network sync is a background refresh).

Adds:
- CountingImapProxy: a localhost TCP proxy that forwards a cleartext IMAP
  session to GreenMail while counting TCP connections and parsing IMAP
  command words.
- ImapFolderOpenLatencyTest: asserts the current no-reuse behaviour (N opens
  => N connections and N LOGINs; list+read => 2 connections) against a real
  in-process IMAP server. Doubles as the harness to validate a future
  connection-reuse fix (flip the counts to assert reuse).
- docs/perf/issue-125-imap-folder-open.md: the per-open round-trip sequence,
  avoidable vs. necessary round-trips, and the recommended per-account
  connection-reuse/keep-alive mitigation with its IDLE / thread-safety /
  battery / stale-connection constraints.

Analysis + harness only; the connection-reuse fix is deferred pending
real-network + real-device measurement (see the doc's measurement plan), so
this references #125 without closing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:24:18 -05:00
JMR-devandClaude Fable 5 e7bb69d2ac perf(mailbox): scope the message-list query to the viewed folder in SQL
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>
2026-07-02 07:16:12 -05:00
JMR-devandClaude Fable 5 e28c52b6bf feat(folders): persist the server-reported IMAP hierarchy delimiter (#66)
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>
2026-07-02 07:02:09 -05:00
JMR-devandClaude Fable 5 9aaf34c95c refactor(security): app-lock UI plumbing cleanup (#104)
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>
2026-07-02 04:38:22 -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
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
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
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
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
JMR-dev 9ca53de3da Merge branch 'main' into feat-screen-unlock 2026-07-01 23:31:14 -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
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
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
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 195c07aa32 Merge remote feat-fetch-all-retention (32b91a1); keep the complete latest-main merge
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>
2026-07-01 22:28:06 -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
JMR-devandClaude Fable 5 0b0f6b7018 ci(release): add tag-triggered signed-release and store-publish workflow
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>
2026-07-01 22:01:34 -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 3f7024d05d docs(play): add privacy policy, data-safety mapping, and permissions justification
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>
2026-07-01 21:52:40 -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 807f2402a5 chore(fdroid): tighten accuracy of CI and KnownVuln wording in audit doc
CI runs ktlint/detekt and the E2E suites (not Android lintDebug), and the
KnownVuln rationale should not imply blanket TLS enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:46:06 -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
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
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
JMR-devandClaude Opus 4.8 528cbd94c4 chore(preflight): add ktlintCheck + detekt to the fast gate
CI's "Static analysis" job runs :app:ktlintCheck :app:detekt, which the
local preflight gate did not, so style violations in test/androidTest
source sets (which lintDebug skips) failed the merge gate only after
push. Add both to the /preflight skill and mirror the change in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:28:25 -05:00
JMR-devandClaude Opus 4.8 e20981d083 style(test): satisfy ktlint in new folder-label tests
Body expression on the signature line (function-signature) and one
argument per wrapped line (argument-list-wrapping) in the tests added
for drawer folder de-duplication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:15:24 -05:00
JMR-devandClaude Opus 4.8 eb0e649c30 fix(test): avoid observeAll() in MessageDaoRetentionTest (CI compile glitch)
The androidTest compile failed ONLY in CI with "Unresolved reference 'observeAll'"
on the single line using dao.observeAll(), while every other MessageDao call in the
same file resolved, the identical observeAll().first().map{}.toSet() in
LibreMailDatabaseTest compiled fine in the same unit, and the file compiled cleanly
locally (even `clean --no-build-cache`). That points to a Kotlin incremental-compilation
artifact specific to the newly-added file, not a code error.

Replace the observeAll()-based readback with explicit getById point lookups — a clearer
per-row assertion that also sidesteps the glitch. Verified on the API 37 emulator (5/5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:08:16 -05:00
JMR-devandClaude Opus 4.8 0f479b2431 fix(mailbox): de-duplicate folder names in the drawer
The drawer rendered every standard-role folder with a generic friendly
name (e.g. "Drafts") and discarded the server name, so a Gmail account
with both a provider built-in folder and a same-named user folder showed
two identical entries (Drafts, Archive, Spam).

De-duplicate labels provider-agnostically: when 2+ folders would render
the same name, the provider's built-in special folder (identified by RFC
6154 SPECIAL-USE flags, now persisted on the folder cache) gets the
provider name appended ("Archive - Gmail"), a nested user folder gets its
parent location ("Reports (Work)"), and a top-level user folder keeps its
plain name. Only triggers on a real collision, so stock accounts are
unchanged.

Adds a `specialUse` column to the folders table (Room v11 -> v12).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:01:11 -05:00
JMR-devandClaude Opus 4.8 1d4103747f test(sync): assert MailMaintenanceGate serializes backfill and prune
The gate's "backfill and prune never interleave" guarantee was only argued
structurally: the MailBackfiller/MailPruner unit tests each build a throwaway,
uncontended gate, so the serialization is never exercised. Add MailMaintenanceGateTest:

- oneGateSerializesConcurrentCriticalSections: 50 coroutines contend on one gate;
  an overlap counter must never exceed 1 (guards against a per-access mutex).
- aConcurrentPruneWaitsForAnInFlightBackfillToReleaseTheGate: a real MailBackfiller
  parks inside the gate (its fetchOlderThan suspended) while a real MailPruner is
  launched concurrently; the two share ONLY the gate, and the prune provably cannot
  enter its critical section until the backfill releases.

Turns the defence-in-depth guarantee from argued to asserted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:52:36 -05:00
JMR-devandClaude Opus 4.8 a73b6410a2 test(sync): tally rows offered to insertNew to prove no double-fetch
The backfiller's "no message fetched twice" claim (full-history + resume tests)
previously rested on the insertNew fake de-duping by id, so a re-fetched page was
silently absorbed and `cached.size == TOTAL` could not fail on it. Count the rows
offered to insertNew BEFORE de-dupe and assert it equals TOTAL - WINDOW, so any
re-request of an already-cached page now fails the test. This isolates the real
boundary-descent guarantee and, unlike a fetchOlderThan call-count, is independent
of BACKFILL_BATCH_SIZE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:33:13 -05:00
JMR-devandClaude Opus 4.8 ec60348c89 test(sync): pin MessageDao retention/backfill boundary SQL on real SQLite
The MailPruner/MailBackfiller unit tests mock the DAO, so the queries that
actually define the device-only retention floor were never exercised against a
real database: the newest-N-by-(timestampMillis, uid) prune selection, the
strict age cutoff, the windowed reconcile that spares backfilled history, and
the lowest-uid / count / oldest floor probes the backfiller stops on.

Add an instrumented MessageDao test on an in-memory Room DB covering all of
them, including the timestamp/uid tie-break direction (a flipped ORDER BY would
locally delete the user's newest mail) and inInbox/folder/account scoping.
This closes the disjoint-sets safety argument at the SQL-boundary level, not
just the orchestration level. Verified on the API 37 emulator (5/5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:22:40 -05:00
JMR-devandClaude Opus 4.8 f70bda77d4 fix(mailbox): project message list to avoid CursorWindow overflow
MessageDao.observeAll() ran `SELECT * FROM messages` and returned full
MessageEntity rows — including the potentially large body/isHtml columns —
for every cached message at once. Dragging big HTML bodies through SQLite's
shared ~2 MB CursorWindow overflowed it once enough bodies were cached,
crashing with "Couldn't read row N from CursorWindow" (#51).

Replace it with observeSummaries(), a body-less column projection into a new
lightweight MessageSummary POJO. The list never renders or searches the body,
and the reader already loads it lazily per-message via getById when a message
is opened, so nothing else needs it.

Add a regression test that reads back rows whose bodies exceed the window.

Closes #51

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:41:06 -05:00
JMR-devandClaude Opus 4.8 b3ac6d4353 fix(build): pin kotlinx-serialization to 1.8.1 for Room migration tests
AGP 9's consistent resolution shares the runtime serialization version with the
androidTest classpath, where androidx.savedstate pins it to 1.7.3. Room 2.8's
schema-bundle serializers are compiled against >= 1.8.0, so MigrationTestHelper
threw AbstractMethodError on GeneratedSerializer.typeParametersSerializers(),
failing every E2E job. Import the serialization BOM as a platform to force 1.8.1.

Verified on-device (API 37): Migration9To10Test fails unpatched, passes patched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:17:23 -05:00
JMR-devandClaude Opus 4.8 ac7d669133 Merge remote-tracking branch 'origin/main' into feat-screen-unlock
Brings the screen-lock app gate (#22) up to date with 25 commits of main
(signatures, backup opt-in, battery optimization, rich compose, reporting).

Conflicts resolved as a union of both features:
- SettingsRepository: adopt main's top-level Keys + shared toAppSettings()
  refactor and thread appLock through it; keep both appLock and includeInBackup
- DatabaseModule: keep provideSignatureDao; keep DatabaseFiles.NAME for DB_NAME
- MainActivity: wrap LibreMailApp(pendingCompose=...) inside AppLockGateHost
- SettingsViewModel/SettingsScreen: union app-lock and battery state/effects;
  keep LocalResources for the app-lock toast (LocalContextGetResourceValueCall lint)
- SettingsScreenTest: construct SettingsViewModel with the merged 5 args
- strings.xml: keep both the app-lock and battery/diagnostics string blocks

Fast gate green with JDK 21: assembleDebug + testDebugUnitTest + lintDebug +
compileDebugAndroidTestKotlin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:17:22 -05:00
JMR-devandClaude Opus 4.8 bad597bc42 feat(sync): default fetch-all history + device-only retention (#12, #13)
Replace the fixed 50-message-per-folder header cap with a background,
resumable full-history backfill, and add a user-configurable device-only
retention limit that prunes local mail beyond it without ever deleting from
the server.

- ImapClient.fetchOlderThan pages a folder backwards in bounded batches,
  locating the boundary by binary search over message numbers (O(log n) tiny
  UID fetches, memory bounded to one batch).
- MailBackfiller + BackfillWorker page each synced folder newest→oldest,
  persisting a per-folder boundary in a new backfill_progress table so a run
  interrupted by process death / network loss resumes exactly where it stopped.
  Runs off the sync mutex, so foreground sync / pull-to-refresh stay responsive.
- MailSyncer now reconciles server deletions only within the recent UID window
  (deleteSyncedInWindowNotIn) instead of wiping everything outside the recent
  50, so backfilled history survives each foreground sync. A materialized
  messages.uid column powers the windowed reconcile and backfill boundary.
- Body/attachment prefetch still honours FetchPolicy (headers first).

- Per-account count/age overrides (nullable) with a global default; 0 = keep
  everything (the default, matching #12).
- MailPruner + PruneWorker delete local rows beyond the limit (cascading
  attachment rows + on-disk cache), never issuing a server delete. Deletes are
  chunked under SQLite's 999-parameter limit.
- Precedence with backfill: backfill pauses (does not complete) at the
  retention floor and both jobs share a maintenance mutex, so they never
  contend; foreground fetch is also capped by the count so it can't re-download
  what pruning just trimmed.
- Settings UI for the global default and per-account override, with copy making
  clear it is device-only, not the server.

Room schema v9→v10 (migration + exported schema + MigrationTestHelper test).
GreenMail tests prove the backfill caches >50 and resumes after interruption;
pruning tests cover count/age limits and never touch the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:17:22 -05:00
JMR-devandClaude Opus 4.8 59c9f9d27e feat(onboarding): opt-in to unrestricted battery/background usage
Add a guided, F-Droid-safe onboarding step and an Advanced Settings recovery
row that let users move LibreMail to "Unrestricted" battery usage, so IMAP
IDLE push (IdleService) and periodic WorkManager sync aren't throttled or
killed by Doze. Deep-links to the system app-details screen rather than the
restricted REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog, so it needs no new
permission and is safe on Play (#17) and F-Droid (#16).

- BatteryPromptDecision: pure, unit-tested gate (supported && !unrestricted && !handled)
- BatteryOptimizationManager: reads isIgnoringBatteryOptimizations, builds the deep-link intent
- Onboarding step shown after the first account is added; skipped when already
  unrestricted or already handled; re-checks status on resume
- Advanced Settings row shows current status and re-opens the system screen
- battery_prompt_handled flag persisted in the settings DataStore (kept out of AppSettings)
- Unit tests for the decision + view model; Espresso E2E for the step

Closes #49

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 12:31:54 -05:00
JMR-dev c3287b66f4 Merge remote-tracking branch 'origin/main' into docs-readme-refresh 2026-07-01 11:06:22 -05:00
JMR-devandClaude Opus 4.8 dde081c4a0 Merge branch 'main' into feat-rich-compose
Renumber the rich-composition schema change onto main's v10 (#43 bcc):
- Migrations.kt: keep MIGRATION_9_10 (bccAddresses) from main; move the rich
  changes (bodyHtml columns + signatures table) into a new MIGRATION_10_11.
- @Database version 10 -> 11; register MIGRATION_10_11; take main's 10.json and
  regenerate 11.json (now carries bccAddresses + bodyHtml + signatures).
- Union OutgoingMessage/ComposeViewModel/Routes (bcc + bodyHtml + signatures +
  reportReview routes); merge both sides' SmtpSender/ComposeViewModel tests.
- Fix MappersHtmlBodyTest positional Draft(...) broken by the inserted bcc field.
Verified: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:56:12 -05:00
JMR-devandClaude Opus 4.8 e395e2af1c Merge branch 'main' into feat-mailto-default-app
Resolve LibreMailApp.kt: union the compose function params so mailto prefill
(pendingCompose/onComposeHandled) coexists with onboarding start-gating
(appViewModel) and the crash dialog (startupViewModel); keep LaunchedEffect +
getValue/remember imports. Verified locally: assembleDebug + testDebugUnitTest +
lintDebug + ktlintCheck + detekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:31:56 -05:00
JMR-devandClaude Opus 4.8 291c9a2b4d Merge branch 'main' into feat-debug-reporting
Resolve conflicts from #40/#41/#44:
- build.gradle.kts: keep DEBUG_REPORT_ENDPOINT field + val, take #40's
  outlookRedirectScheme (gmailRedirectScheme was deleted).
- LibreMailApp.kt: function takes BOTH appViewModel (start-dest gating, #44)
  and startupViewModel (crash dialog, #42); use renamed AccountPickerScreen.
- SettingsScreen.kt: keep both the Diagnostics (#42) and Backup (#41) sections.
Verified locally: assembleDebug + testDebugUnitTest + lintDebug + ktlintCheck + detekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:18:28 -05:00
JMR-dev 781966e0a0 Merge remote-tracking branch 'origin/main' into feat-onboarding-flow 2026-07-01 09:59:41 -05:00
JMR-dev df5b99aff9 Merge remote-tracking branch 'origin/main' into feat-backup-optin 2026-07-01 09:49:24 -05:00
JMR-devandClaude Opus 4.8 25e1a8b83c test(onboarding): scroll app-password fields/button into view before tapping
Real cause of the API 29-36 E2E timeout (the earlier 5s->15s bump didn't help,
proving it wasn't slowness): AppPasswordSetupScreen is a scrolling Column and the
"Test and add" button sits below the fold on the short default matrix emulator, so
the positional performClick was a silent no-op -> no add -> no navigation -> the
add-another wait never resolved. It passed on API 37 only because that job uses a
taller pixel_2 AVD. performScrollTo() each field + the button before interacting,
matching the existing pattern in SettingsScreenTest. Verified compileDebugAndroid
TestKotlin + ktlintCheck on JDK 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:27:29 -05:00
JMR-devandClaude Opus 4.8 876539b514 test(onboarding): widen onboarding E2E waitForText timeout to 15s
OnboardingFlowTest passed on the API-37 job but timed out (ComposeTimeoutException
after 5000ms) across the animation-disabled API 29-36 matrix, at the single
async-gated transition: click -> viewModelScope coroutine -> addImapAccount ->
DONE -> LaunchedEffect -> navigate -> AddAnother render. The flow is correct
(green on API 37; ManualSetupScreenTest proves the add-callback path); the 5s cap
was just too tight for that compound step on slower matrix emulators. waitUntil
returns as soon as the text appears, so the happy path is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:41:05 -05:00
JMR-devandClaude Opus 4.8 3ea166607c test(reporting): compile instrumented SettingsScreen test with onReportProblem
The #32/#33 change added a required onReportProblem parameter to SettingsScreen
but left the existing instrumented SettingsScreenTest calling the old signature,
so :app:compileDebugAndroidTestKotlin failed in every E2E job (the local fast
gate never compiles the androidTest variant). Pass onReportProblem = {} in the
test. Verified with :app:compileDebugAndroidTestKotlin on JDK 21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:41:01 -05:00
JMR-devandClaude Opus 4.8 b643c3bb61 docs: refresh README for onboarding/app-password/rich-compose/opt-in features
Bring README in line with the post-batch shipped state (issue #20, folding in
#31's README reconciliation):

- Rewrite the status blurb and feature list to cover the onboarding flow, rich
  compose (HTML + multipart/alternative + signatures), full-history backfill
  with a device-only retention cap, opt-in app lock, mailto/default-app, and
  opt-in local debug reporting.
- Remove the Gmail OAuth setup section and the "no stored passwords for Gmail"
  claim; Gmail/Yahoo/iCloud are now app-password IMAP/SMTP vendors.
- Add an "Accounts and onboarding" section (Outlook OAuth; Gmail/Yahoo/iCloud
  app password with vendor app-password pages + Gmail 2SV note; Other IMAP/SMTP)
  and keep the Outlook OAuth setup section.
- Add a "Privacy and data flow" note: opt-in cache encryption, local
  user-initiated debug reporting (no hosted pipeline), and opt-in Android Backup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:38:27 -05:00
JMR-devandClaude Opus 4.8 bee5737ec4 feat(compose): rich HTML editor, multipart send, and signatures
Bring rich composition to LibreMail (issues #36, #37, #38, and #23).

#36 HTML editor + toolbar
- New pure, JVM-testable rich-text model (`richtext/`): RichTextContent with
  inline styles + links and block markers ("• ", "N. ", "> "), serializing to a
  narrow email-safe HTML subset and back (fromHtml is a faithful inverse).
- Rich editor in ComposeScreen with a bold/italic/underline, bulleted/numbered
  list, block-quote, and link toolbar, backed by AnnotatedString. Unformatted
  text stays plaintext-only (null HTML) so it feels unchanged and is accessible.
- #23: rounded corners on the compose fields/body via MaterialTheme.shapes.

#37 multipart/alternative + reply/forward quoting
- SmtpSender builds multipart/alternative (text/plain + text/html), nested in
  multipart/mixed when there are attachments; GraphSender sends HTML content.
- HtmlToText produces a readable text/plain fallback; ReplyBuilder quotes HTML
  originals as clean blockquotes (tags stripped) without corruption.
- HTML body persists/restores through drafts and the outbox (new nullable
  bodyHtml columns; Room v9->v10 migration + schema).

#38 signatures
- New signatures table (multiple per account, one default) + repository/DAO;
  migration backfills the existing per-account signature as the default.
- Rich signatures reuse the #36 editor; a Signatures management screen (list,
  add/edit/delete, set default) is linked from per-account settings.
- The account's default signature auto-inserts on new compose / reply / forward
  (honoring the enable toggle), placed above the quote, and stays editable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:30:33 -05:00
JMR-devandClaude Opus 4.8 63b49b10da feat(security): screen-lock app gate + auth-bound cache decrypt (#22)
Add an opt-in "Require screen lock" setting that gates the whole app behind
BiometricPrompt (strong biometric with device-credential fallback) and binds
the encrypted cache's SQLCipher passphrase to user authentication.

- App-lock gate: AppLockGateHost wraps the app; a pure AppLockGate state machine
  locks on cold start / resume-after-timeout and unlocks on auth.
- Auth-bound decrypt: DatabaseKeyCipher seals the DB passphrase with a Keystore
  key requiring user auth (setUserAuthenticationRequired, time-bound validity,
  setInvalidatedByBiometricEnrollment). PassphraseSession holds the unwrapped
  passphrase in memory; provideDatabase reads it only after auth.
- The non-auth master key (KeystoreCrypto) is unchanged, so background credential
  access (IDLE push) still works.
- Invalidation / lock removal: KeyInvalidationPolicy decides clear-vs-disable;
  the cache is wiped only at cold start in provideDatabase (never while Room holds
  it open) via a persisted flag + process restart, then re-synced. No corruption.
- Enabling requires a secure device lock; disabling reseals the passphrase back
  under the master key first (guarded to avoid a passphrase mismatch).

Adds androidx.biometric; MainActivity becomes a FragmentActivity (required by
BiometricPrompt). JVM tests cover the gate state machine, invalidation policy,
and passphrase session; the Keystore/BiometricPrompt/restart paths are
device-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:24:37 -05:00
JMR-devandClaude Opus 4.8 e2e2c36d1d feat(onboarding): first-run flow, vendor picker, and app-password setup
Implements the onboarding epic (#26–#31) as a single feature:

- #26 First-run nav scaffold: gate the start destination on the account
  count (no accounts -> onboarding, else mailbox) via AppViewModel, holding
  render until the count is known so there is no cold-start flash. Onboarding
  is a nested nav graph with a graph-scoped OnboardingViewModel tracking the
  first account added this session. Removes NoAccountState in favour of a
  shared welcome/empty state.
- #28 Provider registry: MailProvider presets for Gmail/Yahoo/iCloud
  (IMAP+SMTP host/port/security, help URL) mirroring Account.outlook, biased
  to STARTTLS where documented; host/port/security unit-tested.
- #27 Vendor picker: AccountPickerScreen replaces the two-button setup screen
  as the single entry point (onboarding + Settings/mailbox "Add account"),
  routing Outlook -> OAuth, Gmail/Yahoo/iCloud -> app-password, Other -> manual.
- #29 App-password guided screen: one reusable screen per provider key with
  explanation + security warning + help link; verifies/persists via
  addImapAccount. ViewModel unit tests cover valid/invalid/failure paths.
- #30 "Add another?" prompt + first-account landing: after each onboarding
  add, offer Yes (back to picker) / No (open the first account's inbox,
  per-account filtered via a MAILBOX account nav arg). Only inside onboarding.
- #31 Instrumented onboarding E2E (welcome -> picker -> app-password add ->
  add-another -> first inbox); managed-device list and CI matrix already in
  lockstep. All new files carry the SPDX header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:13:32 -05:00
JMR-devandClaude Opus 4.8 51c790d6bf feat(mailto): handle mailto: links and email share intents
Add intent filters so LibreMail handles mailto: (ACTION_VIEW / SENDTO)
and email SEND / SEND_MULTIPLE intents, opening a prefilled compose screen.

- MailtoParser: pure RFC 6068 parser (multiple recipients, to/cc/bcc/
  subject/body, percent-encoding; preserves a literal '+'); JVM-tested.
- IntentComposeParser: builds a ComposePrefill from a mailto: URI or the
  EXTRA_EMAIL/CC/BCC/SUBJECT/TEXT share extras.
- MainActivity parses the launch/new intent and hands a one-shot prefill to
  the NavHost (guarded against config-change duplication).
- Compose form gains a Bcc field; Routes carry cc/bcc/body deep-link args.
- bcc wired end-to-end: OutgoingMessage, SMTP + Graph senders, and outbox +
  drafts persistence via Room migration v9 -> v10.
- Multi-account send is served by the existing From picker on compose.

Default mail app: Android exposes no public RoleManager email role, so the
intent filters are what make LibreMail appear on the system "Open by default"
/ default-apps screen where the platform/OEM supports it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:08:06 -05:00
JMR-devandClaude Opus 4.8 d1b0593a8c feat(reporting): opt-in debug reporting client (capture + review/submit)
Implements #32 and #33: a strictly opt-in, F-Droid-safe debug reporting
client. Nothing ever leaves the device unless the user taps Submit.

#32 capture:
- CrashReporter installs a Thread.setDefaultUncaughtExceptionHandler (wired in
  LibreMailApplication) that persists a structured crash record (stack trace +
  app/version/device metadata + recent log ring buffer) locally, then delegates
  to the previous handler. Never auto-sent.
- RingLogBuffer + AppLog: a bounded in-memory, non-PII log ring buffer.
- DiagnosticsCollector assembles a minimal bundle: app version, Android/device,
  a fixed non-PII settings allow-list, and the log buffer.
- ReportStore persists pending reports as JSON files (not Room, so crash-time
  saves are robust and independent of the encrypted/migrating DB).
- "Report a problem" entry point in Settings creates a report on demand.

#33 review & submit:
- ReportReviewScreen shows the full payload verbatim (exactly what would be
  sent), a free-text comment field, and a prominent PII disclaimer, with
  explicit Submit / Discard and Copy / Save-to-file alternatives.
- Submission is user-initiated only: ReportUploadWorker (WorkManager, queue +
  retry, success/failure surfaced) POSTs to BuildConfig.DEBUG_REPORT_ENDPOINT,
  which is EMPTY by default (ingest server #34 is out of scope for this repo).
- On next launch a saved crash report is offered for review via a dialog.

Tests: 23 JVM unit tests covering crash capture + persistence (offered next
launch), diagnostic-bundle assembly (minimal, non-PII), JSON round-trip, and
the "nothing sent without Submit" invariant.

Closes #32
Closes #33

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:03:13 -05:00
JMR-devandClaude Opus 4.8 ffbb882227 feat(backup): opt-in Android Backup for settings only
Add an opt-in (off by default) toggle to include app data in system
Android Backup / Auto Backup, gated so only re-creatable user
preferences are ever backed up.

- Flip allowBackup to true and add LibreMailBackupAgent, which enforces
  the runtime opt-in: onFullBackup runs only when the user enables
  "Include settings in Android Backup" (default off), so no data leaves
  the device otherwise. allowBackup is a manifest flag and can't be
  toggled at runtime, hence the agent.
- Rewrite data_extraction_rules.xml (API 31+) and add backup_rules.xml
  (API 29-30) as strict allowlists that back up ONLY the
  libremail_settings DataStore. The Keystore-sealed cache passphrase
  (libremail_dbkey) and the encrypted credentials + mail-cache database
  (libremail.db) are excluded by omission; the cache re-downloads on
  next sync.
- Add includeInBackup preference + setter (nudges BackupManager on
  change) and a "Backup" settings section with F-Droid-honest copy
  (off by default, uses Google infrastructure).
- BackupPolicy is the single source of truth for eligible/excluded
  paths; unit tests cover the toggle default and assert the shipped XML
  resources include only settings and never the secrets/DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:56:18 -05:00
JMR-devandClaude Opus 4.8 657af48052 refactor(auth): remove dead Gmail OAuth code path
Gmail authenticates via app password + preconfigured IMAP/SMTP (decision
in #9), never OAuth, so the Gmail-OAuth implementation was unreachable
dead code. Remove it while keeping Outlook's AppAuth OAuth path intact.

- Delete auth/GmailAuthManager.kt (shared OAuthResult/FreshToken kept).
- Drop AuthType.OAUTH_GMAIL and its exhaustive `when` branch, the
  gmailAuthManager injection, and the SCOPE_GMAIL constant in
  MailConnectionFactory.
- Remove GMAIL_OAUTH_* BuildConfig fields, gmailOAuthClientId, and the
  gmailRedirectScheme val from app/build.gradle.kts.
- Repoint the AppAuth manifestPlaceholders["appAuthRedirectScheme"] to
  the Outlook scheme (org.libremail.outlook). AppAuth's bundled manifest
  now registers that scheme on RedirectUriReceiverActivity, so the app
  manifest's now-redundant Outlook intent-filter is removed; the
  AppCompat theme override (crash fix) is preserved. Verified the merged
  manifest.
- Drop GMAIL_OAUTH_CLIENT_ID from secrets.properties.example.

authType persists as the enum name in a TEXT column, so removing a
constant needs no Room schema change or migration.

Closes #39

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:44:17 -05:00
JMR-dev c129701590 claude settings and gradle config 2026-06-30 21:48:03 -05:00
JMR-devandClaude Opus 4.8 9ce88a881d build: pin the Gradle daemon to JDK 21
AGP 9.2 does not support JDK 25; committing the daemon-JVM criteria makes
every contributor's Gradle daemon run on JDK 21 regardless of JAVA_HOME.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:44:12 -05:00
JMR-devandClaude Opus 4.8 0754ad4ebf ci: enforce ktlint + detekt on pull requests
Add a `static-analysis` job (JDK 21 + Android SDK) that runs
`:app:ktlintCheck :app:detekt` and uploads the reports, and add it to the
`ci-passed` aggregating gate so lint regressions block merges like the
other checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:44:12 -05:00
JMR-devandClaude Opus 4.8 921a9a0668 chore(lint): adopt ktlint + detekt, format and fix all findings
Wire ktlint-gradle 14.2.0 and detekt 2.0.0-alpha.5 (the only detekt line
with Gradle 9 support) through the version catalog.

- .editorconfig: official Kotlin style, 120-col limit, @Composable exempt
  from function-naming.
- config/detekt/detekt.yml: slim overrides on detekt's defaults —
  @Composable exemptions for the OOP-era metrics, sane ReturnCount /
  ThrowsCount / TooManyFunctions thresholds, and TooGenericExceptionCaught
  off at the resilient network/push boundaries (which now log).

Findings fixed in code (behaviour-preserving; 81 unit tests still pass):
- SwallowedException: SendWorker / IdleService now log the caught exception.
- roleOf (Folder) and extractBody (ImapClient) split into named helpers.
- MailSyncer: hoisted a 4-condition `if` into a named val.
- TopDest extracted to its own file; ~14 magic numbers -> named constants.

The remainder is the ktlint auto-format across the module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:42:52 -05:00
JMR-devandClaude Opus 4.8 c24c53ea01 chore: add CLAUDE.md, SPDX-header hook, and preflight skill
- CLAUDE.md: build gotchas (JDK 17-21, AGP built-in Kotlin, KSP-not-KAPT),
  test stack, the SPDX header rule, Conventional Commits, and commands.
- .claude/settings.json + hooks/check-spdx.py: PostToolUse hook that warns
  (non-blocking) when a .kt/.kts file lacks the SPDX license header.
- .claude/skills/preflight: runs the fast CI gate (assembleDebug +
  testDebugUnitTest + lintDebug) locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:42:31 -05:00
JMR-devandClaude Opus 4.8 97950d0b6c fix(test): stop closing the in-memory DB under the still-active AccountSettings VM
AccountSettingsScreenTest closed its Room in-memory database in @After while
the ViewModel's `settings` Flow (kept alive by stateIn/WhileSubscribed) was
still querying it. That race surfaced as "connection pool has been closed"
(API 29) and "attempt to re-open an already-closed object" (API 36) on the
slower CI legs, while passing on 30-35/37 and locally.

Leave the in-memory DB unclosed (reclaimed with the test process) so the
lingering flow never hits a closed connection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:20:30 -05:00
JMR-devandClaude Opus 4.8 40290660ae feat: per-account settings for signatures and notifications
Add a per-account settings area (reached by tapping an account in
Settings), starting with signatures and notifications.

- Storage: new Room `account_settings` table (schema v9, MIGRATION_8_9)
  with a cascading foreign key to `accounts`; AccountSettings model and
  AccountSettingsRepository (a missing row resolves to defaults).
- Signatures: plain-text per-account signature (RFC 3676 "-- "
  delimiter) auto-inserted below new messages and reply/forward drafts,
  and swapped when the From-account changes.
- Notifications: one notification channel + channel group per account so
  Android manages sound/vibration/importance (deep-linked from the app)
  and the shade bundles per account, plus an in-app per-account on/off
  gate. minSdk 29 >= API 26, so channels are always available (no
  pre-channel fallback needed).
- UI: per-account settings screen (signature field/toggle, notification
  toggle, system deep-link, remove account); shared settings components.

Verified: JVM unit tests, lintVitalRelease (NewApi), and the full
instrumented suite (30/30) on the API 37 emulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:05:37 -05:00
JMR-devandClaude Opus 4.8 7987333149 ci: require the API 37 preview E2E job to merge
The custom-provisioned API 37 preview emulator has been stable, so fold its E2E
job into the aggregating "CI passed" gate's needs. Because branch protection
requires only that single check, no settings change is needed.

Drop the now-inaccurate "non-blocking" wording from the job name and comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:17:15 -05:00
JMR-devandClaude Opus 4.8 8e29aebc2e fix: render reader emails readably in dark mode
The reader rendered HTML emails as black-on-black in dark mode: the WebView
background was transparent (so the near-black app surface showed through) and the
injected CSS set no text or background color, so the WebView fell back to its
default black text.

Wrap each email with explicit, theme-derived background, text, and link colors
(surface / onSurface / primary) plus a matching color-scheme, set the WebView
background to the surface color, and allow WebView algorithmic darkening where
supported as a backstop for emails that hardcode their own foreground colors.

Add JVM contrast guards: HtmlBodyTest pins the wrapper's readability contract
(explicit colors meeting WCAG AA), and ColorSchemeContrastTest audits the
fallback light/dark Material schemes' role pairs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 18:17:06 -05:00
JMR-devandClaude Opus 4.8 96111b40df feat: long-press multi-select, aggressive prefetch, and download indicators
Add a long-press contextual action bar to the mailbox: Archive, Delete,
Spam, Move, Select All, and (single-selection only) Reply, Reply All, and
Forward. Reply All/Spam/Delete are confirmed first; deleting from Trash or
Spam warns that it is permanent.

- Delete moves to Trash and Spam moves to the Spam folder; only deletes
  already in Trash/Spam do a permanent IMAP expunge. Archive/Spam/Move
  resolve the destination per account so the unified inbox works.
- Reply/Reply All/Forward force a fresh server fetch of the original
  (recipients + body via BODY.PEEK), build a quoted draft, and open compose;
  a spinner shows during the fetch and they error via snackbar if offline.

Add a top-level "Message downloading" setting (Always fetch all / Fetch all
on Wi-Fi / Always on-demand; default Always) that aggressively pre-caches
full bodies and all attachment bytes during sync (outside the sync lock,
without marking mail read), into a persistent per-part attachment cache so
messages and attachments open instantly and offline.

Show an "available offline" mark on cached list rows and a per-attachment
"downloaded" check in the reader.

Extract a Syncer interface (MailSyncer implements it) so the mailbox can be
driven end-to-end in tests.

Tests: 66 unit + 27 instrumented, all passing on the API 37 emulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:20:22 -05:00
JMR-devandClaude Opus 4.8 fe0593dba1 ci: pin ANDROID_AVD_HOME so the API 37 preview emulator finds its AVD
The preview emulator failed every boot with "Unknown AVD name [api37]" and
exited immediately (no device -> the bounded wait timed out). avdmanager had
created the AVD under $ANDROID_SDK_HOME/.android/avd while the emulator searched
$ANDROID_SDK_HOME/avd and $HOME/.android/avd. Pin ANDROID_AVD_HOME to one path
both tools use, carry it to the boot step via $GITHUB_ENV, and list AVDs after
creation to verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:59:35 -05:00
JMR-devandClaude Opus 4.8 b8086f89df ci: make API 37 preview emulator boot fail-fast and diagnosable
The custom-provisioned preview job hung in 'Boot emulator and run E2E' until
the 35-min cap: adb wait-for-device had no timeout and the emulator's own
output was never captured, so a failed boot was both invisible and unbounded.
Bound the wait with a single 300s timeout covering device registration + full
boot, retry once, capture the emulator log, and dump it (plus logcat) on
failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:45:41 -05:00
JMR-devandClaude Opus 4.8 957da0c46f ci: run debug-build and unit-tests on x86_64
Linux-arm64 runners can't set up the SDK here: android-actions/setup-android's
sdkmanager fails (exit 1) on the android-37.0 preview platform, and the emulator
package has no arm64-Linux build. These are pure build/JVM-unit jobs whose
results are host-arch-independent, so x86_64 loses no device coverage (real
arm64 ABI coverage would require arm64 emulators, i.e. macOS hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:28:55 -05:00