Commit Graph
141 Commits
Author SHA1 Message Date
JMR-dev bb9b7f39c4 Merge branch 'main' into feat-screen-unlock 2026-07-01 23:40:23 -05:00
Jason Ross 8d503abd7e Merge pull request #106 from JMR-dev/fix-html-preview-snippets
fix(mailbox): derive plain-text preview snippets from HTML bodies
2026-07-01 23:38:47 -05:00
JMR-dev 9ca53de3da Merge branch 'main' into feat-screen-unlock 2026-07-01 23:31:14 -05:00
Jason Ross c18ab42c6c Merge branch 'main' into fix-html-preview-snippets 2026-07-01 23:29:44 -05:00
Jason Ross 5c0e3af38d Merge pull request #107 from JMR-dev/feat-room-migration-tests
test(db): add Room migration tests with MigrationTestHelper
2026-07-01 23:28:59 -05:00
Jason Ross 657a3aefea Merge branch 'main' into feat-room-migration-tests 2026-07-01 23:17:11 -05:00
Jason Ross 89b2aca141 Merge pull request #105 from JMR-dev/feat-release-workflow
ci(release): add tag-triggered signed-release and store-publish workflow
2026-07-01 23:15:51 -05:00
JMR-devandClaude Fable 5 c06a387b3c fix(mailbox): derive plain-text preview snippets from HTML bodies
snippetOf() stripped only tag delimiters with a single regex on every
body, HTML or not: <style>/<script> text leaked into HTML snippets,
entities stayed encoded, and plain-text bodies had literal <...> text
eaten as if it were markup.

Replace it with Snippet.of(body, isHtml), which finally consults the
isHtml flag both call sites already had: HTML bodies go through
HtmlToText (script/style content dropped, tags stripped, entities
decoded), plain text gets no markup handling at all; both paths keep
the whitespace collapsing and the 140-char cap. HtmlToText's entity
decoding is now a single-pass decoder that also handles decimal/hex
numeric character references and never re-decodes produced characters.

Snippets are persisted when a body is first fetched and never
re-derived, so existing rows would keep their broken snippets forever;
a data-only v13->v14 migration re-derives every cached row's snippet
with the corrected logic (schema unchanged relative to v13, exported
14.json committed).

