Commit Graph
507 Commits
Author SHA1 Message Date
Jason Ross 48fe8d2fb0 Merge pull request #229 from JMR-dev/fix-prune-backfill-cache-lock-gate
fix(sync): gate PruneWorker & BackfillWorker on the encrypted-cache lock
2026-07-03 11:14:48 -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
Jason Ross 82ed7dad1a Merge pull request #223 from JMR-dev/perf-mailbox-page-folder-search
perf(mailbox): page the per-account folder view and search
2026-07-03 11:00:45 -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
Jason Ross cbeac0f26f Merge pull request #222 from JMR-dev/ci-e2e-boot-retry
ci(e2e): retry the API-29 emulator boot to absorb the android-emulator-runner keyevent race
2026-07-03 10:35:58 -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 68a225ed9c ci(e2e): retry the API-29 emulator boot to absorb the android-emulator-runner keyevent race
The matrix E2E (29) job intermittently fails (~2%, API-29 only) in
reactivecircus/android-emulator-runner's un-guarded, fatal post-boot
`adb shell input keyevent 82`: on snapshot resume sys.boot_completed=1 is
restored before system_server republishes the `input` binder service, so
the job aborts before Gradle runs with "No service published for: input"
(fast-fail ~1m43s). Proven on run 28667366203.

Make the "Run E2E tests" step non-fatal (id + continue-on-error) and add a
guarded second attempt (if steps.e2e.outcome == 'failure'). Two independent
boots drop the race to ~0.04%; a genuine failure on both attempts still
fails the job (outcome, not conclusion). Definitive manual-boot fix: #218.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:23:36 -05:00
Jason Ross 440d1e9c62 Merge pull request #216 from JMR-dev/perf-mailbox-list
perf(mailbox): bound paging window (maxSize) and memoize per-row timestamp
2026-07-03 10:11:52 -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 0e2be05be7 Merge pull request #209 from JMR-dev/chore-preflight-compile-androidtest
chore(preflight): compile androidTest source set in the fast gate
2026-07-03 09:54:15 -05:00
Jason Ross ae977c7539 Merge main into chore-preflight-compile-androidtest 2026-07-03 09:39:04 -05:00
Jason Ross 78c0fded29 Merge pull request #208 from JMR-dev/fix-sqlcipher-native-lib-load
fix(cache): load SQLCipher native lib before every keyed open
2026-07-03 09:38:35 -05:00
JMR-devandClaude Opus 4.8 3728efb55e chore(preflight): compile androidTest source set in the fast gate
assembleDebug, testDebugUnitTest, and lintDebug never compile the
androidTest source set, so a change that breaks it (e.g. an
instrumented test calling a UI API that just changed signature) passed
preflight locally yet only failed once CI ran. Add
:app:compileDebugAndroidTestKotlin to the fast gate to catch that
class of breakage before pushing, and update CLAUDE.md's summary of
the gate to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:27:12 -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 7784dbf3ba Merge pull request #207 from JMR-dev/fix-204-contentid-validation
chore(security): validate Content-ID before MIME/Graph use
2026-07-03 08:57:53 -05:00
Jason Ross 364fe32aaa Merge main into fix-204-contentid-validation 2026-07-03 07:23:23 -05:00
Jason Ross 1a0d140c03 Merge pull request #206 from JMR-dev/fix-205-fontfamily-whitelist
chore(security): whitelist font-family on HTML emit
2026-07-03 07:22:53 -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
Jason Ross 54c941d771 Merge pull request #203 from JMR-dev/fix-release-uri-grants
fix(compose): release persistable URI permissions for attachments/inline images
2026-07-03 06:48:09 -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
Jason Ross 947edab341 Merge pull request #201 from JMR-dev/feat-77-inline-images
feat(compose): inline images
2026-07-03 01:46:59 -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 0a2ee66f6f Merge pull request #199 from JMR-dev/feat-177-draft-autosave
feat(compose): debounced periodic draft autosave
2026-07-03 00:58:34 -05:00
Jason Ross ad8fd1e24f Merge main into feat-177-draft-autosave 2026-07-03 00:45:19 -05:00
Jason Ross b5655de214 Merge pull request #200 from JMR-dev/feat-72-font-family-picker
feat(compose): font family picker with bundled open-source fonts
2026-07-03 00:44:50 -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 e3d54aadd2 Merge pull request #198 from JMR-dev/feat-76-paragraph-alignment
feat(compose): paragraph alignment
2026-07-03 00:08:40 -05:00
Jason Ross 6fff3fcf02 Merge main into feat-76-paragraph-alignment 2026-07-02 23:55:54 -05:00
Jason Ross 8e44d1000c Merge pull request #197 from JMR-dev/feat-78-remember-font-size
feat(compose): remember last-used font and size
2026-07-02 23:55:23 -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
Jason Ross 79c3f57834 Merge pull request #194 from JMR-dev/feat-160-app-password-disclaimer
feat(accountsetup): warn against using account password for app password
2026-07-02 23:40:50 -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
Jason Ross 1663a2781b Merge pull request #195 from JMR-dev/feat-73-font-size-control
feat(compose): font size control
2026-07-02 23:27:55 -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
Jason Ross ca437671e3 Merge pull request #196 from JMR-dev/feat-172-gpl-license-onboarding
feat(onboarding): require GPL-3.0 license agreement as the first screen
2026-07-02 23:08:47 -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
JMR-devandClaude Opus 4.8 23fa389c0a feat(compose): add font size control to the formatting toolbar
Add a preset-size dropdown (10/12/14/18/24pt, plus Default to clear) to the
compose FormattingToolbar via a new FontSizePicker composable, applying
RichStyle.FontSize over the selection through the existing generalized
applyStyle/clearStyle toggle path (no font-size-specific branching needed).
The anchor button shows the selection's current size, or "Default" when
unset/mixed. The rich-text foundation already provided RichStyle.FontSize,
its pt/px-tolerant HTML round-trip, and pt->sp mapping for in-editor
rendering; this ticket wires up the missing UI control.

Closes #73

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:38:22 -05:00
JMR-devandClaude Opus 4.8 c3cd567da2 feat(accountsetup): warn against using account password for app password
New users unfamiliar with app passwords commonly try their regular
account password first and get a confusing auth failure. Add a
disclaimer as supporting text directly under the "App password" field
on AppPasswordSetupScreen (shared by every preset provider), instead
of leaving the warning only in the intro InfoCards above.

Closes #160

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:29:53 -05:00
Jason Ross 4872d24981 Merge pull request #190 from JMR-dev/feat-156-154-aol-provider
feat(accountsetup): add AOL provider with app-password help and IMAP/SMTP presets
2026-07-02 21:37:38 -05:00
Jason Ross af22467817 Merge main into feat-156-154-aol-provider 2026-07-02 21:26:33 -05:00