import org.gradle.testing.jacoco.plugins.JacocoTaskExtension import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.FileInputStream import java.util.Properties plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) alias(libs.plugins.hilt) jacoco } // Firebase config for push, kept out of the repo like the upload key. Absent // on a fresh clone and on CI, in which case the plugin is skipped and the app // builds without push rather than failing — PushManager checks at runtime // whether Firebase actually initialized and no-ops when it didn't. // // It lives under src/release because the debug variant's application id carries // a `.debug` suffix, and the plugin fails outright on a config with no client // for the id being built. Scoping it to release leaves debug with no config at // all, which the WARN strategy below turns from an error into a line of build // output — debug builds get no push, which is what they got before Firebase // existed here. val googleServicesFile = file("src/release/google-services.json") if (googleServicesFile.exists()) { apply(plugin = "com.google.gms.google-services") extensions.configure( "googleServices", ) { missingGoogleServicesStrategy = com.google.gms.googleservices.GoogleServicesPlugin.MissingGoogleServicesStrategy.WARN } } // Upload-key credentials, kept out of the repo. Absent on a fresh clone and on // CI, in which case release builds are simply left unsigned rather than failing. val keystorePropertiesFile = rootProject.file("keystore.properties") val keystoreProperties = Properties().apply { if (keystorePropertiesFile.exists()) { FileInputStream(keystorePropertiesFile).use { load(it) } } } // Whether this build is being made to measure coverage. See buildTypes.debug. val coverageBuild = providers.gradleProperty("coverage").isPresent android { namespace = "social.grain" compileSdk = 37 defaultConfig { applicationId = "social.grain" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = 26 targetSdk = 36 // Must increase on every upload, including builds that never ship. versionCode = 15 versionName = "0.8.0" vectorDrawables.useSupportLibrary = true // The grain-serve a build talks to. The client ID, the OAuth redirect // scheme (the host reversed) and the proxy target all follow it, so // `oauthScheme`, which the manifest registers, has to match it. buildConfigField("String", "SERVER_URL", "\"https://grain.social\"") manifestPlaceholders["oauthScheme"] = "social.grain" } signingConfigs { create("release") { if (keystoreProperties.isNotEmpty()) { storeFile = rootProject.file(keystoreProperties.getProperty("storeFile")) storePassword = keystoreProperties.getProperty("storePassword") keyAlias = keystoreProperties.getProperty("keyAlias") keyPassword = keystoreProperties.getProperty("keyPassword") } } } buildTypes { debug { applicationIdSuffix = ".debug" // A debug install sits beside a release one and registers the same // `grain://oauth` callback, so the system's "open with" chooser // lists both. Two entries reading "Grain" is a coin flip that sends // a sign-in back to the wrong app; this is what tells them apart. manifestPlaceholders["appLabel"] = "Grain debug" // Coverage is measured on debug: it is the only variant the tests // run against, and JaCoCo's instrumentation and R8 are a bad pair — // release shrinks away the very code the report wants to name. // // Off unless `-Pcoverage` is passed, because // `enableAndroidTestCoverage` instruments the *app* APK, not just // the test one — leaving it on puts the JaCoCo runtime and a probe // in every branch of the debug build that `just emulator` installs // for the two-minute iteration loop. `just coverage` passes the // flag. Toggling it recompiles, which is the price of not paying // for coverage on every unrelated install. // // `enableUnitTestCoverage` is deliberately *not* set: see the // JacocoTaskExtension block below for why the JVM half is measured // with the agent instead. enableAndroidTestCoverage = coverageBuild } // A debug build against next.grain.social, the grain-serve deploy // beside production, installed beside the other two. create("next") { initWith(getByName("debug")) applicationIdSuffix = ".next" matchingFallbacks += listOf("debug") manifestPlaceholders["appLabel"] = "Grain next" manifestPlaceholders["oauthScheme"] = "social.grain.next" buildConfigField("String", "SERVER_URL", "\"https://next.grain.social\"") } release { manifestPlaceholders["appLabel"] = "@string/app_name" // This is the *upload* key, not the app signing key. Google holds // the latter under Play App Signing and re-signs each release, so // losing this one is recoverable through Play support. signingConfig = signingConfigs.getByName("release").takeIf { keystoreProperties.isNotEmpty() } isMinifyEnabled = true isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } buildFeatures { compose = true buildConfig = true } packaging { resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" } testOptions { unitTests { // android.jar's stubs throw by default, so a unit test that only // brushes past `android.util.Log` on an error path fails for that // and nothing else. Returning defaults keeps those paths testable // off a device; anything that needs a real Android behaviour still // belongs in androidTest. isReturnDefaultValues = true // Robolectric reads the merged manifest and the resource table, so // a unit test can inflate a theme and read a string the same way // the app does. isIncludeAndroidResources = true } } // The runtime AGP packages into the instrumented APK. Pinned to the same // version as the `jacoco` block below so the `.ec` files it writes are // readable by the report task. testCoverage { jacocoVersion = libs.versions.jacoco.get() } } jacoco { toolVersion = libs.versions.jacoco.get() } /** * Coverage for the JVM half, measured with the JaCoCo agent rather than AGP's * `enableUnitTestCoverage`. * * That flag instruments the class files ahead of time, and the unit tests here * run under Robolectric — which loads the app's classes in its own sandbox * classloader, re-instrumenting them on the way in. The probes survive but * report to a copy of the JaCoCo runtime living inside the sandbox, which * nothing ever dumps: every Robolectric test contributed exactly nothing to the * report, silently, while the handful of plain-JVM tests still showed up. * * The agent transforms whatever the JVM defines, sandbox classloader included, * and keeps its runtime in the system loader where the dump can find it. */ tasks.withType().configureEach { extensions.configure(JacocoTaskExtension::class.java) { isEnabled = coverageBuild // Robolectric's loader defines classes with no source location, which // the agent skips by default — and skipping them is the same silent // empty report by another route. isIncludeNoLocationClasses = true excludes = listOf("jdk.internal.*") } } // AGP 9 compiles Kotlin itself, so the JVM target moves out of the removed // android.kotlinOptions block and onto the Kotlin extension. kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) // NavigationSuiteScaffold picks the bottom bar / nav rail / drawer for the // window it finds itself in; `adaptive` supplies currentWindowAdaptiveInfo. implementation(libs.androidx.material3.adaptive.navigation.suite) implementation(libs.androidx.adaptive) // The ~50 icon core set only. `material-icons-extended` is frozen at 1.7.8 // and carries thousands of vectors this app never draws; the two dozen it // does use that aren't here are vendored in ui/icons + res/drawable. implementation(libs.androidx.material.icons.core) debugImplementation(libs.androidx.ui.tooling) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.browser) implementation(libs.androidx.exifinterface) implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.compose) implementation(libs.h3) // MapLibre, drawing grain's own Protomaps basemap — the same tiles as // grain.social, no API key and no Play services. See assets/basemap. implementation(libs.maplibre.compose) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) implementation(libs.hilt.android) implementation(libs.hilt.navigation.compose) ksp(libs.hilt.compiler) implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp) implementation(libs.okhttp.logging) implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.junit) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.ui.test.junit4) testImplementation(libs.androidx.ui.test.manifest) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.test.manifest) } // MARK: - Coverage // // Neither half of the test suite writes a report — only raw execution data, of // two different kinds in two different places: an `.exec` the JaCoCo agent // dumps from the JVM tests, and an `.ec` per connected device from the // instrumented run. This task is what merges them. // // Both are optional inputs, deliberately. `testDebugUnitTest` alone produces an // honest report — the screens run under Robolectric, so the JVM half covers // nearly everything — and a clean tree with neither file gives a 0% report // rather than a build failure. val coverageExclusions = listOf( // Android's own generated resource and manifest plumbing. "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*", // Hilt/Dagger. KSP emits a component, a module wrapper and a factory per // injection site; counting them scores the annotation processor, not us. "**/*_Factory*.*", "**/*_MembersInjector*.*", "**/*_HiltModules*.*", "**/Dagger*.*", "**/Hilt_*.*", "**/*_Hilt*.*", "**/hilt_aggregated_deps/**", "**/*_GeneratedInjector*.*", "**/*_ComponentTreeDeps*.*", // The aggregating sentinels Hilt emits into its own package rather than // ours; they carry no name of ours to match the patterns above. "dagger/hilt/internal/**", // kotlinx.serialization's generated `$serializer` objects — machine-written // encode/decode loops whose branches no test will ever walk exhaustively. "**/*\$\$serializer.*", // Compose lifts every non-capturing lambda in a file into one synthetic // ComposableSingletons class. It is generated, and it is enormous. "**/ComposableSingletons*.*", // @Preview functions exist to be looked at in Studio, not run. "**/Previews*.*", ) // AGP 9 compiles Kotlin itself rather than delegating to a separate // `compileDebugKotlin`, so the classes land under `intermediates`, not the // `tmp/kotlin-classes/debug` that every pre-9 JaCoCo recipe on the internet // points at. Both are listed: the stale path simply contributes nothing on // AGP 9, and keeps this working if a future version moves back. val coverageClassDirs = listOf( "tmp/kotlin-classes/debug", "intermediates/classes/debug/transformDebugClassesWithAsm/dirs", "intermediates/javac/debug/classes", ) /** * What both coverage tasks measure, applied to each of them. * * Shared so the gate and the report can never disagree: a rule that fails on a * number the report doesn't show is a rule nobody can act on. * * The ordering is the subtle half. Neither task *depends* on a test task — * both read whatever execution data is on disk, which is what lets one run * grade the JVM half and another the merged pair. But when a test task is in * the same invocation, which is the normal way to run these, it has to go * first: without this the report came out grading the previous run, and Gradle * failed the build outright once the gate was added beside it. */ fun JacocoReportBase.useGrainCoverageData() { // The classes being measured have to be compiled before anything measures // them. `matching` rather than a name, so a build that never configures the // task isn't broken by referring to it. dependsOn(tasks.matching { it.name == "transformDebugClassesWithAsm" }) mustRunAfter( tasks.matching { it.name == "testDebugUnitTest" || it.name == "connectedDebugAndroidTest" }, ) classDirectories.setFrom( files(coverageClassDirs.map { dir -> fileTree(layout.buildDirectory.dir(dir)) { exclude(coverageExclusions) } }), ) sourceDirectories.setFrom(files("src/main/java")) // Globbed rather than named. The instrumented run writes its `.ec` under a // directory named for the device it ran on, so the path is not knowable // until a device exists. executionData.setFrom( fileTree(layout.buildDirectory) { include( // The agent's own output, for the JVM half. "jacoco/*.exec", // Where AGP files it when `enableUnitTestCoverage` is on, kept // so a report still merges cleanly if that is ever turned back on. "outputs/unit_test_code_coverage/**/*.exec", "outputs/code_coverage/**/*.ec", ) }, ) } tasks.register("coverageReport") { group = "verification" description = "Merges unit-test and instrumented coverage into one HTML/XML report." useGrainCoverageData() reports { html.required = true xml.required = true csv.required = false } doLast { val report = reports.html.outputLocation.get().asFile.resolve("index.html") logger.lifecycle("Coverage report: file://$report") } } /** * The gate. Fails the build when line coverage drops below the bar. * * Line rather than branch: branch coverage sits around 60%, and most of what it * counts in a Compose codebase is the null checks and default-argument masks * the compiler writes, not decisions anyone made. Gating on that would be * gating on the compiler. */ tasks.register("coverageVerification") { group = "verification" description = "Fails when line coverage falls below the agreed floor." useGrainCoverageData() // JacocoCoverageVerification ships an `onlyIf` that skips the whole task // when it finds no execution data, which turns "the tests never ran" into a // passing gate — the one failure a gate exists to catch. Replaced with a // check that says so instead. setOnlyIf("always, so a missing run fails rather than skips") { true } doFirst { check(!executionData.isEmpty) { "No coverage execution data under ${layout.buildDirectory.get()}. " + "Run the tests with -Pcoverage first — a gate that passes " + "because nothing ran is worse than no gate at all." } } violationRules { rule { limit { counter = "LINE" value = "COVEREDRATIO" // The JVM half alone measures 81%. The bar is the target rather // than the high-water mark: a point of slack absorbs a screen // landing before its test without turning the gate into // something people learn to re-run. minimum = "0.80".toBigDecimal() } } } }