~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
411 lines · 17.5 KB Raw History
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<com.google.gms.googleservices.GoogleServicesPlugin.GoogleServicesPluginConfig>(
        "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<Test>().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<JacocoReport>("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<JacocoCoverageVerification>("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()
            }
        }
    }
}