Files
LibreMediaConverter/app/build.gradle.kts
T
JMR-devandClaude Opus 5 650ca8fca3 Pick a file the way a user does, then rotate the phone
Two things nothing in this repo asserted, and they are one test class because
separately the second one asserts nothing new.

THE PICKER. ConverterScreen opens SAF with a MIME filter, and a filter is a thing
that can hide the user's file. Narrow it and the app still builds, still renders,
and still passes every JVM test -- the user taps "Choose file" and gets an empty
picker. The round trip now runs for real: DocumentsUI is driven with UiAutomator to
a fixture root, and the app is asserted to come back with the file.

The file card's name is not the only assertion, because a name proves less than it
looks: it comes from a metadata query, which a URI with no read grant answers just
as well. The "Container: MP4" detail row only appears once something has opened the
file and read its header, so it is what says the picker handed back a URI the app
can USE.

THE ROTATION. MainActivity declares no configChanges, and ConversionViewModel holds
the picked file in a plain MutableStateFlow with NO SavedStateHandle behind it.
Nothing persists it. The only thing that carries it across a rotation is the
ViewModelStore the Activity retains -- which no test anywhere asserted.

Two guards run before that assertion, because both ways it could pass while proving
nothing are silent: the display rotation really changed, and MainActivity really was
a different instance afterwards. Without the second one this is a recomposition test
wearing a rotation's name.

MUTATIONS, RUN RATHER THAN ASSERTED, on a local API 34 emulator.

Narrowing the filter to arrayOf("application/x-lmc-no-such-type") takes the fixture
root out of the picker entirely -- DocumentsUI matches the request against
Root.COLUMN_MIME_TYPES and drops roots that cannot answer -- and both tests fail:

  java.lang.IllegalArgumentException: the system picker never showed
      BySelector [TEXT='\QLMC R38 fixtures\E']

Making the ViewModel composition-scoped fails ONLY the rotation test:

  androidx.compose.ui.test.ComposeTimeoutException: Condition (a node tagged
      converter.fileCard.name exists) still not satisfied after 30000 ms

and :app:testDebugUnitTest stays BUILD SUCCESSFUL under it. That divergence is what
#64 exists to establish and what its own comment doubted; the PR body has the
verdict and why the doubt was reasonable.

THE PROVIDER HAD TO BE JAVA. It is the only Java file in the module. A
manifest-declared provider is a component of the instrumentation PACKAGE, so the
system starts a plain org.libremediaconverter.test process for it with only the test
APK on its dex path -- and the test APK is built without the Kotlin stdlib, because
the app APK has it and duplicating it is what checkDebugAndroidTestDuplicateClasses
prevents. The Kotlin draft died on its first query:

  java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics;
      at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument

The compiler emits that reference for the null checks on nearly every function, so
no Kotlin dialect avoids it. Same reason nothing in that file imports androidx.

No new test tags: CHOOSE_FILE, FILE_CARD_NAME and detailRow already named both ends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:36:31 -05:00

354 lines
17 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.
// Robolectric loads every class it touches through its own sandbox classloader, and those
// classes arrive with no source location. JaCoCo skips no-location classes by default, so
// without this block **not one Robolectric test counts** -- and Robolectric is what exercises
// the framework edge here: the workers, the publisher, both ViewModels, every Compose screen.
//
// Measured on e06b082, same 335 tests, same 0 failures, only this block added:
//
// LINE 29.7% -> 69.2% OutputPublisher 0.0% -> 97.5%
// BRANCH 29.8% -> 53.2% ConversionViewModel 0.0% -> 85.4%
//
// The discriminator, if this ever looks like superstition: inside ConverterScreenKt, `describe`
// is the one non-Composable and is exercised by a plain JVM test -- it reported 8/8 covered while
// every @Composable in the same class reported 0, including ones whose mutations demonstrably
// failed the build when reverted.
//
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
// no location for either, and the test JVM dies rather than reporting a number.
tasks.withType<Test>().configureEach {
extensions.configure<JacocoTaskExtension> {
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
}
}
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 the MAIN source set is Kotlin, so
// the javac output (BuildConfig and R only) is not read at all. There is now one hand-written
// Java file in the module -- androidTest's FixtureDocumentsProvider, which cannot be Kotlin
// because the process it runs in has no Kotlin stdlib; its own header explains why. It is in
// androidTest, so it is not in this task's classDirectories and this stays accurate.
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)
// For `runTest` alone, in EscapedCoroutineErrors.kt. It arrives transitively with the
// rule above anyway; declared because a test file imports it directly, and an import of
// something nobody asked for breaks the day the library that pulled it in stops.
testImplementation(libs.kotlinx.coroutines.test)
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)
// androidTest only, and it has to be: UiAutomator drives the whole device, including
// windows belonging to other packages. The system file picker is one -- DocumentsUI runs
// in its own process, so Compose's matchers cannot see it and Espresso's cannot either
// (both are scoped to this process's view hierarchy). Nothing on the JVM has a device to
// drive, so there is no unit-test counterpart to add it to.
androidTestImplementation(libs.androidx.uiautomator)
debugImplementation(libs.compose.ui.test.manifest)
}