Files
LibreMediaConverter/app/build.gradle.kts
T
JMR-dev 4a8e30099e Notice if the release job loses the permission that lets it publish
build.yml's `release` job declares `contents: write`, and nothing checked it. Deleting those
lines leaves actionlint clean and CodeQL silent -- a narrower permission is not an alert --
and the job is `if: startsWith(github.ref, 'refs/tags/v')`, so no pull request and no merge
can exercise it. Measured with the declaration removed: every gating check still passed. The
first thing that would notice is a release failing to publish, at the moment someone is
trying to cut one.

The deletion also looks like tidying. #106 has just put a top-level `permissions: contents:
read` directly above it, so a reader could reasonably take the job-level block for a
duplicate. It is an override, and a comment saying so is not a check.

BackupExclusionsTest is the precedent: configuration rather than code, load bearing, and
unguarded because nothing compiles it.

The part worth reading twice is the second commit-worth of work in here. The test passed,
and then the mutation that is supposed to redden it did not:

  BUILD SUCCESSFUL in 614ms

Gradle cannot infer that a test depends on a file outside the source set, so the task stayed
UP-TO-DATE and the test never ran. Under --rerun-tasks the same mutation failed it properly,
which is the tell: the assertion was right and the wiring was not. A guard that does not
re-run when its subject changes is not a guard -- it is a test that will be green on the day
it matters, which is worse than no test because it reads as cover.

Fixed by declaring the workflow as a task input. Verified the whole way round afterwards,
without --rerun-tasks: mutate the file and the task re-runs and fails; restore it and the
task re-runs and passes.

What this pins and what it does not: it asserts the declaration exists in the release job's
block. It cannot assert a release actually publishes -- that needs a tag push, which is the
thing no PR can do. A tripwire against silent removal, not proof the path works, and the
KDoc says so.

Closes #107.
2026-08-25 15:38:14 -05:00

365 lines
18 KiB
Kotlin

import org.gradle.api.tasks.PathSensitivity
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 {
// ReleasePermissionTest reads .github/workflows/build.yml, and Gradle cannot infer that a
// test depends on a file outside the source set. Without this the task stays UP-TO-DATE
// when the workflow changes, so the guard goes stale exactly when it matters. Measured:
// deleting the release job's `contents: write` and re-running gave "BUILD SUCCESSFUL in
// 614ms" with the test never executing; the same mutation under --rerun-tasks failed it.
// A guard that does not re-run when its subject changes is not a guard.
inputs.file(rootProject.file(".github/workflows/build.yml"))
.withPropertyName("releaseWorkflow")
.withPathSensitivity(PathSensitivity.RELATIVE)
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 ConversionViewModelProbeFailureTest. 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)
}