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().configureEach { extensions.configure { 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("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) }