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>
In age-based retention, MailSyncer fetched the newest-N headers but only capped that window by the
retention COUNT, not the age cutoff. On a low-traffic mailbox whose newest-N span older than the
cutoff, each sync re-inserted messages the age pruner had just deleted, and the next prune deleted
them again — a churn loop of wasted DB writes + prune deletes (issue #193).
Sync now drops fetched messages older than policy.ageCutoffMillis before persisting (the same cutoff
the pruner uses), so sync and prune keep exactly the same set in both retention modes. Count/unlimited
modes have a null cutoff and are unchanged. The empty-folder wipe is keyed on the raw fetch (server
truth), so a folder holding only past-cutoff mail is left to the pruner rather than wiped.
MailPruner's KDoc now documents the sync alignment for both modes.
Closes#193
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>
DiagnosticsCollector now includes one "<provider> (<authType>)" entry per account (the count is the
list size) in DebugReport.accounts, alongside the existing settings + recent-log capture. The provider
is a coarse bucket derived from the IMAP host (Gmail/Yahoo/iCloud/Outlook/AOL/Other) — never the raw
host or email — so no PII leaks; a custom domain buckets to "Other". Accounts are cached like settings
so crash reports (built on the crashing thread) include the last-known snapshot; accounts live in the
non-auth AccountDatabase, so reading them never blocks on the encrypted cache.
Recent device/app logs were already captured (RingLogBuffer) and serialize as the report's "logs".
Closes#235
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds ReportPurgeWorker, deleting locally-stored crash/problem reports older than
30 days via new ReportStore.purgeOlderThan(cutoffMillis). Scheduled as a periodic
WorkManager job with a charging constraint (SyncScheduler.schedulePeriodicReportPurge,
enqueued at startup alongside sync/backfill/prune) so it never costs battery. Reports
are file-backed (no DB), so the worker needs no cache-lock gate.
Also discloses the auto-deletion: the problem-reports list and the submission review
screen state reports are deleted from the device after 1 month.
Tests: ReportStore.purgeOlderThan cutoff (boundary kept); ReportPurgeWorker computes a
~30-day cutoff and retries on failure.
Closes#239
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>
Give accounts a user-controlled order (issue #164). Every surface that
lists accounts -- the Settings list, the drawer account switcher, and the
compose account picker -- reads the same `ORDER BY sortOrder` query, so a
reorder in Settings is honored app-wide. In Settings a row can be
long-pressed and dragged to a new position; the order persists and
survives restart.
Data layer:
- AccountEntity gains `sortOrder` (@ColumnInfo defaultValue "0"); AccountDao
orders by it and adds insertAtEnd / reorder / nextSortOrder / setSortOrder,
the mutations wrapped in transactions.
- New accounts are appended (current max + 1) via insertAtEnd.
Migration (AccountDatabase v1 -> v2):
- ACCOUNT_MIGRATION_1_2 adds the column and backfills existing accounts by
their previous alphabetical (email) rank, so the already-shown order does
not shuffle on upgrade. Registered in AccountDatabaseModule; 2.json is
exported and AccountMigrationTest replays and validates it.
- AccountDataMigrator (the pre-#111 cache->account-db move) creates the
v2-shaped table and applies the same email-rank backfill, since
ACCOUNT_MIGRATION_1_2 does not run for that path.
UI:
- AccountReorderList drives long-press drag over a plain Column (no nested
lazy list inside the scrolling settings column, no extra dependency); the
pure reorder-index maths (commitDrag) is unit-tested.
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>
Restores Unicode-aware case-insensitive substring search (approach A of #227).
Paging (#223) moved search to a SQL LIKE scan whose case-folding is ASCII-only,
so non-ASCII terms stopped matching case-insensitively.
Adds per-field casefold columns to `messages` (senderFold/senderEmailFold/
subjectFold/snippetFold), each = Kotlin lowercase() of its source (Unicode-aware).
Per-field (not one concatenated column) because the fields are maintained by
partial UPDATEs that don't carry all four: toEntity sets all folds, updateBody
keeps snippetFold in sync, updateHeaderContent keeps the header folds -- via thin
DAO default-method wrappers so the five call sites are unchanged. Search matches
the fold columns with a pattern built from the lowercased query.
Schema v18->v19 (additive; ASCII lower() backfill, non-ASCII rows re-fold on next
write). Adds a MigrationTest v18->v19 case and a repo test asserting the query is
casefolded.
Closes#232
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>