Commit Graph
257 Commits
Author SHA1 Message Date
Jason Ross 3fedc854de Merge main into feat-164-reorder-accounts 2026-07-03 16:09:16 -05:00
Jason Ross f7285a0152 Merge branch 'main' into fix-193-age-retention-sync-window 2026-07-03 15:47:48 -05:00
Jason Ross 944bd45a1b Merge branch 'main' into fix-255-crash-prompt-gating 2026-07-03 15:31:03 -05:00
JMR-devandClaude Opus 4.8 6333dd4511 fix(reporting): gate startup crash prompt to a legitimate <24h crash, first re-open only
The auto-submit crash prompt over-triggered: it re-surfaced the newest saved
crash report on every launch, with no age bound, so a pre-update crash kept
popping "LibreMail crashed" long after the crash was fixed (#255).

Gate StartupReportViewModel.pendingCrash so a crash is auto-offered:
- first re-open only — dismiss() now persists a "surfaced" marker instead of an
  in-memory-only hide, so a report is offered at most once across launches; it
  stays in the store (still listed in Problem Reports) and only discard() deletes.
- < 24h only — inject a clock provider and filter to createdAtMillis within 24h.
- legitimate crash only — reports come solely from CrashReporter's uncaught-
  exception handler, so update / force-stop / user-close create none; made
  explicit and covered by a test.

The marker is a minimal additive `surfaced` flag on DebugReport (persisted in
storage JSON, kept out of the submission payload; a missing flag = not surfaced)
plus ReportStore.markSurfaced(id). Extracted StartupCrashPrompt from LibreMailApp
so the real dialog + gating is E2E-testable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:17:22 -05:00
Jason Ross 5a114517dc Merge main into test-247-coverage-sync-workers-transport-auth 2026-07-03 14:58:28 -05:00
JMR-devandClaude Opus 4.8 be0e699fcc test(coverage): lane 2 — sync, workers, transport & auth to >=95%
Test-only (zero production changes). Raises JVM unit-test LINE coverage
for the sync/worker, IMAP/SMTP/Graph transport, and OAuth packages:
data/sync 98.6%, mail 96.7%, auth 100.0% LINE.

New/extended cover:
- SendWorker outbox drain (SMTP/Graph, may-have-sent, SMTP fallback, staged
  attachments), MailConnectionFactory token cache/refresh, MailSyncer.syncAll,
  SendScheduler, MailBackfiller pre-existing-row refresh.
- ImapConnectionCache reuse + drop-retry, ImapClient fetchAttachment/setFlag/
  deleteMessage/idle + edge cases, GraphSender.send transport.
- OutlookAuthManager token exchange/refresh + failure branches, OAuth models.

Instruction/branch coverage stays lower (coroutine suspend-state synthetics
under synchronous mocks) — a known JaCoCo x coroutines limitation, not
untested logic; JaCoCo config is untouched (owned by the capstone lane).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:54:12 -05:00
Jason Ross 78b7ebf777 Merge main into fix-193-age-retention-sync-window 2026-07-03 14:48:15 -05:00
Jason Ross 02a0082c7f Merge main into feat-164-reorder-accounts 2026-07-03 14:48:12 -05:00
Jason Ross 50f8cf2e2b Merge branch 'main' into test-249-coverage-viewmodels-nonui 2026-07-03 14:34:32 -05:00
JMR-devandClaude Opus 4.8 b5997f75fe test(coverage): lane 4 — ViewModels & non-UI modules to >=95%
Add JVM unit tests (test-only; no production changes) covering the in-scope
ViewModels + UI state holders and the reporting/push/power/contacts modules
for issue #249.

New ViewModel coverage: Drafts, Outbox, Signatures, SignatureEdit,
AccountSettings, AccountSetup, ManualSetup, ProblemReports, StartupReport,
plus gap-filling for Compose, Mailbox, Reader, Settings, ReportReview and
AppPassword (contacts autocomplete, inline images, send/refresh failure
branches, drawer/search hooks, state-holder value semantics).

New module coverage: AppLog, AppVersionProvider, ReportSubmitter,
ReportUploadWorker (reachable paths), CrashReporter.install, LogEntry,
IntentComposeParser, ContactsRepository, ContactsPermissionManager,
IdlePushManager, BatteryOptimizationManager (Context methods) and
AndroidBatteryStatusProvider.

Android-framework-bound classes with no JVM seam are deliberately left to the
instrumented suite: IdleService (foreground Service), ReportUploadScheduler
(WorkManager.getInstance is not statically mockable), the HTTP transmit path in
ReportUploadWorker (unreachable while BuildConfig.DEBUG_REPORT_ENDPOINT is
empty), and CrashReporter.terminate (calls exitProcess).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:26:41 -05:00
Jason Ross d3f8136f12 Merge main into feat-164-reorder-accounts 2026-07-03 14:10:09 -05:00
Jason Ross 873f45ff33 Merge main into fix-193-age-retention-sync-window 2026-07-03 14:10:06 -05:00
JMR-devandClaude Opus 4.8 e2606b7751 test(coverage): lane 1 — repository, mappers & domain logic to >=95%
Add JVM-only unit tests (74 across 4 new, purely-additive files) covering
the data/repository, data-mapper, and pure domain packages. No production
code is changed.

- AccountRepositoryImplTest: first tests for AccountRepositoryImpl — add/
  test/delete/observe + reset-backfill, success and rejected-LIST failure
  paths (class now 100% instruction & line).
- MailRepositoryImplCoverageTest: the MailRepositoryImpl methods/edges the
  existing suite skipped — observe-* flows, getMessage/getDraft, setStarred,
  deleteMessage, sendMessage + copyAttachments (incl. unreadable-URI skip),
  searchServer (all-accounts vs. filtered), and the account/row-gone
  fall-throughs.
- MappersTest: entity<->domain mappers not otherwise pinned, incl. the
  unknown-enum fallbacks and FetchedMessage id/uid rules.
- DomainModelCoverageTest: AccountSettings.signatureBlock branches,
  Signature.plainText, default-arg constructors, and display-name fallbacks
  (domain/model now 100% instruction & line).

Closes #246

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:55:51 -05:00
JMR-devandClaude Opus 4.8 e823f9b17e fix(sync): bound the foreground fetch window by the age cutoff in age retention
In age-based retention, MailSyncer fetched the newest-N headers but only capped that window by the
retention COUNT, not the age cutoff. On a low-traffic mailbox whose newest-N span older than the
cutoff, each sync re-inserted messages the age pruner had just deleted, and the next prune deleted
them again — a churn loop of wasted DB writes + prune deletes (issue #193).

Sync now drops fetched messages older than policy.ageCutoffMillis before persisting (the same cutoff
the pruner uses), so sync and prune keep exactly the same set in both retention modes. Count/unlimited
modes have a null cutoff and are unchanged. The empty-folder wipe is keyed on the raw fetch (server
truth), so a folder holding only past-cutoff mail is left to the pruner rather than wiped.

MailPruner's KDoc now documents the sync alignment for both modes.

Closes #193

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:17:24 -05:00
Jason Ross f10129c4c6 Merge main into feat-164-reorder-accounts 2026-07-03 12:55:51 -05:00
Jason Ross 9cd136f81b Merge main into build-192-jacoco 2026-07-03 12:43:05 -05:00
JMR-devandClaude Opus 4.8 535cbcb49a build(coverage): finish wiring JaCoCo unit-test coverage reporting
Completes the crash-interrupted #192 WIP (app/build.gradle.kts already had a
jacocoTestReport task and toolVersion pin recovered onto build-192-jacoco):

- Move the JaCoCo tool version into gradle/libs.versions.toml instead of a
  hardcoded string in app/build.gradle.kts, matching how every other plugin
  version in this repo is sourced.
- Fix the generated-code exclusion list against the real compileDebugKotlin
  output (verified by inspecting the compiled class tree): Room's
  KSP-generated `_Impl` DAOs/database and the Compose compiler's per-file
  ComposableSingletons holders are the only generated code that actually
  lands in classDirectories, since Hilt/Dagger's generated Java and AGP's
  BuildConfig/R/Manifest are compiled by a separate javac task this report
  never reads. Drop the blanket `**/*$$*` exclude the WIP had — it was
  silently discarding ~200 real classes' worth of coverage on Kotlin's own
  `$$inlined$` synthetic classes (e.g. Flow.map { ... } transforms in the
  repositories), which is hand-written logic, not generated boilerplate.
- Add Hilt_*/Dagger* prefix patterns so the (currently inert,
  belt-and-suspenders) Hilt exclusions are actually correct if the
  classDirectories scope ever changes.
- Add a minimal CI step to the existing unit-tests job that runs
  jacocoTestReport and uploads the XML+HTML report as a build artifact.
  No coverage threshold gate yet (a jacocoTestCoverageVerification rule is
  a natural follow-up once there's a baseline).
- Document the new :app:jacocoTestReport task in CLAUDE.md.

Verified on JDK 21: fast gate (assembleDebug, testDebugUnitTest,
compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt) plus
jacocoTestReport all pass, from both a warm and a `clean` build. The
report shows real signal (30% instruction / 38% line coverage) with no
generated classes leaking in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:23:40 -05:00
JMR-devandClaude Opus 4.8 9a69d175a8 feat(settings): reorder accounts by drag in settings
Give accounts a user-controlled order (issue #164). Every surface that
lists accounts -- the Settings list, the drawer account switcher, and the
compose account picker -- reads the same `ORDER BY sortOrder` query, so a
reorder in Settings is honored app-wide. In Settings a row can be
long-pressed and dragged to a new position; the order persists and
survives restart.

Data layer:
- AccountEntity gains `sortOrder` (@ColumnInfo defaultValue "0"); AccountDao
  orders by it and adds insertAtEnd / reorder / nextSortOrder / setSortOrder,
  the mutations wrapped in transactions.
- New accounts are appended (current max + 1) via insertAtEnd.

Migration (AccountDatabase v1 -> v2):
- ACCOUNT_MIGRATION_1_2 adds the column and backfills existing accounts by
  their previous alphabetical (email) rank, so the already-shown order does
  not shuffle on upgrade. Registered in AccountDatabaseModule; 2.json is
  exported and AccountMigrationTest replays and validates it.
- AccountDataMigrator (the pre-#111 cache->account-db move) creates the
  v2-shaped table and applies the same email-rank backfill, since
  ACCOUNT_MIGRATION_1_2 does not run for that path.

UI:
- AccountReorderList drives long-press drag over a plain Column (no nested
  lazy list inside the scrolling settings column, no extra dependency); the
  pure reorder-index maths (commitDrag) is unit-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:22:16 -05:00
JMR-dev 05dde9399c Merge remote-tracking branch 'origin/main' into continue-192 2026-07-03 12:11:26 -05:00
JMR-devandClaude Opus 4.8 cbc9fc62ef wip: recover work from interrupted session (issue #192)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 12:04:36 -05:00
JMR-devandClaude Opus 4.8 7e69fcf185 chore(ui): migrate hiltViewModel to its new androidx.hilt.lifecycle.viewmodel.compose package
Clears the "hiltViewModel is deprecated; moved to package
androidx.hilt.lifecycle.viewmodel.compose" compile warnings across the 16 ui/**
files. Pure import swap: the old androidx.hilt.navigation.compose.hiltViewModel
inline-delegates to the new symbol, which is already transitively on the compile
classpath via the pinned hilt-navigation-compose:1.3.0 -- no dependency change,
identical signatures, behaviour byte-for-byte identical.

Closes #236

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:57:10 -05:00
Jason Ross 34de875601 Merge main into test-cache-lock-gate-coverage 2026-07-03 11:28:04 -05:00
Jason Ross b76447b8cf Merge main into build-release-arm-only 2026-07-03 11:15:18 -05:00
Jason Ross 2a07b2423e Merge main into test-cache-lock-gate-coverage 2026-07-03 11:15:17 -05:00
JMR-devandClaude Opus 4.8 6d75bf5c6d test(sync): cover the cache-lock gate on SyncWorker/SendWorker + the provisioner await
Locks in the "pre-auth DB entry point defers while the encrypted cache is locked"
invariant that had zero coverage (which is how the PruneWorker/BackfillWorker gap
in #224 slipped in). Adds SyncWorkerTest and SendWorkerTest (locked -> retry with
the Lazy DB deps never resolved; unlocked -> runs), and a DatabaseProvisionerTest
case proving prepareCache() suspends on an auth-bound resolvePassphrase until it
resolves.

IdleService shares the same guard but is an Android Service (its start path needs
startForeground/Context), so its gate is covered by the instrumented test (#226)
rather than a JVM unit test.

Closes #225

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:04:34 -05:00
Jason Ross e2da97a2ff Merge main into fix-prune-backfill-cache-lock-gate 2026-07-03 11:01:14 -05:00
JMR-devandClaude Opus 4.8 a62d5edfdd build(release): build the release APK for ARM only (arm64-v8a + armeabi-v7a)
Adds ndk.abiFilters = ["arm64-v8a", "armeabi-v7a"] to the release build
type only, so the release APK/AAB ship the two ARM ABIs (64-bit + 32-bit)
instead of a universal build. x86 and x86_64 are intentionally dropped.
defaultConfig and the debug type are left untouched: CI's E2E matrix runs
the debug build on x86_64 emulators and needs the x86_64 native libs
(incl. libsqlcipher.so).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:57:30 -05:00
JMR-devandClaude Opus 4.8 7b5819021a fix(sync): gate PruneWorker & BackfillWorker on the encrypted-cache lock
PruneWorker and BackfillWorker were the only two pre-auth background DB entry
points that opened the database without first checking
EncryptedCacheGuard.isCacheLocked(). With encryptCache + appLock both on and a
headless cold start where the user hasn't authenticated (WorkManager after
reboot, or the periodic backfill/prune window while locked), the first DAO call
runs prepareCache() -> resolvePassphrase() -> session.await(), parking the
worker thread until unlock and serializing other DB openers behind the held
prepareCache mutex. Self-heals on unlock, but wastes wakelock/battery and makes
zero progress while locked.

Switch both workers' MailPruner/MailBackfiller injection to dagger.Lazy and add
the isCacheLocked() guard before resolving it, mirroring SyncWorker/SendWorker.
Add JVM regression tests (locked -> retry with the Lazy dep never resolved;
unlocked -> runs; failure -> retry).

Closes #224

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:54:48 -05:00
Jason Ross 6eb1a74a5b Merge branch 'main' into perf-mailbox-page-folder-search 2026-07-03 10:48:36 -05:00
JMR-devandClaude Opus 4.8 541f4809cb perf(mailbox): page the per-account folder view and search
Issue #124 paged only the unified "All inboxes" browse list. The
per-account folder view and every search path still loaded the whole
folder / entire unified inbox into memory and re-materialized it on each
cache write. Extend Paging 3 to them, mirroring the unified pager.

- MessageDao: add Room PagingSources for the per-account browse list
  (`pagingFolderSummaries`, `inInbox = 1`) and for paged search
  (`pagingUnifiedFolderSearchSummaries` / `pagingFolderSearchSummaries`).
  The search queries LIKE-match the same columns the old in-memory
  `matchesSearch` filter scanned (sender, sender address, subject,
  snippet) and leave `inInbox` unfiltered so transient server-search hits
  still surface. Read-only @Query methods — no schema change, no migration.
- MailRepository: add `pagedFolderMessages` and the two paged-search
  flows via a shared `mailboxPager` whose PagingConfig adds
  `maxSize = MAILBOX_PAGE_SIZE * 5` so a long scroll drops far-offscreen
  pages. A `likePattern` helper escapes the LIKE metacharacters (\ % _)
  so a query containing them still matches literally. The unified pager
  (`pagedUnifiedFolderMessages`) is left untouched for its sibling PR.
- MailboxViewModel: collapse the old `messages` list flow and the
  unified-only paged flow into one `pagedMessages` that dispatches each
  (account, folder, query) to the matching pager; `cachedIn` is kept so
  "select all" reads the loaded snapshot. Removes the now-dead
  observe*FolderMessages / matchesSearch paths.
- MailboxScreen: render every mode from the single paged list. Gate the
  empty state on `itemCount == 0 && refresh is NotLoading &&
  append.endOfPaginationReached` so it no longer flashes on an
  empty→loaded transition, preserving the search "no results", the
  syncing-folder spinner (#149), and browse "no messages" states.

Behaviour is preserved: search filtering columns/scope, multi-select and
"select all", unread counts, and the browse-vs-search state machine
(server search + debounce + open/close) are unchanged — only the local
list materialization moved from Kotlin into paged SQL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:32:49 -05:00
JMR-devandClaude Opus 4.8 5cfd1b4065 perf(mailbox): bound paging window (maxSize) and memoize per-row timestamp
Two fixes from the Paging 3 perf audit of the mailbox list (#212, #213):

- Bound the unified-inbox paging window (#212): the only PagingConfig left
  maxSize at Int.MAX_VALUE, so pages were never evicted and a deep scroll
  accumulated the whole inbox in memory. Set maxSize = MAILBOX_PAGE_SIZE * 5
  (200), satisfying maxSize >= pageSize + 2*prefetchDistance (120). Safe with
  enablePlaceholders = false (the UI null-guards evicted positions).

- Memoize the per-row relative timestamp (#213): MessageRow formatted it via
  DateUtils.getRelativeTimeSpanString un-remembered, allocating a String on
  every recomposition. Wrapped in remember(timestampMillis).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:56:08 -05:00
Jason Ross c1b80c3668 Merge branch 'main' into fix-sqlcipher-native-lib-load 2026-07-03 09:25:38 -05:00
JMR-devandClaude Opus 4.8 592a797dd0 fix(cache): load SQLCipher native lib before every keyed open
The opt-in encrypted cache crash-looped on launch (UnsatisfiedLinkError:
No implementation found for SQLiteConnection.nativeOpen) on any cold start
after encryption was enabled — reported after an app upgrade.

System.loadLibrary("sqlcipher") was only invoked as a side effect of an
actual plaintext<->encrypted conversion (DatabaseEncryption.migrate) or the
one-time #111 account migration. On a steady-state start the cache is
already encrypted and the account migration is already done, so both no-op
and nothing loads the native library before Room opens the keyed database
via SupportOpenHelperFactory -> nativeOpen. The previous process survived
only because an earlier conversion had loaded the .so in-memory; the next
cold start (e.g. an upgrade) crashes.

Load the library explicitly in DatabaseProvisioner whenever it commits to an
encrypted open (idempotent; no-ops when already loaded). Add regression
assertions: the encrypted path must load it, the plaintext path must not.

Verified on a Pixel 10 Pro XL — an in-place update preserving the already-
encrypted cache now launches to the mailbox instead of crash-looping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:22:03 -05:00
Jason Ross 364fe32aaa Merge main into fix-204-contentid-validation 2026-07-03 07:23:23 -05:00
JMR-devandClaude Opus 4.8 69cce168ef chore(security): validate Content-ID before MIME/Graph use
SmtpSender.inlinePart built the MIME header as `<${attachment.contentId}>`
and GraphSender put contentId straight into the Graph JSON — neither
stripped CR/LF or other ISO control characters. Not exploitable today
(contentId is always an app-generated `img-<uuid>@libremail`), but if an
external value ever reached contentId the SMTP path would be a MIME
header-injection vector.

New shared sanitizeContentId() strips ISO control chars (incl. CR/LF),
applied at both sinks: the SMTP Content-ID header and the Graph JSON
field. No behavior change for the app-generated ids in use today.

Tests: SmtpSenderTest sends an inline image whose contentId contains
`\r\nX-Injected: evil` and asserts (via GreenMail) no injected header
appears on any MIME part and the Content-ID stays a single line;
GraphSenderTest asserts the control chars are stripped from the payload.

Closes #204

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:11:00 -05:00
JMR-devandClaude Opus 4.8 7d7fef7b90 chore(security): whitelist font-family on HTML emit
RichTextHtml.inlineCss and baseCss interpolated the font-family CSS value into
the emitted style attribute raw. The whole attribute is escaped (escapeAttr), so
a value can't break out of style="…" or inject a tag, and it isn't reachable
today (the picker only offers the fixed FontRegistry stacks; reply/forward
flattens sender HTML to plaintext first) — but a non-registry value would let
`;`/`:` inject a sibling CSS declaration inside the attribute.

Constrain the emitted font-family to a safe charset (the characters a real font
stack uses — letters, digits, spaces, commas, quotes, hyphens, periods,
underscores), dropping anything else instead of emitting it raw. All seven
bundled FontRegistry stacks are within this set, so the built-in fonts are
unaffected. RichTextHtml stays a pure module (no dependency on the UI-layer
FontRegistry), so the charset restriction is the layer-clean form of the
whitelist.

Test: a RichStyle.FontFamily("Arial; color:red") no longer appears raw in the
emitted HTML (inline and base-style), while a registry stack with quotes/commas
still emits.

Closes #205

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 07:09:33 -05:00
JMR-devandClaude Opus 4.8 1a8d6e1f7b fix(compose): release persistable URI permissions for attachments/inline images
The compose attachment picker and inline-image picker call
takePersistableUriPermission on every pick, but nothing ever released
those grants (releasePersistableUriPermission was absent repo-wide). The
app accumulated indefinite read access to every file/photo ever attached
and could hit the per-app persisted-grant cap — after which the take
(swallowed by runCatching) silently fails and a later draft's image
won't reload. (Post-batch security review, Low.)

The picked bytes are copied into the app cache at enqueue
(copyAttachments), so a grant is only truly needed to reload an image
when a *draft* is reopened. New AttachmentUriGrants releases a URI's
grant once no remaining draft or outbox row references it; callers invoke
it after the referencing row is gone:
- MailRepositoryImpl.deleteDraft (a deleted draft can't reopen)
- MailRepositoryImpl.cancelOutboxMessage
- SendWorker after a send succeeds, and when a queued message is dropped
  because its account was removed
A URI still referenced by another live draft/outbox row is kept; a
release of a grant not actually held throws and is swallowed.

Also hardens attachment filenames: sanitizeAttachmentName strips path
separators and ISO control chars (incl. CR/LF) from picked/received
display names before they become on-disk or MIME filenames, so a crafted
name can't traverse directories or inject header lines. Applied in the
compose picker (queryFileName) and outbox/incoming staging.

Tests: pure release-decision (unreferencedUris), the filename sanitizer,
and the repository wiring (deleteDraft/cancelOutboxMessage call the
releaser with the removed row's URIs, after deleting the row).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 03:29:43 -05:00
JMR-devandClaude Opus 4.8 96b2da1753 feat(compose): inline images end to end (picker to SMTP/Graph)
Wire inline images through the whole send pipeline.

Compose UI: an image-picker (image/*, persistable URI grant, mirroring the
attachment picker) behind a new toolbar button appended at the END of the
toolbar — after the block/link buttons and the font/size/align controls — so it
never shifts the bullet button the compose E2E taps without scrolling. Picking
an image adds an inline OutgoingAttachment and hands the editor a
PendingInlineImage, which RichTextEditing.insertImage drops as a [image: name]
token + RichImage(contentId) at the caret. Deleting the token drops the image:
onBodyChange reconciles inline attachments against the body's surviving cid:
references. Inline images are tracked in ComposeUiState alongside regular
attachments but kept out of the attachment-chip row.

Domain/persistence: OutgoingAttachment gains contentId/isInline; the shared
draft/outbox attachment JSON carries them (drafts need no migration — an older
draft reads back as a plain attachment). The outbox stages files by index as
before but now also stores per-file {contentId,isInline} metadata in a new
OutboxEntity.attachments column (Room 17 -> 18, MIGRATION_17_18, DEFAULT '' per
the bccAddresses precedent so fresh-install == migrated; 18.json committed). The
send worker pairs each staged file with its metadata by index (with a positional
fallback for messages queued before the column existed).

SMTP (SmtpSender): inline images wrap the body in a multipart/related, each with
a Content-ID matching the HTML's cid: and inline disposition; regular
attachments keep today's multipart/mixed shape.
Graph (GraphSender): inline fileAttachments carry isInline + contentId.

Tests: GreenMail asserts multipart/related with a Content-ID matching the cid;
GraphSenderTest asserts the inline payload; a mapper test proves an inline
image's cid<->file pairing round-trips a draft save/reopen; RichTextEditing
tests cover insertImage (token/RichImage placement + offset shift + html
round-trip); MigrationTest gains migrate17To18. Reader-side cid: rendering stays
out of scope (follow-up).

Closes #77

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:33:48 -05:00
Jason Ross ad8fd1e24f Merge main into feat-177-draft-autosave 2026-07-03 00:45:19 -05:00
JMR-devandClaude Opus 4.8 071034de65 feat(compose): add a font family picker with bundled open-source fonts
Bundle four SIL OFL 1.1 fonts in res/font (no build-time downloads, F-Droid
safe): Inter, Lora, and JetBrains Mono as weight-variable TTFs plus a static
Merriweather Regular cut (its variable font is 4.4 MB) — ~1.5 MB total. Each
family's OFL license text is committed under THIRD_PARTY_LICENSES/, and *.ttf/
*.otf are marked binary in .gitattributes so the bytes commit intact.

Add FontRegistry: display name <-> email-safe CSS stack <-> Compose FontFamily,
with three generic Sans/Serif/Monospace entries that need no bundled file. Each
bundled stack names the family first then falls back to a generic (e.g.
'Lora', Georgia, serif), so a recipient whose client lacks the face still gets
a sensible one. resolveFontFamily maps a stored CSS stack back to a FontFamily
for in-editor rendering, and is now passed into RichTextBodyField from
ComposeScreen so styled runs actually render in their font.

Add FontPicker (ui/compose/format): a toolbar dropdown applying
RichStyle.FontFamily(css) via the generalized applyStyle/clearStyle path, with a
leading "Default" entry that clears it; each menu entry previews itself in its
own face. Appended at the END of the toolbar (with the size/alignment controls),
after the block and link buttons — per the #73/#76 lesson, nothing may shift the
bullet/numbered/quote buttons that the compose E2E taps without scrolling.

Tests: FontRegistryTest (JVM) proves every CSS stack survives an html
round-trip, the resolver maps known/unknown stacks, and bundled stacks end in a
generic fallback; a compile-only FontPickerTest drives the picker in isolation
(default label, current-font label, menu lists every font, picking reports the
css / Default clears).

Closes #72

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:31:09 -05:00
JMR-devandClaude Opus 4.8 bbc0715666 feat(compose): debounced periodic draft autosave
Adds a debounced periodic draft save alongside the existing exit-time
save, so an in-progress compose survives a background-kill without going
through the back gesture. Observes the persisted body/recipient/subject/
attachment fields, coalescing rapid keystrokes into one write after a
~1.5s idle window (viewModelScope), and flushes immediately on ON_STOP
from ComposeScreen so the last keystrokes within the window aren't lost.

Prerequisite bug fix: draftId was a nullable val, so
saveOrDeleteDraft()'s `id = draftId ?: UUID.randomUUID()` minted a fresh
id on every call. Harmless when it ran only once at exit, but periodic
autosave would insert a new duplicate draft row per tick. The persist id
is now generated once (persistedDraftId) and reused for every save this
session; a draftPersisted flag drives delete-on-empty and delete-on-send
so an autosaved new draft is never orphaned.

onExit()'s save-or-delete-on-back behavior and the "don't save mid-send"
guard are unchanged; autosave mirrors the same guard (plus a navigated
guard so a post-send tick can't re-create a sent message's draft).

Closes #177

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 00:22:42 -05:00
Jason Ross 6fff3fcf02 Merge main into feat-76-paragraph-alignment 2026-07-02 23:55:54 -05:00
JMR-devandClaude Opus 4.8 df5c1aa2f9 feat(compose): add paragraph alignment to the formatting toolbar
Add setAlignment(content, start, end, align) and alignmentAt(...) ops to
RichTextEditing with paragraph-range bookkeeping (mirroring toggleBlock).
setAlignment marks every line the selection touches, leaves untouched
paragraphs alone, and stores START as "no alignment" (dropping the range) so
an otherwise-plain paragraph stays plaintext-only; CENTER/END become explicit
ranges. It emits the same canonical form RichTextHtml.fromHtml returns — one
merged range per run of adjacent same-aligned lines, and blank paragraphs
anchor no range (the HTML model can't pin text-align to an empty <p>) — so the
model, its HTML, and the editor's ParagraphStyle rendering never drift.
alignmentAt returns the shared alignment (START default for plain paragraphs,
null when mixed), driving a three-state control directly.

Add ParagraphAlignmentControl (start/center/end glyph buttons) and append it —
plus the existing FontSizePicker — at the END of the toolbar, after the block
and link buttons. Per the #73 lesson, nothing may shift the bullet/numbered/
quote buttons rightward or the compose E2E's no-scroll performClick on "•" (and
siblings) misses.

Editor renders alignment via the existing ParagraphStyle(textAlign) path
(applyAlignment routes through it, preserving the selection since alignment
never changes the text).

Tests: setAlignment/alignmentAt covered thoroughly at the JVM layer (caret,
multi-paragraph merge, mid-block split, untouched paragraphs, blank lines,
empty document, mixed selections) plus a serialize->parse round-trip fixpoint
on setAlignment output; applyAlignment covered in RichTextEditorTest; a
compile-only androidTest drives the control in isolation.

Closes #76

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:51:50 -05:00
Jason Ross 2df6b0f82a Merge main into feat-78-remember-font-size 2026-07-02 23:41:19 -05:00
JMR-devandClaude Opus 4.8 5e5116cbca feat(compose): remember last-used font and size
Persists the font family/size from a sent formatted message to the
settings DataStore (SettingsRepository.setLastFont), taking the
message-wide base style if set, else the last FontFamily/FontSize
span. Brand-new compositions (draftId == null) seed a RichBaseStyle
from the remembered preference so the whole message defaults to it;
resumed drafts and replies/forwards are untouched, and messages with
no remembered font stay plaintext-only.

Closes #78

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:32:58 -05:00
Jason Ross d9cfff80ec Merge main into feat-160-app-password-disclaimer 2026-07-02 23:28:26 -05:00
JMR-devandClaude Opus 4.8 9c6a969c17 fix(compose): keep the bullet button tappable by appending the font-size control last
The font-size dropdown was inserted before the block-marker buttons, and its
wide "Default"/"N pt" anchor pushed the "•" bullet button past the right edge
of the horizontally-scrolling toolbar on the Pixel 2 E2E device (411dp wide,
minus the compose column's 16dp padding = 379dp usable). ComposeScreenTest's
formattingToolbar_bulletButtonMarksTheLineAndSendsItAsHtml taps the bullet
without scrolling first, so performClick targeted a center that was clipped
off-screen and the tap silently missed — the line was never marked, failing
all 8 instrumented legs deterministically (expected "• Buy milk", got "Buy milk").

The block-toggle logic was never touched; this was pure toolbar overflow. Move
FontSizePicker to the end of the toolbar (after the link button) so every
pre-existing glyph button keeps the exact position it has on main and the
bullet stays within the initial viewport. Add a comment recording the ordering
constraint for future toolbar tickets.

Also add a JVM unit test (RichTextEditorTest) that drives the same bullet-tap
flow through applyBlock + RichTextHtml.toHtml, pinning "• Buy milk" and
<ul><li>Buy milk</li></ul> so a regression in that block/HTML path is caught
by testDebugUnitTest without an emulator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 23:13:59 -05:00
Jason Ross b7c7415fef Merge main into feat-160-app-password-disclaimer 2026-07-02 23:09:17 -05:00
Jason Ross 1634c57837 Merge main into feat-73-font-size-control 2026-07-02 23:09:16 -05:00
JMR-devandClaude Opus 4.8 bae25b20f3 feat(onboarding): require GPL-3.0 license agreement as the first screen
Inserts a new LicenseScreen ahead of OnboardingWelcomeScreen as the
onboarding graph's start destination: the user must scroll the full
GPL-3.0 text and tap Agree before reaching anything else, or Decline
to exit the app outright. Acceptance is persisted
(SettingsRepository.licenseAccepted) so a user who agrees but exits
before adding an account isn't asked again, and the
NotificationPermissionEffect() request (#151) stays scoped to
OnboardingWelcomeScreen so it never fires on the license screen.

Closes #172

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:56:34 -05:00