[needs careful review] feat(sync): default fetch-all history + device-only retention (#12, #13) #46

Merged
JMR-dev merged 15 commits from feat-fetch-all-retention into main 2026-07-02 04:03:50 +00:00
15 Commits
Author SHA1 Message Date
Jason Ross 5bc2b4d6b1 Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:53:24 -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 0350572c9f Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:30:52 -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
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
Jason Ross 32b91a181b Merge branch 'main' into feat-fetch-all-retention 2026-07-01 22:02:38 -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
Jason Ross f1e9af53f5 Merge branch 'main' into feat-fetch-all-retention 2026-07-01 15:42:38 -05:00
JMR-devandClaude Opus 4.8 77f837e67a Merge main into feat-fetch-all-retention
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>
2026-07-01 15:34:47 -05:00
JMR-devandClaude Opus 4.8 eb0e649c30 fix(test): avoid observeAll() in MessageDaoRetentionTest (CI compile glitch)
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>
2026-07-01 15:08:16 -05:00
JMR-devandClaude Opus 4.8 1d4103747f test(sync): assert MailMaintenanceGate serializes backfill and prune
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>
2026-07-01 14:52:36 -05:00
JMR-devandClaude Opus 4.8 a73b6410a2 test(sync): tally rows offered to insertNew to prove no double-fetch
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>
2026-07-01 14:33:13 -05:00
JMR-devandClaude Opus 4.8 ec60348c89 test(sync): pin MessageDao retention/backfill boundary SQL on real SQLite
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>
2026-07-01 14:22:40 -05:00
JMR-devandClaude Opus 4.8 b3ac6d4353 fix(build): pin kotlinx-serialization to 1.8.1 for Room migration tests
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>
2026-07-01 13:17:23 -05:00
JMR-devandClaude Opus 4.8 bad597bc42 feat(sync): default fetch-all history + device-only retention (#12, #13)
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>
2026-07-01 13:17:22 -05:00