Advances the client (app-repo) slice of the debug-report ingest pipeline: the
existing opt-in ReportUploadWorker now runs a best-effort PII anonymization pass
and seals each report with end-to-end (envelope) encryption to a maintainer
public key BEFORE it leaves the device, and fails closed if it cannot.
- ReportAnonymizer: pre-upload redaction of the free-text comment + log lines
(emails, host:port, IPv4, JWTs, Bearer/Basic, key=value secrets). Re-scrubs the
stack trace. Deliberately retains the user-supplied reply-to email (#159).
- ReportPayloadEncryptor / HybridReportPayloadEncryptor: AES-256-GCM content key
wrapped with RSA-OAEP-SHA256 to a public key; JSON envelope. JCA only, no new
dependency, no GMS -> F-Droid-safe. Public key from BuildConfig
DEBUG_REPORT_PUBLIC_KEY (empty default); private key stays with the maintainer.
- ReportUploadWorker fails closed: uploads only when an endpoint AND a usable
encryption key are configured; never transmits plaintext. PII-free AppLog at the
anonymize/encrypt/upload lifecycle points.
- Does NOT put R2/S3 credentials or SigV4 signing in the app (would violate the
F-Droid + secrets-never-in-app constraints); the app POSTs the encrypted envelope
to the ingest Worker, which holds the R2 secrets server-side (#11/#34).
- Unit tests for anonymization, encryption round-trip, and the worker paths
(success/retry/failure, fail-closed). docs/debug-report-privacy.md documents the
data flow, anonymization, encryption scheme, and key custody (for #16/#20).
Closes#34
At account-add both LibreMailApplication's push collector and
IdleService.reconcileWatchers react to the accounts table. The account row was
inserted before its credential was saved, so a watcher could observe the new
account and call MailConnectionFactory.resolveSecret before the secret existed,
logging "No stored credentials" on the first IDLE attempt (it self-healed on
retry, but fired a failed IDLE + log noise on every add).
Primary fix: reorder the writes so the credential is committed before the account
row (the credentials table has no FK to accounts; account_settings does, so its
ensureDefaults still follows the insert). Any reactive observer of the account row
is then guaranteed to see the credential.
Defense-in-depth: resolveSecret now throws a typed MissingCredentialsException and
the IDLE watcher treats it as a transient miss, deferring quietly (short flat
re-check, PII-free info log) instead of the warn + exponential backoff a real
connection drop gets. Genuinely-absent credentials keep deferring without noise.
Tests: AccountRepositoryImplTest pins the credential-before-row order
(coVerifyOrder); MailConnectionFactoryTest asserts the typed exception; a new
instrumented test drives the real repository add path against a real
AccountDatabase + Keystore-backed CredentialStore and proves the secret is
resolvable the instant the account row becomes observable.
Adds AppLog breadcrumbs to the message-open path so a debug report can show
where the reader's spinner time goes:
- ImapClient.withStore: per-op connect vs. work timing plus a live
connect-per-op connection gauge (issue #125's provider-ceiling context).
- fetchBodyMarkingSeen: select/body/flag phase timings plus PII-free size
counts (RFC822 size, body chars, attachment count).
- MailRepositoryImpl.openMessage: end-to-end open latency plus the
cached-vs-fetched branch, keyed by accountLogRef and logSafeFolderLabel.
- ReaderViewModel: spinner-to-ready latency, split success vs. failure.
All breadcrumbs are PII-free: accounts are logged via the existing
accountLogRef hash, folders via the existing logSafeFolderLabel allowlist,
and everything else is sizes/durations/booleans only.
Fixes the 4 unit-test classes that exercise this code without mocking
android.util.Log (a throwing stub under plain JVM tests): mockkStatic(Log)
is now installed in MailRepositoryImplCoverageTest, ImapClientBackfillTest,
ImapFolderOpenLatencyTest, and ReaderViewModelActionsTest, following the
existing MailBackfillerTest/ImapClientTest conventions. detekt.yml gains two
more ForbiddenImport excludes for the newly Log-importing test files.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a detekt style>ForbiddenImport rule that forbids `import android.util.Log`
so all logging flows through org.libremail.reporting.AppLog, which mirrors each
line into the debug-report RingLogBuffer. A raw android.util.Log import writes to
Logcat only and never reaches a user-reviewed DebugReport (epic #324, strangler
final step).
Excludes the AppLog facade itself (the one sanctioned wrapper) and the unit tests
that mockkStatic(Log) to verify forwarding — AppLog forwards to Log, a throwing
stub under plain JVM unit tests, so those tests must mock it; they do not bypass
the facade.
Closes#331
Part of #324
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire ktlint-gradle 14.2.0 and detekt 2.0.0-alpha.5 (the only detekt line
with Gradle 9 support) through the version catalog.
- .editorconfig: official Kotlin style, 120-col limit, @Composable exempt
from function-naming.
- config/detekt/detekt.yml: slim overrides on detekt's defaults —
@Composable exemptions for the OOP-era metrics, sane ReturnCount /
ThrowsCount / TooManyFunctions thresholds, and TooGenericExceptionCaught
off at the resilient network/push boundaries (which now log).
Findings fixed in code (behaviour-preserving; 81 unit tests still pass):
- SwallowedException: SendWorker / IdleService now log the caught exception.
- roleOf (Folder) and extractBody (ImapClient) split into named helpers.
- MailSyncer: hoisted a 4-condition `if` into a named val.
- TopDest extracted to its own file; ~14 magic numbers -> named constants.
The remainder is the ktlint auto-format across the module.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>