Files
LibreMediaConverter/CLAUDE.md
T
JMR-devandClaude Opus 5 e9542d2223 Let the libraries float on minor and patch
Library versions now read "1.+" instead of "1.19.0". Three groups stay pinned,
and the reasons differ:

  agp/kotlin/ksp are version-locked to each other -- AGP 9.3.1's POM declares
  kotlin-gradle-plugin 2.2.10, so a float that picked up Kotlin 2.4.x would put
  the Compose compiler ahead of the Kotlin AGP actually compiles with.

  ktlint/detekt/jacoco because a linter is not a library. A library bump that
  misbehaves usually still compiles; a new lint rule makes files nobody touched
  stop passing, turning a PR red for something absent from its diff. Upgrading
  those is worth a commit that reads the new findings.

  The FFmpeg AAR is a committed file, not a coordinate.

The componentSelection block is the part that makes this safe rather than the
part that makes it work. Gradle resolves "+" to the highest version it can find
and does not skip prereleases, and androidx routinely publishes alphas numbered
above the current stable: lifecycle 2.12.0-alpha01, work 2.12.0-rc01, navigation
2.10.0-rc01, datastore 1.3.0-alpha10, annotation 1.11.0-alpha01 all outrank the
releases this app uses. Without the guard, five dependencies would have moved
onto unreleased code on the next build with nothing in the diff to say so. With
it, every float resolves to exactly the version that was pinned before -- checked
against :app:dependencies, not assumed.

So this changes nothing today. Every library was already at its newest stable
when the catalog was audited; floating is about what happens next month, not
this commit.

Trying a prerelease is still possible: name the exact version, which pins it
rather than floating it. That is the right way round -- an alpha should be a
deliberate act with a version number attached to it.

Verified: ktlint, detekt, lint, unit tests, androidTest compile, assembleDebug
all green, and the configuration cache still reuses across runs of the same task
set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:46:59 -05:00

7.3 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

LibreMediaConverter is an Android media converter (Kotlin, Jetpack Compose, Material 3) with two conversion engines: Media3 Transformer for the hardware path and FFmpeg for everything the platform cannot do. See @README.md for the architecture and @LICENSES/README.md for the split license — this file covers only what is not obvious from the code.

Build, test, lint

Everything is on Java 24 — daemon, CI, IDE, and the app's own bytecode. Four places say so and they must not drift apart:

Where What sets it
Gradle daemon gradle/gradle-daemon-jvm.properties → toolchainVersion=24
CI java-version: '24' in both workflows
IDE .idea/misc.xml
App bytecode compileOptions in app/build.gradle.kts

Do not pick a JDK for the daemon — the repo does. gradle-daemon-jvm.properties carries foojay download URLs per platform, so Gradle provisions and runs the daemon on Java 24 regardless of what JAVA_HOME says (that only sets the launcher — ./gradlew --version prints both). Change it with ./gradlew updateDaemonJvm --jvm-version=NN, never by hand.

24, not 25, is deliberate. Kotlin 2.2.10 refuses jvmTarget 25 outright — its available targets stop at 24 — so the app's bytecode cannot join a 25 toolchain, and 24 is the highest number all four rows can actually hold. D8 dexes Java 24 class files and R8 minifies them, both verified.

The Gradle wrapper does not float and cannot: distributionUrl names one archive and distributionSha256Sum is that file's checksum. Bump it with ./gradlew wrapper --gradle-version X --gradle-distribution-sha256-sum <sha> so the two stay consistent.

./gradlew :app:assembleDebug          # build debug APK
./gradlew :app:testDebugUnitTest      # JVM unit tests
./gradlew :app:ktlintCheck            # formatting
./gradlew :app:ktlintFormat           # fix formatting in place
./gradlew :app:detekt                 # static analysis
./gradlew :app:lintDebug              # Android lint
./gradlew :app:jacocoTestReport       # coverage (XML+HTML under app/build/reports/jacoco/)
# single unit test:
./gradlew :app:testDebugUnitTest --tests "org.libremediaconverter.model.ConversionRouterTest"

CI's "Static analysis" gate is exactly ./gradlew :app:ktlintCheck :app:detekt :app:lintDebug --continue. Run it with --continue locally too: one round trip gives you all three lists instead of the first one that fails.

