Adapts the lint and code-quality setup from the sibling LibreMail repo, then follows the thread it exposed into the toolchain.
Read commit by commit — each one is a single decision with its reasoning, and the ktlintFormat reformat is isolated so it can be reviewed with whitespace diffing off.
What this adds
.editorconfig
ktlint intellij_idea at 120 columns
.gitattributes
LibreMail's, plus *.aar / *.so — this repo commits an FFmpeg AAR its list didn't cover
config/detekt/detekt.yml
Compose relaxations ported; LibreMail-specific rules dropped as dead config
ktlint owns formatting, detekt owns static analysis (its formatting ruleset stays off, so the two can never disagree about a line), and Android lint covers what neither can see. That last one is a deliberate divergence: LibreMail keeps lintDebug as a local preflight step because it has a /preflight skill, and this repo has none — so CI is the only thing that would ever run it.
Findings
35 of them, on code neither tool had ever seen. Every one was fixed or relaxed with the reason written next to it; nothing was suppressed to quiet the build.
Fixed because the tool was right — a genuinely missing @UnstableApi on ConversionDependencies; two bare 1000s doing µs→ms and s→ms conversion in a file that handles both; raw 34/35 API levels; take(3); setProgress(100, …).
Relaxed because the rule didn't fit — chiefly excluding model/ from ReturnCount and CyclomaticComplexMethodonly. It is the decision layer: ConversionRouter.route scores 17 because the app can give 17 distinct answers to "which engine, and why", and its own comment records that their order decides which message the user sees. LongMethod, NestedBlockDepth and the rest still apply there.
UsableSpace is set to informational rather than disabled. It is a real finding — hasSpaceFor() reads File.usableSpace, which ignores reclaimable cache, so the app can refuse a conversion it had room for. The fix changes when a job is rejected and can throw, which is a behaviour change to a safety check and wants its own commit and test. informational keeps it in every report instead of hiding it.
Toolchain
Java was scattered across four numbers nobody had chosen together: daemon on 25, CI installing 17, IDE on 25, bytecode at 17. All four now say 25.
Getting the bytecode row there took a real change. AGP 9's built-in Kotlin compiles with the KGP it bundles (2.2.10), which caps jvmTarget at 24. The root build file now puts KGP and the lockstep Compose compiler plugin on the buildscript classpath — LibreMail's pattern — so AGP picks up 2.4.10, which supports through 26. Verified rather than assumed, because a silent downgrade looks exactly like success: compiled classes report major version 69, D8 dexes them, R8 minifies them.
An earlier pass tried to land everything on 24 and was wrong twice. Java 24 is a non-LTS that went EOL in July 2025 and is not in Adoptium's repo at all — those builds only passed because Gradle auto-provisioned it via foojay. And Kotlin's cap was never on the toolchain, only on the bytecode; the two are independent axes.
Also: Gradle to 9.7.1. The wrapper deliberately does not float — distributionUrl and distributionSha256Sum are a matched pair, the same integrity reasoning the workflows already apply to action SHAs.
Floating versions
Libraries float on minor + patch (coreKtx = "1.+"). Pinned deliberately: agp/kotlin/ksp (version-locked to each other), ktlint/detekt/JaCoCo (a new lint rule turns a PR red for something absent from its diff), and smart-exception (a 0.x minor may break, and this one is reached only when an FFmpeg call fails — the least-exercised path in the app).
The componentSelection guard in app/build.gradle.kts is what makes this safe, not what makes it work. Gradle resolves + to the highest version it finds and does not skip prereleases — and androidx routinely publishes alphas numbered above current stable. At time of writing lifecycle, navigation, work, datastore and annotation all did, so five dependencies would have moved onto unreleased code on the next build with nothing in the diff to say so.
This changes nothing today: every library was already at its newest stable when audited. Floating is about next month.
Verification
Green locally: ktlintCheck, detekt, lintDebug, testDebugUnitTest, compileDebugAndroidTestKotlin, assembleDebug, assembleRelease. Configuration cache stores and reuses.
Coverage measures 31% of lines — reported, not gated. That number is exactly why LibreMail's 0.84 floor was evidence about LibreMail rather than something to copy.
Instrumented tests were not run locally (emulators segfault on this host); the API 33–36 matrix in this PR covers them.
Adapts the lint and code-quality setup from the sibling [LibreMail](https://github.com/JMR-dev/LibreMail) repo, then follows the thread it exposed into the toolchain.
Read commit by commit — each one is a single decision with its reasoning, and the `ktlintFormat` reformat is isolated so it can be reviewed with whitespace diffing off.
## What this adds
| | |
|---|---|
| `.editorconfig` | ktlint `intellij_idea` at 120 columns |
| `.gitattributes` | LibreMail's, plus `*.aar` / `*.so` — this repo commits an FFmpeg AAR its list didn't cover |
| `config/detekt/detekt.yml` | Compose relaxations ported; LibreMail-specific rules dropped as dead config |
| `app/build.gradle.kts` | ktlint, detekt, JaCoCo, `lint { warningsAsErrors = true }` |
| `status_check.yml` | new `Static analysis` job + a coverage report step |
| `CLAUDE.md` | new |
**ktlint owns formatting, detekt owns static analysis** (its formatting ruleset stays off, so the two can never disagree about a line), and Android lint covers what neither can see. That last one is a deliberate divergence: LibreMail keeps `lintDebug` as a local preflight step because it has a `/preflight` skill, and this repo has none — so CI is the only thing that would ever run it.
## Findings
35 of them, on code neither tool had ever seen. Every one was fixed or relaxed with the reason written next to it; nothing was suppressed to quiet the build.
Fixed because the tool was right — a genuinely missing `@UnstableApi` on `ConversionDependencies`; two bare `1000`s doing µs→ms and s→ms conversion in a file that handles both; raw `34`/`35` API levels; `take(3)`; `setProgress(100, …)`.
Relaxed because the rule didn't fit — chiefly excluding `model/` from `ReturnCount` and `CyclomaticComplexMethod` **only**. It is the decision layer: `ConversionRouter.route` scores 17 because the app can give 17 distinct answers to "which engine, and why", and its own comment records that their *order* decides which message the user sees. `LongMethod`, `NestedBlockDepth` and the rest still apply there.
`UsableSpace` is set to `informational` rather than disabled. It is a real finding — `hasSpaceFor()` reads `File.usableSpace`, which ignores reclaimable cache, so the app can refuse a conversion it had room for. The fix changes *when a job is rejected* and can throw, which is a behaviour change to a safety check and wants its own commit and test. `informational` keeps it in every report instead of hiding it.
## Toolchain
Java was scattered across four numbers nobody had chosen together: daemon on 25, CI installing 17, IDE on 25, bytecode at 17. All four now say **25**.
Getting the bytecode row there took a real change. AGP 9's built-in Kotlin compiles with the KGP it bundles (2.2.10), which caps `jvmTarget` at 24. The root build file now puts KGP and the lockstep Compose compiler plugin on the buildscript classpath — LibreMail's pattern — so AGP picks up 2.4.10, which supports through 26. Verified rather than assumed, because a silent downgrade looks exactly like success: compiled classes report **major version 69**, D8 dexes them, R8 minifies them.
An earlier pass tried to land everything on 24 and was wrong twice. Java 24 is a non-LTS that went EOL in July 2025 and is not in Adoptium's repo at all — those builds only passed because Gradle auto-provisioned it via foojay. And Kotlin's cap was never on the toolchain, only on the bytecode; the two are independent axes.
Also: Gradle to 9.7.1. The wrapper deliberately does **not** float — `distributionUrl` and `distributionSha256Sum` are a matched pair, the same integrity reasoning the workflows already apply to action SHAs.
## Floating versions
Libraries float on minor + patch (`coreKtx = "1.+"`). Pinned deliberately: `agp`/`kotlin`/`ksp` (version-locked to each other), ktlint/detekt/JaCoCo (a new lint rule turns a PR red for something absent from its diff), and `smart-exception` (a 0.x minor may break, and this one is reached only when an FFmpeg call *fails* — the least-exercised path in the app).
**The `componentSelection` guard in `app/build.gradle.kts` is what makes this safe, not what makes it work.** Gradle resolves `+` to the highest version it finds and does not skip prereleases — and androidx routinely publishes alphas numbered *above* current stable. At time of writing lifecycle, navigation, work, datastore and annotation all did, so five dependencies would have moved onto unreleased code on the next build with nothing in the diff to say so.
This changes nothing today: every library was already at its newest stable when audited. Floating is about next month.
## Verification
Green locally: `ktlintCheck`, `detekt`, `lintDebug`, `testDebugUnitTest`, `compileDebugAndroidTestKotlin`, `assembleDebug`, `assembleRelease`. Configuration cache stores and reuses.
Coverage measures **31% of lines** — reported, not gated. That number is exactly why LibreMail's 0.84 floor was evidence about LibreMail rather than something to copy.
Instrumented tests were not run locally (emulators segfault on this host); the API 33–36 matrix in this PR covers them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Adapts the lint and code-quality setup from the sibling LibreMail repo, then follows the thread it exposed into the toolchain.
Read commit by commit — each one is a single decision with its reasoning, and the
ktlintFormatreformat is isolated so it can be reviewed with whitespace diffing off.What this adds
.editorconfigintellij_ideaat 120 columns.gitattributes*.aar/*.so— this repo commits an FFmpeg AAR its list didn't coverconfig/detekt/detekt.ymlapp/build.gradle.ktslint { warningsAsErrors = true }status_check.ymlStatic analysisjob + a coverage report stepCLAUDE.mdktlint owns formatting, detekt owns static analysis (its formatting ruleset stays off, so the two can never disagree about a line), and Android lint covers what neither can see. That last one is a deliberate divergence: LibreMail keeps
lintDebugas a local preflight step because it has a/preflightskill, and this repo has none — so CI is the only thing that would ever run it.Findings
35 of them, on code neither tool had ever seen. Every one was fixed or relaxed with the reason written next to it; nothing was suppressed to quiet the build.
Fixed because the tool was right — a genuinely missing
@UnstableApionConversionDependencies; two bare1000s doing µs→ms and s→ms conversion in a file that handles both; raw34/35API levels;take(3);setProgress(100, …).Relaxed because the rule didn't fit — chiefly excluding
model/fromReturnCountandCyclomaticComplexMethodonly. It is the decision layer:ConversionRouter.routescores 17 because the app can give 17 distinct answers to "which engine, and why", and its own comment records that their order decides which message the user sees.LongMethod,NestedBlockDepthand the rest still apply there.UsableSpaceis set toinformationalrather than disabled. It is a real finding —hasSpaceFor()readsFile.usableSpace, which ignores reclaimable cache, so the app can refuse a conversion it had room for. The fix changes when a job is rejected and can throw, which is a behaviour change to a safety check and wants its own commit and test.informationalkeeps it in every report instead of hiding it.Toolchain
Java was scattered across four numbers nobody had chosen together: daemon on 25, CI installing 17, IDE on 25, bytecode at 17. All four now say 25.
Getting the bytecode row there took a real change. AGP 9's built-in Kotlin compiles with the KGP it bundles (2.2.10), which caps
jvmTargetat 24. The root build file now puts KGP and the lockstep Compose compiler plugin on the buildscript classpath — LibreMail's pattern — so AGP picks up 2.4.10, which supports through 26. Verified rather than assumed, because a silent downgrade looks exactly like success: compiled classes report major version 69, D8 dexes them, R8 minifies them.An earlier pass tried to land everything on 24 and was wrong twice. Java 24 is a non-LTS that went EOL in July 2025 and is not in Adoptium's repo at all — those builds only passed because Gradle auto-provisioned it via foojay. And Kotlin's cap was never on the toolchain, only on the bytecode; the two are independent axes.
Also: Gradle to 9.7.1. The wrapper deliberately does not float —
distributionUrlanddistributionSha256Sumare a matched pair, the same integrity reasoning the workflows already apply to action SHAs.Floating versions
Libraries float on minor + patch (
coreKtx = "1.+"). Pinned deliberately:agp/kotlin/ksp(version-locked to each other), ktlint/detekt/JaCoCo (a new lint rule turns a PR red for something absent from its diff), andsmart-exception(a 0.x minor may break, and this one is reached only when an FFmpeg call fails — the least-exercised path in the app).The
componentSelectionguard inapp/build.gradle.ktsis what makes this safe, not what makes it work. Gradle resolves+to the highest version it finds and does not skip prereleases — and androidx routinely publishes alphas numbered above current stable. At time of writing lifecycle, navigation, work, datastore and annotation all did, so five dependencies would have moved onto unreleased code on the next build with nothing in the diff to say so.This changes nothing today: every library was already at its newest stable when audited. Floating is about next month.
Verification
Green locally:
ktlintCheck,detekt,lintDebug,testDebugUnitTest,compileDebugAndroidTestKotlin,assembleDebug,assembleRelease. Configuration cache stores and reuses.Coverage measures 31% of lines — reported, not gated. That number is exactly why LibreMail's 0.84 floor was evidence about LibreMail rather than something to copy.
Instrumented tests were not run locally (emulators segfault on this host); the API 33–36 matrix in this PR covers them.
🤖 Generated with Claude Code