import org.gradle.api.tasks.Exec import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ktlint) alias(libs.plugins.roborazzi) } val repositoryRoot = rootProject.projectDir.parentFile val workspaceVersion = Regex("""(?m)^version\s*=\s*\"([^\"]+)\"""") .find(repositoryRoot.resolve("Cargo.toml").readText()) ?.groupValues?.get(1) ?: error("workspace version is missing from Cargo.toml") fun semverVersionCode(version: String): Int { val match = Regex("""^(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$""").matchEntire(version) ?: error("Android version must start with major.minor.patch: $version") val (major, minor, patch) = match.destructured.toList().take(3).map(String::toLong) require(minor in 0..999 && patch in 0..999) { "minor and patch must fit three digits" } val code = major * 1_000_000 + minor * 1_000 + patch require(code in 1..2_100_000_000) { "semantic version is outside Android's version-code range" } return code.toInt() } // The visible Android version is the workspace/release-tag version. Only the // monotonically increasing Play version code may be overridden by release CI. val releaseVersionName = workspaceVersion val versionCodeOverride = providers.environmentVariable("ANDROID_VERSION_CODE").orNull val releaseVersionCode = versionCodeOverride?.toIntOrNull() ?: if (versionCodeOverride == null) semverVersionCode(releaseVersionName) else error("ANDROID_VERSION_CODE must be an integer") require(releaseVersionCode in 1..2_100_000_000) { "ANDROID_VERSION_CODE must be a positive Play-compatible integer" } tasks.register("printAndroidVersion") { group = "help" description = "Prints the resolved Android version name and code for release validation." doLast { println("versionName=$releaseVersionName") println("versionCode=$releaseVersionCode") } } android { namespace = "io.narl.protonstream" compileSdk = 36 buildToolsVersion = "37.0.0" ndkVersion = "30.0.16248370" defaultConfig { applicationId = "io.narl.protonstream" minSdk = 31 targetSdk = 36 versionCode = releaseVersionCode versionName = releaseVersionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" ndk { abiFilters += listOf("arm64-v8a", "x86_64") } externalNativeBuild { cmake { arguments += "-DPSTR_MPV_ROOT=${layout.buildDirectory.dir("generated/mpv").get().asFile.absolutePath}" arguments += "-DPSTR_RUST_JNI_ROOT=${layout.buildDirectory.dir("generated/jniLibs").get().asFile.absolutePath}" cppFlags += listOf("-std=c++20") } } } buildFeatures { compose = true buildConfig = true } sourceSets.named("main") { java.srcDir(layout.buildDirectory.dir("generated/source/uniffi")) jniLibs.srcDir(layout.buildDirectory.dir("generated/jniLibs")) jniLibs.srcDir(layout.buildDirectory.dir("generated/mpv/jniLibs")) } externalNativeBuild { cmake { path = file("src/main/cpp/CMakeLists.txt") version = "3.22.1" } } val releaseStore = System.getenv("ANDROID_RELEASE_STORE_FILE") if (releaseStore != null) { signingConfigs.create("release") { storeFile = file(releaseStore) storePassword = System.getenv("ANDROID_RELEASE_STORE_PASSWORD") keyAlias = System.getenv("ANDROID_RELEASE_KEY_ALIAS") keyPassword = System.getenv("ANDROID_RELEASE_KEY_PASSWORD") } } buildTypes { debug { applicationIdSuffix = ".debug" versionNameSuffix = "-debug" } release { isMinifyEnabled = true isShrinkResources = true if (releaseStore != null) signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", // Applied to the shipping APK on purpose; the file explains the // trade-off and what each keep is repairing. "proguard-instrumentation.pro", ) testProguardFiles("proguard-test-rules.pro") } } // The minified path is what ships, and R8 breaks reflective UniFFI/JNA // surfaces in ways no debug run can see. CI sets this to `release` and runs // connectedAndroidTest against it; locally the default keeps builds fast. testBuildType = providers.environmentVariable("ANDROID_TEST_BUILD_TYPE").orNull ?: "debug" lint { warningsAsErrors = true abortOnError = true checkDependencies = true // The baseline records what was already here when the gate went in, so // anything *new* fails. It is not a permanent exemption: every entry in // it maps to an open bug in docs/BUGS.md (the SharedPreferences // `commit()` calls are B20, the PiP one is B40), and the file should get // shorter as those land. Regenerate with `gradlew updateLintBaseline` // after a fix, and never to make an unrelated failure go away. baseline = file("lint-baseline.xml") // Generated UniFFI bindings are not ours to fix. disable += setOf("UnusedIds") // Dependency freshness is not a defect, and as errors these break the // gate whenever someone else cuts a release. Upgrades are a deliberate // act with their own verification; `gradlew lintDebug -Dlint...` or a // dependency bot is the right place to surface them, not CI. informational += setOf( "GradleDependency", "NewerVersionAvailable", "AndroidGradlePluginVersion", // The same for the platform: raising targetSdk opts into new // behaviour and needs its own pass on a device. "OldTargetApi", ) sarifReport = true } testOptions { unitTests { isIncludeAndroidResources = true isReturnDefaultValues = true } } packaging { jniLibs.useLegacyPackaging = false resources.excludes += setOf("/META-INF/{AL2.0,LGPL2.1}") } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } } kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } val rustJniDir = layout.buildDirectory.dir("generated/jniLibs") val uniffiSourceDir = layout.buildDirectory.dir("generated/source/uniffi") val hostRustLibrary = repositoryRoot.resolve( "target/debug/${System.mapLibraryName("pstr_android")}", ) val buildLibmpvAndroid by tasks.registering(Exec::class) { group = "native" description = "Builds the pinned GPL libmpv and stages both Android ABIs." workingDir(repositoryRoot) commandLine("bash", repositoryRoot.resolve("scripts/build-libmpv-android.sh").absolutePath) // No declared outputs: the script's STAMP is the up-to-date check. Gradle // deletes the declared outputs of a task it has no history for, which on a // fresh CI checkout threw away the tree just restored from the cache and // rebuilt libmpv from source on every run. } val buildRustHost by tasks.registering(Exec::class) { group = "rust" description = "Builds the host cdylib used by UniFFI binding generation." workingDir(repositoryRoot) commandLine("cargo", "build", "-p", "pstr-android", "--lib") inputs.files(fileTree(repositoryRoot.resolve("crates")) { include("**/*.rs", "**/Cargo.toml") }) outputs.file(hostRustLibrary) } val generateUniFfiBindings by tasks.registering(Exec::class) { group = "rust" description = "Generates Kotlin bindings from the pstr-android cdylib." dependsOn(buildRustHost) workingDir(repositoryRoot) doFirst { uniffiSourceDir.get().asFile.mkdirs() } commandLine( "cargo", "run", "-p", "pstr-android", "--features", "bindgen", "--bin", "uniffi-bindgen", "--", "generate", "--library", hostRustLibrary.absolutePath, "--language", "kotlin", "--no-format", "--out-dir", uniffiSourceDir.get().asFile.absolutePath, ) inputs.file(hostRustLibrary) outputs.dir(uniffiSourceDir) } val buildRustAndroid by tasks.registering(Exec::class) { group = "rust" description = "Builds pstr-android for every supported ABI with cargo-ndk." workingDir(repositoryRoot) doFirst { rustJniDir.get().asFile.mkdirs() } commandLine( "cargo", "ndk", "--target", "arm64-v8a", "--target", "x86_64", "--platform", "31", "--output-dir", rustJniDir.get().asFile.absolutePath, "build", "--release", "-p", "pstr-android", "--lib", ) environment("CARGO_TARGET_DIR", repositoryRoot.resolve("target/android").absolutePath) inputs.files(fileTree(repositoryRoot.resolve("crates")) { include("**/*.rs", "**/Cargo.toml") }) outputs.dir(rustJniDir) } tasks.named("preBuild") { dependsOn(generateUniFfiBindings, buildRustAndroid, buildLibmpvAndroid) } tasks.configureEach { if (name.contains("CMake", ignoreCase = true)) { dependsOn(buildRustAndroid, buildLibmpvAndroid) } } dependencies { implementation(libs.androidx.core) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.navigation.compose) implementation(libs.androidx.work.runtime) implementation(libs.jna) { artifact { type = "aar" } } implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.navigation.suite) debugImplementation(libs.androidx.compose.ui.tooling) testImplementation(libs.junit) testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.work.testing) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.roborazzi) testImplementation(libs.roborazzi.compose) testImplementation(libs.roborazzi.junit.rule) androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.work.testing) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) debugImplementation(libs.androidx.compose.ui.test.manifest) } ktlint { version.set("1.5.0") android.set(true) ignoreFailures.set(false) filter { // UniFFI writes these; they are regenerated on every build. exclude { it.file.path.contains("${File.separator}generated${File.separator}") } // Vendored from rustls-platform-verifier. Restyling it would make the // next sync with upstream a merge conflict for no benefit. exclude { it.file.path.contains("${File.separator}org${File.separator}rustls${File.separator}") } } } // Screenshot baselines live beside the tests that draw them, so a change to how // a screen looks shows up in review as a changed image. `recordRoborazziDebug` // rewrites them; `verifyRoborazziDebug` fails on any difference. roborazzi { outputDir.set(file("src/test/screenshots")) } // The generated UniFFI directory is a main source dir, so ktlint reads it even // though the filter above drops every file in it. Without this, Gradle refuses // the build for an undeclared dependency between the two tasks. tasks.matching { it.name.startsWith("runKtlintCheckOver") || it.name.startsWith("runKtlintFormatOver") } .configureEach { dependsOn(generateUniFfiBindings) }