Before treating a change as done, run: assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + ktlintCheck + detekt + lintDebug. compileDebugAndroidTestKotlin matters more here than it looks — the instrumented suite cannot run on this machine (below), so without it an androidTest compile error is not discovered until CI. ktlint and detekt also cover the test/androidTest source sets that lintDebug skips.

Instrumented tests do not run locally

Two independent reasons, so do not spend time on either:

  • Emulators segfault on this host. qemu dies on every AVD. Instrumented tests run on CI or on the physical Pixel, never in a local emulator.
  • The API 37 image is broken. android-37.0 crash-loops surfaceflinger inside its own gralloc mapper, so every test fails there regardless of this app. docs/api-37-emulator-crash.md records the evidence and the ruled-out fixes; CI's matrix therefore stops at API 36 even though targetSdk is 37. API 37 needs a manual check on the Pixel 10 Pro XL before each release.

On a device or emulator, build only the ABI it can execute:

./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64

FFmpeg's native libraries dominate the APK, so shipping arm64 to an x86_64 emulator doubles the install for code that can never run — and on API 37 the full APK does not fit at all.

Conventions

  • ktlint owns formatting, detekt owns static analysis. detekt's formatting ruleset is off, so the two can never disagree about the same line. Never hand-fix a formatting complaint — run ktlintFormat. Style is intellij_idea at 120 columns, set in .editorconfig.
  • detekt config is config/detekt/detekt.yml, merged onto detekt's defaults (buildUponDefaultConfig = true), so it carries only the rules this codebase legitimately breaks — each with the reason written next to it. Relax a rule that way or fix the code; never a bare @Suppress. Do not invent config keys: unknown ones are rejected.
  • The model package is excluded from ReturnCount and CyclomaticComplexMethod only. It is the decision layer, where one branch is one documented user-visible outcome and the metric counts answers rather than complexity. Every other rule still applies there.
  • Coverage is reported, not gated — currently ~31% of lines. A floor needs a baseline that has settled first.
  • kotlin.code.style=official. Gradle stays Kotlin DSL.

Dependency versions

Libraries float on minor + patch (coreKtx = "1.+"). Three groups deliberately do not:

  • agp, kotlin, ksp are version-locked to each other. AGP 9.3.1's POM declares kotlin-gradle-plugin 2.2.10, and that is what AGP's built-in Kotlin compiles with. Android lint will suggest Kotlin 2.4.10; taking it breaks the Compose compiler unless KGP is also forced onto the root buildscript classpath. Move all three together, by hand, or none.
  • ktlint, detekt and JaCoCo are pinned. A new rule in a linter makes files nobody touched stop passing, so CI goes red on a PR whose diff cannot explain it. Upgrading them is its own commit: run the tool, read the new findings, fix or relax them.
  • The FFmpeg AAR is a committed file, not a coordinate.

+ does not mean "newest stable" on its own — Gradle will happily resolve it to an alpha, and androidx routinely publishes alphas numbered above the current stable (at last check: lifecycle, navigation, work, datastore and annotation all did). The componentSelection block in app/build.gradle.kts rejects prereleases, which is the only reason 2.+ means 2.11.0 rather than 2.12.0-alpha01. Do not remove it. To try a prerelease, name the exact version in the catalog — that pins it, which is the right way round.

Because versions float, a build can change without a commit. ./gradlew :app:dependencies --configuration debugRuntimeClasspath shows what actually resolved.

Traps

  • Do not apply org.jetbrains.kotlin.android. AGP 9 has built-in Kotlin; applying the legacy plugin fails the build. This is why libs.versions.toml pins kotlin to AGP's bundled KGP version rather than the newest Kotlin release — the Compose compiler plugin must match it.
  • The FFmpeg AAR is committed under bin/, deliberately. It is not on any Maven repo (ffmpeg-kit was archived and delisted). Rebuilding per CI run made red builds ambiguous: broken code, or a cross-compile that hiccuped? bin/README.md has provenance and how to regenerate it.
  • Anything touching Media3 carries @UnstableApi rather than swallowing the marker with @OptIn. Android lint's UnsafeOptInUsageError catches a missed one.
  • Release builds ship both ABIs. -PabiFilters is a test-run override only; build.yml verifies the released APK carries every ABI and that all native libraries are 16 KB aligned.