mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
The generated native inventory stored one line per string with every
extraction site inline, sorted alphabetically. Two PRs reusing a string
("Retry") both rewrote its line, and a feature's new strings ("Group by",
"Group chat") landed in the same insertion gap, so parallel native PRs
conflicted in apps/.i18n/native-source.json and needed a rebase plus a
full CI rerun.
Inventory v3 writes one line per extraction site, clustered by source
file, with clusters ordered by a hash of the path so new sibling files do
not share a gap. Each row's position depends only on its own content, so
independent edits merge cleanly (including GitHub's server-side merge)
and the merged text equals a fresh baseline. The new
scripts/native-i18n-inventory.ts owns serialization and parsing; the
generator, Android/Gradle and Apple readers migrate to it together and
receive the same entries in the same order as before.
771 lines
27 KiB
Kotlin
771 lines
27 KiB
Kotlin
import com.android.build.api.variant.impl.VariantOutputImpl
|
|
import groovy.json.JsonSlurper
|
|
import org.gradle.api.tasks.Exec
|
|
import org.gradle.api.tasks.PathSensitivity
|
|
import org.gradle.process.ExecOperations
|
|
import java.io.File
|
|
import java.security.MessageDigest
|
|
import java.time.Instant
|
|
import java.time.ZoneOffset
|
|
import java.time.format.DateTimeFormatter
|
|
import java.util.Properties
|
|
import java.util.zip.ZipFile
|
|
import javax.inject.Inject
|
|
|
|
@CacheableTask
|
|
abstract class GenerateNativeI18n
|
|
@Inject
|
|
constructor(
|
|
private val execOperations: ExecOperations,
|
|
) : DefaultTask() {
|
|
@get:InputFiles
|
|
@get:PathSensitive(PathSensitivity.RELATIVE)
|
|
abstract val sourceFiles: ConfigurableFileCollection
|
|
|
|
@get:Internal
|
|
abstract val repositoryDirectory: DirectoryProperty
|
|
|
|
@get:OutputDirectory
|
|
abstract val kotlinDirectory: DirectoryProperty
|
|
|
|
@get:OutputDirectory
|
|
abstract val resourceDirectory: DirectoryProperty
|
|
|
|
@TaskAction
|
|
fun generate() {
|
|
execOperations.exec {
|
|
workingDir(repositoryDirectory.get().asFile)
|
|
commandLine("node", "scripts/android-app-i18n.ts", "generate")
|
|
}
|
|
}
|
|
}
|
|
|
|
abstract class ExtractCloudflareSodium : DefaultTask() {
|
|
@get:InputFile
|
|
@get:PathSensitive(PathSensitivity.NONE)
|
|
abstract val archive: RegularFileProperty
|
|
|
|
@get:Input
|
|
abstract val entries: MapProperty<String, String>
|
|
|
|
@get:OutputDirectory
|
|
abstract val outputDirectory: DirectoryProperty
|
|
|
|
@TaskAction
|
|
fun extract() {
|
|
val source = archive.get().asFile
|
|
val digest = MessageDigest.getInstance("SHA-256")
|
|
source.inputStream().use { input ->
|
|
val buffer = ByteArray(8192)
|
|
while (true) {
|
|
val size = input.read(buffer)
|
|
if (size == -1) break
|
|
digest.update(buffer, 0, size)
|
|
}
|
|
}
|
|
val checksum = digest.digest().joinToString("") { "%02x".format(it) }
|
|
check(checksum == "f66eac31ea413c1d5d068b46ade11d3295c86ec9d6cd29ff158ba58ef51db51a") {
|
|
"The pinned libsodium 1.0.22 archive checksum does not match."
|
|
}
|
|
ZipFile(source).use { zip ->
|
|
val files =
|
|
entries.get().map { (entry, destination) ->
|
|
(zip.getEntry(entry)?.takeUnless { it.isDirectory } ?: error("Missing libsodium native entry: $entry")) to destination
|
|
}
|
|
val output = outputDirectory.get().asFile
|
|
// Only this task's generated output is replaced, after every required ABI is validated.
|
|
if (output.exists()) check(output.deleteRecursively())
|
|
check(output.mkdirs())
|
|
files.forEach { (entry, destination) ->
|
|
val target = output.resolve(destination)
|
|
check(target.parentFile.isDirectory || target.parentFile.mkdirs())
|
|
zip.getInputStream(entry).use { input -> target.outputStream().use(input::copyTo) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
abstract class GenerateGatewayProtocol : Exec() {
|
|
@get:OutputDirectory
|
|
abstract val outputDirectory: DirectoryProperty
|
|
}
|
|
|
|
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
|
|
val openClawAndroidApplicationId = "ai.openclaw.app"
|
|
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 =
|
|
(providers.gradleProperty(name).orNull ?: 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 }
|
|
|
|
plugins {
|
|
alias(libs.plugins.android.application)
|
|
alias(libs.plugins.ktlint)
|
|
alias(libs.plugins.kotlin.compose)
|
|
alias(libs.plugins.kotlin.serialization)
|
|
alias(libs.plugins.ksp)
|
|
}
|
|
|
|
val generateGatewayProtocol =
|
|
tasks.register<GenerateGatewayProtocol>("generateGatewayProtocol") {
|
|
val repositoryRoot = rootProject.projectDir.resolve("../..").canonicalFile
|
|
val manifest = repositoryRoot.resolve("scripts/native-protocol-inputs.json")
|
|
val protocolInputs = JsonSlurper().parse(manifest) as Map<*, *>
|
|
val directories = protocolInputs["directories"] as List<*>
|
|
val files = protocolInputs["files"] as List<*>
|
|
inputs
|
|
.files(
|
|
directories.map { directory ->
|
|
fileTree(repositoryRoot.resolve(directory as String)) {
|
|
include("**/*.ts", "**/*.mts", "**/*.mjs", "**/*.json")
|
|
exclude("**/node_modules/**", "**/*.test.*", "**/*.spec.*", "**/.*", "**/.*/**")
|
|
}
|
|
},
|
|
files.map { file -> repositoryRoot.resolve(file as String) },
|
|
).withPathSensitivity(PathSensitivity.RELATIVE)
|
|
outputDirectory.set(layout.buildDirectory.dir("generated/openclaw-protocol"))
|
|
val nodeName = if (System.getProperty("os.name").startsWith("Windows")) "node.exe" else "node"
|
|
val nodeCandidates =
|
|
providers
|
|
.environmentVariable("PATH")
|
|
.orNull
|
|
.orEmpty()
|
|
.split(File.pathSeparator)
|
|
.map { directory -> File(directory, nodeName) } +
|
|
listOf(File("/opt/homebrew/bin/node"), File("/usr/local/bin/node"))
|
|
val node =
|
|
nodeCandidates.firstOrNull { it.isFile && it.canExecute() }
|
|
?: error("Node.js is required to build the Gateway protocol models.")
|
|
workingDir(repositoryRoot)
|
|
commandLine(
|
|
node.absolutePath,
|
|
repositoryRoot.resolve("scripts/prepare-native-protocol.mjs").path,
|
|
"--language",
|
|
"kotlin",
|
|
"--out",
|
|
outputDirectory.get().asFile.absolutePath,
|
|
)
|
|
}
|
|
|
|
androidComponents.onVariants { variant ->
|
|
variant.sources.kotlin?.addGeneratedSourceDirectory(generateGatewayProtocol, GenerateGatewayProtocol::outputDirectory)
|
|
}
|
|
|
|
// NuGet is used only as an upstream native artifact container, never as a managed/runtime dependency.
|
|
val cloudflareSodiumArchive =
|
|
configurations.create("cloudflareSodiumArchive") {
|
|
isCanBeConsumed = false
|
|
isCanBeResolved = true
|
|
isTransitive = false
|
|
}
|
|
dependencies { add(cloudflareSodiumArchive.name, "nuget:libsodium:1.0.22@nupkg") }
|
|
val extractCloudflareSodium =
|
|
tasks.register<ExtractCloudflareSodium>("extractCloudflareSodium") {
|
|
archive.set(layout.file(cloudflareSodiumArchive.elements.map { it.single().asFile }))
|
|
entries.set(
|
|
mapOf(
|
|
"runtimes/android-arm/native/libsodium.so" to "armeabi-v7a/libsodium.so",
|
|
"runtimes/android-arm64/native/libsodium.so" to "arm64-v8a/libsodium.so",
|
|
"runtimes/android-x86/native/libsodium.so" to "x86/libsodium.so",
|
|
"runtimes/android-x64/native/libsodium.so" to "x86_64/libsodium.so",
|
|
),
|
|
)
|
|
outputDirectory.set(layout.buildDirectory.dir("generated/cloudflare-sodium/jniLibs"))
|
|
}
|
|
// Select the JVM architecture, including translated JVMs. These files never enter APK sources.
|
|
val sodiumTestHost =
|
|
when (System.getProperty("os.name") to System.getProperty("os.arch")) {
|
|
"Linux" to "amd64", "Linux" to "x86_64" -> {
|
|
"linux-x64" to "libsodium.so"
|
|
}
|
|
|
|
"Mac OS X" to "aarch64", "Mac OS X" to "arm64" -> {
|
|
"osx-arm64" to "libsodium.dylib"
|
|
}
|
|
|
|
"Mac OS X" to "amd64", "Mac OS X" to "x86_64" -> {
|
|
"osx-x64" to "libsodium.dylib"
|
|
}
|
|
|
|
else -> {
|
|
if (System.getProperty("os.name").startsWith("Windows")) {
|
|
when (System.getProperty("os.arch")) {
|
|
"amd64", "x86_64" -> "win-x64" to "sodium.dll"
|
|
"aarch64", "arm64" -> "win-arm64" to "sodium.dll"
|
|
"x86", "i386" -> "win-x86" to "sodium.dll"
|
|
else -> null
|
|
}
|
|
} else {
|
|
null
|
|
}
|
|
}
|
|
}
|
|
val extractCloudflareSodiumTest =
|
|
tasks.register<ExtractCloudflareSodium>("extractCloudflareSodiumTest") {
|
|
archive.set(layout.file(cloudflareSodiumArchive.elements.map { it.single().asFile }))
|
|
val (runtime, filename) = checkNotNull(sodiumTestHost) { "No pinned libsodium test library for this JVM host." }
|
|
val upstreamFilename = if (filename == "sodium.dll") "libsodium.dll" else filename
|
|
entries.set(mapOf("runtimes/$runtime/native/$upstreamFilename" to filename))
|
|
outputDirectory.set(layout.buildDirectory.dir("generated/cloudflare-sodium/test-$runtime"))
|
|
}
|
|
val generateNativeI18n =
|
|
tasks.register<GenerateNativeI18n>("generateNativeI18n") {
|
|
val repositoryRoot = rootProject.projectDir.resolve("../..").canonicalFile
|
|
repositoryDirectory.set(repositoryRoot)
|
|
sourceFiles.from(
|
|
listOf(
|
|
"scripts/android-app-i18n.ts",
|
|
"scripts/native-i18n-inventory.ts",
|
|
"scripts/native-i18n-locales.ts",
|
|
"scripts/lib/canonical-json.mjs",
|
|
"scripts/lib/direct-run.mjs",
|
|
"packages/normalization-core/src/expect.ts",
|
|
"apps/.i18n/native-source.json",
|
|
"apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json",
|
|
).map(repositoryRoot::resolve),
|
|
fileTree(repositoryRoot.resolve("apps/android")) {
|
|
include(
|
|
"app/src/main/res/values/strings.xml",
|
|
"app/src/main/res/values/assistant.xml",
|
|
"app/src/thirdParty/res/values/accessibility_strings.xml",
|
|
"wear/src/main/res/values/strings.xml",
|
|
)
|
|
},
|
|
fileTree(repositoryRoot.resolve("apps/.i18n/native")) { include("*.json") },
|
|
listOf(
|
|
"apps/android/app/src/main/java",
|
|
"apps/android/app/src/play/java",
|
|
"apps/android/app/src/thirdParty/java",
|
|
"apps/android/wear/src/main/java",
|
|
).map { relativePath ->
|
|
fileTree(repositoryRoot.resolve(relativePath)) {
|
|
include("**/*.kt")
|
|
exclude("**/NativeStringResources.kt")
|
|
}
|
|
},
|
|
)
|
|
kotlinDirectory.set(layout.projectDirectory.dir("build/generated/native-i18n/kotlin"))
|
|
resourceDirectory.set(layout.projectDirectory.dir("build/generated/native-i18n/res"))
|
|
}
|
|
androidComponents.onVariants { variant ->
|
|
variant.sources.jniLibs?.addGeneratedSourceDirectory(extractCloudflareSodium, ExtractCloudflareSodium::outputDirectory)
|
|
variant.sources.kotlin?.addGeneratedSourceDirectory(generateNativeI18n, GenerateNativeI18n::kotlinDirectory)
|
|
variant.sources.res?.addGeneratedSourceDirectory(generateNativeI18n, GenerateNativeI18n::resourceDirectory)
|
|
}
|
|
|
|
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(rootProject.file("../../ui/public/provider-icons").path)
|
|
assets.directories.add("../../shared/mermaid/assets")
|
|
assets.directories.add(thirdPartyLicensesDir.path)
|
|
}
|
|
}
|
|
|
|
defaultConfig {
|
|
applicationId = openClawAndroidApplicationId
|
|
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 {
|
|
applicationIdSuffix = ".debug"
|
|
versionNameSuffix = "-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/legacy/picnic/lowmcL1.bin.properties",
|
|
"org/bouncycastle/pqc/legacy/picnic/lowmcL3.bin.properties",
|
|
"org/bouncycastle/pqc/legacy/picnic/lowmcL5.bin.properties",
|
|
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
|
|
)
|
|
}
|
|
}
|
|
|
|
lint {
|
|
lintConfig = file("lint.xml")
|
|
warningsAsErrors = true
|
|
}
|
|
|
|
testOptions {
|
|
unitTests.isIncludeAndroidResources = true
|
|
}
|
|
}
|
|
|
|
androidComponents {
|
|
val adbExecutable = sdkComponents.adb
|
|
onVariants { variant ->
|
|
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
|
|
}
|
|
|
|
if (variant.buildType == "debug") {
|
|
val variantNameCapitalized = variant.name.replaceFirstChar(Char::titlecase)
|
|
tasks.register<Exec>("run$variantNameCapitalized") {
|
|
group = "install"
|
|
description = "Installs and launches the ${variant.name} app."
|
|
dependsOn("install$variantNameCapitalized")
|
|
commandLine(
|
|
adbExecutable.get().asFile.absolutePath,
|
|
"shell",
|
|
"am",
|
|
"start",
|
|
"-W",
|
|
"-n",
|
|
"${variant.applicationId.get()}/$openClawAndroidApplicationId.MainActivity",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
kotlin {
|
|
compilerOptions {
|
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
|
allWarningsAsErrors.set(true)
|
|
}
|
|
}
|
|
|
|
ktlint {
|
|
version.set(libs.versions.ktlint.cli)
|
|
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.window)
|
|
|
|
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 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.jna.get()}@aar")
|
|
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.mockwebserver)
|
|
testImplementation(libs.robolectric)
|
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
|
testRuntimeOnly(libs.junit.platform.launcher)
|
|
testRuntimeOnly(libs.junit.vintage.engine)
|
|
// The Android AAR has bionic dispatch; JVM vectors need the same-version host dispatch JAR.
|
|
testRuntimeOnly("${libs.jna.get()}@jar")
|
|
|
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
|
androidTestImplementation(libs.androidx.test.runner)
|
|
androidTestImplementation(libs.androidx.uiautomator)
|
|
}
|
|
|
|
tasks.withType<Test>().configureEach {
|
|
useJUnitPlatform()
|
|
// This platform fixture is loaded by Robolectric, not by JUnit's unsandboxed test discovery.
|
|
exclude("**/ControlUiAuthWebViewShadow.class")
|
|
if (sodiumTestHost != null) {
|
|
dependsOn(extractCloudflareSodiumTest)
|
|
val nativeDirectory = extractCloudflareSodiumTest.flatMap { it.outputDirectory }
|
|
inputs.dir(nativeDirectory)
|
|
systemProperty("jna.library.path", nativeDirectory.get().asFile.absolutePath)
|
|
systemProperty(
|
|
"openclaw.sodium.test.library",
|
|
nativeDirectory
|
|
.get()
|
|
.file(sodiumTestHost.second)
|
|
.asFile.absolutePath,
|
|
)
|
|
}
|
|
testLogging {
|
|
events("failed")
|
|
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
|
}
|
|
}
|
|
|
|
val validateOpenClawReleaseSigning =
|
|
tasks.register("validateOpenClawReleaseSigning") {
|
|
val signingConfigured = hasAndroidReleaseSigning
|
|
doLast {
|
|
check(signingConfigured) {
|
|
"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."
|
|
}
|
|
}
|
|
}
|
|
|
|
val validateOpenClawReleaseBuildMetadata =
|
|
tasks.register("validateOpenClawReleaseBuildMetadata") {
|
|
val metadataProvided =
|
|
explicitOpenClawBuildCommit != null && explicitOpenClawBuildTimestamp != null
|
|
doLast {
|
|
check(metadataProvided) {
|
|
"Android release builds require -PopenclawBuildCommit and -PopenclawBuildTimestamp. " +
|
|
"Use the repository Android release helper."
|
|
}
|
|
}
|
|
}
|
|
|
|
val validateThirdPartyLicenseAssets =
|
|
tasks.register("validateThirdPartyLicenseAssets") {
|
|
val licensesDir = thirdPartyLicensesDir
|
|
val licensesPath = licensesDir.relativeTo(rootProject.projectDir).path
|
|
inputs.dir(licensesDir)
|
|
doLast {
|
|
if (!licensesDir.isDirectory) {
|
|
error("Missing Android third-party license directory: $licensesPath")
|
|
}
|
|
val invalidFiles =
|
|
licensesDir
|
|
.walkTopDown()
|
|
.filter { file -> file.isFile && file.extension.lowercase() != "txt" }
|
|
.map { file -> file.relativeTo(licensesDir).path }
|
|
.toList()
|
|
|
|
if (invalidFiles.isNotEmpty()) {
|
|
error(
|
|
"Android third-party license assets must be .txt files:\n" +
|
|
invalidFiles.joinToString(separator = "\n") { path -> "- $path" },
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
val generateMermaidAssets =
|
|
tasks.register<Exec>("generateMermaidAssets") {
|
|
val repositoryRoot = rootProject.projectDir.resolve("../..").canonicalFile
|
|
workingDir(repositoryRoot)
|
|
commandLine("pnpm", "--dir", "packages/mermaid-renderer", "build")
|
|
inputs
|
|
.files(
|
|
fileTree(repositoryRoot.resolve("packages/mermaid-renderer")) {
|
|
exclude("node_modules/**", "dist/**")
|
|
},
|
|
fileTree(repositoryRoot.resolve("packages/normalization-core")) {
|
|
include("src/**", "package.json")
|
|
},
|
|
repositoryRoot.resolve("tsconfig.json"),
|
|
).withPathSensitivity(PathSensitivity.RELATIVE)
|
|
inputs.file(repositoryRoot.resolve("pnpm-lock.yaml"))
|
|
outputs.dir(repositoryRoot.resolve("apps/shared/mermaid/assets/mermaid"))
|
|
}
|
|
|
|
tasks.matching { task -> task.name == "preBuild" }.configureEach {
|
|
dependsOn(validateThirdPartyLicenseAssets, generateMermaidAssets)
|
|
}
|
|
|
|
tasks.matching { task -> task.name.startsWith("merge") && task.name.endsWith("Assets") }.configureEach {
|
|
dependsOn(generateMermaidAssets)
|
|
}
|
|
|
|
androidComponents {
|
|
onVariants(selector().withBuildType("release")) { variant ->
|
|
// Validate the selected variant graph, not task arguments that can also contain "Release".
|
|
variant.lifecycleTasks.registerPreBuild(validateOpenClawReleaseSigning, validateOpenClawReleaseBuildMetadata)
|
|
val variantName = variant.name
|
|
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
|
|
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",
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|