Files
nvrl 99abc8f205 build(android): move to NDK r30 and mpv-android 687c7fd
Upstream mpv-android moved its build to NDK r30 (30.0.16248370), and each
mpv-android revision pins its own NDK, so the two move together. The new
revision also updates harfbuzz to 14.5.0, unibreak to 8.0, fribidi to
1.0.17, libxml2 to 2.15.4, fontconfig to 2.18.3 and curl to 8.22.0, and
builds libass with assembly on x86_64 (nasm, already installed in CI).

r30 brings an LLVM update with codegen and LTO fixes and sysroots up to
API 37. It is also the NDK current SDK managers install, so a fresh setup
no longer needs r29 installed beside it.

The script change invalidates the cached libmpv tree, so CI rebuilds it
once. The staged libraries are the same set as before, all 16 KiB aligned.
2026-10-04 02:42:53 +02:00

312 lines
12 KiB
Kotlin

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) }