Commit Graph
191 Commits
Author SHA1 Message Date
Jason Ross 4a139faf61 Merge branch 'main' into feat-screen-unlock 2026-07-01 15:39:48 -05:00
Jason Ross a5644f4e02 Merge pull request #55 from JMR-dev/chore-preflight-static-analysis
chore(preflight): add ktlintCheck + detekt to the fast gate
2026-07-01 15:38:17 -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
Jason Ross 9f7540a2f6 Merge pull request #54 from JMR-dev/fix-drawer-duplicate-folders
fix(mailbox): de-duplicate folder names in the drawer
2026-07-01 15:24:57 -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
Jason Ross adcfc7a6dc Merge pull request #52 from JMR-dev/fix/message-list-cursorwindow-overflow
fix(mailbox): project message list to avoid CursorWindow overflow
2026-07-01 13:54:36 -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
Jason Ross 4175b3f440 Merge pull request #50 from JMR-dev/feat/49-onboarding-battery-optimization
feat(onboarding): opt-in to unrestricted battery/background usage
2026-07-01 12:42:52 -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
Jason Ross 2ce527ad77 Merge pull request #48 from JMR-dev/docs-readme-refresh
docs: refresh README for onboarding/app-password/rich-compose/opt-in features (#20)
2026-07-01 11:16:50 -05:00
JMR-dev c3287b66f4 Merge remote-tracking branch 'origin/main' into docs-readme-refresh 2026-07-01 11:06:22 -05:00
Jason Ross 9ab1765116 Merge pull request #47 from JMR-dev/feat-rich-compose
feat(compose): rich HTML editor, multipart send, and signatures (#23, #36, #37, #38)
2026-07-01 11:06:21 -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
Jason Ross dbe258126b Merge pull request #43 from JMR-dev/feat-mailto-default-app
feat(mailto): handle mailto: links and email share intents (#25)
2026-07-01 10:41: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
Jason Ross d2b9d990ad Merge pull request #42 from JMR-dev/feat-debug-reporting
feat(reporting): opt-in debug reporting client (capture + review/submit) (#32, #33)
2026-07-01 10:28:45 -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
Jason Ross f47efadfa6 Merge pull request #44 from JMR-dev/feat-onboarding-flow
feat(onboarding): first-run flow, vendor picker, and app-password setup (#26-#31)
2026-07-01 10:10:58 -05:00
JMR-dev 781966e0a0 Merge remote-tracking branch 'origin/main' into feat-onboarding-flow 2026-07-01 09:59:41 -05:00
Jason Ross 085475bf93 Merge pull request #41 from JMR-dev/feat-backup-optin
feat(backup): opt-in Android Backup for settings only (#21)
2026-07-01 09:59:39 -05:00
JMR-dev df5b99aff9 Merge remote-tracking branch 'origin/main' into feat-backup-optin 2026-07-01 09:49:24 -05:00
Jason Ross 4504d34fc6 Merge pull request #40 from JMR-dev/fix-remove-gmail-oauth
refactor(auth): remove dead Gmail OAuth code path (#39)
2026-07-01 09:41:41 -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
Jason Ross 64fb82ebd9 Merge pull request #8 from JMR-dev/chore/kotlin-linting
chore: Kotlin linting (ktlint + detekt), CLAUDE.md tooling, and CI enforcement
2026-06-30 22:01:37 -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
Jason Ross 0adf1319f6 Merge pull request #7 from JMR-dev/feat-per-account-settings
feat: per-account settings for signatures and notifications
2026-06-30 20:28:54 -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