mirror of
https://github.com/BrendanGreenlee/openclaw-android-heartbeat.git
synced 2026-08-17 16:49:14 +00:00
Import official OpenClaw Android app (apps/android @ 71a59512ba476df3328cf485d84748121cf341f2)
Pristine fork source for PROJ-0088. v1 will turn this into a WebView web shell; the WebSocket node infrastructure stays intact for v2 heartbeat.
This commit is contained in:
commit
7a380c40ed
656 changed files with 209982 additions and 0 deletions
478
app/build.gradle.kts
Normal file
478
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
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)
|
||||
}
|
||||
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue