Files
openclaw/apps/android/app/build.gradle.kts
2026-08-01 08:16:56 -07:00

546 lines
18 KiB
Kotlin

import com.android.build.api.variant.impl.VariantOutputImpl
import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.process.ExecOperations
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Properties
import javax.inject.Inject
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
val thirdPartyLicensesDir = rootProject.file("THIRD_PARTY_LICENSES")
val openClawRepositoryRoot = rootProject.projectDir.resolve("../..").canonicalFile
val canvasA2uiAssetsDir = layout.buildDirectory.dir("generated/canvasA2uiAssets")
val openClawAndroidVersionProperties =
Properties().apply {
if (!openClawAndroidVersionFile.isFile) {
error("Missing Android version properties. Run `pnpm android:version:sync`.")
}
openClawAndroidVersionFile.inputStream().use(::load)
}
fun requireOpenClawAndroidVersionProperty(name: String): String =
openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() }
?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.")
val openClawAndroidVersionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME")
val openClawAndroidVersionCode =
requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull()
?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.")
fun optionalOpenClawBuildProperty(name: String): String? =
providers
.gradleProperty(name)
.orNull
?.trim()
?.takeIf { it.isNotEmpty() }
val fullGitCommitPattern = Regex("^[a-f0-9]{40}$")
val buildTimestampFormatter =
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC)
val explicitOpenClawBuildCommit =
optionalOpenClawBuildProperty("openclawBuildCommit")
?.lowercase()
?.also { commit ->
if (!fullGitCommitPattern.matches(commit)) {
error("openclawBuildCommit must be a full 40-character hexadecimal Git commit.")
}
}
val explicitOpenClawBuildTimestamp =
optionalOpenClawBuildProperty("openclawBuildTimestamp")
?.let { timestamp ->
if (!Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$").matches(timestamp)) {
error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.")
}
val instant =
runCatching { Instant.parse(timestamp) }
.getOrElse { error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.") }
buildTimestampFormatter.format(instant)
}
val repositoryBuildCommit =
if (explicitOpenClawBuildCommit == null) {
runCatching {
providers
.exec {
workingDir(rootProject.projectDir)
commandLine("git", "rev-parse", "HEAD")
}.standardOutput
.asText
.get()
.trim()
.lowercase()
.takeIf(fullGitCommitPattern::matches)
}.getOrNull()
} else {
null
}
val openClawBuildCommit = explicitOpenClawBuildCommit ?: repositoryBuildCommit ?: "unknown"
// Keep every variant generated by one Gradle invocation on the same build instant.
val invocationBuildTimestamp =
providers.provider { buildTimestampFormatter.format(Instant.now()) }.get()
val openClawBuildTimestamp = explicitOpenClawBuildTimestamp ?: invocationBuildTimestamp
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() }
val androidKeyPassword = providers.gradleProperty("OPENCLAW_ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotBlank() }
val resolvedAndroidStoreFile =
androidStoreFile?.let { storeFilePath ->
if (storeFilePath.startsWith("~/")) {
"${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}"
} else {
storeFilePath
}
}
val hasAndroidReleaseSigning =
listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null }
val wantsAndroidReleaseBuild =
gradle.startParameter.taskNames.any { taskName ->
taskName.contains("Release", ignoreCase = true) ||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
}
val missingAndroidBuildMetadata =
explicitOpenClawBuildCommit == null || explicitOpenClawBuildTimestamp == null
if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
error(
"Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " +
"OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " +
"OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.",
)
}
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.ktlint)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
abstract class StageCanvasA2uiTask
@Inject
constructor(
private val execOperations: ExecOperations,
) : DefaultTask() {
@get:Internal abstract val repoRoot: DirectoryProperty
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sourceFiles: ConfigurableFileCollection
@get:OutputDirectory abstract val outputDirectory: DirectoryProperty
@TaskAction
fun stage() {
val root = repoRoot.get().asFile
execOperations.exec {
workingDir(root)
commandLine(
"node",
"scripts/sync-native-a2ui.mjs",
"--write",
"--output",
outputDirectory
.get()
.dir("CanvasA2UI")
.asFile.absolutePath,
)
}
}
}
val stageCanvasA2ui =
tasks.register<StageCanvasA2uiTask>("stageCanvasA2ui") {
group = "build"
description = "Stages the plugin-owned Canvas A2UI renderer for native apps."
repoRoot.set(openClawRepositoryRoot)
sourceFiles.from(
openClawRepositoryRoot.resolve("package.json"),
openClawRepositoryRoot.resolve("pnpm-lock.yaml"),
openClawRepositoryRoot.resolve("scripts/bundle-a2ui.mjs"),
openClawRepositoryRoot.resolve("scripts/sync-native-a2ui.mjs"),
openClawRepositoryRoot.resolve("extensions/canvas/package.json"),
openClawRepositoryRoot.resolve("extensions/canvas/scripts/bundle-a2ui.mjs"),
openClawRepositoryRoot.resolve("extensions/canvas/src/host/a2ui/index.html"),
)
sourceFiles.from(openClawRepositoryRoot.resolve("extensions/canvas/src/host/a2ui-app"))
outputDirectory.set(canvasA2uiAssetsDir)
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
android {
namespace = "ai.openclaw.app"
// AndroidX Core 1.19 and Lifecycle 2.11 require API 37 compilation.
// targetSdk stays separate so runtime behavior changes remain an explicit migration.
compileSdk = 37
// Release signing is local-only; keep the keystore path and passwords out of the repo.
signingConfigs {
if (hasAndroidReleaseSigning) {
create("release") {
storeFile = project.file(checkNotNull(resolvedAndroidStoreFile))
storePassword = checkNotNull(androidStorePassword)
keyAlias = checkNotNull(androidKeyAlias)
keyPassword = checkNotNull(androidKeyPassword)
}
}
}
sourceSets {
getByName("main") {
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
assets.directories.add(thirdPartyLicensesDir.path)
}
}
defaultConfig {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
versionCode = openClawAndroidVersionCode
versionName = openClawAndroidVersionName
buildConfigField("String", "GIT_COMMIT", "\"$openClawBuildCommit\"")
buildConfigField("String", "BUILD_TIMESTAMP", "\"$openClawBuildTimestamp\"")
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
flavorDimensions += "store"
productFlavors {
create("play") {
dimension = "store"
manifestPlaceholders["nodeForegroundServiceType"] = "connectedDevice|microphone"
}
create("thirdParty") {
dimension = "store"
manifestPlaceholders["nodeForegroundServiceType"] = "connectedDevice|microphone|location"
}
}
buildTypes {
release {
if (hasAndroidReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
isShrinkResources = true
ndk {
debugSymbolLevel = "SYMBOL_TABLE"
}
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
isMinifyEnabled = false
}
}
bundle {
language {
// The in-app picker can select a locale outside the device language list.
// Without a Play Core download path, every translated resource must stay installed.
enableSplit = false
}
}
buildFeatures {
compose = true
buildConfig = true
}
androidResources {
generateLocaleConfig = true
localeFilters +=
listOf(
"ar",
"de",
"en",
"es",
"fa",
"fr",
"hi",
"in",
"it",
"ja",
"ko",
"nl",
"pl",
"pt-rBR",
"ru",
"sv",
"th",
"tr",
"uk",
"vi",
"zh-rCN",
"zh-rTW",
)
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources {
excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/*.version",
"/META-INF/LICENSE*.txt",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
"org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties",
"org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties",
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
)
}
}
lint {
lintConfig = file("lint.xml")
warningsAsErrors = true
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
}
androidComponents {
onVariants { variant ->
variant.sources.assets?.addGeneratedSourceDirectory(
stageCanvasA2ui,
StageCanvasA2uiTask::outputDirectory,
)
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
val versionName = output.versionName.orNull ?: "0"
val buildType = variant.buildType
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
val outputFileName =
if (flavorName == null) {
"openclaw-$versionName-$buildType.apk"
} else {
"openclaw-$versionName-$flavorName-$buildType.apk"
}
output.outputFileName = outputFileName
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
allWarningsAsErrors.set(true)
}
}
ktlint {
android.set(true)
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
}
dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)
implementation(project(":wear-shared"))
implementation(libs.play.services.wearable)
implementation(libs.androidx.core.ktx)
// AppCompat owns per-app locale persistence and Activity recreation on API 31-32.
implementation(libs.androidx.appcompat)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.webkit)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
// R8 will tree-shake unused icons when minify is enabled on release builds.
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation(libs.androidx.compose.ui.tooling)
// Material Components (XML theme + resources)
implementation(libs.material)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.security.crypto)
// Room owns separate disposable gateway cache and durable client-state databases.
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.exifinterface)
implementation(libs.okhttp)
implementation(libs.media3.datasource.okhttp)
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.ui)
implementation(libs.bcprov)
implementation(libs.coil.compose)
implementation(libs.coil.svg)
implementation(libs.commonmark)
implementation(libs.commonmark.ext.autolink)
implementation(libs.commonmark.ext.gfm.strikethrough)
implementation(libs.commonmark.ext.gfm.tables)
implementation(libs.commonmark.ext.task.list.items)
// CameraX (for node.invoke camera.* parity)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.video)
implementation(libs.barcode.scanning)
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation(libs.dnsjava)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.kotest.runner.junit5)
testImplementation(libs.kotest.assertions.core)
testImplementation(libs.mockwebserver)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit4)
testRuntimeOnly(libs.junit.vintage.engine)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.uiautomator)
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
events("failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
val validateOpenClawReleaseBuildMetadata =
tasks.register("validateOpenClawReleaseBuildMetadata") {
doLast {
if (missingAndroidBuildMetadata) {
error(
"Android release builds require -PopenclawBuildCommit and -PopenclawBuildTimestamp. " +
"Use the repository Android release helper.",
)
}
}
}
val validateThirdPartyLicenseAssets =
tasks.register("validateThirdPartyLicenseAssets") {
inputs.dir(thirdPartyLicensesDir)
doLast {
if (!thirdPartyLicensesDir.isDirectory) {
error("Missing Android third-party license directory: ${thirdPartyLicensesDir.relativeTo(rootProject.projectDir)}")
}
val invalidFiles =
thirdPartyLicensesDir
.walkTopDown()
.filter { file -> file.isFile && file.extension.lowercase() != "txt" }
.map { file -> file.relativeTo(thirdPartyLicensesDir).path }
.toList()
if (invalidFiles.isNotEmpty()) {
error(
"Android third-party license assets must be .txt files:\n" +
invalidFiles.joinToString(separator = "\n") { path -> "- $path" },
)
}
}
}
tasks.matching { task -> task.name == "preBuild" }.configureEach {
dependsOn(validateThirdPartyLicenseAssets)
}
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
val variantName = variant.name
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
val preBuildTaskName = "pre${variantNameCapitalized}Build"
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
val mergedJar =
layout.buildDirectory.file(
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
)
tasks.matching { task -> task.name == preBuildTaskName }.configureEach {
dependsOn(validateOpenClawReleaseBuildMetadata)
}
val stripTask =
tasks.register(stripTaskName) {
inputs.file(mergedJar)
outputs.file(mergedJar)
doLast {
val jarFile = mergedJar.get().asFile
if (!jarFile.exists()) {
return@doLast
}
val unpackDir = temporaryDir.resolve("merged-java-res")
delete(unpackDir)
copy {
from(zipTree(jarFile))
into(unpackDir)
exclude(dnsjavaInetAddressResolverService)
}
delete(jarFile)
ant.invokeMethod(
"zip",
mapOf(
"destfile" to jarFile.absolutePath,
"basedir" to unpackDir.absolutePath,
),
)
}
}
tasks.matching { it.name == mergeTaskName }.configureEach {
finalizedBy(stripTask)
}
tasks.matching { it.name == minifyTaskName }.configureEach {
dependsOn(stripTask)
}
}
}