Closes #85

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:13:16 -05:00
JMR-dev 63b553ab8e Merge branch 'main' into feat-screen-unlock
# Conflicts:
#	app/src/main/kotlin/org/libremail/di/DatabaseModule.kt
#	app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
2026-07-01 23:12:56 -05:00
Jason Ross 2474981c9e Merge branch 'main' into feat-release-workflow 2026-07-01 23:05:29 -05:00
Jason Ross b698c17cd0 Merge pull request #46 from JMR-dev/feat-fetch-all-retention
[needs careful review] feat(sync): default fetch-all history + device-only retention (#12, #13)
2026-07-01 23:03:49 -05:00
JMR-dev 2feaa0bb30 Merge branch 'main' into feat-screen-unlock
# Conflicts:
#	app/src/main/kotlin/org/libremail/MainActivity.kt
2026-07-01 22:57:23 -05:00
Jason Ross 59e068e326 Merge branch 'main' into feat-release-workflow 2026-07-01 22:54:12 -05:00
Jason Ross 5bc2b4d6b1 Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:53:24 -05:00
Jason Ross 8cc4ea22f8 Merge pull request #97 from JMR-dev/feat-play-compliance
docs(play): add privacy policy, data-safety mapping, and permissions justification
2026-07-01 22:52:21 -05:00
JMR-dev 4e9e21e847 Merge remote-tracking branch 'origin/main' into feat-fetch-all-retention 2026-07-01 22:46:08 -05:00
Jason Ross 295312937d Merge branch 'main' into feat-play-compliance 2026-07-01 22:40:06 -05:00
Jason Ross 2dd47432ea Merge pull request #110 from JMR-dev/feat-message-options-top-bar
feat(message): move message actions from dropdown to top-bar icons
2026-07-01 22:39:22 -05:00
Jason Ross 85b4597939 Merge branch 'main' into feat-play-compliance 2026-07-01 22:31:42 -05:00
Jason Ross 0350572c9f Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:30:52 -05:00
JMR-devandClaude Fable 5 c5c2da8e68 test(db): add Room migration tests with MigrationTestHelper
Closes the gap where app/schemas was exported but never validated (#63):

- androidx.room:room-testing (androidTest) + ship the exported schemas as
  androidTest assets so MigrationTestHelper can build old-version databases.
- MigrationTest: 11->12 asserts folders.specialUse arrives defaulting to 0
  with existing rows intact; a chain-integrity test requires exactly one
  migration per version step up to the newest exported schema; a full
  v7->latest replay validates every step against its exported JSON and
  asserts seeded v7 data and each migration's backfills survive. The
  migration list is discovered from Migrations.kt and the target version
  from the exported schemas, so a future migration is covered by just
  committing its schema JSON.
- Pin kotlinx-serialization to 1.8.1 via its BOM: androidx.savedstate pins
  1.7.3 transitively (shared with androidTest by AGP 9 consistent
  resolution), and Room 2.8's schema-bundle serializers need >= 1.8.0 or
  MigrationTestHelper throws AbstractMethodError parsing the schema JSON.
  Identical to the pin already proven on the feat-fetch-all-retention
  branch, so the two merge cleanly in either order.
- Refresh comments that described migration tests as future work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:30:14 -05:00
Jason Ross 4d26a91f59 Merge branch 'main' into feat-message-options-top-bar 2026-07-01 22:29:50 -05:00
JMR-devandClaude Fable 5 f66ec3d7fb fix(security): harden app-lock + encrypted-cache flows (PR #45 review)
Addresses 14 of the 15 confirmed findings from the max-effort review of the
screen-lock app gate. The remaining one (accounts/credentials share the
auth-bound cache DB) needs a device-tested Room migration and is filed
separately; its blast radius is reduced here by eliminating the spurious wipes.

- Cold-start deadlock: LibreMailApplication injects AccountRepository lazily so
  the Room DB is never built on the main thread before unlock.
- Passphrase source of truth: DatabaseKeyStore.resolvePassphrase() keys off
  which seal exists, not the app-lock setting; passphrase() refuses to mint a
  master key while an auth seal exists.
- Toggle-order strand: disabling app-lock reseals under the master key whenever
  an auth seal exists (not gated on the encryptCache setting).
- Crash-safe clear protocol: wipe + reset seals, then clear the flag last; set
  clear-pending before flipping app-lock off.
- isInvalidated(): treats a lapsed auth window (UserNotAuthenticated) as valid,
  and onForeground short-circuits when app-lock is off.
- unwrapSealedPassphrase: classifies all decrypt failures — no crash after a
  successful auth.
- Headless entry points: SyncWorker/SendWorker/IdleService fail fast via
  EncryptedCacheGuard instead of blocking DB construction while locked.
- sealWithMaster: deletes the orphaned auth key (no spurious later wipe).
- Lock-bypass race: AppLockGate ignores a background recorded after a foreground
  pass began; the ViewModel captures the foreground timestamp synchronously.
- FLAG_SECURE: set while app-lock is on (recents/screenshot protection).
- Resume + re-lock: the gate covers content with an opaque overlay instead of
  removing it, so no stale frame renders and in-progress state (nav, drafts)
  survives re-lock.
- Retry feedback: lock emissions carry a nonce so a retry updates the UI.

Tests: AppLockGate stale-foreground race cases + an exhaustive
KeyInvalidationPolicy table. Fast gate green + androidTest compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:29:48 -05:00
JMR-devandClaude Opus 4.8 195c07aa32 Merge remote feat-fetch-all-retention (32b91a1); keep the complete latest-main merge
32b91a1 merged an older main: it dropped main's F-Droid content (docs/fdroid-compliance.md,
fastlane metadata, the build.gradle.kts dependenciesInfo block) and its CI failed only on an
E2E (30) infra flake (~110s in 'Run E2E tests'). This side merges the LATEST main, restores that
content, resolves build.gradle.kts keeping both additions, and is fast-gate + androidTest-compile
green. Supersede 32b91a1 via -s ours.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:28:06 -05:00
Jason Ross 67957a3961 Merge pull request #92 from JMR-dev/fix-move-by-role-specialuse
fix(mail): prefer special-use folder when resolving move-by-role destination
2026-07-01 22:20:47 -05:00
JMR-devandClaude Opus 4.8 5283d29d5d Merge branch 'main' into feat-fetch-all-retention
Resolve the build.gradle.kts conflict by keeping both additions: the
androidTest Room-schema srcDir (this branch) and main's F-Droid
dependenciesInfo block. Full fast gate + androidTest compile green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:18:50 -05:00
JMR-devandClaude Fable 5 1d796e3c41 feat(message): move message actions from dropdown to top-bar icons
The multi-select contextual action bar buried Archive, Spam, Move,
Select all, and the single-selection Reply/Reply All/Forward behind one
MoreVert dropdown; only Close and Delete were direct. Promote the
common actions to direct IconButtons, matching the reader app bar's
icons-not-menus pattern: Archive (Done glyph - material-icons-core has
no archive icon, so this leans on the "done = archive" mail idiom),
Spam (Warning), and Delete, each with a contentDescription for
accessibility.

The overflow keeps only the long tail: Move (no usable core glyph, per
the ticket it stays text-labeled), Select all, and the
single-selection reply actions. All conditional visibility is
preserved: Archive/Spam still hide while viewing their own role
folder, Move still requires a single-account selection, and the reply
actions still require exactly one selected message. Four 48dp actions
plus Close still fit a 320dp-wide bar; the count title just truncates
earlier.

UI tests: the direct Archive icon archives without opening the
overflow, the direct Spam icon still confirms before reporting, the
Archive icon hides inside the archive folder, and the overflow test
now keys on Select all instead of the promoted Archive.

Closes #87

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:13:25 -05:00
Jason Ross bb3dcd8217 Merge branch 'main' into fix-move-by-role-specialuse 2026-07-01 22:07:18 -05:00
Jason Ross 9f582ecac4 Merge pull request #91 from JMR-dev/feat-fdroid-compliance
chore(fdroid): add F-Droid metadata, license audit, and anti-feature docs
2026-07-01 22:06:38 -05:00
Jason Ross 32b91a181b Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:02:38 -05:00
JMR-devandClaude Fable 5 0b0f6b7018 ci(release): add tag-triggered signed-release and store-publish workflow
Rewrite the manual-dispatch release.yml into the issue-#19 pipeline:
v* tag push (or dispatch with dry-run/re-release inputs) runs the fast
CI gate, builds bundleRelease + assembleRelease signed from base64
keystore secrets (falling back to *-unsigned artifacts when unset),
generates a Conventional-Commit changelog and SHA-256 checksums, then
creates the GitHub release and fans out to secret-gated Google Play
publish (staged rollout supported), a documented Galaxy Store manual
stub, and an S3-compatible archive under releases/<tag>/. Every
credentialed stage skips with a clear notice while the store accounts
(#16/#17/#18) don't exist yet; no secret lives in the repo and
app/build.gradle.kts is unchanged. docs/release.md documents the
secrets, flows, and per-store manual fallbacks.

Part of #19

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:01:34 -05:00
JMR-devandClaude Opus 4.8 6ea02f588d fix(sync): resolve code-review findings on fetch-all history + retention
Addresses the review of PR #46 (#12/#13):
- Age-retention backfill/prune loop: mark a folder complete at the
  retention floor and resume from the persisted nextBeforeUid low-water
  mark; loosening resumes via AccountRepository.resetBackfillProgress.
- Guard the windowed reconcile bound to the lowest positive UID so a
  getUID==-1 message can't collapse it and wipe backfilled history.
- Order count-based retention by uid DESC to match the fetch window,
  ending the re-fetch/re-prune churn for high-UID/old-Date messages.
- BackfillWorker chains slices while work remains.
- Extract shared effectiveRetention / isActiveNetworkUnmetered /
  attachmentCacheDir helpers; remove dead deleteSyncedNotIn/getForAccount;
  refresh only pre-existing rows in persistBatch; add composite index
  (accountId, folder, uid) with migration + regenerated 13.json.

Adds an age-floor prune regression test. Fast gate + androidTest compile
green on JDK 21.

Follow-ups filed for below-the-cut findings: #93, #94, #95, #96.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:53:43 -05:00
JMR-devandClaude Fable 5 3f7024d05d docs(play): add privacy policy, data-safety mapping, and permissions justification
Repo-actionable deliverables for the Google Play compliance work (issue #17),
every claim verified against the code and the built release artifacts:

- PRIVACY.md: user-facing privacy policy (device-local mail cache, optional
  SQLCipher encryption, traffic only to the user's own mail provider,
  on-device-only contacts autocomplete, strictly local opt-in debug reports,
  no ads/analytics/tracking SDKs).
- docs/play-data-safety.md: Play Data safety questionnaire mapping -- answer
  'no data collected/shared' with per-category code evidence, the policy
  exemptions relied on, a dependency audit, and a conservative fallback.
- docs/play-permissions.md: merged-manifest permission audit (incl. the
  WorkManager-injected WAKE_LOCK / RECEIVE_BOOT_COMPLETED) with paste-ready
  Console justifications for READ_CONTACTS, POST_NOTIFICATIONS, and the
  FOREGROUND_SERVICE_DATA_SYNC declaration + demo-video script.
- docs/play-compliance.md: verified targetSdk 37 (requirement: 35+), 16 KB
  page-size compliance (all packaged .so PT_LOAD p_align=0x4000, incl.
  sqlcipher-android 4.16.0), bundleRelease AAB check, the Gmail-app-password /
  no-CASA OAuth note, the console-steps checklist with drafted content-rating
  and listing answers, and repo findings (push-mail default vs docs, README
  minSdk/app-lock drift, debug-key release fallback).
- README.md: link PRIVACY.md and note the no-Google-OAuth/no-CASA status
  (fuller README pass stays issue #20).

Part of #17.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:52:40 -05:00
JMR-devandClaude Fable 5 a6436719b1 fix(mail): prefer special-use folder when resolving move-by-role destination
resolveRoleFolder().pick() chose the destination for archive/reportSpam/
trash as the first selectable folder with the matching role, in server
LIST order. A provider's built-in folder (role via an RFC 6154 attribute,
e.g. [Gmail]/Spam via \Junk) and a same-named user folder (role via
roleFromDisplayName) can share a role, so the winner depended on which
one the server happened to LIST first — silently misrouting mail past
the provider's junk training, retention, and auto-purge.

Prefer the server-advertised special-use folder among same-role matches:
maxByOrNull { it.specialUse } picks a specialUse=true folder over
name-derived ones, and, because maxByOrNull returns the first max, keeps
the existing LIST-order behavior when no special-use folder exists.

Closes #58

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:48:46 -05:00
JMR-devandClaude Fable 5 807f2402a5 chore(fdroid): tighten accuracy of CI and KnownVuln wording in audit doc
CI runs ktlint/detekt and the E2E suites (not Android lintDebug), and the
KnownVuln rationale should not imply blanket TLS enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:46:06 -05:00
JMR-devandClaude Fable 5 db96134492 chore(fdroid): add F-Droid metadata, license audit, and anti-feature docs
Prepare for F-Droid publication (issue #16):

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:43:58 -05:00
Jason Ross 0880dd7f54 Merge pull request #80 from JMR-dev/feat-compose-attachment-reminder
feat(compose): prompt before sending when a mentioned attachment is missing
2026-07-01 18:20:57 -05:00
JMR-dev d687f11518 Merge remote-tracking branch 'origin/main' into feat-compose-attachment-reminder
# Conflicts:
#	app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt
#	app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt
2026-07-01 18:04:59 -05:00
Jason Ross 0dd6933b13 Merge pull request #82 from JMR-dev/feat-compose-collapsible-cc-bcc
feat(compose): collapse Cc/Bcc into expandable links under the To field
v0.2.0
2026-07-01 17:29:51 -05:00
Jason Ross eeeb838658 Merge branch 'main' into feat-compose-collapsible-cc-bcc 2026-07-01 17:18:15 -05:00
Jason Ross 1828109894 Merge pull request #81 from JMR-dev/feat-richtext-foundation
feat(richtext): parameterized styles, alignment/image/base-style channels, HTML round-trip
2026-07-01 17:15:57 -05:00
Jason Ross bce597b03c Merge branch 'main' into feat-compose-collapsible-cc-bcc 2026-07-01 17:12:43 -05:00
JMR-devandClaude Fable 5 4782b24453 fix(richtext): preserve blank lines between aligned paragraphs; share span merging
Code-review fixes: an all-empty paragraph group (a blank line isolated by an
alignment split) emitted <p></p>, which the parser collapses — it now emits one
<br> per line so blank lines round-trip. The identical span-merge helper that
existed in both the parser and RichTextEditing is now a single shared
mergeSameValueSpans() in RichText.kt, and the private applyBlock/applyLink drop
their never-used default font resolver.

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

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

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

Closes #70

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

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

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

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

Fixes #56

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 15:51:16 -05:00
Jason Ross f1e9af53f5 Merge branch 'main' into feat-fetch-all-retention 2026-07-01 15:42:38 -05:00