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>
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>
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>
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 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>
The MailPruner/MailBackfiller unit tests mock the DAO, so the queries that
actually define the device-only retention floor were never exercised against a
real database: the newest-N-by-(timestampMillis, uid) prune selection, the
strict age cutoff, the windowed reconcile that spares backfilled history, and
the lowest-uid / count / oldest floor probes the backfiller stops on.
Add an instrumented MessageDao test on an in-memory Room DB covering all of
them, including the timestamp/uid tie-break direction (a flipped ORDER BY would
locally delete the user's newest mail) and inInbox/folder/account scoping.
This closes the disjoint-sets safety argument at the SQL-boundary level, not
just the orchestration level. Verified on the API 37 emulator (5/5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AGP 9's consistent resolution shares the runtime serialization version with the
androidTest classpath, where androidx.savedstate pins it to 1.7.3. Room 2.8's
schema-bundle serializers are compiled against >= 1.8.0, so MigrationTestHelper
threw AbstractMethodError on GeneratedSerializer.typeParametersSerializers(),
failing every E2E job. Import the serialization BOM as a platform to force 1.8.1.
Verified on-device (API 37): Migration9To10Test fails unpatched, passes patched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the fixed 50-message-per-folder header cap with a background,
resumable full-history backfill, and add a user-configurable device-only
retention limit that prunes local mail beyond it without ever deleting from
the server.
- ImapClient.fetchOlderThan pages a folder backwards in bounded batches,
locating the boundary by binary search over message numbers (O(log n) tiny
UID fetches, memory bounded to one batch).
- MailBackfiller + BackfillWorker page each synced folder newest→oldest,
persisting a per-folder boundary in a new backfill_progress table so a run
interrupted by process death / network loss resumes exactly where it stopped.
Runs off the sync mutex, so foreground sync / pull-to-refresh stay responsive.
- MailSyncer now reconciles server deletions only within the recent UID window
(deleteSyncedInWindowNotIn) instead of wiping everything outside the recent
50, so backfilled history survives each foreground sync. A materialized
messages.uid column powers the windowed reconcile and backfill boundary.
- Body/attachment prefetch still honours FetchPolicy (headers first).
- Per-account count/age overrides (nullable) with a global default; 0 = keep
everything (the default, matching #12).
- MailPruner + PruneWorker delete local rows beyond the limit (cascading
attachment rows + on-disk cache), never issuing a server delete. Deletes are
chunked under SQLite's 999-parameter limit.
- Precedence with backfill: backfill pauses (does not complete) at the
retention floor and both jobs share a maintenance mutex, so they never
contend; foreground fetch is also capped by the count so it can't re-download
what pruning just trimmed.
- Settings UI for the global default and per-account override, with copy making
clear it is device-only, not the server.
Room schema v9→v10 (migration + exported schema + MigrationTestHelper test).
GreenMail tests prove the backfill caches >50 and resumes after interruption;
pruning tests cover count/age limits and never touch the server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>