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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>