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", "--import", "tsx", "scripts/sync-native-a2ui.mts", "--write", "--output", outputDirectory .get() .dir("CanvasA2UI") .asFile.absolutePath, ) } } } val stageCanvasA2ui = tasks.register("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.mts"), openClawRepositoryRoot.resolve("scripts/sync-native-a2ui.mts"), 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() .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().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) } } }