Commit Graph
170 Commits
Author SHA1 Message Date
JMR-devandClaude Opus 4.8 8f8608a430 feat(debug): dev-only pause/halt mail-fetch hook for test harnesses (#393)
The on-device perf harness cannot force a genuinely uncached body fetch: proactive
backfill (#12) and post-sync body prefetch (#88/#89) warm the cache before a test can
open a message. Add a debug-only, adb-reachable hook to pause proactive fetch so a real
uncached open can be measured.

Components:
- DebugFetchGate (src/main): thread-safe in-memory holder of paused FetchScopes
  (BACKFILL, PREFETCH; `all` alias). Defaults to not-paused; HEADER_SYNC and on-demand
  OPEN are never gateable.
- FetchGateReceiver (src/debug only): BroadcastReceiver registered in the debug manifest,
  driven by `adb shell am broadcast -a org.libremail.debug.FETCH_GATE -n .../FetchGateReceiver
  --es action <pause|resume|query> --es scope <backfill,prefetch|all>`. Returns the state as
  ordered-broadcast result data (paused=[...]) for a synchronous read-back.

Enforcement (each read guarded by BuildConfig.DEBUG so R8 strips it from release):
- BackfillWorker.doWork() entry -> skip-and-reschedule when BACKFILL is paused, mirroring
  the existing cache-lock deferral (covers periodic + backfillNow()).
- MailSyncer/MailBackfiller.prefetchIfEnabled -> early-return when PREFETCH is paused.
  openMessage / fetchBodyMarkingSeen / fetchAttachment are deliberately NOT gated.

Debug-only: receiver + <receiver> live wholly in src/debug; every gate read in main is
behind BuildConfig.DEBUG. Verified on assembleRelease that R8 strips DebugFetchGate /
FetchScope / FetchGateReceiver and the log strings from the release APK, and the merged
release manifest has no FETCH_GATE receiver.

PII-free AppLog breadcrumbs on pause/resume/query and on each gate-triggered defer/skip
(scope names only).

Tests: DebugFetchGateTest, BackfillWorkerTest / MailSyncerTest / MailBackfillerTest
enforcement cases, and FetchGateReceiverInstrumentedTest (ordered-broadcast -> gate ->
read-back; gated worker defers while an un-gated path runs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:13:22 -05:00
Jason Ross 5a8c4e386e Merge branch 'main' into fix-359-sqlcipher-16kb 2026-07-06 16:56:27 -05:00
JMR-devandClaude Opus 4.8 ca6d90b602 test(settings): cancel viewModelScope before db.close in SignaturesScreenTest to fix a Room teardown race (flaky on API-37 CI)
SignaturesScreenTest built a real SignaturesViewModel by hand but tore down
with a bare `db.close()` that never cancelled viewModelScope. The ViewModel's
`signatures` StateFlow is a Room InvalidationTracker Flow kept alive by
stateIn(WhileSubscribed(5_000)), so the collector could stay live up to 5s
after the UI detached — a re-query then landed on the just-closed in-memory DB
and threw SQLITE_MISUSE ("connection is closed"). Timing-dependent, hence the
intermittent API-37 CI failure in tappingRadioOnNonDefault_makesItTheDefault.

Fix: hold the ViewModel in an androidx.lifecycle.ViewModelStore and, in @After,
call store.clear() (→ ViewModel.onCleared() → cancels viewModelScope) BEFORE
db.close(), so the collector is gone before the DB closes. Behaviour and
assertions are unchanged; the fix removes the race by construction.

Audited the androidTest tree for the same hazard and fixed two siblings the
same way:
- AccountSettingsScreenTest: had the same live-Room-Flow-vs-close race,
  previously worked around by never closing the in-memory DB at all; now
  clears the ViewModel then closes the DB.
- ComposeScreenTest: ComposeViewModel's init launches a viewModelScope
  coroutine that reads the real accountSettings/signature Room repos; clear
  the store before db.close() to avoid the same in-flight-read-vs-close race.

Verified locally: connectedDebugAndroidTest green for all three classes
(12/12) on a cold-booted emulator, plus the JVM fast gate (assembleDebug,
testDebugUnitTest, jacocoTestCoverageVerification, compileDebugAndroidTestKotlin,
lintDebug, ktlintCheck, detekt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:46:40 -05:00
JMR-devandClaude Opus 4.8 bfe5d46654 fix(security): fail closed on cache-encryption load failure with an error gate (#359)
Reworks #367. When SQLCipher native library loading fails while the opt-in
encrypted cache is enabled, the app previously degraded to a plaintext cache
(a silent fail-open that defeats the feature). Now it FAILS CLOSED.

DatabaseProvisioner raises a distinct CacheEncryptionUnavailableException
instead of degrading: it does NOT open plaintext, NOT wipe the on-disk
ciphertext, and NOT write the encryptCache setting. The throw is not
memoized, so a later launch re-attempts and recovers automatically if the
library loads.

A new CacheEncryptionGate wraps the app inside AppLockGateHost (so the
passphrase is already unlocked), probes prepareCache() before any DB-backed
screen composes, and on failure shows CacheEncryptionErrorScreen with the
exact message "Error - decryption could not proceed. Native decryption
library load failure." plus a "Report a problem" action. That action
generates an EPHEMERAL PII-free report via the existing DiagnosticsCollector
(never written to ReportStore, since encryption is unavailable in that
moment) for on-screen review and explicit Copy/Save; the copy says so.

The plaintext AccountDatabase tolerates the exception so accounts stay
readable for the error gate and the report. The encryptCache setting is now
written by exactly one caller: the user Settings toggle.

Tests: fail-closed raises the signal with no plaintext open / no wipe / no
setting write / not memoized; the gate VM resolves Ready vs Unavailable and
builds the ephemeral report; an instrumented error-screen UI test and an
AccountDatabase-resilience instrumented test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:48:35 -05:00
Jason Ross 5c715e1676 Merge branch 'main' into fix-359-sqlcipher-16kb 2026-07-06 12:50:29 -05:00
JMR-devandClaude Opus 4.8 921681812c fix(data): degrade encrypted cache to plaintext on SQLCipher native-load failure
On Android 15+ / SDK 37 devices with 16 KB memory pages (e.g. Pixel 10 Pro XL,
and the API-37 `google_apis_ps16k` emulator image), a native `.so` not aligned
for 16 KB pages fails to load with `UnsatisfiedLinkError` at
`SQLiteConnection.nativeOpen`. With the opt-in SQLCipher encrypted cache on, this
crashed the app on every cold start (issue #359, x4 on-device) instead of
degrading, and encryption silently never applied.

Fix: DatabaseProvisioner's encryption gate now catches `LinkageError`
(UnsatisfiedLinkError and related native-link failures) when opening/converting
the encrypted cache and degrades cleanly instead of propagating the crash — it
turns `encryptCache` off (so the next start does not re-attempt and re-wipe),
clears any on-disk ciphertext the plaintext framework opener cannot parse
(resetting its now-useless seals), and opens the cache unencrypted. The cache is
a re-syncable copy of server mail, so clearing it loses nothing that cannot be
re-fetched. PII-free AppLog.w breadcrumb on the degrade path.

Dependency: no bump needed or available. The repo already pins the newest
SQLCipher it references, `net.zetetic:sqlcipher-android:4.16.0`, which
docs/play-compliance.md certifies (ELF p_align = 0x4000) as 16 KB-aligned on
every ABI; SQLCipher has shipped 16 KB-aligned binaries since well before it, and
the other two bundled `.so` files (Compose graphics-path, DataStore
shared-counter) are already 16 KB-aligned per that doc. The graceful-degrade
catch is therefore the actionable fix.

Tests:
- Unit (DatabaseProvisionerTest): a simulated native-load failure degrades to a
  plaintext open without crashing, turns encryptCache off, and wipes + reseals an
  already-encrypted cache.
- Instrumented (DatabaseProvisionerInstrumentedTest): a fresh encrypt-on start
  loads the real SQLCipher native library and opens the keyed cache — CI's API-37
  `google_apis_ps16k` 16 KB job exercises the actual `.so` load, catching any
  future 16 KB-alignment regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:26:05 -05:00
JMR-devandClaude Opus 4.8 52503c000c fix(push): stop IdleService dataSync FGS crash-loop on exhausted 24h cap
Root cause: after #302's runtime-cap fallBackToPeriodicSync() stops the
dataSync foreground service, IdleService was restarted (START_STICKY
null-intent redelivery + explicit startForegroundService) and onStartCommand
unconditionally called startForeground(DATA_SYNC) while the rolling-24h budget
was still exhausted. The platform rejected the start with
ForegroundServiceStartNotAllowedException; it was uncaught, the process
crashed, and START_STICKY restarted straight back into the same rejection -- a
crash loop until the 24h window freed budget (#354).

Fix (IdleService.kt):
- onStartCommand now returns START_NOT_STICKY. Push is app-managed
  (LibreMailApplication.ensurePushStarted deterministically restarts it), so the
  sticky null-intent auto-restart was redundant and fired exactly when a dataSync
  FGS start is illegal.
- Guard the foreground start via a new JVM-testable IdleForegroundStarter seam:
  a ForegroundServiceStartNotAllowedException (caught via its IllegalStateException
  supertype, so no minSdk-29 class load) degrades like the cap handler --
  schedulePeriodicSync(), keep the degraded POLLING notification, stopSelf()
  promptly (avoids the "did not call startForeground in time" ANR) -- instead of
  propagating.
- Record the cap event (elapsedRealtime); while still inside the cap window,
  onStartCommand skips the now-guaranteed-illegal foreground start entirely.
- onTimeout stop path kept fast so ForegroundServiceDidNotStopInTimeException
  stays mitigated.

PII-free AppLog.w/i on the degrade paths.

Tests:
- Unit (IdleForegroundStarterTest): onStartCommand returns START_NOT_STICKY; a
  rejected start is caught and routed to degrade without propagating; the cap
  window skips the attempt; a non-ISE propagates.
- Instrumented (IdleServiceForegroundStartInstrumentedTest): the degrade path on
  a real Context -- rejection caught, periodic-sync fallback scheduled, degraded
  "instant delivery paused" notification built, watching skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:17:19 -05:00
JMR-devandClaude Opus 4.8 46bc0e2258 Merge main into perf-187-covering-index
Resolve DatabaseModule conflict from #320: main replaced the explicit .addMigrations(...) chain with .addMigrations(*ALL_MIGRATIONS) plus an introspectable ALL_MIGRATIONS list guarded by databaseModuleRegistersEveryDeclaredMigration (registered == declared). Add MIGRATION_19_20 to ALL_MIGRATIONS so the unified-inbox covering-index migration (cache schema v19->v20) is both registered on the Room builder and satisfies that safety-net test. Schema 20.json, the v20 @Database version, and DatabaseEncryptionTest's schema-version assertion (20) are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 13:05:07 -05:00
JMR-devandClaude Opus 4.8 0cb9bb2906 refactor(data): route DB/keystore logging through AppLog (#327)
Migrates the DB/keystore area's raw android.util.Log calls to AppLog so
key-invalidation and DB-conversion breadcrumbs land in the process
RingLogBuffer (and thus a user-reviewed debug report) even in release
builds, where Log.d is otherwise stripped from Logcat only.

- DatabaseKeyCipher: 4 auth-bound-key decision points (encrypt retry,
  isInvalidated's three branches) now log via AppLog.d(tag, msg, e).
- DatabaseEncryption.migrate: adds an AppLog.i "converting local cache
  database (targetEncrypted=...)" breadcrumb at the start, alongside the
  existing "converted" completion line now routed through AppLog.d.
- AccountDataMigrator: the "moved account tables into the account
  database: $present" breadcrumb (table names only) now routed through
  AppLog.d.

No PII or key material is logged; table-name sets and boolean flags only.

Adds instrumented tests (DatabaseKeyCipher is device-only and
behavior-preserving, so no new test there) asserting the breadcrumbs
land in a RingLogBuffer and never contain the seeded email, secret, or
passphrase.

Part of #324.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:12:59 -05:00
JMR-devandClaude Opus 4.8 e4457cfec1 test(db): expect schema v20 in encryption round-trip after v19->v20 bump
MIGRATION_19_20 (issue #187) bumped the Room cache schema to version 20,
but DatabaseEncryptionTest.schemaVersionIsCarriedOntoTheEncryptedFile still
asserted the plaintext -> encrypted conversion carried version 19, so it
failed across all E2E levels after the rebase onto main.

DatabaseEncryption.migrate() carries PRAGMA user_version dynamically
(userVersion = source.version -> target.version = userVersion), and a fresh
Room open now stamps 20, so v20 genuinely survives the conversion. Update the
expected constant to 20; the assertion's intent (the version survives the
round-trip) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 03:53:46 -05:00
Jason Ross 7bd909882f Merge main into fix-reporting-pii-mainthread 2026-07-04 03:43:43 -05:00
Jason Ross def89c4e2a Merge main into fix-reporting-pii-mainthread 2026-07-04 03:21:30 -05:00
Jason Ross 1649154ac4 Merge main into fix-303-304-ui-correctness 2026-07-04 03:21:27 -05:00
Jason Ross c3d335a753 Merge main into fix-303-304-ui-correctness 2026-07-04 03:01:24 -05:00
Jason Ross 42b8bcc91e Merge main into fix-310-311-312-dao 2026-07-04 03:01:22 -05:00
Jason Ross 2289f402e5 Merge main into perf-187-covering-index 2026-07-04 03:01:21 -05:00
Jason Ross 61dc35e290 Merge main into fix-reporting-pii-mainthread 2026-07-04 03:01:17 -05:00
Jason Ross 205f1f6de1 Merge main into fix-303-304-ui-correctness 2026-07-04 02:37:40 -05:00
JMR-devandClaude Opus 4.8 6245533368 fix(ui): reader Reply quotes original + guard one-shot actions against double-tap
#303: the reader's Reply now routes through MailRepository.buildReplyDraft
(quotes the original into a <blockquote>, bakes the signature, prefixes Re:/Fwd:
without double-prefixing) and opens compose on the built draft via
ReaderEvent.OpenCompose — the same high-fidelity path the mailbox uses — instead
of a bare compose prefill with an empty body. Adds Reply-All and Forward via an
app-bar overflow menu.

#304: SignatureEditViewModel.save, ReportReviewViewModel.submit,
AccountSettingsViewModel.removeAccount, and ProblemReportsViewModel.createManualReport
now flip a busy/saving flag synchronously before the first suspension and gate
their buttons, so a rapid double-tap can't create duplicate signatures/reports,
enqueue two uploads, or over-pop the back stack.

Closes #303
Closes #304

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 02:36:15 -05:00
Jason Ross c98eac6c5b Merge main into fix-310-311-312-dao 2026-07-04 02:32:20 -05:00
JMR-devandClaude Opus 4.8 edbb1eb839 perf/fix(data): batch sync updates, paging tiebreaker, migration-registration test
#310: add a single-transaction MessageDao.updateHeaderContents(List<MessageEntity>)
and route MailSyncer's per-message updateHeaderContent loop through it, so a folder's
recent-window refresh commits once instead of once per message (fsync/journal write
per message, amplified on the encrypted cache).

#311: append the `id` primary key as a tiebreaker to the four paged MessageDao
`ORDER BY timestampMillis DESC` queries for a total order, so rows sharing a second
(bulk mail) can't duplicate or skip across a LIMIT/OFFSET page boundary. Pure query-text
change: the exported Room schema (identityHash) is derived from table/index structure,
not @Query SQL, so no schema re-export or version bump; id is already in the projection,
so no new index.

#312: expose the registered migration list as DatabaseModule.ALL_MIGRATIONS (spread into
addMigrations) and assert in MigrationTest that it equals the reflectively-discovered set
of every Migration val, so a migration forgotten in addMigrations fails a test instead of
crash-looping all upgrading users at DB open (there is deliberately no destructive fallback).

Tests: new MessageDaoTest cases for the batch update and the id tiebreaker (all four
pagers), and the MigrationTest registration assertion; wired updateHeaderContents into
MailSyncConcurrencyTest's fake DAO. Instrumented MessageDaoTest + MigrationTest (31 tests)
green on a local emulator; unit tests + androidTest compile + ktlint + detekt green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 02:30:50 -05:00
Jason Ross df57dcd18d Merge main into perf-187-covering-index 2026-07-04 02:26:52 -05:00
Jason Ross 6810cc5390 Merge main into fix-account-lifecycle-integrity 2026-07-04 02:26:51 -05:00
Jason Ross 5e2d65d91a Merge main into fix-reporting-pii-mainthread 2026-07-04 02:26:50 -05:00
JMR-devandClaude Opus 4.8 005f8a3aca fix(reporting): scrub PII from crash stack traces + move ReportStore scan off the main thread
A crash report captured throwable.stackTraceToString() verbatim, so mail/network
exceptions (Jakarta Mail, java.net) could embed server host:port tokens and account
emails/usernames in the report's stackTrace field — violating the PII-free-reports
constraint. Add StackTraceScrubber, applied in DiagnosticsCollector before the trace
enters toSubmissionPayload()/toStorageJson(): it keeps the non-PII value (exception
class names + every frame's class/method/file/line) and drops each header line's
free-text message (where hostnames/usernames live), then redacts any residual email
or host:port left on a wrapped continuation line. Frame lines are untouched, so a
frame's File.kt:42 is never mistaken for a host:port.

ReportStore did MutableStateFlow(scan()) in its constructor — a dir list + read +
JSON-parse of every stored report. As an eager @Singleton dep of CrashReporter, whose
install() runs on the MAIN thread in Application.onCreate(), this was main-thread disk
I/O that grows with the 30-day retention. Seed the flow empty and dispatch the initial
scan to an injectable scope (Dispatchers.IO by default); reactive consumers update when
it lands, and writes still re-scan synchronously so a crash-time save is never lost.

Tests: StackTraceScrubberTest (host/ip/port/email dropped from a ConnectException +
auth-failure trace while classes/frames survive; regex redaction of a continuation
line; null-message trace preserved verbatim); DiagnosticsCollector end-to-end scrub
test; ReportStore empty-seed + off-thread populate via a StandardTestDispatcher. Store
constructions in existing tests use an Unconfined scope to keep their synchronous
reopen semantics.

Closes #294
Closes #296

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 02:18:10 -05:00
JMR-devandClaude Opus 4.8 cae11233f3 fix(data): account-lifecycle data integrity (non-destructive upsert, id normalization, deleteAccount cleanup)
#309: AccountDao no longer uses @Insert(REPLACE). New insertIfAbsent (IGNORE)
+ @Update back a non-destructive upsert, and insertAtEnd updates an existing id
in place (preserving its sortOrder) instead of REPLACE. Re-adding an existing
account id (e.g. re-authing an Outlook account, whose id is the deterministic
outlook:<email>) therefore no longer cascade-deletes its account_settings +
signatures.

#305: normalizeEmailForAccountId always lowercases the domain (mail domains are
case-insensitive), and the whole address for the consumer providers (Gmail,
Yahoo, iCloud, AOL, Outlook). Applied at every id-derivation site
(MailProvider.createAccount, Account.outlook, ManualSetupViewModel) so
differently-cased addresses can't spawn duplicate accounts. The displayed email
keeps the user's casing.

#299: deleteAccount collects the account's message ids and draft attachment URIs
while the rows still exist, deletes the rows (now including the account's
drafts), then deleteRecursively()'s each message's on-disk attachment cache dir
and releases the drafts' now-unreferenced persistable URI grants.

Adds unit tests for id normalization + deleteAccount cleanup and DAO-level
instrumented tests for the non-destructive create/update.

Closes #309
Closes #305
Closes #299

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 02:13:21 -05:00
JMR-devandClaude Opus 4.8 8904351a96 fix(push): handle Service.onTimeout to survive the dataSync FGS runtime cap
IdleService runs continuously as a FOREGROUND_SERVICE_TYPE_DATA_SYNC
foreground service (push is on by default). With targetSdk 37, Android 14+'s
dataSync FGS runtime cap (~6h per rolling 24h) calls Service.onTimeout(...)
and then force-stops the service — throwing a system FGS-timeout exception —
if it doesn't stop itself. IdleService overrode onStartCommand/onDestroy/onBind
but not onTimeout, so after ~6 cumulative hours push silently died and the app
hit the exception; on API 35+ the budget is cumulative and a restart can't
recover it until the next 24h window.

Override both onTimeout(startId) (deprecated, API 34) and
onTimeout(startId, fgsType) (API 35+); both route to a clean shutdown that
re-asserts the already-scheduled 15-minute periodic sync, swaps the persistent
notification to a degraded "paused" text and DETACHes it so it survives, then
stopForeground(DETACH) + stopSelf so we never leave a dataSync FGS running past
its cap (the exact condition the platform kills on). This mirrors the existing
low-battery PushMode.POLLING fallback.

The push-status text choice is pulled into a pure PushStatusNotification.statusTextRes
seam and unit-tested on the JVM; the built notification's new timed-out text is
covered by PushStatusNotificationInstrumentedTest.

Closes #302

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 02:09:01 -05:00
Jason Ross 991f9b77e4 Merge main into perf-187-covering-index 2026-07-04 00:38:29 -05:00
Jason Ross bdb49f996f Merge main into perf-187-covering-index 2026-07-04 00:17:05 -05:00
Jason Ross b78921cd0b Merge main into chore-237-localclipboard-migration 2026-07-04 00:17:04 -05:00
JMR-devandClaude Opus 4.8 191e802eac chore(ui): migrate LocalClipboardManager to LocalClipboard
LocalClipboardManager/ClipboardManager are deprecated in Compose in favor
of LocalClipboard's suspend Clipboard API. Migrates the one call site,
ReportReviewScreen's "Copy report" action: LocalClipboardManager.current
becomes LocalClipboard.current, and the synchronous
clipboard.setText(AnnotatedString(...)) becomes a suspend
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(...))) run inside
the existing rememberCoroutineScope(). The clipboard interaction is
pulled into a small internal suspend function, copyReportPayloadToClipboard,
so it's unit-testable against a mocked Clipboard without an emulator.

Closes #237.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:16:32 -05:00
JMR-devandClaude Opus 4.8 5a3669f017 perf(db): covering index for unified-inbox summary scans
The paged "All inboxes" query (MessageDao.pagingUnifiedFolderSummaries:
WHERE folder = ? AND inInbox = 1 ORDER BY timestampMillis DESC) had no
folder-leading index, so it SCANned the whole messages table via
index_messages_timestampMillis and filtered folder/inInbox per row.

Add a (folder, inInbox, timestampMillis) index so the two equality
predicates become an index seek and the ORDER BY is supplied by the
index. EXPLAIN QUERY PLAN for the query goes from
  SCAN messages USING INDEX index_messages_timestampMillis
to
  SEARCH messages USING INDEX index_messages_folder_inInbox_timestampMillis (folder=? AND inInbox=?)
with no temp B-tree sort.

Pure additive index (no column/table change): bump the Room DB to v20
with MIGRATION_19_20 (CREATE INDEX IF NOT EXISTS), register it in
DatabaseModule, export 20.json, and add a MigrationTest that runs the
migration and asserts the index shape plus the SEARCH plan on real
Android SQLite.

Closes #187

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 00:14:39 -05:00
Jason Ross eda5103da2 Merge main into test-257-reportupload-seams-e2e 2026-07-03 23:58:56 -05:00
JMR-devandClaude Opus 4.8 0b44276909 test(reporting): ReportUpload testability seams + push IdleService E2E
Coverage lane 4 (#249) flagged reporting/push classes as unreachable by tests.
Add minimal, behaviour-preserving seams and the tests they unblock (issue #257):

- ReportUploadScheduler: inject Provider<WorkManager> (mirroring SyncScheduler)
  instead of calling the WorkManager.getInstance static that MockK can't stub on
  the abstract WorkManager (AbstractMethodError). New ReportUploadSchedulerTest
  pins the per-report unique-work name + REPLACE policy.
- ReportUploadWorker: take the ingest endpoint via a new @DebugReportEndpoint
  qualifier (provided from BuildConfig.DEBUG_REPORT_ENDPOINT in ReportingModule)
  rather than reading the BuildConfig static inline. New ReportUploadWorkerHttpTest
  drives the transmit path against an in-process JDK HttpServer on loopback and
  covers 2xx success + delete, 4xx failure, 5xx retry/attempt-cap, and network
  error. Production value is unchanged (empty by default).
- IdleService: extract the foreground-notification channel + push-mode-to-text
  logic into PushStatusNotification. New PushStatusNotificationInstrumentedTest
  asserts channel importance and the IDLE/POLLING notification text with a real
  application Context (never a mocked Context).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:58:00 -05:00
Jason Ross 24b9572179 Merge main into test-221-cold-open-encrypted-cache 2026-07-03 23:31:47 -05:00
JMR-devandClaude Opus 4.8 17d7135f59 test(db): instrumented cold-open of a pre-encrypted cache
Guards the SQLCipher cold-start crash fixed in 592a797 (bug #210): a cold
process opening an already-encrypted cache with nothing to convert reached
Room's keyed nativeOpen with the native .so unloaded and crash-looped with
UnsatisfiedLinkError. Every existing on-device test (DatabaseEncryptionTest,
DatabaseProvisionerInstrumentedTest, DatabaseModuleInstrumentedTest,
AccountDataMigratorTest) runs a conversion first, which loads the process-global
library in-process, masking the bug exactly as production did.

System.loadLibrary is process-global, so the instrumentation process can no
longer observe a cold open once it has minted the encrypted fixture. This adds
ColdOpenCacheProbe -- a debug-only ContentProvider declared with
android:process=":coldopen" -- to host the open in a separate, pristine app
process. The test mints the encrypted fixture in the instrumentation process
(a file created by a prior encrypted DB instance) and drives the cold open in
the :coldopen process via ContentResolver.call, mirroring DatabaseProvisioner's
encrypted branch + DatabaseModule's open lambda against the real DatabaseEncryption,
DeferredOpenHelperFactory and SupportOpenHelperFactory. A cold probe (a keyed open
with no preceding load, asserted to throw UnsatisfiedLinkError) makes the isolation
self-verifying: the test fails rather than passing hollow if the library was
already loaded in the harness process.

Verified locally on an API 36 emulator (connectedDebugAndroidTest): 1 test,
0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 23:30:49 -05:00
Jason Ross 6bde35507b Merge main into test-275-lane5-followup-screens 2026-07-03 22:53:16 -05:00
Jason Ross 46c4110b19 Merge main into test-275-lane5-followup-screens 2026-07-03 22:39:38 -05:00
JMR-devandClaude Opus 4.8 65ff45ec31 test(coverage): lane 5 follow-up — UI tests for remaining screens
Adds instrumented Compose UI tests for the screens #250/#274 left uncovered,
so lane 6's ui-package coverage ratchet (#251, >=95%) can pass:

- ColorSwatchRow (compose/format): none entry + swatch rendering, selection
  callbacks, and selected-state semantics.
- LockScreen: locked title/body, optional error text, unlock callback.
- AddAnotherAccountScreen: confirmation + both onboarding choices.
- SignatureEditScreen: real ViewModel over an in-memory Room-backed
  SignatureRepository — new-vs-edit title, create/update round-trips.
- ReportReviewScreen: real ViewModel over a file-backed ReportStore (submitter
  stubbed disabled) — disclaimer/fields render, Submit gated on comment length
  + email validity, discard deletes and leaves.
- AppPasswordSetupScreen: real ViewModel over FakeAccountRepository — provider
  chrome + credential add, and the app-password help link asserted via
  Espresso-Intents (mirrors AccountPickerScreenTest) so no real browser opens.

All 23 tests pass locally on an API 36 emulator.

Closes #275

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:38:47 -05:00
Jason Ross e4a27944bd Merge main into test-220-native-lib-before-keyed-open 2026-07-03 22:37:29 -05:00
JMR-devandClaude Opus 4.8 9ab7c22c48 fix(test): assert only the empty-state gate in MailboxScreenTest loading test
The compose FAB does not render reliably under a never-completing refresh==Loading pager (flaked as not-displayed, not-found, then waitForText-timeout across CI runs). Drop the positive FAB anchor; assert only mailbox_empty.assertDoesNotExist() — the actual #219 gate behavior, which is stable and idle-completes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:21:32 -05:00
JMR-devandClaude Opus 4.8 44c662a0fd fix(test): make MailboxScreenTest empty-state gate robust under perpetual Loading
Poll for the compose FAB via waitForText instead of a one-shot assert: under refresh==Loading the LazyPagingItems presenter settles non-deterministically, and the FAB flaked as both not-displayed and not-found across CI runs. Keeps the stable mailbox_empty assertDoesNotExist gate check (#219).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:53:35 -05:00
Jason Ross c38db3b213 Merge main into test-220-native-lib-before-keyed-open 2026-07-03 21:49:03 -05:00
Jason Ross ddc8c30ade Merge main into fix-219-empty-state-flash 2026-07-03 21:49:03 -05:00
Jason Ross fc6a79ddb0 Merge main into test-220-native-lib-before-keyed-open 2026-07-03 21:37:29 -05:00
JMR-devandClaude Opus 4.8 1ff9d32b80 test(db): pin native-lib-before-keyed-open at the DatabaseModule factory site
DatabaseProvisionerTest (mocked) and DatabaseProvisionerInstrumentedTest
(real SQLCipher) both pin that prepareCache() loads SQLCipher's native
library for the encrypted branch, but neither exercises
DatabaseModule.provideDatabase itself — the instrumented one opens
through a hand-rolled SupportOpenHelperFactory, bypassing the branch
that actually maps CacheOpenMode to a real factory. A regression that
breaks that wiring would slip through both existing guards.

Adds DatabaseModuleInstrumentedTest, calling provideDatabase directly
and driving the first real open through its own
DeferredOpenHelperFactory lambda: the encrypted branch loads the
native lib and opens a genuinely-encrypted file, the plaintext branch
never touches the native lib, and a fault-injected load failure
proves the keyed open is causally gated on the load rather than just
usually preceded by it.

Closes #220

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:36:34 -05:00
JMR-devandClaude Opus 4.8 36cbbe7b5d test(onboarding): assert the Outlook onboarding button launches the browser
Extends #274's AccountPickerScreenTest with an Espresso-Intents check that
tapping Outlook fires AppAuth's authorization intent. AppAuth always routes
through its own AuthorizationManagementActivity before it ever reaches a
real browser, so that component name is the one characteristic of the
launch that's both guaranteed and installed-browser-independent; matching
it also lets the test stub a canceled result so no real browser opens.
Verified against the real OutlookAuthManager + AppAuth 0.11.1 on a
google_apis API 29 emulator (the same image CI's managed devices use).
Redirect handling, token exchange, and account creation stay out of scope
per #276.

Closes #276

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:30:00 -05:00
JMR-dev 264cad83f8 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt
2026-07-03 21:18:02 -05:00
JMR-devandClaude Opus 4.8 bc45c9f3c9 fix(test): assert a robust node in MailboxScreenTest empty-state-gate test
emptyState_isHidden_whileTheInboxPagerIsStillLoading asserted the compose
FAB with assertIsDisplayed(), but MailboxScreen renders no loading
affordance in this exact scenario (isSyncingFolder only flips true from
selectFolder(), which this test never calls), so there is nothing else
guaranteed visible while refresh == Loading. The FAB is unconditionally
composed in Scaffold's floatingActionButton slot regardless of loading
state, so its role here is only to prove the screen composed rather than
crashing or rendering blank. Swap to assertExists(), which checks presence
in the semantics tree without requiring on-screen visibility, and keep the
core assertion (mailbox_empty assertDoesNotExist()) that verifies the
actual issue #219 behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:59:36 -05:00
Jason Ross 2498725f83 Merge main into fix-219-empty-state-flash 2026-07-03 20:49:15 -05:00