Per the repo owner's decision, preflight now runs the API 37 preview
emulator locally instead of leaving it to CI. Since there is no Gradle
Managed Device DSL path to the nonstandard android-37.0 /
google_apis_ps16k image, add a stdlib-only, cross-platform Python 3
helper (.claude/skills/preflight/api37_e2e.py) that mirrors CI's
e2e-preview job EXACTLY: same system image string
(system-images;android-37.0;google_apis_ps16k;x86_64), same emulator
flags, same provisioning/boot sequence. It installs the image via
sdkmanager, creates the AVD via avdmanager, cold-boots headless, waits
for sys.boot_completed, runs :app:connectedDebugAndroidTest, then tears
the emulator + AVD down. Cross-platform: per-OS tool discovery/suffixes
and cmd /c wrapping for Windows .bat launchers.
Update SKILL.md + CLAUDE.md so preflight runs api35 + api36 (GMDs) +
api37 (this script), and the app/build.gradle.kts managed-devices
comment now points at the script. Add a caveat that emulators need a
free hardware hypervisor (VT-x/WHPX) — shut down VirtualBox/other VMs
first or the AVD hangs at 0% CPU.
Validated syntactically only (python -m py_compile + ast.parse +
argparse --help); no emulator was booted and no build was run, to avoid
contending with an in-progress api36 run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the local preflight gate from api36 (the sole latest-API GMD
run) to api35 + api36, the top two stable levels in the E2E matrix.
Both Gradle Managed Devices already existed in app/build.gradle.kts
(the api29..36 loop) — confirmed via `:app:tasks --group verification`,
no emulator run needed.
API 37 (preview) was investigated but NOT added as a GMD: its only
published system image is the nonstandard "android-37.0" /
google_apis_ps16k pairing that ci.yml's e2e-preview job installs by
hand via sdkmanager. ManagedVirtualDevice's apiLevel (Int) builds
"android-<N>" and apiPreview (codename) builds "android-<Codename>" —
neither produces "android-37.0", the same gap ci.yml documents as why
reactivecircus/android-emulator-runner can't provision it either.
docs/perf/issue-124-unified-inbox-paging.md independently corroborates
this: its API 37 measurements used a physical Pixel, not an AVD. There
is no api37DebugAndroidTest task to run, so it stays CI-only
(e2e-preview) until a managed-device-compatible image ships; the
comment above testOptions.managedDevices in app/build.gradle.kts now
documents this in detail for the next person who looks.
.claude/skills/preflight/SKILL.md and CLAUDE.md are updated to run
both api35DebugAndroidTest and api36DebugAndroidTest as part of the
required gate, with the API 37 gap called out inline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Narrow the pull_request trigger to opened/reopened/ready_for_review so
per-commit pushes to open PRs no longer storm the runners via a
rebase-of-all-PRs (PR_FILTER: all) on every synchronize event. Pin
PR_READY_STATE to "all" so draft PRs remain in scope for updates
triggered by push (main advancing) and opened.
Closes#262
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The auto-submit crash prompt over-triggered: it re-surfaced the newest saved
crash report on every launch, with no age bound, so a pre-update crash kept
popping "LibreMail crashed" long after the crash was fixed (#255).
Gate StartupReportViewModel.pendingCrash so a crash is auto-offered:
- first re-open only — dismiss() now persists a "surfaced" marker instead of an
in-memory-only hide, so a report is offered at most once across launches; it
stays in the store (still listed in Problem Reports) and only discard() deletes.
- < 24h only — inject a clock provider and filter to createdAtMillis within 24h.
- legitimate crash only — reports come solely from CrashReporter's uncaught-
exception handler, so update / force-stop / user-close create none; made
explicit and covered by a test.
The marker is a minimal additive `surfaced` flag on DebugReport (persisted in
storage JSON, kept out of the submission payload; a missing flag = not surfaced)
plus ReportStore.markSurfaced(id). Extracted StartupCrashPrompt from LibreMailApp
so the real dialog + gating is E2E-testable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per repo-owner preference, drop the explicit types list and use the
default pull_request event set, keeping only the base-branch filter
(branches: [main]).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The workflow only fired on push to main, so a PR opened during a quiet
period (no subsequent merge to main) sat behind main until manually
updated. Add an opened/reopened/ready_for_review pull_request trigger;
synchronize is intentionally excluded to avoid re-running on every push,
including the autoupdate action's own branch updates.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add JVM unit tests (test-only; no production changes) covering the in-scope
ViewModels + UI state holders and the reporting/push/power/contacts modules
for issue #249.
New ViewModel coverage: Drafts, Outbox, Signatures, SignatureEdit,
AccountSettings, AccountSetup, ManualSetup, ProblemReports, StartupReport,
plus gap-filling for Compose, Mailbox, Reader, Settings, ReportReview and
AppPassword (contacts autocomplete, inline images, send/refresh failure
branches, drawer/search hooks, state-holder value semantics).
New module coverage: AppLog, AppVersionProvider, ReportSubmitter,
ReportUploadWorker (reachable paths), CrashReporter.install, LogEntry,
IntentComposeParser, ContactsRepository, ContactsPermissionManager,
IdlePushManager, BatteryOptimizationManager (Context methods) and
AndroidBatteryStatusProvider.
Android-framework-bound classes with no JVM seam are deliberately left to the
instrumented suite: IdleService (foreground Service), ReportUploadScheduler
(WorkManager.getInstance is not statically mockable), the HTTP transmit path in
ReportUploadWorker (unreachable while BuildConfig.DEBUG_REPORT_ENDPOINT is
empty), and CrashReporter.terminate (calls exitProcess).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add JVM-only unit tests (74 across 4 new, purely-additive files) covering
the data/repository, data-mapper, and pure domain packages. No production
code is changed.
- AccountRepositoryImplTest: first tests for AccountRepositoryImpl — add/
test/delete/observe + reset-backfill, success and rejected-LIST failure
paths (class now 100% instruction & line).
- MailRepositoryImplCoverageTest: the MailRepositoryImpl methods/edges the
existing suite skipped — observe-* flows, getMessage/getDraft, setStarred,
deleteMessage, sendMessage + copyAttachments (incl. unreadable-URI skip),
searchServer (all-accounts vs. filtered), and the account/row-gone
fall-throughs.
- MappersTest: entity<->domain mappers not otherwise pinned, incl. the
unknown-enum fallbacks and FetchedMessage id/uid rules.
- DomainModelCoverageTest: AccountSettings.signatureBlock branches,
Signature.plainText, default-arg constructors, and display-name fallbacks
(domain/model now 100% instruction & line).
Closes#246
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the latest-API-level emulator E2E (api36DebugAndroidTest, the
highest level in the E2E matrix and its Gradle Managed Device task) an
actually-run, required step:
- CLAUDE.md: preflight now runs api36DebugAndroidTest, and a change is
not done until that E2E runs and passes locally (not merely compiles).
The full multi-API matrix and the API 37 preview job stay CI's job.
- preflight skill: add the api36 E2E as the final step, note the
emulator/managed-device precondition, and replace the old
"don't run E2E locally" guidance so the two files agree.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
State plainly that a change isn't complete without passing unit tests
and E2E/instrumented tests covering it, with no softening about
running the emulator matrix locally being optional.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codify that a task/PR isn't complete without both passing unit tests
and E2E/instrumented tests covering the change. Writing and committing
the E2E/instrumented test is required; only running it against a
booted emulator locally stays optional, since CI's E2E matrix covers
that.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the crash-interrupted #192 WIP (app/build.gradle.kts already had a
jacocoTestReport task and toolVersion pin recovered onto build-192-jacoco):
- Move the JaCoCo tool version into gradle/libs.versions.toml instead of a
hardcoded string in app/build.gradle.kts, matching how every other plugin
version in this repo is sourced.
- Fix the generated-code exclusion list against the real compileDebugKotlin
output (verified by inspecting the compiled class tree): Room's
KSP-generated `_Impl` DAOs/database and the Compose compiler's per-file
ComposableSingletons holders are the only generated code that actually
lands in classDirectories, since Hilt/Dagger's generated Java and AGP's
BuildConfig/R/Manifest are compiled by a separate javac task this report
never reads. Drop the blanket `**/*$$*` exclude the WIP had — it was
silently discarding ~200 real classes' worth of coverage on Kotlin's own
`$$inlined$` synthetic classes (e.g. Flow.map { ... } transforms in the
repositories), which is hand-written logic, not generated boilerplate.
- Add Hilt_*/Dagger* prefix patterns so the (currently inert,
belt-and-suspenders) Hilt exclusions are actually correct if the
classDirectories scope ever changes.
- Add a minimal CI step to the existing unit-tests job that runs
jacocoTestReport and uploads the XML+HTML report as a build artifact.
No coverage threshold gate yet (a jacocoTestCoverageVerification rule is
a natural follow-up once there's a baseline).
- Document the new :app:jacocoTestReport task in CLAUDE.md.
Verified on JDK 21: fast gate (assembleDebug, testDebugUnitTest,
compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt) plus
jacocoTestReport all pass, from both a warm and a `clean` build. The
report shows real signal (30% instruction / 38% line coverage) with no
generated classes leaking in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clears the "hiltViewModel is deprecated; moved to package
androidx.hilt.lifecycle.viewmodel.compose" compile warnings across the 16 ui/**
files. Pure import swap: the old androidx.hilt.navigation.compose.hiltViewModel
inline-delegates to the new symbol, which is already transitively on the compile
classpath via the pinned hilt-navigation-compose:1.3.0 -- no dependency change,
identical signatures, behaviour byte-for-byte identical.
Closes#236
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>