Files
LibreMediaConverter/app/build.gradle.kts
T
JMR-devandClaude Opus 5 ce4d0ff7d4 Keep the selected tab through the recreation targetSdk 37 guarantees
AppRoot held the selected tab in `remember`, which survives recomposition and nothing
else. MainActivity declares no configChanges, so every rotation and every resize
destroys and recreates the Activity, and the tab went back to Convert each time.

The KDoc directly above that line is the argument for why it matters: from targetSdk 37
Android ignores screenOrientation, resizableActivity and the aspect-ratio limits on any
display at least 600dp wide, and the Android 16 opt-out is gone, so the app is resized
and rotated whether or not it is ready. The shell was written for that case and then
lost its own state to it. Both ViewModels are Activity-scoped and come back intact, so
a conversion in flight was never at risk -- only the tab, which is what makes this
visibly wrong rather than merely stale.

rememberSaveable, with a Saver that writes the constant's NAME. Three ways to make an
enum saveable and the reasons for this one:

  - autoSaver already accepts it. An enum is Serializable, so plain
    `rememberSaveable { mutableStateOf(Destination.CONVERT) }` compiles, works, and
    passes the restoration test below unchanged. That is a reason to be explicit, not a
    reason not to be: nothing in the declaration says Destination has to stay
    Serializable, so the implicit route keeps working right up until someone makes it a
    value class or a sealed interface -- and then stops, silently, on a path only a
    rotation reaches.

  - The ordinal is a position, not an identity. Inserting a tab between Convert and Join
    would redefine every value already written down. A name only changes when someone
    renames a constant, which is an edit that shows up in a diff. It also reads as
    itself in a Bundle dump.

  - An unknown name restores to null, which rememberSaveable treats as "nothing saved"
    and falls back to Convert. That is exactly what a downgrade or a renamed constant
    leaves behind, and Convert is the right answer for it.

The test runs on the JVM, which took two changes to reach.

AppRoot and Destination are `internal` rather than `private` -- the unit test source set
is a friend of main, so this stays invisible outside the module -- and AppRoot takes its
`content` as a defaulted parameter instead of calling Content() directly. Nothing in the
app passes it. It is there because both screens resolve a ViewModel, which builds a
WorkManager and a media probe, and none of that has anything to do with which tab is
selected; the test hands in a tagged Box and drives the shell alone. Content() stays
private and is still what the app gets.

compose-ui-test-junit4 joins the JVM test source set. It was already in the catalog for
androidTest, it is inside the prerelease guard via its androidx. group, and its version
comes from the BOM, so this adds no new pinning argument. It is there because
createComposeRule() runs under Robolectric: a red test in androidTest is one nobody on
this host can execute (CLAUDE.md), which is not a loop anyone can work in.

ui-test-manifest is NOT repeated on that source set. It supplies the ComponentActivity
the rule launches, and the existing debugImplementation entry already puts it in the
merged manifest the unit tests build against -- checked by removing the line and
watching AppRootRestorationTest stay green, rather than assumed.

What the test does and does not prove. StateRestorationTester's
emulateSavedInstanceStateRestore() disposes the composition and rebuilds it, so anything
held only by `remember` is gone -- that is what makes it bite. It saves into an in-memory
map rather than parcelling through a Bundle, so it cannot tell a name from an ordinal
from autoSaver. The saved representation is pinned separately by three pure-JVM tests
over the Saver itself, which is where the choice above is actually held down.

Verified by writing it red first, against the restructured AppRoot with `remember` still
in place: all three restoration tests failed at the post-restore assertion with
"Expected exactly '1' node but could not find any node that satisfies:
(TestTag = 'content:JOIN')", while every assertion before the restore -- including the
bar's own selected state -- passed. Six new tests, 186 green in all.

Not addressed here, and deliberately: MainActivity still declares no configChanges, and
should not. Handling the configuration change is not the same as keeping one enum, and
Compose's saved-state machinery is the mechanism the platform intends for it.

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

