import com.android.build.api.variant.impl.VariantOutputImpl import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.util.Properties val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider" val openClawAndroidVersionFile = rootProject.file("Config/Version.properties") val thirdPartyLicensesDir = rootProject.file("THIRD_PARTY_LICENSES") 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) } 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.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(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) // 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 the disposable transcript cache and durable chat outbox; migrations preserve outbox rows. implementation(libs.androidx.room.runtime) ksp(libs.androidx.room.compiler) implementation(libs.androidx.exifinterface) implementation(libs.okhttp) 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) testRuntimeOnly(libs.junit.vintage.engine) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.uiautomator) } tasks.withType().configureEach { useJUnitPlatform() } 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) } } }