317 lines
15 KiB
Kotlin

import org.gradle.testing.jacoco.tasks.JacocoReport
plugins {
// Applied by id: these two come from the root buildscript classpath, which is what
// overrides AGP's bundled Kotlin. See the comment in the root build file.
id("com.android.application")
// Required even under AGP 9: the Compose compiler plugin is NOT built in.
id("org.jetbrains.kotlin.plugin.compose")
// Lint/format. Resolved from the Gradle Plugin Portal, not AGP's buildscript
// classpath -- neither is an Android plugin.
alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
// JaCoCo (Gradle built-in) instruments the JVM testDebugUnitTest task. Report only:
// there is deliberately no coverage gate, see the jacocoTestReport block below.
jacoco
}
android {
namespace = "org.libremediaconverter"
compileSdk {
version = release(37)
}
defaultConfig {
applicationId = "org.libremediaconverter"
minSdk = 33
// AGP 9 defaults targetSdk to compileSdk, so always state it explicitly.
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 64-bit only. The 16 KB page-size rules apply to these ABIs; 32-bit is not
// required by Play and would add ~2 more copies of the FFmpeg .so files.
//
// Overridable so a test run can build for just the ABI it will execute on.
// FFmpeg's native libraries dominate the APK, so shipping both ABIs to an
// x86_64 emulator doubles it for no benefit -- and on API 37, whose system
// image leaves less free userdata, the full APK does not fit at all:
// "Requested internal only, but not enough space".
//
// ./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64
//
// Release builds ignore this and always ship both.
ndk {
val requested = (findProperty("abiFilters") as String?)
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
abiFilters += requested ?: listOf("arm64-v8a", "x86_64")
}
}
buildFeatures {
compose = true
}
buildTypes {
release {
optimization {
// R8 full mode. Keep rules for the JNI boundary live in
// src/main/keepRules/rules.keep -- without them the native FFmpeg
// calls break at runtime in release builds only.
enable = true
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
lint {
// ktlint and detekt both fail the build on any finding. Android lint by default
// aborts on errors only, so warnings would land in the report while the gate stayed
// green -- a gate that passes while the report has content is not a gate.
warningsAsErrors = true
// Already the default. Stated so a later edit cannot turn the gate off by accident.
abortOnError = true
// Dependency-freshness nags. These do not describe this code: they go red the day
// someone else publishes a release, which would turn a PR red for a reason its
// author cannot see in their own diff and cannot fix by changing anything they
// wrote. They also want the network at lint time. Upgrades are a deliberate act
// here -- the Kotlin version in particular is pinned to AGP's bundled KGP and is
// NOT free to follow the newest release -- so they are chosen, not nagged for.
disable += setOf("AndroidGradlePluginVersion", "NewerVersionAvailable", "GradleDependency")
// A real suggestion, deliberately not acted on in this commit. hasSpaceFor() reads
// File.usableSpace, which under-reports because it ignores cache the system could
// reclaim -- so the app can refuse a conversion it actually had room for.
// StorageManager.getAllocatableBytes is the better answer, but swapping it in
// changes when a job is rejected and can throw IOException, which is a behaviour
// change to a safety check and deserves its own commit and its own test rather
// than a drive-by in a tooling change. `informational` keeps it visible in every
// lint report instead of hiding it, while letting the gate pass until then.
informational += "UsableSpace"
}
testOptions {
unitTests {
// Robolectric needs the merged manifest and the compiled resource table to build
// an Android runtime on the JVM. Without this, AGP hands the unit tests a stub
// android.jar with no resources and Robolectric cannot start.
isIncludeAndroidResources = true
all {
// Robolectric's native runtime calls System.load(), which Java 25 reports as
// a restricted method -- four lines of warning on every test run, and a hard
// failure in some later JDK. Granting it explicitly says the native access is
// known and wanted rather than leaving the JVM to guess.
it.jvmArgs("--enable-native-access=ALL-UNNAMED")
}
}
}
packaging {
jniLibs {
// Uncompressed .so, so the APK zip-aligns them on 16 KB boundaries.
useLegacyPackaging = false
}
}
}
// Top-level: android.kotlinOptions {} was removed in AGP 9.
// jvmTarget is inherited from compileOptions.targetCompatibility.
kotlin {
compilerOptions {}
}
// --- Prerelease guard for the floating dependency versions ------------------------------
// The library versions in libs.versions.toml float on minor + patch ("1.+"). Gradle resolves
// `+` to the highest version it finds, and it does NOT skip prereleases -- so without this,
// androidx would quietly hand the app an alpha. That is not hypothetical here: at the time
// of writing lifecycle, navigation, work, datastore and annotation ALL publish an alpha or
// rc numbered above their newest stable, so five of the floats would have moved onto
// unreleased code on the next build, with nothing in the diff to say so.
//
// Rejecting them here means "+" reads as "the newest RELEASED version", which is what
// floating was meant to buy.
//
// Note that this applies to STATIC versions too, not only floating ones: naming
// "2.12.0-alpha01" in the catalog does not get you that alpha, it fails to resolve. Verified,
// because the obvious assumption is the opposite. To take an androidx prerelease deliberately,
// drop the group from the guarded list below for as long as you need it.
// Groups whose versions this project actually floats. The guard applies to these and to
// nothing else, which is the whole point.
//
// The first version of this was a blanket rule over every group, and it broke every E2E job
// while every local check stayed green. AGP resolves its OWN tooling through this project's
// configurations, and the Unified Test Platform that runs connectedAndroidTest depends on
// com.google.testing.platform artifacts pinned at 0.0.9-alpha04. Rejecting those made
// :app:connectedDebugAndroidTest unresolvable. Nothing that runs without a device touches
// that configuration, so it passed here and failed on all four API levels at once.
//
// Scoping to the groups we float is also why detekt's alpha needs no exception any more: we
// do not float dev.detekt, so the guard has no opinion about it. An allowlist of exceptions
// would have needed a new entry every time AGP pulled in another prerelease tool.
val floatedGroupPrefixes = listOf("androidx.", "junit", "com.arthenica")
// Matches both spellings androidx and friends use: "-alpha01" and "-alpha.1".
val prereleaseMarker =
Regex("""[-.](alpha|beta|rc|eap|dev|snapshot|pre|m)[-.]?\d*$""", RegexOption.IGNORE_CASE)
configurations.configureEach {
resolutionStrategy {
componentSelection {
all {
val floated = floatedGroupPrefixes.any { candidate.group.startsWith(it) }
if (floated && prereleaseMarker.containsMatchIn(candidate.version)) {
reject("prerelease; floating versions take released builds only")
}
}
}
}
}
detekt {
// Merge the project overrides in config/detekt onto detekt's bundled defaults, so this
// repo's file only has to carry the rules it actually changes.
buildUponDefaultConfig = true
config.setFrom(rootProject.file("config/detekt/detekt.yml"))
}
// Pin the coverage agent rather than inheriting whatever Gradle bundles.
jacoco {
toolVersion = libs.versions.jacoco.get()
}
// --- Unit-test coverage -------------------------------------------------------------------
// Report only. There is deliberately no coverage gate: a floor is only meaningful against a
// measured baseline, and the JVM test stack here is still junit-only. This task produces the
// number a floor would need; add `jacocoTestCoverageVerification` once it is known.
// Generated code, stripped from the denominator so the percentage reflects hand-written Kotlin.
// No DI framework is in use, so there are no Hilt/Dagger patterns to exclude.
val jacocoGeneratedExcludes = listOf(
"**/R.class",
"**/R\$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
// Room lands in a later phase; its KSP output comes out the same door as hand-written code.
"**/*_Impl*",
// One per file with @Composable lambdas -- Compose compiler output, not written by anyone.
"**/ComposableSingletons*",
)
// AGP 9 compiles Kotlin through its built-in compiler, which writes here rather than to the
// classic `tmp/kotlin-classes/debug`. All hand-written code in this module is Kotlin, so the
// javac output (BuildConfig and R only) is not read at all.
val jacocoDebugKotlinClasses = layout.buildDirectory.dir(
"intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes",
)
// Accept both the base `jacoco` plugin's default exec location and AGP's
// enableUnitTestCoverage one, so the wiring survives either being the source of truth.
val jacocoExecutionData = fileTree(layout.buildDirectory) {
include(
"jacoco/testDebugUnitTest.exec",
"outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec",
)
}
tasks.register<JacocoReport>("jacocoTestReport") {
// The exec data does not exist until the tests have run.
dependsOn("testDebugUnitTest")
group = "verification"
description = "Generates JaCoCo XML + HTML coverage for the debug JVM unit tests."
reports {
xml.required.set(true)
html.required.set(true)
}
classDirectories.setFrom(fileTree(jacocoDebugKotlinClasses) { exclude(jacocoGeneratedExcludes) })
sourceDirectories.setFrom(files("src/main/java"))
executionData.setFrom(jacocoExecutionData)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Media3 Transformer: the hardware conversion path (MediaCodec decode -> GL surface
// -> MediaCodec encode). Apache-2.0, so no licensing exposure.
implementation(libs.media3.transformer)
implementation(libs.media3.effect)
implementation(libs.media3.common)
implementation(libs.media3.muxer)
// FFmpeg, committed under bin/. Not on any Maven repo: ffmpeg-kit was archived and
// delisted, and ffmpeg-kit-next is source-only by design.
//
// The prebuilt archive is checked in on purpose. Rebuilding it per CI run made test
// results ambiguous -- a red build could mean broken code or a cross-compile that
// hiccuped. See bin/README.md for provenance and how to regenerate it.
implementation(files(rootProject.file("bin/ffmpeg-kit-next-8.1.1.aar")))
// A local .aar carries no transitive dependencies, so the wrapper's own runtime
// dependency has to be declared here explicitly.
implementation(libs.smart.exception.java)
// Durable job queue. WorkManager survives process death, which is what makes the
// queue resumable after the foreground-service timeout fires.
implementation(libs.androidx.work.runtime.ktx)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material3.windowsize)
debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit)
// An Android runtime on the JVM. Everything else in src/test is a pure function; this is
// here for the one thing a pure function cannot assert -- that a staged file is really
// gone from a real cacheDir. Instrumented tests do not run on the development host, so
// without it that assertion could only be written where nobody can execute it.
testImplementation(libs.robolectric)
// Already in the catalog for androidTest, and already inside the prerelease guard via its
// androidx. group. WorkManagerTestInitHelper + SynchronousExecutor are what let a JVM test
// drive a ViewModel through a real WorkManager to SUCCEEDED, which is where the cleanup
// handle is set -- the wiring the leak actually lived in.
testImplementation(libs.androidx.work.testing)
// Compose's own test rules, on the JVM source set as well as androidTest. Already in the
// catalog, already inside the prerelease guard via its androidx. group, and versioned by
// the BOM, so this adds no new pinning argument.
//
// Here rather than only in androidTest because ui-test-junit4 runs under Robolectric:
// createComposeRule() drives a real composition on the JVM. The defect it was added for
// -- the selected tab not surviving recreation -- is caught by StateRestorationTester,
// and putting that test where the instrumented suite lives would mean nobody on this
// host could ever watch it go red.
//
// ui-test-manifest is deliberately NOT repeated here. It supplies the ComponentActivity
// the rule launches, and the debugImplementation entry below already puts it in the
// merged manifest the unit tests build against -- checked by removing it and watching
// the tests stay green.
testImplementation(platform(libs.compose.bom))
testImplementation(libs.compose.ui.test.junit4)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.work.testing)
debugImplementation(libs.compose.ui.test.manifest)
}