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:
Brendan Greenlee 2026-08-01 15:13:34 +00:00
commit 7a380c40ed
656 changed files with 209982 additions and 0 deletions

478
app/build.gradle.kts Normal file
View 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)
}
}
}

13
app/lint.xml Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<lint>
<issue id="AndroidGradlePluginVersion" severity="ignore" />
<issue id="GradleDependency" severity="ignore" />
<issue id="IconLauncherShape" severity="ignore" />
<issue id="NewerVersionAvailable" severity="ignore" />
<!-- OpenClaw uses date-based version codes (yyyyMMddNN), which are high but still below the Android max. -->
<issue id="HighAppVersionCode" severity="ignore" />
<!-- Target SDK follows the current release train; bump only after platform compatibility testing. -->
<issue id="OldTargetApi" severity="ignore" />
</lint>

8
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,8 @@
-dontwarn org.bouncycastle.**
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn com.sun.jna.**
-dontwarn javax.naming.**
-dontwarn lombok.Generated
-dontwarn org.slf4j.impl.StaticLoggerBinder
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor

View file

@ -0,0 +1,273 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "924cec9afdb455dced2592399a08f5da",
"entities": [
{
"tableName": "outbox_commands",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `text` TEXT NOT NULL, `thinkingLevel` TEXT NOT NULL, `createdAtMs` INTEGER NOT NULL, `status` TEXT NOT NULL, `retryCount` INTEGER NOT NULL, `lastError` TEXT, `gatedEpoch` INTEGER, `ownerAgentId` TEXT, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thinkingLevel",
"columnName": "thinkingLevel",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAtMs",
"columnName": "createdAtMs",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "retryCount",
"columnName": "retryCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastError",
"columnName": "lastError",
"affinity": "TEXT"
},
{
"fieldPath": "gatedEpoch",
"columnName": "gatedEpoch",
"affinity": "INTEGER"
},
{
"fieldPath": "ownerAgentId",
"columnName": "ownerAgentId",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "outbox_attachments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, `position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, `durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "commandId",
"columnName": "commandId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mimeType",
"columnName": "mimeType",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fileName",
"columnName": "fileName",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "durationMs",
"columnName": "durationMs",
"affinity": "INTEGER"
},
{
"fieldPath": "byteLength",
"columnName": "byteLength",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_outbox_attachments_commandId",
"unique": false,
"columnNames": [
"commandId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `${TABLE_NAME}` (`commandId`)"
}
]
},
{
"tableName": "outbox_attachment_chunks",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`attachmentId` TEXT NOT NULL, `chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))",
"fields": [
{
"fieldPath": "attachmentId",
"columnName": "attachmentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "chunkIndex",
"columnName": "chunkIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bytes",
"columnName": "bytes",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"attachmentId",
"chunkIndex"
]
}
},
{
"tableName": "composer_send_admissions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "ownerAgentId",
"columnName": "ownerAgentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "client_state_metadata",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
},
{
"tableName": "gateway_removals",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `phase` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "phase",
"columnName": "phase",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '924cec9afdb455dced2592399a08f5da')"
]
}
}

View file

@ -0,0 +1,146 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "b40806fd03e11cc1094f31253e9432fb",
"entities": [
{
"tableName": "cached_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "updatedAtMs",
"columnName": "updatedAtMs",
"affinity": "INTEGER"
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey"
]
}
},
{
"tableName": "cached_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "textPartsJson",
"columnName": "textPartsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestampMs",
"columnName": "timestampMs",
"affinity": "INTEGER"
},
{
"fieldPath": "idempotencyKey",
"columnName": "idempotencyKey",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey",
"rowOrder"
]
}
},
{
"tableName": "cached_gateway_owners",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b40806fd03e11cc1094f31253e9432fb')"
]
}
}

View file

@ -0,0 +1,177 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "6faa4088ccfb9dd4dd8bc3e6619de163",
"entities": [
{
"tableName": "cached_sessions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `displayName` TEXT, `updatedAtMs` INTEGER, `status` TEXT, `startedAt` INTEGER, `endedAt` INTEGER, `runtimeMs` INTEGER, `outputTokens` INTEGER, `hasRunMetadata` INTEGER NOT NULL, `rowOrder` INTEGER NOT NULL, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "displayName",
"columnName": "displayName",
"affinity": "TEXT"
},
{
"fieldPath": "updatedAtMs",
"columnName": "updatedAtMs",
"affinity": "INTEGER"
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT"
},
{
"fieldPath": "startedAt",
"columnName": "startedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "endedAt",
"columnName": "endedAt",
"affinity": "INTEGER"
},
{
"fieldPath": "runtimeMs",
"columnName": "runtimeMs",
"affinity": "INTEGER"
},
{
"fieldPath": "outputTokens",
"columnName": "outputTokens",
"affinity": "INTEGER"
},
{
"fieldPath": "hasRunMetadata",
"columnName": "hasRunMetadata",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey"
]
}
},
{
"tableName": "cached_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, `rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, `timestampMs` INTEGER, `idempotencyKey` TEXT, PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionKey",
"columnName": "sessionKey",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rowOrder",
"columnName": "rowOrder",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "textPartsJson",
"columnName": "textPartsJson",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestampMs",
"columnName": "timestampMs",
"affinity": "INTEGER"
},
{
"fieldPath": "idempotencyKey",
"columnName": "idempotencyKey",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId",
"agentId",
"sessionKey",
"rowOrder"
]
}
},
{
"tableName": "cached_gateway_owners",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
"fields": [
{
"fieldPath": "gatewayId",
"columnName": "gatewayId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "agentId",
"columnName": "agentId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"gatewayId"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6faa4088ccfb9dd4dd8bc3e6619de163')"
]
}
}

View file

@ -0,0 +1,274 @@
package ai.openclaw.app.ui
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.ui.chat.ChatWidgetExportDestination
import ai.openclaw.app.ui.chat.exportChatWidgetImage
import ai.openclaw.app.ui.chat.widgetExportFileName
import android.content.ClipboardManager
import android.content.ContentUris
import android.content.pm.ActivityInfo
import android.graphics.BitmapFactory
import android.os.SystemClock
import android.provider.MediaStore
import android.view.View
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class CanvasHostLifecycleTest {
@get:Rule
val activityRule = ActivityScenarioRule(CanvasLifecycleTestActivity::class.java)
@Before
fun resetMetrics() {
CanvasLifecycleTestMetrics.reset()
}
@Test
fun hiddenHostRetainsOneWebViewWithoutBlockingShellInput() {
activityRule.scenario.onActivity { activity ->
assertEquals(CanvasController.PresentationState.Unmounted, activity.controller.presentationState.value)
assertNull(activity.host)
}
val presentElapsedMs = activityRule.scenario.readActivity { activity -> activity.presentSlowPage() }
assertTrue(
"present waited for the remote page: ${presentElapsedMs}ms",
presentElapsedMs < canvasLifecycleSlowPageDelayMs / 2,
)
assertTrue("slow page never finished", activityRule.scenario.waitForPageFinished())
val firstWebView =
activityRule.scenario.readActivity { activity ->
val host = checkNotNull(activity.host)
assertEquals(1, host.childCount)
assertEquals(CanvasController.PresentationState.Visible, activity.controller.presentationState.value)
val webView = checkNotNull(activity.currentWebView())
activity.hideCanvas()
assertEquals(CanvasController.PresentationState.Hidden, activity.controller.presentationState.value)
webView
}
assertTrue(
"hidden host remained visible",
activityRule.scenario.waitUntilActivity { activity -> activity.host?.visibility == View.INVISIBLE },
)
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
assertTrue(device.click(device.displayWidth / 2, device.displayHeight / 2))
device.waitForIdle()
activityRule.scenario.onActivity { activity ->
assertEquals(1, activity.underlayClickCount)
repeat(3) {
activity.presentFastPage()
activity.hideCanvas()
}
assertEquals(1, activity.host?.childCount)
assertTrue(firstWebView === activity.currentWebView())
}
}
@Test
fun stalePageCompletionCannotReshowCanvasAfterHide() {
activityRule.scenario.onActivity { activity -> activity.presentSlowPage() }
assertTrue(
"Canvas host was not attached",
activityRule.scenario.waitUntilActivity { activity -> activity.currentWebView() != null },
)
activityRule.scenario.onActivity { activity -> activity.hideCanvas() }
assertTrue(
"hidden host remained visible",
activityRule.scenario.waitUntilActivity { activity -> activity.host?.visibility == View.INVISIBLE },
)
assertTrue("slow page never finished", activityRule.scenario.waitForPageFinished())
activityRule.scenario.onActivity { activity ->
assertEquals(CanvasController.PresentationState.Hidden, activity.controller.presentationState.value)
assertEquals(View.INVISIBLE, checkNotNull(activity.host).visibility)
assertNotNull(activity.currentWebView())
}
}
@Test
fun rendererTerminationForgetsFailedPageAndNextShowRecreatesIt() {
activityRule.scenario.onActivity { activity -> activity.presentFastPage() }
assertTrue("initial page never finished", activityRule.scenario.waitForPageFinished())
val firstWebView =
activityRule.scenario.readActivity { activity ->
assertNotNull(activity.controller.currentUrl())
checkNotNull(activity.currentWebView())
}
val terminated =
activityRule.scenario.readActivity { activity ->
activity.currentWebView()?.webViewRenderProcess?.terminate() == true
}
assertTrue("WebView renderer did not terminate", terminated)
assertTrue(
"renderer loss did not clear the invalid WebView",
activityRule.scenario.waitUntilActivity { activity -> activity.currentWebView() == null },
)
activityRule.scenario.onActivity { activity ->
assertEquals(CanvasController.PresentationState.Hidden, activity.controller.presentationState.value)
assertNull(activity.controller.currentUrl())
assertEquals(0, activity.host?.childCount)
activity.showCanvas()
}
assertTrue(
"next show did not create a replacement WebView",
activityRule.scenario.waitUntilActivity { activity -> activity.currentWebView() != null },
)
assertTrue("replacement scaffold never finished", activityRule.scenario.waitForPageFinished())
activityRule.scenario.onActivity { activity ->
assertEquals(CanvasController.PresentationState.Visible, activity.controller.presentationState.value)
assertEquals(1, activity.host?.childCount)
assertNotEquals(firstWebView, activity.currentWebView())
}
}
@Test
fun configurationChangesKeepTheSameHostAndWebView() {
activityRule.scenario.onActivity { activity -> activity.presentFastPage() }
assertTrue("initial page never finished", activityRule.scenario.waitForPageFinished())
val firstHost = activityRule.scenario.readActivity { activity -> checkNotNull(activity.host) }
val firstWebView = activityRule.scenario.readActivity { activity -> checkNotNull(activity.currentWebView()) }
activityRule.scenario.onActivity { activity ->
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()).waitForIdle()
activityRule.scenario.onActivity { activity ->
assertTrue(firstHost === activity.host)
assertTrue(firstWebView === activity.currentWebView())
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
@Test
fun renderedWebViewExportsPngToClipboardAndDownloads() {
activityRule.scenario.onActivity { activity -> activity.presentFastPage() }
assertTrue("widget proof page never finished", activityRule.scenario.waitForPageFinished())
val activity = activityRule.scenario.readActivity { it }
val webView = activityRule.scenario.readActivity { checkNotNull(it.currentWebView()) }
val title = "Widget export proof ${UUID.randomUUID()}"
val fileName = widgetExportFileName(title)
val clipboard = activity.getSystemService(ClipboardManager::class.java)
var downloadsUri: android.net.Uri? = null
try {
runBlocking {
exportChatWidgetImage(activity, webView, title, ChatWidgetExportDestination.Clipboard)
}
val clipboardUri = checkNotNull(clipboard.primaryClip?.getItemAt(0)?.uri)
assertEquals("image/png", activity.contentResolver.getType(clipboardUri))
activity.contentResolver.openInputStream(clipboardUri).use { input ->
val bitmap = checkNotNull(BitmapFactory.decodeStream(input))
assertTrue(bitmap.width > 0)
assertTrue(bitmap.height > 0)
bitmap.recycle()
}
runBlocking {
exportChatWidgetImage(activity, webView, title, ChatWidgetExportDestination.Downloads)
}
downloadsUri =
activity.contentResolver
.query(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.MediaColumns._ID),
"${MediaStore.MediaColumns.DISPLAY_NAME} = ?",
arrayOf(fileName),
null,
).use { cursor ->
checkNotNull(cursor)
assertTrue(cursor.moveToFirst())
ContentUris.withAppendedId(
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)),
)
}
assertEquals("image/png", activity.contentResolver.getType(downloadsUri))
activity.contentResolver.openInputStream(downloadsUri).use { input ->
val bitmap = checkNotNull(BitmapFactory.decodeStream(input))
assertTrue(bitmap.width > 0)
assertTrue(bitmap.height > 0)
bitmap.recycle()
}
} finally {
downloadsUri?.let { activity.contentResolver.delete(it, null, null) }
clipboard.clearPrimaryClip()
File(activity.cacheDir, "exports")
.walkTopDown()
.firstOrNull { it.isFile && it.name == fileName }
?.parentFile
?.deleteRecursively()
}
}
}
@RunWith(AndroidJUnit4::class)
class CanvasHostReleaseTest {
@Before
fun resetMetrics() {
CanvasLifecycleTestMetrics.reset()
}
@Test
fun activityTeardownReleasesTheHostAndWebView() {
ActivityScenario.launch(CanvasLifecycleTestActivity::class.java).use { scenario ->
scenario.onActivity { activity -> activity.presentFastPage() }
}
assertTrue(
"AndroidView onRelease was not called",
waitUntil { CanvasLifecycleTestMetrics.hostReleaseCount.get() == 1 },
)
assertEquals(1, CanvasLifecycleTestMetrics.webViewDestroyCount.get())
}
}
private inline fun <T> ActivityScenario<CanvasLifecycleTestActivity>.readActivity(crossinline block: (CanvasLifecycleTestActivity) -> T): T {
var result: Result<T>? = null
onActivity { activity -> result = runCatching { block(activity) } }
return checkNotNull(result).getOrThrow()
}
private fun ActivityScenario<CanvasLifecycleTestActivity>.waitForPageFinished(): Boolean = waitUntilActivity { activity -> activity.currentWebView()?.progress == 100 }
private inline fun ActivityScenario<CanvasLifecycleTestActivity>.waitUntilActivity(
timeoutMs: Long = 5_000L,
crossinline predicate: (CanvasLifecycleTestActivity) -> Boolean,
): Boolean =
waitUntil(timeoutMs) {
readActivity(predicate)
}
private fun waitUntil(
timeoutMs: Long = 5_000L,
predicate: () -> Boolean,
): Boolean {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
if (predicate()) return true
SystemClock.sleep(20)
}
return predicate()
}

View file

@ -0,0 +1,32 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="${applicationId}.permission.RUN_VOICE_E2E"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.RUN_VOICE_E2E" />
<application>
<!-- Compose/Robolectric needs a generic host; the app theme keeps AppCompat dialog tests valid. -->
<activity
android:name="androidx.activity.ComponentActivity"
android:exported="false"
android:theme="@style/Theme.OpenClawNode" />
<activity
android:name=".ui.CanvasLifecycleTestActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation"
android:exported="false"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
<receiver
android:name=".VoiceE2eReceiver"
android:permission="${applicationId}.permission.RUN_VOICE_E2E"
android:exported="false">
<intent-filter>
<action android:name="ai.openclaw.app.debug.RUN_VOICE_E2E" />
</intent-filter>
</receiver>
<service
android:name=".VoiceE2eService"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,242 @@
package ai.openclaw.app
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.util.Base64
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import java.io.File
private const val tag = "VoiceE2E"
private const val resultFileName = "voice_e2e_result.json"
class VoiceE2eReceiver : BroadcastReceiver() {
override fun onReceive(
context: Context,
intent: Intent,
) {
context.startService(
Intent(context, VoiceE2eService::class.java)
.putExtras(intent),
)
}
}
class VoiceE2eService : Service() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
val command = intent ?: return START_NOT_STICKY
serviceScope.launch {
try {
runCommand(command)
} finally {
stopSelf(startId)
}
}
return START_NOT_STICKY
}
override fun onDestroy() {
serviceScope.cancel()
super.onDestroy()
}
private suspend fun runCommand(intent: Intent) {
try {
val app = applicationContext as NodeApp
val runtime = app.ensureRuntime()
val mode =
intent
.getDecodedStringExtra("mode")
?.trim()
.orEmpty()
.ifEmpty { "both" }
if (mode == "stop") {
runtime.cancelMicCapture()
runtime.setTalkModeEnabled(false)
writeResult("""{"ok":true,"mode":"stop"}""")
return
}
val connect = !intent.getBooleanExtra("noConnect", false)
val connectTimeoutMs = intent.getLongExtra("connectTimeoutMs", 20_000L)
if (connect) {
configureGateway(runtime = runtime, intent = intent)
}
if (connect || !runtime.isConnected.value) {
awaitGateway(runtime = runtime, timeoutMs = connectTimeoutMs)
}
startActivity(
Intent(actionOpenVoiceE2e)
.setClass(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
)
if (mode == "connect") {
val resultJson = """{"ok":true,"mode":"connect","connected":true}"""
writeResult(resultJson)
Log.i(tag, "PASS $resultJson")
return
}
val transcript =
intent
.getDecodedStringExtra("transcript")
?.trim()
.orEmpty()
.ifEmpty { "Reply exactly: Android voice e2e normal path ok." }
val realtimeReply =
intent
.getDecodedStringExtra("realtimeAssistant")
?.trim()
.orEmpty()
.ifEmpty { "Android realtime voice e2e relay path ok." }
val timeoutMs = intent.getLongExtra("timeoutMs", 60_000L)
val result =
runtime.runVoiceE2e(
mode = mode,
transcript = transcript,
realtimeAssistantText = realtimeReply,
timeoutMs = timeoutMs,
)
val resultJson = encodeResult(result)
writeResult(resultJson)
Log.i(tag, "PASS $resultJson")
} catch (err: Throwable) {
val resultJson =
buildJsonObject {
put("ok", JsonPrimitive(false))
put("error", JsonPrimitive(err.message ?: err::class.java.simpleName))
}.toString()
writeResult(resultJson)
Log.e(tag, "FAIL $resultJson", err)
}
}
private fun configureGateway(
runtime: NodeRuntime,
intent: Intent,
) {
val host =
intent
.getDecodedStringExtra("host")
?.trim()
.orEmpty()
.ifEmpty { "127.0.0.1" }
val port = intent.getIntExtra("port", 18789)
runtime.setManualEnabled(true)
runtime.setManualHost(host)
runtime.setManualPort(port)
runtime.setManualTls(intent.getBooleanExtra("tls", false))
runtime.setOnboardingCompleted(true)
runtime.connect(
ai.openclaw.app.gateway.GatewayEndpoint
.manual(host, port),
NodeRuntime.GatewayConnectAuth(
token = intent.getDecodedStringExtra("token"),
bootstrapToken = intent.getDecodedStringExtra("bootstrapToken"),
password = intent.getDecodedStringExtra("password"),
),
)
}
private suspend fun awaitGateway(
runtime: NodeRuntime,
timeoutMs: Long,
) {
try {
withTimeout(timeoutMs) {
while (!runtime.isConnected.value) {
voiceE2eTerminalGatewayFailure(runtime.gatewayConnectionProblem.value)?.let { error(it) }
delay(100L)
}
}
} catch (err: TimeoutCancellationException) {
throw IllegalStateException(
voiceE2eGatewayTimeoutMessage(
timeoutMs = timeoutMs,
statusText = runtime.statusText.value,
problem = runtime.gatewayConnectionProblem.value,
),
err,
)
}
}
private fun encodeResult(result: NodeRuntime.VoiceE2eResult): String =
buildJsonObject {
put("ok", JsonPrimitive(true))
put("normal", result.normal?.let(::encodeSlice) ?: JsonNull)
put("realtime", result.realtime?.let(::encodeSlice) ?: JsonNull)
}.toString()
private fun encodeSlice(slice: NodeRuntime.VoiceE2eSliceResult) =
buildJsonObject {
put("mode", JsonPrimitive(slice.mode))
put("status", JsonPrimitive(slice.status))
put("userText", slice.userText?.let(::JsonPrimitive) ?: JsonNull)
put("assistantText", slice.assistantText?.let(::JsonPrimitive) ?: JsonNull)
}
private fun writeResult(json: String) {
File(cacheDir, resultFileName).writeText(json)
}
}
private fun Intent.getDecodedStringExtra(name: String): String? {
val encoded = getStringExtra("${name}Base64")
if (!encoded.isNullOrBlank()) {
return String(Base64.decode(encoded, Base64.NO_WRAP), Charsets.UTF_8)
}
return getStringExtra(name)
}
internal fun voiceE2eTerminalGatewayFailure(problem: GatewayConnectionProblem?): String? =
problem
?.takeIf { it.pauseReconnect && !it.canAutoRetry }
?.message
?.trim()
?.takeIf { it.isNotEmpty() }
internal fun voiceE2eGatewayTimeoutMessage(
timeoutMs: Long,
statusText: String,
problem: GatewayConnectionProblem?,
): String {
val detail =
problem
?.message
?.trim()
?.takeIf { it.isNotEmpty() }
?: statusText.trim().takeIf { it.isNotEmpty() }
return buildString {
append("Gateway connection timed out after ")
append(timeoutMs)
append(" ms")
if (detail != null) {
append(": ")
append(detail)
}
}
}

View file

@ -0,0 +1,129 @@
package ai.openclaw.app.ui
import ai.openclaw.app.node.CanvasController
import android.os.Bundle
import android.os.SystemClock
import android.util.Base64
import android.webkit.WebView
import android.widget.Button
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import java.util.concurrent.atomic.AtomicInteger
const val canvasLifecycleSlowPageDelayMs = 2_000L
class CanvasLifecycleTestActivity : ComponentActivity() {
val controller = CanvasController()
internal var host: CanvasHostView? = null
internal set
var underlayClickCount: Int = 0
private set
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CanvasLifecycleTestContent(
activity = this,
onUnderlayClick = { underlayClickCount += 1 },
)
}
}
fun presentSlowPage(): Long = presentHtml(slowPageHtml)
fun presentFastPage(): Long = presentHtml("<html><body>ready</body></html>")
fun hideCanvas() {
controller.hide()
}
fun showCanvas() {
controller.show()
}
fun currentWebView(): WebView? = host?.currentWebView
private fun presentHtml(html: String): Long {
val startedAt = SystemClock.elapsedRealtime()
val encoded = Base64.encodeToString(html.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
controller.navigate("data:text/html;base64,$encoded")
controller.show()
return SystemClock.elapsedRealtime() - startedAt
}
}
@Composable
private fun CanvasLifecycleTestContent(
activity: CanvasLifecycleTestActivity,
onUnderlayClick: () -> Unit,
) {
val state by activity.controller.presentationState.collectAsState()
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
Button(context).apply {
setOnClickListener { onUnderlayClick() }
}
},
modifier = Modifier.fillMaxSize(),
)
if (state != CanvasController.PresentationState.Unmounted) {
AndroidView(
factory = { context ->
CanvasHostView(
context = context,
controller = activity.controller,
isTrustedPage = { false },
onA2uiMessage = {},
).also { host ->
activity.host = host
host.updateVisible(state == CanvasController.PresentationState.Visible)
}
},
update = { host ->
host.updateVisible(state == CanvasController.PresentationState.Visible)
},
modifier = Modifier.fillMaxSize(),
onRelease = { host ->
val hadWebView = host.currentWebView != null
host.release()
if (activity.host === host) activity.host = null
CanvasLifecycleTestMetrics.hostReleaseCount.incrementAndGet()
if (hadWebView) CanvasLifecycleTestMetrics.webViewDestroyCount.incrementAndGet()
},
)
}
}
}
object CanvasLifecycleTestMetrics {
val hostReleaseCount = AtomicInteger()
val webViewDestroyCount = AtomicInteger()
fun reset() {
hostReleaseCount.set(0)
webViewDestroyCount.set(0)
}
}
private val slowPageHtml =
"""
<html>
<body>
<script>
const deadline = Date.now() + $canvasLifecycleSlowPageDelayMs;
while (Date.now() < deadline) {}
</script>
ready
</body>
</html>
""".trimIndent()

View file

@ -0,0 +1,160 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<application
android:name=".NodeApp"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.OpenClawNode">
<service
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="${nodeForegroundServiceType}" />
<service
android:name=".wear.WearProxyListenerService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/openclaw/wear/v1/request" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.gms.wearable.CHANNEL_EVENT" />
<data
android:scheme="wear"
android:host="*"
android:path="/openclaw/wear/v1/realtime/audio" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.gms.wearable.CHANNEL_EVENT" />
<data
android:scheme="wear"
android:host="*"
android:pathPrefix="/openclaw/wear/v1/realtime/audio/" />
</intent-filter>
</service>
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
<service
android:name=".node.DeviceNotificationListenerService"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- External sender tasks must converge on one visible composer and share queue. -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="audio/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="video/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="application/pdf" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" />
<data android:mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation" />
<data android:mimeType="text/csv" />
<data android:mimeType="text/markdown" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,37 @@
# KaTeX Android assets
- Version: KaTeX 0.17.0 (`v0.17.0`, released 2026-05-22)
- Source: https://github.com/KaTeX/KaTeX/releases/tag/v0.17.0
- Release archive: https://github.com/KaTeX/KaTeX/releases/download/v0.17.0/katex.tar.gz
- Published and verified archive SHA-256: `f77cf2555a40e879a4784e43e229d41867f106125bc05b15161de3e761b64b88`
Only the minified runtime, its stylesheet, and the WOFF2 fonts referenced by that stylesheet are copied from the release archive. `index.html` and `renderer.js` are OpenClaw's local rendering shell. No auto-render extension, mhchem extension, source map, WOFF, or TTF file is bundled.
## SHA-256
```text
0cdd387c9590a1a9f9794560022dbb59654a7d86f187aa0c81495ad42d3a7308 fonts/KaTeX_AMS-Regular.woff2
de7701e42cf1f4cf0b766c03fb27977207eee2f4fd5d76fa82188406da43ea4c fonts/KaTeX_Caligraphic-Bold.woff2
5d53e70ad607c2352162dec9e0923fb54ecdafaccbf604cd8dcf7d00facb989b fonts/KaTeX_Caligraphic-Regular.woff2
74444efd593c005e3f4573b44524704c0af0a937fe911cca9e94068d0d140d3f fonts/KaTeX_Fraktur-Bold.woff2
51814d270d06ff0255dba0799994fa4d8c84d11f09951d47595f4abb1f3602dc fonts/KaTeX_Fraktur-Regular.woff2
0f60d1b897938ec918c8ce073092411baf9438f6739465693ff18b0f9d20b021 fonts/KaTeX_Main-Bold.woff2
99cd42a3c072d918f2f44984a807cf7aa16e13545fd0875fc07c6c65f99e715b fonts/KaTeX_Main-BoldItalic.woff2
97479ca6cce906abc961ecac96faa5f9ca2e61b8e7670d475826bcdee9a7c267 fonts/KaTeX_Main-Italic.woff2
c2342cd8b869e01752a9321dc17213fc40d4d04c79688c1d43f2cf316abd7866 fonts/KaTeX_Main-Regular.woff2
dc47344dbb6cb5b655c8460d561f4df5f501b90c804ad3c6cec65fe322351ab1 fonts/KaTeX_Math-BoldItalic.woff2
7af58c5ec8f132a2ddde9027c6d7814decce4d3b822a11192a42a20e2e973264 fonts/KaTeX_Math-Italic.woff2
e99ae51144bf1232efcc1bfe5add36262c6866b0faab24fa75740e1b98577a62 fonts/KaTeX_SansSerif-Bold.woff2
00b26ac825e2095056396e0553b8ac26d3f8ad158c3826e28b4c45b385c4714a fonts/KaTeX_SansSerif-Italic.woff2
68e8c73ef42afd3ccec58bf0fba302cce448938e7fc020a5e31f8a952eee1342 fonts/KaTeX_SansSerif-Regular.woff2
036d4e95149b69ff9bcc0cd55771efeb25ffa3947293e69acd78d5ac328c684b fonts/KaTeX_Script-Regular.woff2
6b47c40166b6dbe21a5dfca7718413f2147fd2399be1ba605d8ad39cedf25dfe fonts/KaTeX_Size1-Regular.woff2
d04c54219f9eaec6d4d4fd42dfb28785975a4794d6b2fc71e566b9cd6db842dd fonts/KaTeX_Size2-Regular.woff2
73d591271b1604960cb10bb90fee021670af7297017e0e98480b332d11f51995 fonts/KaTeX_Size3-Regular.woff2
a4af7d414440a1c1790825cfb700cf9cf43b0f2c4b04f0ebc523011ad9853ec0 fonts/KaTeX_Size4-Regular.woff2
71d517d67827787cfabdf186914cc3358eda539e37931941f2b2fd4a21f68c0b fonts/KaTeX_Typewriter-Regular.woff2
e73bea6d899a3f89595d37f5713cb5ddabc4084fc428a5931782628ee14799e2 index.html
a34ad8fc188e8f5a3af7ceaa2a58d7210c6c9171335a15bff2b48ebcd6a6f5b0 katex.min.css
45fbe318fea878fdc0a111913dc1f87894b2c439360d0228c086ef313f213efc katex.min.js
3cefe6fba565e189311b29265eb842484a637e1a80b0b5e766ab05bfc9ad452f renderer.js
```

Binary file not shown.

View file

@ -0,0 +1,20 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'"
>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="katex.min.css">
<style>
html, body { margin: 0; padding: 0; overflow: hidden; background: transparent; }
#math { box-sizing: border-box; display: inline-block; min-width: 100%; width: max-content; padding: 2px 1px; }
.katex-display { margin: 0; }
</style>
<script defer src="katex.min.js"></script>
<script defer src="renderer.js"></script>
</head>
<body><div id="math"></div></body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,34 @@
"use strict";
window.renderMath = async (job) => {
const container = document.getElementById("math");
try {
document.body.style.width = `${job.widthCssPx}px`;
container.style.color = job.color;
container.style.fontSize = `${job.fontSizeCssPx}px`;
container.replaceChildren();
katex.render(job.latex, container, {
displayMode: true,
maxExpand: 1000,
maxSize: 10,
strict: "ignore",
throwOnError: true,
trust: false,
});
await document.fonts.ready;
await new Promise((resolve) => setTimeout(resolve, 0));
const initialBounds = container.getBoundingClientRect();
const width = Math.ceil(Math.max(initialBounds.width, container.scrollWidth));
document.body.style.width = `${width}px`;
await new Promise((resolve) => setTimeout(resolve, 0));
const finalBounds = container.getBoundingClientRect();
const height = Math.ceil(Math.max(finalBounds.height, container.scrollHeight));
window.ChatMathBridge.postMessage(
JSON.stringify({ id: job.id, widthCssPx: width, heightCssPx: height, success: true }),
);
} catch (_) {
window.ChatMathBridge.postMessage(
JSON.stringify({ id: job.id, widthCssPx: 0, heightCssPx: 0, success: false }),
);
}
};

View file

@ -0,0 +1,40 @@
package ai.openclaw.app
import android.content.res.AssetManager
internal const val ANDROID_LICENSE_ASSET_DIRECTORY = "openclaw/licenses"
internal data class AndroidLicenseNotice(
val title: String,
val fileName: String,
val text: String,
)
internal fun loadAndroidLicenseNotices(assetManager: AssetManager): List<AndroidLicenseNotice> {
val files =
assetManager
.list(ANDROID_LICENSE_ASSET_DIRECTORY)
.orEmpty()
.filter(::isAndroidLicenseFileName)
return files
.map { fileName ->
val rawText =
assetManager
.open("$ANDROID_LICENSE_ASSET_DIRECTORY/$fileName")
.bufferedReader(Charsets.UTF_8)
.use { reader -> reader.readText() }
AndroidLicenseNotice(title = androidLicenseTitleFromFileName(fileName), fileName = fileName, text = rawText)
}.sortedWith(
compareBy<AndroidLicenseNotice, String>(String.CASE_INSENSITIVE_ORDER) { notice -> notice.title }
.thenBy(String.CASE_INSENSITIVE_ORDER) { notice -> notice.fileName },
)
}
internal fun isAndroidLicenseFileName(fileName: String): Boolean = fileName.endsWith(".txt", ignoreCase = true)
internal fun androidLicenseTitleFromFileName(fileName: String): String =
fileName
.substringBeforeLast('.')
.trim()
.ifBlank { "License" }

View file

@ -0,0 +1,436 @@
package ai.openclaw.app
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal object AndroidScreenshotFixture {
@Volatile private var scene: AndroidScreenshotScene = AndroidScreenshotScene.Home
fun configure(scene: AndroidScreenshotScene) {
this.scene = scene
}
const val gatewayId = "android-screenshot-gateway"
const val mainSessionKey = "agent:main:node-screenshot"
const val primarySessionTitle = "Android release planning"
const val cronJobId = "android-release-digest"
const val cronJobName = "Android release digest"
val agents =
listOf(
GatewayAgentSummary(
id = "main",
name = "Molty",
emoji = "M",
),
)
val models =
listOf(
GatewayModelSummary(
id = "gpt-5.2",
name = "GPT-5.2",
provider = "openai",
available = true,
supportsVision = true,
supportsAudio = true,
supportsVideo = true,
supportsDocuments = true,
supportsReasoning = true,
contextTokens = 200_000,
),
)
val providers =
listOf(
GatewayModelProviderSummary(
id = "openai",
displayName = "OpenAI",
status = "ready",
profileCount = 1,
),
)
val nodes =
GatewayNodesDevicesSummary(
nodes =
listOf(
GatewayNodeSummary(
id = "android-screenshot",
displayName = "Pixel",
remoteIp = "100.64.0.24",
version = BuildConfig.VERSION_NAME,
deviceFamily = "Android",
paired = true,
connected = true,
approvalState = GatewayNodeApprovalState.Approved,
pendingRequestId = null,
capabilities = listOf("camera", "location", "notifications"),
commands = emptyList(),
),
),
pendingDevices = emptyList(),
pairedDevices = emptyList(),
)
val channels =
GatewayChannelsSummary(
updatedAtMs = 1_783_555_200_000,
channels =
listOf(
GatewayChannelSummary(
id = "discord",
label = "Discord",
accountCount = 1,
enabled = true,
configured = true,
linked = true,
running = true,
connected = true,
error = null,
),
),
)
fun request(
method: String,
paramsJson: String?,
): String =
when (method) {
"health" -> buildJsonObject { put("ok", JsonPrimitive(true)) }.toString()
"chat.history" -> chatHistory()
"sessions.list" -> sessionList(paramsJson)
"chat.metadata" -> chatMetadata()
"cron.list" -> cronList()
"cron.get" -> cronJob().toString()
"cron.runs" -> cronRuns()
"openclaw.chat" -> systemAgentChat(paramsJson)
else -> error("Screenshot fixture does not implement gateway method $method with params $paramsJson")
}
private fun systemAgentChat(paramsJson: String?): String {
val message =
paramsJson
?.let { Json.parseToJsonElement(it).jsonObject["message"] }
?.jsonPrimitive
?.contentOrNull
return buildJsonObject {
put("sessionId", JsonPrimitive("android-screenshot-openclaw"))
put(
"reply",
JsonPrimitive(
if (message == null) {
"I can check Gateway status, repair configuration, change models, or connect channels."
} else {
"Ill keep this conversation separate from ordinary agent chat."
},
),
)
put("action", JsonPrimitive("none"))
if (message == null) {
put(
"question",
buildJsonObject {
put("id", JsonPrimitive("help"))
put("header", JsonPrimitive("OpenClaw"))
put("question", JsonPrimitive("What should we look at first?"))
put(
"options",
buildJsonArray {
add(
buildJsonObject {
put("label", JsonPrimitive("Check status"))
put("description", JsonPrimitive("Review the Gateway and active services."))
put("recommended", JsonPrimitive(true))
put("reply", JsonPrimitive("Check Gateway status"))
},
)
add(
buildJsonObject {
put("label", JsonPrimitive("Review setup"))
put("description", JsonPrimitive("Inspect models, channels, and configuration."))
put("reply", JsonPrimitive("Review setup"))
},
)
},
)
},
)
}
}.toString()
}
private fun cronList(): String =
buildJsonObject {
put(
"jobs",
buildJsonArray {
add(cronJob())
},
)
}.toString()
private fun cronJob() =
buildJsonObject {
put("id", JsonPrimitive(cronJobId))
put("name", JsonPrimitive(cronJobName))
put("enabled", JsonPrimitive(true))
put("createdAtMs", JsonPrimitive(1_783_468_800_000))
put("updatedAtMs", JsonPrimitive(1_783_555_200_000))
put("configRevision", JsonPrimitive("sha256:screenshot-fixture"))
put(
"schedule",
buildJsonObject {
put("kind", JsonPrimitive("every"))
put("everyMs", JsonPrimitive(86_400_000))
put("anchorMs", JsonPrimitive(1_783_468_800_000))
},
)
put("sessionTarget", JsonPrimitive("isolated"))
put("wakeMode", JsonPrimitive("now"))
put(
"payload",
buildJsonObject {
put("kind", JsonPrimitive("agentTurn"))
put("message", JsonPrimitive("Summarize Android release readiness."))
put("model", JsonPrimitive("openai/gpt-5.2"))
},
)
put(
"state",
buildJsonObject {
put("nextRunAtMs", JsonPrimitive(1_783_641_600_000))
put("lastRunAtMs", JsonPrimitive(1_783_555_200_000))
put("lastStatus", JsonPrimitive("ok"))
put("lastDurationMs", JsonPrimitive(1_842))
put("consecutiveErrors", JsonPrimitive(0))
put("consecutiveSkipped", JsonPrimitive(0))
put("lastDeliveryStatus", JsonPrimitive("delivered"))
},
)
}
private fun cronRuns(): String =
buildJsonObject {
put(
"entries",
buildJsonArray {
add(
buildJsonObject {
put("ts", JsonPrimitive(1_783_555_200_000))
put("jobId", JsonPrimitive(cronJobId))
put("runId", JsonPrimitive("android-release-digest-run-2"))
put("action", JsonPrimitive("finished"))
put("status", JsonPrimitive("ok"))
put("summary", JsonPrimitive("Release checklist ready"))
put("durationMs", JsonPrimitive(1_842))
put("deliveryStatus", JsonPrimitive("delivered"))
put("model", JsonPrimitive("openai/gpt-5.2"))
},
)
add(
buildJsonObject {
put("ts", JsonPrimitive(1_783_468_800_000))
put("jobId", JsonPrimitive(cronJobId))
put("runId", JsonPrimitive("android-release-digest-run-1"))
put("action", JsonPrimitive("finished"))
put("status", JsonPrimitive("error"))
put("error", JsonPrimitive("Play publish blocked"))
put("durationMs", JsonPrimitive(927))
put("deliveryStatus", JsonPrimitive("not-requested"))
put("model", JsonPrimitive("openai/gpt-5.2"))
},
)
},
)
}.toString()
private fun chatHistory(): String =
buildJsonObject {
put("sessionId", JsonPrimitive("screenshot-session"))
put("thinkingLevel", JsonPrimitive("low"))
put(
"messages",
buildJsonArray {
add(chatMessage("user", "What is blocking the Android release?", 1_783_555_020_000))
add(
chatMessage(
"assistant",
"Two review threads are still open on the release branch, and the localization sync needs one more pass. " +
"Once those land, the changelog draft is ready for review and the tag can go out.",
1_783_555_080_000,
),
)
add(chatMessage("user", "Summarize the open review feedback for me.", 1_783_555_140_000))
add(
chatMessage(
"assistant",
"The main thread asks for a regression test around session restore, and the second one wants the new " +
"config key documented before merge. Both are small; I can draft patches for each if you want.",
1_783_555_200_000,
),
)
add(chatMessage("user", "Draft a short status update for the team.", 1_783_555_260_000))
add(
chatMessage(
"assistant",
"The Android release is close. Two review follow-ups and one localization pass remain; once those land, " +
"the changelog can be reviewed and the tag can go out.",
1_783_555_320_000,
),
)
},
)
put(
"sessionInfo",
buildJsonObject {
put("key", JsonPrimitive(mainSessionKey))
put("displayName", JsonPrimitive("New chat"))
put("updatedAt", JsonPrimitive(1_783_555_320_000))
put("unread", JsonPrimitive(false))
put("modelProvider", JsonPrimitive("openai"))
put("model", JsonPrimitive("gpt-5.2"))
put("contextTokens", JsonPrimitive(200_000))
},
)
}.toString()
private fun chatMessage(
role: String,
content: String,
timestamp: Long,
) = buildJsonObject {
put("role", JsonPrimitive(role))
put("content", JsonPrimitive(content))
put("timestamp", JsonPrimitive(timestamp))
}
private fun sessionList(paramsJson: String?): String {
val spawnedBy =
paramsJson
?.let {
runCatching {
Json
.parseToJsonElement(it)
.jsonObject["spawnedBy"]
?.jsonPrimitive
?.contentOrNull
}.getOrNull()
}
if (scene == AndroidScreenshotScene.Swarm && spawnedBy != null) {
val children = swarmChildren(spawnedBy)
return buildJsonObject {
put("sessions", buildJsonArray { children.forEach(::add) })
put("count", JsonPrimitive(children.size))
put("totalCount", JsonPrimitive(children.size))
put("hasMore", JsonPrimitive(false))
}.toString()
}
return buildJsonObject {
put(
"sessions",
buildJsonArray {
add(session("discord:release-planning", primarySessionTitle, 1_783_555_200_000))
add(session("main", "Product notes", 1_783_468_800_000))
add(session("discord:android", "Android QA", 1_783_382_400_000))
},
)
put("totalCount", JsonPrimitive(3))
}.toString()
}
private fun swarmChildren(parentKey: String) =
listOf(
swarmChild("research-polling", "National polling", "done", parentKey),
swarmChild("research-work", "Work and labor", "running", parentKey),
swarmChild("research-health", "Health", "running", parentKey),
swarmChild("research-trust", "Governance and trust", null, parentKey, queued = true),
swarmChild("research-media", "Media signals", "failed", parentKey),
)
private fun swarmChild(
key: String,
label: String,
status: String?,
parentKey: String,
queued: Boolean = false,
) = session("agent:main:subagent:$key", label, 1_783_555_320_000).toMutableMap().let { values ->
buildJsonObject {
values.forEach { (field, value) -> put(field, value) }
put("parentSessionKey", JsonPrimitive(parentKey))
put("spawnedBy", JsonPrimitive(parentKey))
put("swarmGroupId", JsonPrimitive("swarm:$parentKey:research"))
put("swarmPhase", JsonPrimitive("Research"))
put("swarmPhaseRank", JsonPrimitive(0))
put("swarmLog", JsonPrimitive("Comparing labor, education, health, trust, and media signals."))
status?.let { put("status", JsonPrimitive(it)) }
if (queued) put("subagentRunState", JsonPrimitive("active"))
if (status == "running") put("hasActiveRun", JsonPrimitive(true))
}
}
private fun session(
key: String,
displayName: String,
updatedAt: Long,
) = buildJsonObject {
put("key", JsonPrimitive(key))
put("displayName", JsonPrimitive(displayName))
put("updatedAt", JsonPrimitive(updatedAt))
put("lastActivityAt", JsonPrimitive(updatedAt))
put("unread", JsonPrimitive(false))
put("archived", JsonPrimitive(false))
put("category", JsonNull)
put("modelProvider", JsonPrimitive("openai"))
put("model", JsonPrimitive("gpt-5.2"))
put("totalTokens", JsonPrimitive(18_420))
put("contextTokens", JsonPrimitive(200_000))
}
private fun chatMetadata(): String =
buildJsonObject {
put("swarmEnabled", JsonPrimitive(scene == AndroidScreenshotScene.Swarm))
put(
"commands",
buildJsonArray {
add(
buildJsonObject {
put("name", JsonPrimitive("status"))
put("description", JsonPrimitive("Show current OpenClaw status"))
put("acceptsArgs", JsonPrimitive(false))
},
)
},
)
put(
"models",
buildJsonArray {
add(
buildJsonObject {
put("id", JsonPrimitive("gpt-5.2"))
put("name", JsonPrimitive("GPT-5.2"))
put("provider", JsonPrimitive("openai"))
put("available", JsonPrimitive(true))
put("reasoning", JsonPrimitive(true))
put("contextWindow", JsonPrimitive(200_000))
put(
"input",
buildJsonArray {
add(JsonPrimitive("text"))
add(JsonPrimitive("image"))
add(JsonPrimitive("audio"))
add(JsonPrimitive("document"))
},
)
},
)
},
)
}.toString()
}

View file

@ -0,0 +1,33 @@
package ai.openclaw.app
import ai.openclaw.app.ui.SettingsRoute
import android.content.Intent
const val extraAndroidScreenshotMode = "openclaw.screenshotMode"
const val extraAndroidScreenshotScene = "openclaw.screenshotScene"
enum class AndroidScreenshotScene(
val rawValue: String,
val homeDestination: HomeDestination,
internal val settingsRoute: SettingsRoute? = null,
) {
Home("home", HomeDestination.Connect),
Chat("chat", HomeDestination.Chat),
Swarm("swarm", HomeDestination.Chat),
Settings("settings", HomeDestination.Settings),
Gateway("gateway", HomeDestination.Settings, SettingsRoute.Gateway),
OpenClaw("openclaw", HomeDestination.Settings, SettingsRoute.SystemAgent),
VoiceWake("voice-wake", HomeDestination.Settings, SettingsRoute.Voice),
;
companion object {
fun fromRawValue(raw: String?): AndroidScreenshotScene = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Home
}
}
fun parseAndroidScreenshotModeIntent(intent: Intent?): AndroidScreenshotScene? {
if (intent?.getBooleanExtra(extraAndroidScreenshotMode, false) != true) {
return null
}
return AndroidScreenshotScene.fromRawValue(intent.getStringExtra(extraAndroidScreenshotScene))
}

View file

@ -0,0 +1,98 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeStringResources
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.notifyNativeLocaleChanged
import android.content.Context
import android.content.res.Resources
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.app.LocaleManagerCompat
import androidx.core.os.LocaleListCompat
import java.util.Locale
/** Keep these tags aligned with androidResources.localeFilters so Android never offers an unsupported locale. */
internal enum class AppLanguage(
val languageTag: String?,
val displayName: String,
) {
System(languageTag = null, displayName = "System"),
English(languageTag = "en", displayName = "English"),
Arabic(languageTag = "ar", displayName = "العربية"),
German(languageTag = "de", displayName = "Deutsch"),
Spanish(languageTag = "es", displayName = "Español"),
Persian(languageTag = "fa", displayName = "فارسی"),
French(languageTag = "fr", displayName = "Français"),
Hindi(languageTag = "hi", displayName = "हिन्दी"),
Indonesian(languageTag = "id", displayName = "Bahasa Indonesia"),
Italian(languageTag = "it", displayName = "Italiano"),
Japanese(languageTag = "ja", displayName = "日本語"),
Korean(languageTag = "ko", displayName = "한국어"),
Dutch(languageTag = "nl", displayName = "Nederlands"),
Polish(languageTag = "pl", displayName = "Polski"),
PortugueseBrazil(languageTag = "pt-BR", displayName = "Português (Brasil)"),
Russian(languageTag = "ru", displayName = "Русский"),
Swedish(languageTag = "sv", displayName = "Svenska"),
Thai(languageTag = "th", displayName = "ไทย"),
Turkish(languageTag = "tr", displayName = "Türkçe"),
Ukrainian(languageTag = "uk", displayName = "Українська"),
Vietnamese(languageTag = "vi", displayName = "Tiếng Việt"),
ChineseSimplified(languageTag = "zh-CN", displayName = "简体中文"),
ChineseTraditional(languageTag = "zh-TW", displayName = "繁體中文"),
;
companion object {
fun fromLanguageTag(languageTag: String?): AppLanguage {
val locale = languageTag?.trim()?.takeIf(String::isNotEmpty)?.let(Locale::forLanguageTag)
return locale?.let(::fromLocale) ?: System
}
internal fun fromLocale(locale: Locale): AppLanguage? {
val exactTag = locale.toLanguageTag()
val exactMatch = entries.firstOrNull { language -> language.languageTag?.equals(exactTag, ignoreCase = true) == true }
if (exactMatch != null) return exactMatch
return entries.firstOrNull { language ->
val supportedLocale = language.languageTag?.let(Locale::forLanguageTag) ?: return@firstOrNull false
LocaleListCompat.matchesLanguageAndScript(locale, supportedLocale)
}
}
}
}
internal fun appLanguageFromLocales(locales: LocaleListCompat): AppLanguage =
if (locales.isEmpty) {
AppLanguage.System
} else {
(0 until locales.size()).firstNotNullOfOrNull { index -> locales[index]?.let(AppLanguage::fromLocale) }
?: AppLanguage.System
}
internal fun currentAppLanguage(): AppLanguage = appLanguageFromLocales(AppCompatDelegate.getApplicationLocales())
internal fun localesForAppLanguage(language: AppLanguage): LocaleListCompat = language.languageTag?.let(LocaleListCompat::forLanguageTags) ?: LocaleListCompat.getEmptyLocaleList()
internal fun setAppLanguage(language: AppLanguage) {
val locales = localesForAppLanguage(language)
NativeStringResources.setApplicationLocales(locales)
if (locales != AppCompatDelegate.getApplicationLocales()) {
AppCompatDelegate.setApplicationLocales(locales)
notifyNativeLocaleChanged()
}
}
internal fun currentSystemLanguageTag(context: Context): String {
val systemLocales = LocaleManagerCompat.getSystemLocales(context)
val locale = systemLocales[0] ?: Resources.getSystem().configuration.locales[0]
return locale.toLanguageTag()
}
internal fun appLanguageRowSubtitle(
language: AppLanguage,
systemLanguageTag: String,
): String {
val languageTag = language.languageTag
if (languageTag != null) {
return nativeString("OpenClaw translations · \$languageTag", languageTag)
}
return nativeString("Follow Android · \$systemLanguageTag", systemLanguageTag)
}

View file

@ -0,0 +1,25 @@
package ai.openclaw.app
/** User-selectable app theme mode for Android appearance settings. */
enum class AppearanceThemeMode(
val rawValue: String,
val displayLabel: String,
) {
System(rawValue = "system", displayLabel = "System"),
Dark(rawValue = "dark", displayLabel = "Dark"),
Light(rawValue = "light", displayLabel = "Light"),
;
fun isDark(systemDark: Boolean): Boolean =
when (this) {
System -> systemDark
Dark -> true
Light -> false
}
companion object {
fun fromRawValue(value: String?): AppearanceThemeMode = entries.firstOrNull { it.rawValue == value?.trim()?.lowercase() } ?: Dark
fun fromDisplayLabel(label: String): AppearanceThemeMode = entries.firstOrNull { it.displayLabel.equals(label.trim(), ignoreCase = true) } ?: Dark
}
}

View file

@ -0,0 +1,223 @@
package ai.openclaw.app
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import androidx.core.content.IntentCompat
import java.util.Locale
/** Android Assistant entry point used by manifest-declared app actions. */
const val actionAskOpenClaw = "ai.openclaw.app.action.ASK_OPENCLAW"
/** Debug action that opens the Voice tab directly for Android E2E automation. */
const val actionOpenVoiceE2e = "ai.openclaw.app.debug.OPEN_VOICE_E2E"
/** Intent extra that carries an optional assistant prompt for app actions. */
const val extraAssistantPrompt = "prompt"
/**
* Top-level home destinations that external actions may request.
*/
enum class HomeDestination {
Connect,
Chat,
Voice,
Screen,
Settings,
}
/**
* Normalized launch request from Android Assistant or explicit app actions.
*/
data class AssistantLaunchRequest(
val source: String,
val prompt: String?,
val autoSend: Boolean,
)
/** Shared content staged in chat for user review before sending. */
data class ShareLaunchRequest(
val text: String?,
val attachments: List<SharedAttachment>,
val droppedAttachmentCount: Int,
)
enum class SharedAttachmentKind {
Image,
Audio,
Video,
Document,
}
data class SharedAttachment(
val uri: Uri,
val kind: SharedAttachmentKind,
val mimeType: String,
)
private data class SharedAttachmentSelection(
val attachments: List<SharedAttachment>,
val droppedCount: Int,
)
internal val SHARED_ATTACHMENT_MIME_ALLOWLIST =
setOf(
"image/*",
"audio/*",
"video/*",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/csv",
"text/markdown",
)
internal val SHARED_AUDIO_DOCUMENT_MIME_TYPES =
SHARED_ATTACHMENT_MIME_ALLOWLIST.filterNot { it == "image/*" || it == "video/*" }.toTypedArray()
internal val SHARED_VIDEO_MIME_TYPES = arrayOf("video/*")
/**
* Parses app-owned navigation actions that should open a specific home tab.
*/
fun parseHomeDestinationIntent(intent: Intent?): HomeDestination? {
val action = intent?.action ?: return null
return when {
// Debug-only shortcut keeps E2E navigation out of release builds.
BuildConfig.DEBUG && action == actionOpenVoiceE2e -> HomeDestination.Voice
else -> null
}
}
/**
* Parse external assistant entry points without starting any UI side effects.
*/
fun parseAssistantLaunchIntent(intent: Intent?): AssistantLaunchRequest? {
val action = intent?.action ?: return null
return when (action) {
Intent.ACTION_ASSIST ->
AssistantLaunchRequest(
source = "assist",
prompt = null,
autoSend = false,
)
actionAskOpenClaw -> {
val prompt = intent.getStringExtra(extraAssistantPrompt)?.trim()?.ifEmpty { null }
AssistantLaunchRequest(
source = "app_action",
prompt = prompt,
autoSend = false,
)
}
else -> null
}
}
/** Parses Android Sharesheet metadata without opening or reading shared payload bytes. */
fun parseShareLaunchIntent(
intent: Intent?,
resolveMimeType: (Uri) -> String?,
): ShareLaunchRequest? {
val action = intent?.action ?: return null
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
val text =
listOf(intent.getStringExtra(Intent.EXTRA_SUBJECT), intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString())
.mapNotNull { value -> value?.trim()?.takeIf { it.isNotEmpty() } }
.distinct()
.joinToString(separator = "\n\n")
.ifEmpty { null }
val attachmentSelection = sharedAttachments(intent, action, resolveMimeType)
if (text == null && attachmentSelection.attachments.isEmpty() && attachmentSelection.droppedCount == 0) return null
return ShareLaunchRequest(
text = text,
attachments = attachmentSelection.attachments,
droppedAttachmentCount = attachmentSelection.droppedCount,
)
}
private fun sharedAttachments(
intent: Intent,
action: String,
resolveMimeType: (Uri) -> String?,
): SharedAttachmentSelection {
val streamUris =
when (action) {
Intent.ACTION_SEND ->
listOfNotNull(IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java))
Intent.ACTION_SEND_MULTIPLE ->
IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java).orEmpty()
else -> emptyList()
}
val clipUris =
intent.clipData
?.let { clip ->
(0 until clip.itemCount).mapNotNull { index -> clip.getItemAt(index).uri }
}.orEmpty()
// Only provider-backed content URIs use the sender's temporary read grant. Rejecting file://
// prevents an external intent from turning OpenClaw into a reader for its own private files.
val validUris =
(streamUris + clipUris)
.filter { uri -> uri.scheme.equals(ContentResolver.SCHEME_CONTENT, ignoreCase = true) }
.distinct()
val fallbackMimeType =
normalizeSharedAttachmentMimeType(intent.type)
?.takeIf(::isStageableSharedAttachmentMimeType)
val resolved = mutableListOf<SharedAttachment>()
var droppedCount = 0
for ((index, uri) in validUris.withIndex()) {
if (resolved.size >= MAX_SHARED_ATTACHMENT_COUNT) {
droppedCount += validUris.size - index
break
}
val providerMimeType =
try {
normalizeSharedAttachmentMimeType(resolveMimeType(uri))
} catch (_: Exception) {
null
}
val mimeType = providerMimeType ?: fallbackMimeType
val kind = sharedAttachmentKindForMimeType(mimeType)
if (!isStageableSharedAttachmentMimeType(mimeType) || kind == null) {
droppedCount += 1
continue
}
resolved += SharedAttachment(uri = uri, kind = kind, mimeType = requireNotNull(mimeType))
}
return SharedAttachmentSelection(
attachments = resolved,
droppedCount = droppedCount,
)
}
internal fun sharedAttachmentKindForMimeType(mimeType: String?): SharedAttachmentKind? {
val normalized = normalizeSharedAttachmentMimeType(mimeType) ?: return null
return when {
normalized.startsWith("image/") -> SharedAttachmentKind.Image
normalized.startsWith("audio/") -> SharedAttachmentKind.Audio
normalized.startsWith("video/") -> SharedAttachmentKind.Video
normalized in SHARED_ATTACHMENT_MIME_ALLOWLIST -> SharedAttachmentKind.Document
else -> null
}
}
internal fun isStageableSharedAttachmentMimeType(mimeType: String?): Boolean {
val normalized = normalizeSharedAttachmentMimeType(mimeType) ?: return false
val kind = sharedAttachmentKindForMimeType(normalized) ?: return false
return kind == SharedAttachmentKind.Image || !normalized.endsWith("/*")
}
internal fun normalizeSharedAttachmentMimeType(mimeType: String?): String? =
mimeType
?.substringBefore(';')
?.trim()
?.lowercase(Locale.US)
?.takeIf { it.isNotEmpty() }
private const val MAX_SHARED_ATTACHMENT_COUNT = 8

View file

@ -0,0 +1,16 @@
package ai.openclaw.app
/** Camera HUD state categories shown over the Android UI during capture. */
enum class CameraHudKind {
Photo,
Recording,
Success,
Error,
}
/** One-shot camera HUD message keyed by token so repeated text still replays. */
data class CameraHudState(
val token: Long,
val kind: CameraHudKind,
val message: String,
)

View file

@ -0,0 +1,316 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.joinedNativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
data class GatewayCronJobDetail(
val id: String,
val name: String,
val description: String,
val enabled: Boolean,
val deleteAfterRun: Boolean,
val scheduleKind: String,
val scheduleLabel: NativeText,
val scheduleDetail: NativeText,
val scheduleAt: String?,
val scheduleEveryMs: Long?,
val scheduleAnchorMs: Long?,
val scheduleCronExpr: String?,
val scheduleTimezone: String?,
val scheduleStaggerMs: Long?,
val scheduleCommand: String?,
val scheduleCwd: String?,
val sessionTarget: String,
val wakeMode: String,
val payloadKind: String,
val payloadText: String?,
val payloadLabel: NativeText,
val payloadModel: String?,
val payloadThinking: String?,
val payloadCommandArgv: List<String>?,
val payloadCommandCwd: String?,
val deliveryLabel: NativeText,
val failureAlertLabel: NativeText,
val createdAtMs: Long,
val updatedAtMs: Long,
val configRevision: String?,
val nextRunAtMs: Long?,
val runningAtMs: Long?,
val lastRunAtMs: Long?,
val lastRunStatus: String?,
val lastError: String?,
val lastDiagnosticSummary: String?,
val lastDurationMs: Long?,
val consecutiveErrors: Long?,
val consecutiveSkipped: Long?,
val lastDeliveryStatus: String?,
val lastDeliveryError: String?,
)
sealed interface GatewayCronJobDetailState {
data object Idle : GatewayCronJobDetailState
data class Loading(
val id: String,
) : GatewayCronJobDetailState
data class Loaded(
val job: GatewayCronJobDetail,
) : GatewayCronJobDetailState
data class Error(
val id: String,
val message: NativeText,
) : GatewayCronJobDetailState
}
internal data class CronJobDetailRequest(
val id: String,
val generation: Long,
)
/** Couples the selected job id to its generation so older RPCs cannot publish into a new screen. */
internal class CronJobDetailRequestGuard {
private val lock = Any()
private var generation = 0L
private var selectedId: String? = null
fun begin(rawId: String): CronJobDetailRequest? {
val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return null
return synchronized(lock) {
generation += 1
selectedId = id
CronJobDetailRequest(id = id, generation = generation)
}
}
fun beginIfCurrent(
rawId: String,
onBegin: (CronJobDetailRequest) -> Unit,
): CronJobDetailRequest? {
val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return null
return synchronized(lock) {
if (selectedId != id) return@synchronized null
generation += 1
CronJobDetailRequest(id = id, generation = generation).also(onBegin)
}
}
fun cancel(onCancel: () -> Unit = {}) {
synchronized(lock) {
generation += 1
selectedId = null
onCancel()
}
}
fun cancelIfCurrent(
rawId: String,
onCancel: () -> Unit,
): Boolean {
val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) {
if (selectedId != id) return@synchronized false
generation += 1
selectedId = null
onCancel()
true
}
}
fun publishIfCurrent(
request: CronJobDetailRequest,
publish: () -> Unit,
): Boolean =
synchronized(lock) {
if (request.generation != generation || request.id != selectedId) return@synchronized false
publish()
true
}
}
internal fun cronJobGetParams(id: String): String =
buildJsonObject {
put("id", JsonPrimitive(id))
}.toString()
internal fun parseGatewayCronJobDetail(job: JsonObject?): GatewayCronJobDetail? {
val value = job ?: return null
val id = value.string("id") ?: return null
val name = value.string("name") ?: return null
val createdAtMs = value.long("createdAtMs") ?: return null
val updatedAtMs = value.long("updatedAtMs") ?: return null
val schedule = value["schedule"].asObjectOrNull() ?: return null
val payload = value["payload"].asObjectOrNull() ?: return null
val sessionTarget = value.string("sessionTarget") ?: return null
val wakeMode = value.string("wakeMode") ?: return null
val payloadKind = payload.string("kind") ?: return null
val scheduleKind = schedule.string("kind") ?: return null
if (scheduleKind !in setOf("at", "every", "cron", "on-exit")) return null
if (payloadKind !in setOf("systemEvent", "agentTurn", "command", "script")) return null
val state = value["state"].asObjectOrNull() ?: return null
return GatewayCronJobDetail(
id = id,
name = name,
description = value.string("description").orEmpty(),
enabled = value.boolean("enabled"),
deleteAfterRun = value.boolean("deleteAfterRun"),
scheduleKind = scheduleKind,
scheduleLabel = cronScheduleLabel(schedule),
scheduleDetail = cronScheduleDetail(schedule),
scheduleAt = schedule.string("at"),
scheduleEveryMs = schedule.long("everyMs"),
scheduleAnchorMs = schedule.long("anchorMs"),
scheduleCronExpr = schedule.string("expr"),
scheduleTimezone = schedule.string("tz"),
scheduleStaggerMs = schedule.long("staggerMs"),
scheduleCommand = schedule.string("command"),
scheduleCwd = schedule.string("cwd"),
sessionTarget = sessionTarget,
wakeMode = wakeMode,
payloadKind = payloadKind,
payloadText = cronPayloadText(payload),
payloadLabel = cronPayloadLabel(payload),
payloadModel = payload.string("model"),
payloadThinking = payload.string("thinking"),
payloadCommandArgv =
(payload["argv"] as? JsonArray)
?.mapNotNull { it.asStringOrNull() },
payloadCommandCwd = payload.string("cwd"),
deliveryLabel = cronDeliveryLabel(value["delivery"].asObjectOrNull()),
failureAlertLabel = cronFailureAlertLabel(value["failureAlert"]),
createdAtMs = createdAtMs,
updatedAtMs = updatedAtMs,
configRevision = value.string("configRevision"),
nextRunAtMs = state.long("nextRunAtMs"),
runningAtMs = state.long("runningAtMs"),
lastRunAtMs = state.long("lastRunAtMs"),
lastRunStatus = cronJobLastRunStatus(state),
lastError = state.string("lastError"),
lastDiagnosticSummary = state.string("lastDiagnosticSummary"),
lastDurationMs = state.long("lastDurationMs"),
consecutiveErrors = state.long("consecutiveErrors"),
consecutiveSkipped = state.long("consecutiveSkipped"),
lastDeliveryStatus = state.string("lastDeliveryStatus"),
lastDeliveryError = state.string("lastDeliveryError"),
)
}
internal fun formatCronInterval(everyMs: Long): NativeText {
val minutes = everyMs / 60_000L
val hours = minutes / 60L
val days = hours / 24L
return when {
days >= 1 && hours % 24L == 0L -> nativeText("Every \${days}d", days)
hours >= 1 && minutes % 60L == 0L -> nativeText("Every \${hours}h", hours)
minutes >= 1 -> nativeText("Every \${minutes}m", minutes)
else -> nativeText("Repeating")
}
}
private fun cronScheduleLabel(schedule: JsonObject): NativeText =
when (schedule.string("kind")) {
"at" -> nativeText("One time")
"every" -> schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating")
"cron" -> schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron")
else -> nativeText("Scheduled")
}
private fun cronScheduleDetail(schedule: JsonObject): NativeText =
when (schedule.string("kind")) {
"at" -> schedule.string("at")?.let(::verbatimText) ?: nativeText("One time")
"every" -> {
val every = schedule.long("everyMs")?.let(::formatCronInterval) ?: nativeText("Repeating")
val anchor = schedule.long("anchorMs")?.let { nativeText("Anchor \$it", it) }
joinedNativeText(" · ", listOfNotNull(every, anchor))
}
"cron" -> {
val expression = schedule.string("expr")?.let(::verbatimText) ?: nativeText("Cron")
val timezone = schedule.string("tz")?.let(::verbatimText)
val stagger = schedule.long("staggerMs")?.takeIf { it > 0L }?.let { nativeText("Stagger \${formatCronInterval(it)}", formatCronInterval(it)) }
joinedNativeText(" · ", listOfNotNull(expression, timezone, stagger))
}
else -> nativeText("Scheduled")
}
private fun cronPayloadText(payload: JsonObject): String? =
when (payload.string("kind")) {
"systemEvent" -> payload.string("text")
"agentTurn" -> payload.string("message")
"command" ->
(payload["argv"] as? JsonArray)
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { value -> value.isNotEmpty() } }
?.joinToString(" ")
"script" -> payload["script"].asStringOrNull()
else -> null
}
private fun cronPayloadLabel(payload: JsonObject): NativeText =
when (payload.string("kind")) {
"systemEvent" -> nativeText("System event")
"agentTurn" -> {
val model = payload.string("model")?.let(::verbatimText)
val thinking = payload.string("thinking")?.let { nativeText("Thinking \$it", it) }
joinedNativeText(" · ", listOfNotNull(nativeText("Agent turn"), model, thinking))
}
"command" -> nativeText("Command")
"script" -> {
val timeout = payload.long("timeoutSeconds")?.let { nativeText("Timeout \${it}s", it) }
val budget = payload.long("toolBudget")?.let { nativeText("\$it tools", it) }
joinedNativeText(" · ", listOfNotNull(nativeText("Script"), timeout, budget))
}
else -> nativeText("Payload")
}
private fun cronDeliveryLabel(delivery: JsonObject?): NativeText {
val value = delivery ?: return nativeText("Default")
val mode = value.string("mode") ?: return nativeText("Default")
return joinedNativeText(
" · ",
listOfNotNull(
verbatimText(mode.replaceFirstChar { it.uppercaseChar() }),
value.string("channel")?.let(::verbatimText),
value.string("to")?.let(::verbatimText),
value.string("accountId")?.let { nativeText("Account \$it", it) },
),
)
}
private fun cronFailureAlertLabel(failureAlert: JsonElement?): NativeText {
if ((failureAlert as? JsonPrimitive)?.booleanOrNull == false) return nativeText("Off")
val alert = failureAlert.asObjectOrNull() ?: return nativeText("Default")
val parts =
listOfNotNull(
alert.long("after")?.let { nativeText("After \$it", it) },
alert.string("mode")?.replaceFirstChar { it.uppercaseChar() }?.let(::verbatimText),
alert.string("channel")?.let(::verbatimText),
alert.string("to")?.let(::verbatimText),
alert.long("cooldownMs")?.takeIf { it > 0L }?.let { nativeText("Cooldown \${formatCronInterval(it)}", formatCronInterval(it)) },
)
return if (parts.isEmpty()) nativeText("On") else joinedNativeText(" · ", parts)
}
private fun JsonObject.string(key: String): String? =
this[key]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
private fun JsonObject.long(key: String): Long? =
(this[key] as? JsonPrimitive)
?.content
?.trim()
?.toLongOrNull()
private fun JsonObject.boolean(key: String): Boolean = (this[key] as? JsonPrimitive)?.booleanOrNull == true

View file

@ -0,0 +1,671 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
data class GatewayCronRunSummary(
val ts: Long,
val runId: String?,
val status: String?,
val summary: String?,
val error: String?,
val durationMs: Long?,
val deliveryStatus: String?,
val sessionKey: String?,
val model: String?,
)
sealed interface GatewayCronRunHistoryState {
data object Idle : GatewayCronRunHistoryState
data class Loading(
val id: String,
) : GatewayCronRunHistoryState
data class Loaded(
val id: String,
val runs: List<GatewayCronRunSummary>,
) : GatewayCronRunHistoryState
data class Error(
val id: String,
val message: String,
) : GatewayCronRunHistoryState
}
enum class GatewayCronAction {
Run,
Enable,
Disable,
Save,
Delete,
}
enum class GatewayCronNoticeKind {
Success,
Warning,
Error,
}
sealed interface GatewayCronActionState {
data object Idle : GatewayCronActionState
data class Running(
val id: String,
val action: GatewayCronAction,
) : GatewayCronActionState
data class Notice(
val id: String,
val message: NativeText,
val kind: GatewayCronNoticeKind,
val deleted: Boolean = false,
) : GatewayCronActionState {
constructor(
id: String,
message: String,
kind: GatewayCronNoticeKind,
deleted: Boolean = false,
) : this(id = id, message = nativeText(message), kind = kind, deleted = deleted)
}
}
/** Owns one queued manual run id per job so a stale tracker cannot clear a newer run. */
internal class PendingCronRunRegistry {
private val lock = Any()
private val runIdsByJob = linkedMapOf<String, String>()
fun contains(rawJobId: String): Boolean {
val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) { runIdsByJob.containsKey(jobId) }
}
fun begin(
rawJobId: String,
rawRunId: String,
publish: (Set<String>) -> Unit,
): Boolean {
val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false
val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) {
if (runIdsByJob.containsKey(jobId)) return@synchronized false
runIdsByJob[jobId] = runId
publish(runIdsByJob.keys.toSet())
true
}
}
fun finish(
rawJobId: String,
rawRunId: String,
publish: (Set<String>) -> Unit,
): Boolean {
val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false
val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false
return synchronized(lock) {
if (runIdsByJob[jobId] != runId) return@synchronized false
runIdsByJob.remove(jobId)
publish(runIdsByJob.keys.toSet())
true
}
}
fun clear(publish: (Set<String>) -> Unit) {
synchronized(lock) {
runIdsByJob.clear()
publish(emptySet())
}
}
}
internal fun nextCronJobsPageOffset(
page: JsonObject?,
requestedOffset: Int,
pageCount: Int,
): Int? {
val responseOffset =
page
?.long("offset")
?.takeIf { it in 0L..Int.MAX_VALUE.toLong() }
?.toInt()
?: requestedOffset
val total = page?.long("total")?.takeIf { it >= 0 }
val hasMore =
(page?.get("hasMore") as? JsonPrimitive)?.booleanOrNull
?: (total != null && responseOffset.toLong() + pageCount < total)
if (!hasMore) return null
val nextOffset =
page
?.long("nextOffset")
?.takeIf { it in 0L..Int.MAX_VALUE.toLong() }
?.toInt()
?: Math.addExact(responseOffset, pageCount)
require(nextOffset > requestedOffset) { "Gateway returned a non-advancing cron jobs page." }
return nextOffset
}
sealed interface GatewayCronScheduleEdit {
data class At(
val at: String,
) : GatewayCronScheduleEdit
data class Every(
val everyMs: String,
val anchorMs: String,
) : GatewayCronScheduleEdit
data class Cron(
val expression: String,
val timezone: String,
val staggerMs: String,
) : GatewayCronScheduleEdit
data class OnExit(
val command: String,
val cwd: String,
) : GatewayCronScheduleEdit
}
sealed interface GatewayCronPayloadEdit {
data class SystemEvent(
val text: String,
) : GatewayCronPayloadEdit
data class AgentTurn(
val message: String,
val model: String,
val thinking: String,
) : GatewayCronPayloadEdit
data class Command(
val argvJson: String,
val cwd: String,
) : GatewayCronPayloadEdit
data class ReadOnlyScript(
val script: String,
) : GatewayCronPayloadEdit
}
data class GatewayCronJobEdit(
val name: String,
val description: String,
val enabled: Boolean,
val deleteAfterRun: Boolean,
val schedule: GatewayCronScheduleEdit,
val sessionTarget: String,
val wakeMode: String,
val payload: GatewayCronPayloadEdit,
) {
fun withSchedule(value: GatewayCronScheduleEdit): GatewayCronJobEdit =
copy(
schedule = value,
deleteAfterRun = deleteAfterRun && value is GatewayCronScheduleEdit.At,
)
}
internal data class CronEditorDraftState(
val baseline: GatewayCronJobEdit,
val edit: GatewayCronJobEdit,
val savePending: Boolean = false,
val saveSucceeded: Boolean = false,
val hasIncomingConflict: Boolean = false,
) {
val isDirty: Boolean
get() = edit != baseline
val requiresResolution: Boolean
get() = isDirty || hasIncomingConflict
fun withEdit(value: GatewayCronJobEdit): CronEditorDraftState = copy(edit = value)
fun saveStarted(): CronEditorDraftState = copy(savePending = true, saveSucceeded = false)
fun saveAborted(): CronEditorDraftState = copy(savePending = false, saveSucceeded = false)
fun observeSaveNotice(kind: GatewayCronNoticeKind): CronEditorDraftState {
if (!savePending) return this
return if (kind == GatewayCronNoticeKind.Success) {
copy(saveSucceeded = true)
} else {
copy(savePending = false, saveSucceeded = false)
}
}
fun observeJob(job: GatewayCronJobDetail): CronEditorDraftState {
val incoming = job.toCronJobEdit()
if (incoming == edit) {
return CronEditorDraftState(
baseline = incoming,
edit = incoming,
)
}
if (incoming == baseline) {
return copy(hasIncomingConflict = false)
}
val canAdopt = !isDirty || saveSucceeded
if (!canAdopt) {
return copy(hasIncomingConflict = true)
}
return CronEditorDraftState(
baseline = incoming,
edit = incoming,
)
}
companion object {
fun from(job: GatewayCronJobDetail): CronEditorDraftState {
val edit = job.toCronJobEdit()
return CronEditorDraftState(
baseline = edit,
edit = edit,
)
}
}
}
internal fun CronEditorDraftState.reconcileRestoredAction(
isConnected: Boolean,
jobId: String,
actionState: GatewayCronActionState,
): CronEditorDraftState {
if (!savePending) return this
// Activity recreation retains the runtime action; process death does not.
// Preserve pending only when the restored runtime still owns this Save.
val retainedSaveState =
when (actionState) {
is GatewayCronActionState.Running ->
actionState.id == jobId && actionState.action == GatewayCronAction.Save
is GatewayCronActionState.Notice -> actionState.id == jobId
GatewayCronActionState.Idle -> false
}
return if (isConnected && retainedSaveState) this else saveAborted()
}
internal enum class GatewayCronRunSkipReason {
NotDue,
AlreadyRunning,
RestartRecoveryPending,
InvalidSpec,
Stopped,
;
val message: String
get() = messageText.resolveNativeText()
val messageText: NativeText
get() =
when (this) {
NotDue -> nativeText("Automation is not due yet.")
AlreadyRunning -> nativeText("Automation is already running.")
RestartRecoveryPending -> nativeText("Gateway restart recovery is still in progress.")
InvalidSpec -> nativeText("Automation has an invalid configuration.")
Stopped -> nativeText("Cron scheduler is stopped.")
}
}
internal sealed interface GatewayCronRunOutcome {
data class Started(
val runId: String?,
) : GatewayCronRunOutcome
data class Skipped(
val reason: GatewayCronRunSkipReason,
) : GatewayCronRunOutcome
data object Rejected : GatewayCronRunOutcome
}
internal fun cronRunShouldRefresh(outcome: GatewayCronRunOutcome): Boolean =
when (outcome) {
is GatewayCronRunOutcome.Started -> true
is GatewayCronRunOutcome.Skipped -> outcome.reason == GatewayCronRunSkipReason.InvalidSpec
GatewayCronRunOutcome.Rejected -> false
}
internal fun cronRunCompletionNotice(
jobId: String,
status: String?,
): GatewayCronActionState.Notice {
val (message, kind) =
when (status) {
"ok" -> nativeText("Automation run finished.") to GatewayCronNoticeKind.Success
"skipped" -> nativeText("Automation run skipped.") to GatewayCronNoticeKind.Warning
"error" -> nativeText("Automation run failed.") to GatewayCronNoticeKind.Error
else -> nativeText("Automation run finished with an unknown status.") to GatewayCronNoticeKind.Warning
}
return GatewayCronActionState.Notice(id = jobId, message = message, kind = kind)
}
internal fun isCronJobRevisionConflict(error: GatewaySession.ErrorShape): Boolean = error.details?.code == "CRON_JOB_CHANGED"
internal fun GatewayCronJobDetail.toCronJobEdit(): GatewayCronJobEdit =
GatewayCronJobEdit(
name = name,
description = description,
enabled = enabled,
// Gateway deletion only runs after a successful one-shot schedule.
deleteAfterRun = deleteAfterRun && scheduleKind == "at",
schedule =
when (scheduleKind) {
"at" -> GatewayCronScheduleEdit.At(at = scheduleAt.orEmpty())
"every" ->
GatewayCronScheduleEdit.Every(
everyMs = scheduleEveryMs?.toString().orEmpty(),
anchorMs = scheduleAnchorMs?.toString().orEmpty(),
)
"cron" ->
GatewayCronScheduleEdit.Cron(
expression = scheduleCronExpr.orEmpty(),
timezone = scheduleTimezone.orEmpty(),
staggerMs = scheduleStaggerMs?.toString().orEmpty(),
)
"on-exit" ->
GatewayCronScheduleEdit.OnExit(
command = scheduleCommand.orEmpty(),
cwd = scheduleCwd.orEmpty(),
)
else -> error("Unsupported cron schedule kind: $scheduleKind")
},
sessionTarget = sessionTarget,
wakeMode = wakeMode,
payload =
when (payloadKind) {
"systemEvent" -> GatewayCronPayloadEdit.SystemEvent(text = payloadText.orEmpty())
"agentTurn" ->
GatewayCronPayloadEdit.AgentTurn(
message = payloadText.orEmpty(),
model = payloadModel.orEmpty(),
thinking = payloadThinking.orEmpty(),
)
"command" ->
GatewayCronPayloadEdit.Command(
argvJson = JsonArray(payloadCommandArgv.orEmpty().map(::JsonPrimitive)).toString(),
cwd = payloadCommandCwd.orEmpty(),
)
"script" -> GatewayCronPayloadEdit.ReadOnlyScript(script = payloadText.orEmpty())
else -> error("Unsupported cron payload kind: $payloadKind")
},
)
internal fun buildCronUpdateParams(
original: GatewayCronJobDetail,
edit: GatewayCronJobEdit,
): String {
val name = edit.name.trim()
require(name.isNotEmpty()) { "Automation name is required." }
val description = edit.description.trim()
val sessionTarget = edit.sessionTarget.trim()
require(
sessionTarget == "main" ||
sessionTarget == "isolated" ||
sessionTarget == "current" ||
(sessionTarget.startsWith("session:") && sessionTarget.removePrefix("session:").isNotBlank()),
) { "Session target must be main, isolated, current, or session:<id>." }
val wakeMode = edit.wakeMode.trim()
require(wakeMode == "now" || wakeMode == "next-heartbeat") {
"Wake mode must be now or next-heartbeat."
}
val schedulePatch = buildCronSchedulePatch(original = original, edit = edit.schedule)
val payloadPatch = buildCronPayloadPatch(original = original, edit = edit.payload)
val patch =
buildJsonObject {
if (name != original.name) put("name", JsonPrimitive(name))
if (description != original.description) put("description", JsonPrimitive(description))
if (edit.enabled != original.enabled) put("enabled", JsonPrimitive(edit.enabled))
if (edit.deleteAfterRun != original.deleteAfterRun) {
put("deleteAfterRun", JsonPrimitive(edit.deleteAfterRun))
}
schedulePatch?.let { put("schedule", it) }
if (sessionTarget != original.sessionTarget) {
put("sessionTarget", JsonPrimitive(sessionTarget))
}
if (wakeMode != original.wakeMode) put("wakeMode", JsonPrimitive(wakeMode))
payloadPatch?.let { put("payload", it) }
}
require(patch.isNotEmpty()) { "No cron changes to save." }
val configRevision =
requireNotNull(original.configRevision) {
"Update the gateway before saving cron changes from Android."
}
return buildJsonObject {
put("id", JsonPrimitive(original.id))
put("expectedConfigRevision", JsonPrimitive(configRevision))
put("patch", patch)
}.toString()
}
internal fun parseGatewayCronRunOutcome(root: JsonObject?): GatewayCronRunOutcome? {
val value = root ?: return null
val ok = value.optionalBoolean("ok") ?: return null
if (!ok) return GatewayCronRunOutcome.Rejected
if (value.optionalBoolean("ran") == true) {
return GatewayCronRunOutcome.Started(runId = value.string("runId"))
}
if (value.optionalBoolean("enqueued") == true) {
val runId = value.string("runId") ?: return null
return GatewayCronRunOutcome.Started(runId = runId)
}
if (value.optionalBoolean("ran") != false) return null
val reason =
when (value.string("reason")) {
"not-due" -> GatewayCronRunSkipReason.NotDue
"already-running" -> GatewayCronRunSkipReason.AlreadyRunning
"restart-recovery-pending" -> GatewayCronRunSkipReason.RestartRecoveryPending
"invalid-spec" -> GatewayCronRunSkipReason.InvalidSpec
"stopped" -> GatewayCronRunSkipReason.Stopped
else -> return null
}
return GatewayCronRunOutcome.Skipped(reason)
}
internal fun parseGatewayCronRunHistory(entries: JsonArray?): List<GatewayCronRunSummary> =
entries
?.mapNotNull { item ->
val value = item.asObjectOrNull() ?: return@mapNotNull null
val ts = value.long("ts") ?: return@mapNotNull null
GatewayCronRunSummary(
ts = ts,
runId = value.string("runId"),
status = value.string("status"),
summary = value.string("summary"),
error = value.string("error"),
durationMs = value.long("durationMs"),
deliveryStatus = value.string("deliveryStatus"),
sessionKey = value.string("sessionKey"),
model = value.string("model"),
)
}.orEmpty()
private fun buildCronSchedulePatch(
original: GatewayCronJobDetail,
edit: GatewayCronScheduleEdit,
): JsonObject? =
when (edit) {
is GatewayCronScheduleEdit.At -> {
require(original.scheduleKind == "at") { "Changing schedule type is not supported here." }
val at = edit.at.trim()
require(at.isNotEmpty()) { "One-time automations need an ISO time." }
if (at == original.scheduleAt) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("at"))
put("at", JsonPrimitive(at))
}
}
}
is GatewayCronScheduleEdit.Every -> {
require(original.scheduleKind == "every") { "Changing schedule type is not supported here." }
val everyMs = edit.everyMs.trim().toLongOrNull()
require(everyMs != null && everyMs > 0L) { "Interval must be a positive number of milliseconds." }
val anchorMs = parseOptionalNonNegativeLong(edit.anchorMs, "Anchor")
if (everyMs == original.scheduleEveryMs && anchorMs == original.scheduleAnchorMs) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("every"))
put("everyMs", JsonPrimitive(everyMs))
anchorMs?.let { put("anchorMs", JsonPrimitive(it)) }
}
}
}
is GatewayCronScheduleEdit.Cron -> {
require(original.scheduleKind == "cron") { "Changing schedule type is not supported here." }
val expression = edit.expression.trim()
require(expression.isNotEmpty()) { "Cron expression is required." }
val timezone = edit.timezone.trim().ifEmpty { null }
val requestedStaggerMs = parseOptionalNonNegativeLong(edit.staggerMs, "Stagger")
val staggerMs =
requestedStaggerMs ?: if (original.scheduleStaggerMs != null) 0L else null
if (
expression == original.scheduleCronExpr &&
timezone == original.scheduleTimezone &&
staggerMs == original.scheduleStaggerMs
) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("cron"))
put("expr", JsonPrimitive(expression))
timezone?.let { put("tz", JsonPrimitive(it)) }
staggerMs?.let { put("staggerMs", JsonPrimitive(it)) }
}
}
}
is GatewayCronScheduleEdit.OnExit -> {
require(original.scheduleKind == "on-exit") { "Changing schedule type is not supported here." }
val command = edit.command.trim()
require(command.isNotEmpty()) { "On-exit automations need a command." }
val cwd = edit.cwd.trim().ifEmpty { null }
if (command == original.scheduleCommand && cwd == original.scheduleCwd) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("on-exit"))
put("command", JsonPrimitive(command))
cwd?.let { put("cwd", JsonPrimitive(it)) }
}
}
}
}
private fun buildCronPayloadPatch(
original: GatewayCronJobDetail,
edit: GatewayCronPayloadEdit,
): JsonObject? =
when (edit) {
is GatewayCronPayloadEdit.SystemEvent -> {
require(original.payloadKind == "systemEvent") { "Changing payload type is not supported here." }
val text = edit.text.trim()
require(text.isNotEmpty()) { "System event text is required." }
if (text == original.payloadText) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("systemEvent"))
put("text", JsonPrimitive(text))
}
}
}
is GatewayCronPayloadEdit.AgentTurn -> {
require(original.payloadKind == "agentTurn") { "Changing payload type is not supported here." }
val message = edit.message.trim()
require(message.isNotEmpty()) { "Agent message is required." }
val model = edit.model.trim().ifEmpty { null }
val thinking = edit.thinking.trim().ifEmpty { null }
if (
message == original.payloadText &&
model == original.payloadModel &&
thinking == original.payloadThinking
) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("agentTurn"))
if (message != original.payloadText) put("message", JsonPrimitive(message))
if (model != original.payloadModel) put("model", model?.let(::JsonPrimitive) ?: JsonNull)
if (thinking != original.payloadThinking) {
put("thinking", thinking?.let(::JsonPrimitive) ?: JsonNull)
}
}
}
}
is GatewayCronPayloadEdit.Command -> {
require(original.payloadKind == "command") { "Changing payload type is not supported here." }
val argv = parseCommandArgv(edit.argvJson)
val cwd = edit.cwd.trim().ifEmpty { null }
if (cwd == null && original.payloadCommandCwd != null) {
error("The gateway does not support clearing a command working directory.")
}
if (argv == original.payloadCommandArgv && cwd == original.payloadCommandCwd) {
null
} else {
buildJsonObject {
put("kind", JsonPrimitive("command"))
if (argv != original.payloadCommandArgv) {
put("argv", JsonArray(argv.map(::JsonPrimitive)))
}
if (cwd != original.payloadCommandCwd) put("cwd", JsonPrimitive(requireNotNull(cwd)))
}
}
}
is GatewayCronPayloadEdit.ReadOnlyScript -> {
require(original.payloadKind == "script" && edit.script == original.payloadText) {
"Script payloads are read-only on Android."
}
null
}
}
private fun parseCommandArgv(raw: String): List<String> {
val value =
runCatching { Json.parseToJsonElement(raw) }.getOrNull() as? JsonArray
?: error("Command argv must be a JSON array.")
val argv =
value.map { item ->
val primitive = item as? JsonPrimitive
primitive?.takeIf { it.isString }?.content?.takeIf { it.isNotEmpty() }
?: error("Command argv entries must be non-empty strings.")
}
require(argv.isNotEmpty()) { "Command argv must contain at least one entry." }
return argv
}
private fun parseOptionalNonNegativeLong(
raw: String,
label: String,
): Long? {
val value = raw.trim()
if (value.isEmpty()) return null
val parsed = value.toLongOrNull()
require(parsed != null && parsed >= 0L) { "$label must be a non-negative number of milliseconds." }
return parsed
}
private fun JsonObject.string(key: String): String? =
this[key]
.asStringOrNull()
?.trim()
?.takeIf { it.isNotEmpty() }
private fun JsonObject.long(key: String): Long? =
(this[key] as? JsonPrimitive)
?.content
?.trim()
?.toLongOrNull()
private fun JsonObject.optionalBoolean(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull

View file

@ -0,0 +1,28 @@
package ai.openclaw.app
import android.content.Context
import android.os.Build
import android.provider.Settings
object DeviceNames {
/** Prefers the user-visible Android device name, then falls back to manufacturer/model text. */
fun bestDefaultNodeName(context: Context): String {
val deviceName =
runCatching {
Settings.Global.getString(context.contentResolver, "device_name")
}.getOrNull()
?.trim()
.orEmpty()
if (deviceName.isNotEmpty()) return deviceName
// Manufacturer/model are best-effort platform fields; keep the final
// fallback stable so stored default names do not become blank.
val model =
listOfNotNull(Build.MANUFACTURER?.takeIf { it.isNotBlank() }, Build.MODEL?.takeIf { it.isNotBlank() })
.joinToString(" ")
.trim()
return model.ifEmpty { "Android Node" }
}
}

View file

@ -0,0 +1,41 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
data class GatewayAgentSummary(
val id: String,
val name: String?,
val emoji: String?,
val avatar: String? = null,
val avatarUrl: String? = null,
val workspaceGit: Boolean = false,
val kind: String? = null,
)
/** Parses validated agents.list rows into the smaller Android display model. */
internal fun parseGatewayAgentSummaries(root: JsonObject): List<GatewayAgentSummary> = (root["agents"] as? JsonArray)?.mapNotNull(::parseGatewayAgentSummary) ?: emptyList()
private fun parseGatewayAgentSummary(item: JsonElement): GatewayAgentSummary? {
val agent = item.asObjectOrNull() ?: return null
val id = agent["id"].asStringOrNull()?.trim().orEmpty()
if (id.isEmpty()) return null
val identity = agent["identity"].asObjectOrNull()
return GatewayAgentSummary(
id = id,
kind = agent["kind"].asStringOrNull().normalizedAgentValue(),
name = agent["name"].asStringOrNull().normalizedAgentValue(),
emoji = identity?.get("emoji").asStringOrNull().normalizedAgentValue(),
avatar = identity?.get("avatar").asStringOrNull().normalizedAgentValue(),
avatarUrl = identity?.get("avatarUrl").asStringOrNull().normalizedAgentValue(),
workspaceGit = (agent["workspaceGit"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() == true,
)
}
internal fun List<GatewayAgentSummary>.selectableAgents(): List<GatewayAgentSummary> = filter { it.kind != "system" }
private fun String?.normalizedAgentValue(): String? = this?.trim()?.takeIf { it.isNotEmpty() }

View file

@ -0,0 +1,587 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import java.util.concurrent.atomic.AtomicLong
data class GatewayExecApprovalSummary(
val id: String,
val commandText: NativeText,
val commandPreview: String?,
val warningText: String?,
val allowedDecisions: List<String>,
val host: String?,
val nodeId: String?,
val agentId: String?,
val createdAtMs: Long?,
val expiresAtMs: Long?,
val resolvingDecision: String? = null,
val errorText: String? = null,
)
internal enum class GatewayApprovalTerminalStatus {
Allowed,
Denied,
Expired,
Cancelled,
}
internal sealed interface GatewayExecApprovalSnapshot {
val id: String
data class Pending(
val summary: GatewayExecApprovalSummary,
) : GatewayExecApprovalSnapshot {
override val id: String = summary.id
}
data class Terminal(
override val id: String,
val status: GatewayApprovalTerminalStatus,
val decision: String?,
) : GatewayExecApprovalSnapshot
}
internal data class GatewayExecApprovalResolution(
val applied: Boolean,
val approval: GatewayExecApprovalSnapshot.Terminal,
val attribution: GatewayExecApprovalResolutionAttribution =
if (applied) GatewayExecApprovalResolutionAttribution.AppliedHere else GatewayExecApprovalResolutionAttribution.PriorResponse,
)
internal enum class GatewayExecApprovalResolutionAttribution {
AppliedHere,
PriorResponse,
Unknown,
}
private val execApprovalNoticePublications = AtomicLong()
data class GatewayExecApprovalNotice(
val approvalId: String,
val message: String,
val warning: Boolean,
// Distinct per constructed notice: a re-requested approval can lose again with an
// identical id/message, and the dismiss compareAndSet must not treat the stale
// banner as equal to its replacement.
val publication: Long = execApprovalNoticePublications.incrementAndGet(),
)
internal fun gatewayExecApprovalResolutionNotice(
resolution: GatewayExecApprovalResolution,
): GatewayExecApprovalNotice =
when (resolution.approval.status) {
GatewayApprovalTerminalStatus.Allowed -> {
val saved = resolution.approval.decision == "allow-always"
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalAllowedMessage(attribution = resolution.attribution, saved = saved),
warning = false,
)
}
GatewayApprovalTerminalStatus.Denied ->
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalDeniedMessage(resolution.attribution),
warning = true,
)
GatewayApprovalTerminalStatus.Expired ->
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalTerminalMessage(resolution.approval.status),
warning = true,
)
GatewayApprovalTerminalStatus.Cancelled ->
GatewayExecApprovalNotice(
approvalId = resolution.approval.id,
message = gatewayExecApprovalTerminalMessage(resolution.approval.status),
warning = true,
)
}
private fun gatewayExecApprovalAllowedMessage(
attribution: GatewayExecApprovalResolutionAttribution,
saved: Boolean,
): String {
if (attribution == GatewayExecApprovalResolutionAttribution.AppliedHere) {
if (saved) return "Approval allowed and saved."
return "Approval allowed once."
}
if (attribution == GatewayExecApprovalResolutionAttribution.PriorResponse) {
if (saved) return "A prior response already allowed this command and saved the choice."
return "A prior response already allowed this command once."
}
if (saved) return "Gateway recorded approval and saved the choice."
return "Gateway recorded approval once."
}
private fun gatewayExecApprovalDeniedMessage(attribution: GatewayExecApprovalResolutionAttribution): String =
when (attribution) {
GatewayExecApprovalResolutionAttribution.AppliedHere -> "Approval denied."
GatewayExecApprovalResolutionAttribution.PriorResponse -> "A prior response already denied this approval."
GatewayExecApprovalResolutionAttribution.Unknown -> "Gateway recorded a denial."
}
private fun gatewayExecApprovalTerminalMessage(status: GatewayApprovalTerminalStatus): String =
when (status) {
GatewayApprovalTerminalStatus.Expired -> "This approval expired before it could be resolved."
GatewayApprovalTerminalStatus.Cancelled -> "This approval was cancelled before it could be resolved."
else -> error("approval is not expired or cancelled")
}
internal fun gatewayExecApprovalRemoteTerminalNotice(
approval: GatewayExecApprovalSnapshot.Terminal,
): GatewayExecApprovalNotice =
gatewayExecApprovalResolutionNotice(
GatewayExecApprovalResolution(applied = false, approval = approval),
)
internal fun gatewayExecApprovalPriorResolutionNotice(id: String): GatewayExecApprovalNotice =
GatewayExecApprovalNotice(
approvalId = id,
message = gatewayExecApprovalPriorResolutionMessage(),
warning = true,
)
private fun gatewayExecApprovalPriorResolutionMessage(): String = "A prior response already resolved this approval."
internal fun normalizeGatewayExecApprovalDecision(value: String): String? =
when (value) {
"allow-once" -> "allow-once"
"allow-always" -> "allow-always"
"deny" -> "deny"
else -> null
}
/** Parses the terminal winner from an authenticated Gateway resolution event. */
internal fun parseGatewayExecApprovalResolvedEventTerminal(
payloadJson: String,
json: Json,
): GatewayExecApprovalSnapshot.Terminal? =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
val id = root.strictApprovalId("id") ?: return null
val decision = root.strictString("decision")?.let(::normalizeGatewayExecApprovalDecision) ?: return null
legacyGatewayExecApprovalTerminal(id, decision)
} catch (_: Throwable) {
null
}
internal enum class GatewayApprovalRpcFamily {
Canonical,
Legacy,
Unavailable,
}
/**
* Selects one read/write family for the lifetime of a Gateway hello catalog.
* Legacy exec.approval.* serves shipped Gateway v4 peers until the minimum supported
* Gateway advertises approval.get/approval.resolve.
*/
internal fun selectGatewayApprovalRpcFamily(methods: Set<String>): GatewayApprovalRpcFamily {
val hasCanonicalGet = "approval.get" in methods
val hasCanonicalResolve = "approval.resolve" in methods
if (hasCanonicalGet && hasCanonicalResolve) return GatewayApprovalRpcFamily.Canonical
if (
!hasCanonicalGet &&
!hasCanonicalResolve &&
"exec.approval.get" in methods &&
"exec.approval.resolve" in methods
) {
return GatewayApprovalRpcFamily.Legacy
}
return GatewayApprovalRpcFamily.Unavailable
}
internal fun buildGatewayExecApprovalGetParams(id: String): JsonObject = buildJsonObject { put("id", id) }
internal fun buildGatewayExecApprovalResolveParams(
id: String,
decision: String,
): JsonObject =
buildJsonObject {
put("id", id)
put("kind", "exec")
put("decision", decision)
}
internal fun parseGatewayExecApprovalListPayload(
payloadJson: String,
json: Json,
): List<GatewayExecApprovalSummary> =
try {
(json.parseToJsonElement(payloadJson) as? JsonArray)
?.mapNotNull(::parseGatewayExecApprovalListEntry)
?.sortedBy { it.createdAtMs ?: Long.MAX_VALUE }
.orEmpty()
} catch (_: Throwable) {
emptyList()
}
internal fun parseGatewayExecApprovalListEntry(item: JsonElement): GatewayExecApprovalSummary? {
val obj = item.asObjectOrNull() ?: return null
val id = obj.strictApprovalId("id") ?: return null
val createdAtMs = obj.strictNonNegativeLong("createdAtMs") ?: return null
val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null
// The legacy list is discovery-only. Its embedded request can contain runtime-only
// details, so rendering waits for the reviewer-safe unified approval projection.
return GatewayExecApprovalSummary(
id = id,
commandText = nativeText("Command request"),
commandPreview = null,
warningText = null,
allowedDecisions = emptyList(),
host = null,
nodeId = null,
agentId = null,
createdAtMs = createdAtMs,
expiresAtMs = expiresAtMs,
)
}
internal fun gatewayExecApprovalTextForDisplay(text: String): String =
when (text) {
"Approval allowed and saved." -> nativeString("Approval allowed and saved.")
"Approval allowed once." -> nativeString("Approval allowed once.")
"A prior response already allowed this command and saved the choice." ->
nativeString("A prior response already allowed this command and saved the choice.")
"A prior response already allowed this command once." ->
nativeString("A prior response already allowed this command once.")
"Gateway recorded approval and saved the choice." ->
nativeString("Gateway recorded approval and saved the choice.")
"Gateway recorded approval once." -> nativeString("Gateway recorded approval once.")
"Approval denied." -> nativeString("Approval denied.")
"A prior response already denied this approval." ->
nativeString("A prior response already denied this approval.")
"Gateway recorded a denial." -> nativeString("Gateway recorded a denial.")
"This approval expired before it could be resolved." ->
nativeString("This approval expired before it could be resolved.")
"This approval was cancelled before it could be resolved." ->
nativeString("This approval was cancelled before it could be resolved.")
"A prior response already resolved this approval." ->
nativeString("A prior response already resolved this approval.")
"Command request" -> nativeString("Command request")
"Resolution outcome unknown. Actions stay disabled until the Gateway record is verified." ->
nativeString("Resolution outcome unknown. Actions stay disabled until the Gateway record is verified.")
"The Gateway still shows this approval as pending. Review it before trying again." ->
nativeString("The Gateway still shows this approval as pending. Review it before trying again.")
"Could not load approval details. Refresh and try again." ->
nativeString("Could not load approval details. Refresh and try again.")
"Could not load approvals." -> nativeString("Could not load approvals.")
"Could not resolve approval. Refresh and try again." ->
nativeString("Could not resolve approval. Refresh and try again.")
else -> text
}
internal fun parseGatewayExecApprovalGetPayload(
payloadJson: String,
json: Json,
expectedId: String,
): GatewayExecApprovalSnapshot? =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
if (!root.hasExactKeys(APPROVAL_GET_RESULT_KEYS)) return null
parseGatewayExecApprovalSnapshot(root["approval"].asObjectOrNull() ?: return null)
?.takeIf { it.id == expectedId }
} catch (_: Throwable) {
null
}
internal fun parseGatewayExecApprovalResolvePayload(
payloadJson: String,
json: Json,
expectedId: String,
expectedDecision: String,
): GatewayExecApprovalResolution? =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
if (!root.hasExactKeys(APPROVAL_RESOLVE_RESULT_KEYS)) return null
val applied = root.strictBoolean("applied") ?: return null
val approval =
parseGatewayExecApprovalSnapshot(root["approval"].asObjectOrNull() ?: return null)
as? GatewayExecApprovalSnapshot.Terminal
?: return null
if (approval.id != expectedId) return null
// `applied=true` claims this write won. A different returned decision is an
// ambiguous write outcome, never evidence that the attempted approval applied.
if (applied && approval.decision != expectedDecision) return null
GatewayExecApprovalResolution(applied = applied, approval = approval)
} catch (_: Throwable) {
null
}
/** Parses the shipped pre-unified exec reviewer projection for old Gateway v4 peers. */
internal fun parseLegacyGatewayExecApprovalGetPayload(
payloadJson: String,
json: Json,
expectedId: String,
createdAtMs: Long?,
): GatewayExecApprovalSnapshot.Pending? =
try {
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return null
val id = obj.strictApprovalId("id") ?: return null
if (id != expectedId) return null
val normalizedCreatedAtMs = createdAtMs?.takeIf { it >= 0 } ?: return null
val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null
val commandText = obj.strictNonEmptyString("commandText") ?: return null
val commandPreview = obj.optionalString("commandPreview") ?: return null
val host = obj.optionalString("host") ?: return null
val nodeId = obj.optionalString("nodeId", requireNonEmpty = true) ?: return null
val agentId = obj.optionalString("agentId", requireNonEmpty = true) ?: return null
val allowedDecisions = parseAllowedDecisions(obj["allowedDecisions"] as? JsonArray) ?: return null
GatewayExecApprovalSnapshot.Pending(
GatewayExecApprovalSummary(
id = id,
commandText = verbatimText(commandText),
commandPreview = commandPreview.value?.takeIf { it != commandText },
warningText = null,
allowedDecisions = allowedDecisions,
host = host.value,
nodeId = nodeId.value,
agentId = agentId.value,
createdAtMs = normalizedCreatedAtMs,
expiresAtMs = expiresAtMs,
),
)
} catch (_: Throwable) {
null
}
internal fun parseLegacyGatewayExecApprovalResolvePayload(
payloadJson: String,
json: Json,
): Boolean =
try {
val root = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return false
root.strictBoolean("ok") == true
} catch (_: Throwable) {
false
}
internal fun legacyGatewayExecApprovalTerminal(
id: String,
decision: String,
): GatewayExecApprovalSnapshot.Terminal? {
val status =
when (decision) {
"allow-once", "allow-always" -> GatewayApprovalTerminalStatus.Allowed
"deny" -> GatewayApprovalTerminalStatus.Denied
else -> return null
}
return GatewayExecApprovalSnapshot.Terminal(id, status, decision)
}
private fun parseGatewayExecApprovalSnapshot(obj: JsonObject): GatewayExecApprovalSnapshot? {
val status = obj.strictString("status") ?: return null
val expectedKeys = APPROVAL_SNAPSHOT_KEYS_BY_STATUS[status] ?: return null
if (!obj.hasExactKeys(expectedKeys)) return null
val id = obj.strictApprovalId("id") ?: return null
obj.strictNonEmptyString("urlPath") ?: return null
val createdAtMs = obj.strictNonNegativeLong("createdAtMs") ?: return null
val expiresAtMs = obj.strictNonNegativeLong("expiresAtMs") ?: return null
val presentation = obj["presentation"].asObjectOrNull() ?: return null
val summary = parseGatewayExecApprovalPresentation(id, createdAtMs, expiresAtMs, presentation) ?: return null
return when (status) {
"pending" -> GatewayExecApprovalSnapshot.Pending(summary)
"allowed" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Allowed,
expectedDecision = setOf("allow-once", "allow-always"),
)?.takeIf { terminal ->
terminal.decision?.let(summary.allowedDecisions::contains) == true
}
"denied" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Denied,
expectedDecision = setOf("deny"),
)
"expired" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Expired,
expectedDecision = null,
)
"cancelled" ->
parseTerminalApproval(
obj = obj,
id = id,
status = GatewayApprovalTerminalStatus.Cancelled,
expectedDecision = null,
)
else -> null
}
}
private fun parseGatewayExecApprovalPresentation(
id: String,
createdAtMs: Long,
expiresAtMs: Long,
presentation: JsonObject,
): GatewayExecApprovalSummary? {
if (!presentation.hasOnlyKeys(EXEC_APPROVAL_PRESENTATION_KEYS)) return null
if (!presentation.keys.containsAll(EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS)) return null
// A unified lookup can return other approval owners. Android's exec inbox must
// never reinterpret plugin copy or metadata as an executable command request.
if (presentation.strictString("kind") != "exec") return null
val commandText = presentation.strictNonEmptyString("commandText") ?: return null
val allowedDecisions = parseAllowedDecisions(presentation["allowedDecisions"] as? JsonArray) ?: return null
val commandPreview = presentation.optionalString("commandPreview") ?: return null
val warningText = presentation.optionalString("warningText") ?: return null
val host = presentation.optionalString("host") ?: return null
val nodeId = presentation.optionalString("nodeId", requireNonEmpty = true) ?: return null
val agentId = presentation.optionalString("agentId", requireNonEmpty = true) ?: return null
return GatewayExecApprovalSummary(
id = id,
commandText = verbatimText(commandText),
commandPreview = commandPreview.value?.takeIf { it != commandText },
warningText = warningText.value,
allowedDecisions = allowedDecisions,
host = host.value,
nodeId = nodeId.value,
agentId = agentId.value,
createdAtMs = createdAtMs,
expiresAtMs = expiresAtMs,
)
}
private fun parseTerminalApproval(
obj: JsonObject,
id: String,
status: GatewayApprovalTerminalStatus,
expectedDecision: Set<String>?,
): GatewayExecApprovalSnapshot.Terminal? {
obj.strictNonNegativeLong("resolvedAtMs") ?: return null
val reason = obj.strictString("reason") ?: return null
if (reason !in APPROVAL_TERMINAL_REASONS) return null
val decision = obj.strictString("decision")
if (expectedDecision == null) {
if (obj.containsKey("decision")) return null
} else if (decision !in expectedDecision) {
return null
}
return GatewayExecApprovalSnapshot.Terminal(id = id, status = status, decision = decision)
}
private fun parseAllowedDecisions(items: JsonArray?): List<String>? {
if (items == null || items.size !in 1..3) return null
val decisions = items.map { item -> item.strictString() ?: return null }
if (decisions.distinct().size != decisions.size || "deny" !in decisions) return null
return decisions.takeIf { values -> values.all { it in APPROVAL_DECISIONS } }
}
private data class OptionalString(
val value: String?,
)
private fun JsonObject.optionalString(
key: String,
requireNonEmpty: Boolean = false,
): OptionalString? {
val value = this[key]
if (value == null || value is JsonNull) return OptionalString(null)
val string = value.strictString() ?: return null
if (requireNonEmpty && string.isEmpty()) return null
return OptionalString(string)
}
private fun JsonObject.strictString(key: String): String? = this[key].strictString()
private fun JsonElement?.strictString(): String? =
(this as? JsonPrimitive)
?.takeIf { it.isString }
?.content
private fun JsonObject.strictNonEmptyString(key: String): String? =
strictString(key)
?.takeIf { it.isNotEmpty() }
private fun JsonObject.strictApprovalId(key: String): String? =
strictString(key)
?.takeIf(::isWellFormedGatewayApprovalId)
private fun JsonObject.strictBoolean(key: String): Boolean? =
(this[key] as? JsonPrimitive)
?.takeUnless { it.isString }
?.booleanOrNull
private fun JsonObject.strictNonNegativeLong(key: String): Long? =
(this[key] as? JsonPrimitive)
?.takeUnless { it.isString }
?.longOrNull
?.takeIf { it >= 0 }
// Closed-schema contract: the gateway protocol declares approval results with
// additionalProperties:false, so additive protocol changes hard-fail old clients by design.
private fun JsonObject.hasExactKeys(expected: Set<String>): Boolean = keys == expected
private fun JsonObject.hasOnlyKeys(allowed: Set<String>): Boolean = keys.all(allowed::contains)
internal fun isWellFormedGatewayApprovalId(value: String): Boolean {
if (value.isEmpty() || value == "." || value == "..") return false
var index = 0
while (index < value.length) {
val current = value[index]
when {
Character.isHighSurrogate(current) -> {
if (index + 1 >= value.length || !Character.isLowSurrogate(value[index + 1])) return false
index += 2
}
Character.isLowSurrogate(current) -> return false
else -> index += 1
}
}
return true
}
private val APPROVAL_GET_RESULT_KEYS = setOf("approval")
private val APPROVAL_RESOLVE_RESULT_KEYS = setOf("applied", "approval")
private val APPROVAL_SNAPSHOT_COMMON_KEYS =
setOf("id", "urlPath", "status", "createdAtMs", "expiresAtMs", "presentation")
private val APPROVAL_SNAPSHOT_KEYS_BY_STATUS =
mapOf(
"pending" to APPROVAL_SNAPSHOT_COMMON_KEYS,
"allowed" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason", "decision"),
"denied" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason", "decision"),
"expired" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason"),
"cancelled" to APPROVAL_SNAPSHOT_COMMON_KEYS + setOf("resolvedAtMs", "reason"),
)
private val EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS = setOf("kind", "commandText", "allowedDecisions")
private val EXEC_APPROVAL_PRESENTATION_KEYS =
EXEC_APPROVAL_PRESENTATION_REQUIRED_KEYS +
setOf("commandPreview", "warningText", "host", "nodeId", "agentId")
private val APPROVAL_DECISIONS = setOf("allow-once", "allow-always", "deny")
private val APPROVAL_TERMINAL_REASONS =
setOf(
"user",
"timeout",
"malformed-verdict",
"no-route",
"run-aborted",
"gateway-restart",
"storage-corrupt",
)

View file

@ -0,0 +1,237 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeText
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeText
import ai.openclaw.app.i18n.resolveNativeText
import ai.openclaw.app.i18n.verbatimText
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
data class GatewayTalkSetupReadiness(
val realtimeTalk: GatewayTalkSetupState,
val dictation: GatewayTalkSetupState,
) {
companion object {
fun unverified(
issue: GatewayTalkSetupIssue = GatewayTalkSetupIssue.CatalogNotLoaded,
): GatewayTalkSetupReadiness =
GatewayTalkSetupReadiness(
realtimeTalk = GatewayTalkSetupState.Unverified(issue),
dictation = GatewayTalkSetupState.Unverified(issue),
)
}
}
sealed interface GatewayTalkSetupState {
data class Ready(
val provider: GatewayTalkProvider,
) : GatewayTalkSetupState
data class NeedsSetup(
val issue: GatewayTalkSetupIssue,
val provider: GatewayTalkProvider? = null,
) : GatewayTalkSetupState
/** Catalog failures must not disable a startup path that the Gateway still validates. */
data class Unverified(
val issue: GatewayTalkSetupIssue,
) : GatewayTalkSetupState
}
enum class GatewayTalkSetupTarget(
val title: NativeText,
) {
REALTIME_TALK(nativeText("Realtime Talk")),
DICTATION(nativeText("Dictation")),
}
sealed interface GatewayTalkSetupIssue {
data object CatalogNotLoaded : GatewayTalkSetupIssue
data object CatalogLoadFailed : GatewayTalkSetupIssue
data class GroupMissing(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class NoProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class UnknownProvider(
val target: GatewayTalkSetupTarget,
val providerId: String,
) : GatewayTalkSetupIssue
data class MissingReadiness(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class ConfigureProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class MissingActiveProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class UnsupportedProvider(
val target: GatewayTalkSetupTarget,
) : GatewayTalkSetupIssue
data class ConfigureSelectedProvider(
val providerLabel: String,
) : GatewayTalkSetupIssue
}
data class GatewayTalkProvider(
val id: String,
val label: String,
)
val GatewayTalkSetupState.isReady: Boolean
get() = this is GatewayTalkSetupState.Ready
val GatewayTalkSetupState.requiresSetup: Boolean
get() = this is GatewayTalkSetupState.NeedsSetup
internal fun isAndroidRealtimeRelayModelSupported(model: String?): Boolean {
val normalized = model?.trim()?.lowercase() ?: return true
// extensions/openai/realtime-quicksilver.ts makes gpt-live WebRTC-only;
// the Gateway relay rejects it instead of providing a usable Android session.
return normalized != "gpt-live" && !normalized.startsWith("gpt-live-")
}
fun gatewayTalkSetupStatusText(state: GatewayTalkSetupState): String =
when (state) {
is GatewayTalkSetupState.Ready -> nativeString("Ready")
is GatewayTalkSetupState.NeedsSetup -> nativeString("Needs setup")
is GatewayTalkSetupState.Unverified -> nativeString("Unverified")
}
fun gatewayTalkSetupDescription(state: GatewayTalkSetupState): String = gatewayTalkSetupDescriptionText(state).resolveNativeText()
internal fun gatewayTalkSetupDescriptionText(state: GatewayTalkSetupState): NativeText =
when (state) {
is GatewayTalkSetupState.Ready ->
nativeText("\${state.provider.label} via Gateway relay", verbatimText(state.provider.label))
is GatewayTalkSetupState.NeedsSetup -> gatewayTalkSetupIssueDescriptionText(state.issue)
is GatewayTalkSetupState.Unverified -> gatewayTalkSetupIssueDescriptionText(state.issue)
}
internal fun gatewayTalkSetupIssueDescriptionText(issue: GatewayTalkSetupIssue): NativeText =
when (issue) {
GatewayTalkSetupIssue.CatalogNotLoaded -> nativeText("Gateway talk catalog not loaded")
GatewayTalkSetupIssue.CatalogLoadFailed -> nativeText("Could not load Gateway talk catalog")
is GatewayTalkSetupIssue.GroupMissing ->
nativeText("Gateway did not return \${issue.target.title} setup", issue.target.title)
is GatewayTalkSetupIssue.NoProvider ->
nativeText("No \${issue.target.title} provider is configured on the Gateway", issue.target.title)
is GatewayTalkSetupIssue.UnknownProvider ->
nativeText("Gateway selected unknown provider \${issue.providerId}", verbatimText(issue.providerId))
is GatewayTalkSetupIssue.MissingReadiness ->
nativeText("Gateway did not return \${issue.target.title} readiness", issue.target.title)
is GatewayTalkSetupIssue.ConfigureProvider ->
nativeText("Configure a \${issue.target.title} provider on the Gateway", issue.target.title)
is GatewayTalkSetupIssue.MissingActiveProvider ->
nativeText("Gateway did not identify the active \${issue.target.title} provider", issue.target.title)
is GatewayTalkSetupIssue.UnsupportedProvider ->
nativeText("Choose a supported \${issue.target.title} provider on the Gateway", issue.target.title)
is GatewayTalkSetupIssue.ConfigureSelectedProvider ->
nativeText("Configure \${issue.providerLabel} on the Gateway", verbatimText(issue.providerLabel))
}
internal fun parseGatewayTalkSetupReadiness(catalog: JsonObject?): GatewayTalkSetupReadiness {
if (catalog == null) return GatewayTalkSetupReadiness.unverified()
return GatewayTalkSetupReadiness(
realtimeTalk =
parseTalkCatalogGroup(catalog = catalog, key = "realtime", target = GatewayTalkSetupTarget.REALTIME_TALK),
dictation =
parseTalkCatalogGroup(catalog = catalog, key = "transcription", target = GatewayTalkSetupTarget.DICTATION),
)
}
private fun parseTalkCatalogGroup(
catalog: JsonObject,
key: String,
target: GatewayTalkSetupTarget,
): GatewayTalkSetupState {
val group =
catalog[key].asObjectOrNull()
?: return GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.GroupMissing(target))
val providers =
(group["providers"] as? JsonArray)
?.mapNotNull(::parseTalkCatalogProvider)
.orEmpty()
val ready = (group["ready"] as? JsonPrimitive)?.booleanOrNull
val activeProviderId = group["activeProvider"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty)
if (providers.isEmpty()) {
return when {
ready == false -> GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.NoProvider(target))
activeProviderId != null ->
GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.UnknownProvider(target, activeProviderId))
else -> GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingReadiness(target))
}
}
if (activeProviderId == null) {
if (ready == false) {
return GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.ConfigureProvider(target))
}
// Older Gateways can omit the selected provider and report alias-backed rows as unconfigured
// even though session startup resolves them. Only an explicit readiness result is authoritative.
return GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingActiveProvider(target))
}
val selected =
// Match Gateway registry precedence: canonical ids win before alias fallback.
providers.firstOrNull { it.matchesId(activeProviderId) }
?: providers.firstOrNull { it.matchesAlias(activeProviderId) }
?: return if (ready == false) {
GatewayTalkSetupState.NeedsSetup(GatewayTalkSetupIssue.UnsupportedProvider(target))
} else {
GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.UnknownProvider(target, activeProviderId))
}
val provider = GatewayTalkProvider(id = selected.id, label = selected.label)
return when (ready) {
true -> GatewayTalkSetupState.Ready(provider)
false ->
GatewayTalkSetupState.NeedsSetup(
issue = GatewayTalkSetupIssue.ConfigureSelectedProvider(selected.label),
provider = provider,
)
null -> GatewayTalkSetupState.Unverified(GatewayTalkSetupIssue.MissingReadiness(target))
}
}
private data class TalkCatalogProvider(
val id: String,
val label: String,
val configured: Boolean,
val aliases: List<String>,
) {
fun matchesId(candidate: String): Boolean = id.equals(candidate, ignoreCase = true)
fun matchesAlias(candidate: String): Boolean = aliases.any { it.equals(candidate, ignoreCase = true) }
}
private fun parseTalkCatalogProvider(item: JsonElement): TalkCatalogProvider? {
val value = item.asObjectOrNull() ?: return null
val id = value["id"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: return null
val label = value["label"].asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) ?: id
val aliases =
(value["aliases"] as? JsonArray)
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf(String::isNotEmpty) }
.orEmpty()
return TalkCatalogProvider(
id = id,
label = label,
configured = (value["configured"] as? JsonPrimitive)?.booleanOrNull == true,
aliases = aliases,
)
}

View file

@ -0,0 +1,34 @@
package ai.openclaw.app
/**
* Persisted location capture mode advertised to the gateway.
*/
enum class LocationMode(
val rawValue: String,
) {
Off("off"),
WhileUsing("whileUsing"),
Always("always"),
;
companion object {
/** Parses persisted location mode text. */
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}
}
}
/** Resolves the in-app mode after Android's external background-location settings return. */
internal fun locationModeAfterBackgroundSettings(
previousMode: LocationMode,
foregroundGranted: Boolean,
backgroundGranted: Boolean,
): LocationMode =
when {
foregroundGranted && backgroundGranted -> LocationMode.Always
!foregroundGranted -> LocationMode.Off
previousMode == LocationMode.Always -> LocationMode.WhileUsing
else -> previousMode
}

View file

@ -0,0 +1,382 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.ui.OpenClawTheme
import ai.openclaw.app.ui.RootScreen
import android.content.Intent
import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Main Android activity that owns Compose UI attachment and runtime UI wiring.
*/
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
private val permissionRequester: PermissionRequester
get() = (application as NodeApp).permissionRequester
private var initializedViewModel: MainViewModel? = null
private var didStartViewModelCollectors = false
private var foreground = false
private val pendingIntentRouter = MainActivityPendingIntentRouter()
private val runtimeUiStarter = MainActivityRuntimeUiStarter()
private var screenshotScene: AndroidScreenshotScene? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingIntentRouter.setInitialIntent(intent)
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester.attach(this)
if (BuildConfig.DEBUG) {
screenshotScene = parseAndroidScreenshotModeIntent(intent)
if (screenshotScene != null) hideScreenshotModeStatusBar()
}
setContent {
var activeViewModel by remember { mutableStateOf<MainViewModel?>(null) }
LaunchedEffect(Unit) {
withFrameNanos { }
withContext(Dispatchers.Default) {
(application as NodeApp).prefs
}
val readyViewModel = viewModel
screenshotScene?.let(readyViewModel::enterScreenshotFixtureMode)
activateViewModel(readyViewModel)
activeViewModel = readyViewModel
}
val currentViewModel = activeViewModel
if (currentViewModel == null) {
OpenClawTheme {
StartupSurface()
}
} else {
val appearanceThemeMode by currentViewModel.appearanceThemeMode.collectAsState()
OpenClawTheme(themeMode = appearanceThemeMode) {
RootScreen(viewModel = currentViewModel)
}
}
}
}
private fun hideScreenshotModeStatusBar() {
WindowCompat
.getInsetsController(window, window.decorView)
.apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.statusBars())
}
}
override fun onStart() {
super.onStart()
foreground = true
initializedViewModel?.setForeground(true)
}
override fun onTopResumedActivityChanged(isTopResumedActivity: Boolean) {
super.onTopResumedActivityChanged(isTopResumedActivity)
// minSdk 31 guarantees this callback and lets multi-resume select the actually interactive task.
updateTopResumedPermissionHost(
isTopResumedActivity = isTopResumedActivity,
activate = { permissionRequester.activate(this) },
deactivate = { permissionRequester.deactivate(this) },
refreshPermissionSurface = { initializedViewModel?.refreshNodePermissionSurface() },
)
}
override fun onStop() {
// Top-resumed ownership normally clears first; this also covers abnormal lifecycle ordering.
permissionRequester.deactivate(this)
foreground = false
if (shouldNotifyRuntimeBackgrounded(isChangingConfigurations)) {
initializedViewModel?.setForeground(false)
}
super.onStop()
}
override fun onDestroy() {
permissionRequester.detach(this)
super.onDestroy()
}
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
val accepted =
pendingIntentRouter.onNewIntent(intent) { routedIntent ->
initializedViewModel?.let { handleLaunchIntent(viewModel = it, intent = routedIntent) }
}
if (!accepted) return
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
) {
// AppCompatActivity marks this callback @CallSuper; it preserves Fragment and ActivityResult dispatch.
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
permissionRequester.onRequestPermissionsResult(requestCode, permissions, grantResults)
initializedViewModel?.refreshNodePermissionSurface()
}
/**
* Wires MainViewModel only after Activity first draw and background prefs warm-up.
*/
private fun activateViewModel(readyViewModel: MainViewModel) {
if (initializedViewModel != null) return
initializedViewModel = readyViewModel
readyViewModel.setForeground(foreground)
startViewModelCollectors(readyViewModel)
if (!readyViewModel.claimInitialIntentRouting()) {
pendingIntentRouter.discardInitialIntent()
}
pendingIntentRouter.activate { initialIntent ->
handleLaunchIntent(viewModel = readyViewModel, intent = initialIntent)
}
readyViewModel.reportShareLaunchOverflow(pendingIntentRouter.takeShareOverflowCount())
}
/**
* Starts lifecycle collectors after ViewModel construction so they cannot force early startup.
*/
private fun startViewModelCollectors(readyViewModel: MainViewModel) {
if (didStartViewModelCollectors) return
didStartViewModelCollectors = true
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
readyViewModel.preventSleep.collect { enabled ->
if (enabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
readyViewModel.runtimeInitialized.collect { ready ->
runtimeUiStarter.onRuntimeInitialized(
ready = ready,
startRuntimeUi = screenshotScene == null,
attachRuntimeUi = {
// Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready.
readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
},
startNodeService = {
NodeForegroundService.start(this@MainActivity)
},
)
}
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
readyViewModel.shareLaunchOverflowRevision.collect { revision ->
if (revision == 0L) return@collect
repeat(readyViewModel.takeShareLaunchOverflowCount()) {
Toast
.makeText(
this@MainActivity,
nativeString("Too many shares are waiting to be added."),
Toast.LENGTH_SHORT,
).show()
}
}
}
}
}
/**
* Routes assistant/app-action intents into ViewModel state without recreating the activity.
*/
private fun handleLaunchIntent(
viewModel: MainViewModel,
intent: Intent?,
) {
if (intent?.isShareLaunchIntent() == true) {
viewModel.handleShareLaunchIntent(intent)
return
}
parseHomeDestinationIntent(intent)?.let { destination ->
viewModel.requestHomeDestination(destination)
return
}
val request = parseAssistantLaunchIntent(intent) ?: return
viewModel.handleAssistantLaunch(request)
}
}
/** Queues shares until ViewModel activation while retaining only the latest ordinary launch intent. */
internal class MainActivityPendingIntentRouter {
private data class PendingLaunchIntent(
val sequence: Long,
val intent: Intent,
val initial: Boolean,
)
private var activated = false
private var sequence = 0L
private val pendingShareIntents = ArrayDeque<PendingLaunchIntent>()
private var pendingNonShareIntent: PendingLaunchIntent? = null
private var shareOverflowCount = 0
fun setInitialIntent(intent: Intent?) {
if (!activated && intent != null) store(intent = intent, initial = true)
}
fun onNewIntent(
intent: Intent,
routeIntent: (Intent) -> Unit,
): Boolean {
if (activated) {
routeIntent(intent)
return true
}
return store(intent = intent, initial = false)
}
fun discardInitialIntent() {
if (activated) return
pendingShareIntents.removeAll { it.initial }
if (pendingNonShareIntent?.initial == true) pendingNonShareIntent = null
}
fun activate(routeIntent: (Intent) -> Unit): Boolean {
if (activated) return false
activated = true
(pendingShareIntents + listOfNotNull(pendingNonShareIntent))
.sortedBy(PendingLaunchIntent::sequence)
.forEach { pending -> routeIntent(pending.intent) }
pendingShareIntents.clear()
pendingNonShareIntent = null
return true
}
fun takeShareOverflowCount(): Int =
shareOverflowCount.also {
shareOverflowCount = 0
}
private fun store(
intent: Intent,
initial: Boolean,
): Boolean {
val pending = PendingLaunchIntent(sequence = sequence++, intent = intent, initial = initial)
if (!intent.isShareLaunchIntent()) {
pendingNonShareIntent = pending
return true
}
if (pendingShareIntents.size >= MAX_PENDING_CHAT_SHARES) {
shareOverflowCount += 1
return false
}
pendingShareIntents.addLast(pending)
return true
}
}
private fun Intent.isShareLaunchIntent(): Boolean = action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE
/** Keeps launch intents one-shot across same-process Activity recreation, but not process death. */
internal class MainActivityInitialIntentGate {
private var claimed = false
fun claim(): Boolean {
if (claimed) return false
claimed = true
return true
}
}
internal fun shouldNotifyRuntimeBackgrounded(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations
internal fun updateTopResumedPermissionHost(
isTopResumedActivity: Boolean,
activate: () -> Unit,
deactivate: () -> Unit,
refreshPermissionSurface: () -> Unit,
) {
if (isTopResumedActivity) {
activate()
refreshPermissionSurface()
} else {
deactivate()
}
}
/** Preserves one-shot runtime UI startup while allowing screenshot fixtures to skip side effects. */
internal class MainActivityRuntimeUiStarter {
private var completed = false
fun onRuntimeInitialized(
ready: Boolean,
startRuntimeUi: Boolean,
attachRuntimeUi: () -> Unit,
startNodeService: () -> Unit,
) {
if (!ready || completed) return
if (!startRuntimeUi) {
completed = true
return
}
attachRuntimeUi()
completed = true
startNodeService()
}
}
@Composable
private fun StartupSurface() {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color.Black,
contentColor = Color.White,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = "OPENCLAW",
fontSize = 22.sp,
fontWeight = FontWeight.Medium,
)
}
}
}

View file

@ -0,0 +1,22 @@
package ai.openclaw.app
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
/** Reuses the existing app task when a system surface brings OpenClaw forward. */
internal fun mainActivityPendingIntent(
context: Context,
requestCode: Int,
): PendingIntent {
val intent =
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,123 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.NativeStringResources
import ai.openclaw.app.i18n.notifyNativeLocaleChanged
import ai.openclaw.app.wear.GoogleWearMessageSender
import ai.openclaw.app.wear.GoogleWearPeerResolver
import ai.openclaw.app.wear.WearProxyBridge
import ai.openclaw.app.wear.WearRealtimeChannelRegistry
import android.app.Application
import android.content.res.Configuration
import android.os.StrictMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicLong
/**
* Android Application singleton that owns process-wide secure prefs and lazy NodeRuntime startup.
*/
class NodeApp : Application() {
val prefs: SecurePrefs by lazy { SecurePrefs(this) }
// System share senders can create overlapping Activity tasks; keep one bounded process queue.
internal val chatShareDraftSeq = AtomicLong()
internal val chatShareDraftQueue = ChatShareDraftQueue()
internal val permissionRequester by lazy { PermissionRequester(this) }
private val runtimeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val runtimeLock = Any()
private var runtimeInstance: NodeRuntime? = null
internal val wearProxyBridge: WearProxyBridge by lazy {
WearProxyBridge(
scope = runtimeScope,
sender = GoogleWearMessageSender(this),
peerResolver = GoogleWearPeerResolver(this),
handleRequest = { sourceNodeId, request ->
ensureBackgroundRuntime().handleWearProxyRequest(sourceNodeId, request)
},
)
}
internal val wearRealtimeChannels: WearRealtimeChannelRegistry by lazy {
WearRealtimeChannelRegistry(this, runtimeScope)
}
/**
* Returns the single NodeRuntime for this process, creating it on first use.
*/
fun ensureRuntime(): NodeRuntime =
synchronized(runtimeLock) {
runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it }
}
/** Creates a cold-process runtime with foreground-only capabilities disabled before publication. */
internal fun ensureBackgroundRuntime(): NodeRuntime =
synchronized(runtimeLock) {
runtimeInstance
?: NodeRuntime(this, prefs, initialForeground = false).also { runtimeInstance = it }
}
internal fun ensureScreenshotFixtureRuntime(): NodeRuntime =
synchronized(runtimeLock) {
check(BuildConfig.DEBUG) { "Android screenshot fixtures require a debug build" }
runtimeInstance?.also { runtime ->
check(runtime.mode == NodeRuntimeMode.ScreenshotFixture) {
"NodeRuntime already started in live mode"
}
} ?: NodeRuntime(this, prefs, NodeRuntimeMode.ScreenshotFixture).also { runtimeInstance = it }
}
/**
* Reads the runtime without forcing startup, used by lifecycle probes and services.
*/
fun peekRuntime(): NodeRuntime? = synchronized(runtimeLock) { runtimeInstance }
/** Disconnects the current or concurrently constructing runtime without blocking the caller. */
internal fun disconnectRuntimeAsync() {
// The process-owned scope outlives a stopping service, so cancellation cannot
// strand an Activity-created runtime that the service has not observed yet.
runtimeScope.launch { peekRuntime()?.disconnect() }
}
/** Clears pairing auth without racing lazy process-runtime construction. */
suspend fun resetGatewaySetupAuth(stableId: String): Boolean {
val runtime =
synchronized(runtimeLock) {
runtimeInstance
?: NodeRuntime.forGatewayAuthReset(this, prefs).also { runtimeInstance = it }
}
return runtime.resetGatewaySetupAuth(stableId)
}
override fun onCreate() {
super.onCreate()
NativeStringResources.install(this)
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
}
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// The process runtime survives Activity recreation, so retained text and
// serialized Home Canvas state need an explicit locale refresh signal.
NativeStringResources.setConfigurationLocales(newConfig)
notifyNativeLocaleChanged()
}
}

View file

@ -0,0 +1,469 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeLocaleChanges
import ai.openclaw.app.i18n.nativeString
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
/** Foreground service that keeps the Android node connection and voice capture visible to the OS. */
class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
private var runtimeRestoreJob: Job? = null
private var activeRuntime: NodeRuntime? = null
private var latestStartId = 0
private var voiceCaptureMode = VoiceCaptureMode.Off
@Volatile private var disconnectRequested = false
override fun onCreate() {
super.onCreate()
ensureChannel()
val initial =
buildNotification(
title = nativeString("OpenClaw Node"),
text = nativeString("Starting…"),
)
startForegroundWithTypes(notification = initial)
}
private fun startRuntimeIfNeeded(startId: Int) {
if (activeRuntime != null || runtimeRestoreJob?.isActive == true || disconnectRequested) return
val app = application as NodeApp
runtimeRestoreJob =
scope.launch(Dispatchers.Default) {
try {
restoreStickyRuntime(
createRuntime = app::ensureBackgroundRuntime,
disconnectRequested = { disconnectRequested },
disconnectRuntime = NodeRuntime::disconnect,
) { restoredRuntime ->
withContext(Dispatchers.Main) {
runtimeRestoreJob = null
if (disconnectRequested) {
false
} else {
activeRuntime = restoredRuntime
observeRuntime(restoredRuntime)
true
}
}
}
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
Log.e("OpenClawNodeService", "Failed to restore node runtime", err)
withContext(Dispatchers.Main) {
runtimeRestoreJob = null
if (!disconnectRequested && !stopSelfResult(startId)) {
startRuntimeIfNeeded(latestStartId)
}
}
}
}
}
private fun observeRuntime(runtime: NodeRuntime) {
// Keep the connection tuple atomic, then split connection and capture work so notification text
// can update without restarting runtime-owned connection work.
notificationJob =
scope.launch {
val notificationStates =
combine(
combine(
runtime.gatewayConnectionDisplay,
runtime.serverName,
runtime.voiceCaptureMode,
runtime.locationMode,
) { connection, server, mode, _ ->
VoiceNotificationBase(
status = connection.statusText,
server = server,
connected = connection.isConnected,
mode = mode,
)
},
combine(
runtime.micEnabled,
runtime.micIsListening,
runtime.talkModeListening,
runtime.talkModeSpeaking,
) { micEnabled, micListening, talkListening, talkSpeaking ->
VoiceNotificationCapture(
micEnabled = micEnabled,
micListening = micListening,
talkListening = talkListening,
talkSpeaking = talkSpeaking,
)
},
) { base, capture ->
VoiceNotificationState(base = base, capture = capture)
}
refreshNotificationOnLocaleChanges(
states = notificationStates,
localeChanges = nativeLocaleChanges,
).collect { update ->
ensureChannelForLocaleRevision(update.localeRevision)
val state = update.state
voiceCaptureMode = state.mode
val title =
when {
state.connected && state.mode == VoiceCaptureMode.TalkMode ->
nativeString("OpenClaw Node · Talk")
state.connected -> nativeString("OpenClaw Node · Connected")
else -> nativeString("OpenClaw Node")
}
val displayStatus = gatewayConnectionStatusForDisplay(state.status)
val text =
(state.server?.let { nativeString("\$status · \$server", displayStatus, it) } ?: displayStatus) +
voiceNotificationSuffix(
mode = state.mode,
manualMicEnabled = state.capture.micEnabled,
manualMicListening = state.capture.micListening,
talkListening = state.capture.talkListening,
talkSpeaking = state.capture.talkSpeaking,
)
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
)
}
}
}
private var channelLocaleRevision: Long? = null
private fun ensureChannelForLocaleRevision(localeRevision: Long) {
if (channelLocaleRevision == localeRevision) return
ensureChannel()
channelLocaleRevision = localeRevision
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
latestStartId = maxOf(latestStartId, startId)
when (intent?.action) {
ACTION_STOP -> {
startSuppressed.set(true)
disconnectRequested = true
runtimeRestoreJob?.cancel()
runtimeRestoreJob = null
notificationJob?.cancel()
notificationJob = null
activeRuntime?.disconnect()
activeRuntime = null
(application as NodeApp).disconnectRuntimeAsync()
stopSelfResult(startId)
return START_NOT_STICKY
}
ACTION_RESUME -> {
startSuppressed.set(false)
disconnectRequested = false
}
ACTION_SET_VOICE_CAPTURE_MODE -> {
voiceCaptureMode = intent.getStringExtra(EXTRA_VOICE_CAPTURE_MODE).toVoiceCaptureMode()
startForegroundWithTypes(
notification =
buildNotification(
title = nativeString("OpenClaw Node"),
text =
if (voiceCaptureMode == VoiceCaptureMode.TalkMode) {
nativeString("Talk mode active")
} else {
nativeString("Connected")
},
),
)
}
}
if (disconnectRequested || startSuppressed.get()) {
// A STOP can lose stopSelfResult to a newer queued start. Let the newest
// start id close the service instead of leaving a disconnected FGS alive.
stopSelfResult(startId)
return START_NOT_STICKY
}
// START_STICKY recreates the service in a fresh process and calls this with a null intent.
startRuntimeIfNeeded(startId)
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
return START_STICKY
}
override fun onDestroy() {
notificationJob?.cancel()
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?) = null
private fun ensureChannel() {
val mgr = getSystemService(NotificationManager::class.java)
val channel =
NotificationChannel(
CHANNEL_ID,
nativeString("Connection"),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = nativeString("OpenClaw node connection status")
setShowBadge(false)
}
mgr.createNotificationChannel(channel)
}
private fun buildNotification(
title: String,
text: String,
): Notification {
val launchPending = mainActivityPendingIntent(this, requestCode = 1)
val visibleText = text + backgroundLocationNotificationSuffix(isBackgroundLocationActive())
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
val stopPending =
PendingIntent.getService(
this,
2,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(visibleText)
.setContentIntent(launchPending)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.addAction(0, nativeString("Disconnect"), stopPending)
.build()
}
private fun startForegroundWithTypes(notification: Notification) {
val serviceTypes =
foregroundServiceTypes(
voiceMode = voiceCaptureMode,
backgroundLocationActive = isBackgroundLocationActive(),
)
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
}
private fun isBackgroundLocationActive(): Boolean {
if (!SensitiveFeatureConfig.backgroundLocationEnabled) return false
if ((application as NodeApp).prefs.locationMode.value != LocationMode.Always) return false
val fineGranted =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val coarseGranted =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
val backgroundGranted =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
return (fineGranted || coarseGranted) && backgroundGranted
}
companion object {
private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1
private const val ACTION_STOP = "ai.openclaw.app.action.STOP"
private const val ACTION_RESUME = "ai.openclaw.app.action.RESUME"
private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE"
private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE"
private val startSuppressed = AtomicBoolean(false)
fun start(context: Context) {
if (startSuppressed.get()) return
val intent = Intent(context, NodeForegroundService::class.java)
context.startForegroundService(intent)
}
fun stop(context: Context) {
startSuppressed.set(true)
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
internal fun resume(
context: Context,
startNow: Boolean,
) {
startSuppressed.set(false)
if (!startNow) return
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_RESUME)
context.startForegroundService(intent)
}
fun setVoiceCaptureMode(
context: Context,
mode: VoiceCaptureMode,
) {
if (startSuppressed.get()) return
val intent =
Intent(context, NodeForegroundService::class.java)
.setAction(ACTION_SET_VOICE_CAPTURE_MODE)
.putExtra(EXTRA_VOICE_CAPTURE_MODE, mode.name)
if (mode == VoiceCaptureMode.TalkMode) {
// Microphone foreground service type must be declared before Talk capture starts.
ContextCompat.startForegroundService(context, intent)
} else {
context.startService(intent)
}
}
}
}
/** Restores process-local state after Android recreates a sticky service in a fresh process. */
internal suspend fun <T> restoreStickyRuntime(
createRuntime: () -> T,
disconnectRequested: () -> Boolean,
disconnectRuntime: (T) -> Unit,
activateRuntime: suspend (T) -> Boolean,
) {
// A queued recovery may begin after STOP; do not construct process state once
// disconnect has already won. The post-create check still closes the race during construction.
if (disconnectRequested()) return
val runtime = createRuntime()
var activated = false
try {
if (!disconnectRequested()) {
activated = activateRuntime(runtime)
}
} finally {
// Ownership transfers only after activation. Stop/cancellation during the
// dispatcher hop must disconnect the recovered runtime instead of leaking it.
if (!activated) {
disconnectRuntime(runtime)
}
}
}
internal fun foregroundServiceTypes(
voiceMode: VoiceCaptureMode,
backgroundLocationActive: Boolean,
): Int {
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
val voiceTypes =
when (voiceMode) {
VoiceCaptureMode.Off -> base
VoiceCaptureMode.ManualMic,
VoiceCaptureMode.TalkMode,
-> base or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
return if (backgroundLocationActive) {
voiceTypes or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
} else {
voiceTypes
}
}
internal fun backgroundLocationNotificationSuffix(active: Boolean): String =
if (active) {
nativeString(" · Location: Always")
} else {
""
}
internal fun voiceNotificationSuffix(
mode: VoiceCaptureMode,
manualMicEnabled: Boolean,
manualMicListening: Boolean,
talkListening: Boolean,
talkSpeaking: Boolean,
): String =
when (mode) {
VoiceCaptureMode.TalkMode ->
when {
talkSpeaking -> nativeString(" · Talk: Speaking")
talkListening -> nativeString(" · Talk: Listening")
else -> nativeString(" · Talk: On")
}
VoiceCaptureMode.ManualMic ->
if (manualMicEnabled) {
if (manualMicListening) {
nativeString(" · Mic: Listening")
} else {
nativeString(" · Mic: Pending")
}
} else {
""
}
VoiceCaptureMode.Off -> ""
}
private fun String?.toVoiceCaptureMode(): VoiceCaptureMode =
VoiceCaptureMode.entries.firstOrNull {
it.name == this
} ?: VoiceCaptureMode.Off
/** Connection fields that drive foreground notification title/body text. */
private data class VoiceNotificationBase(
val status: String,
val server: String?,
val connected: Boolean,
val mode: VoiceCaptureMode,
)
/** Voice capture fields that affect foreground-service type and suffix. */
private data class VoiceNotificationCapture(
val micEnabled: Boolean,
val micListening: Boolean,
val talkListening: Boolean,
val talkSpeaking: Boolean,
)
/** Aggregated notification state from runtime flows. */
private data class VoiceNotificationState(
val base: VoiceNotificationBase,
val capture: VoiceNotificationCapture,
) {
val status: String
get() = base.status
val server: String?
get() = base.server
val connected: Boolean
get() = base.connected
val mode: VoiceCaptureMode
get() = base.mode
}
/** Re-emits stable runtime state when app-owned notification copy changes locale. */
internal data class LocaleAwareNotificationState<T>(
val state: T,
val localeRevision: Long,
)
internal fun <T> refreshNotificationOnLocaleChanges(
states: Flow<T>,
localeChanges: Flow<Long>,
): Flow<LocaleAwareNotificationState<T>> =
combine(states, localeChanges) { state, localeRevision ->
LocaleAwareNotificationState(state = state, localeRevision = localeRevision)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
package ai.openclaw.app
import java.time.Instant
import java.time.ZoneId
private val nativeChannelNotificationPackages =
setOf(
"com.discord",
"com.whatsapp",
"com.whatsapp.w4b",
"org.telegram.messenger",
"org.telegram.messenger.web",
"org.thunderdog.challegram",
"org.thoughtcrime.securesms",
)
/** Package-filter mode used before notification events are forwarded to the gateway. */
enum class NotificationPackageFilterMode(
val rawValue: String,
) {
Allowlist("allowlist"),
Blocklist("blocklist"),
;
companion object {
/** Parses persisted filter mode text, defaulting to blocklist for safer forwarding. */
fun fromRawValue(raw: String?): NotificationPackageFilterMode = entries.firstOrNull { it.rawValue == raw?.trim()?.lowercase() } ?: Blocklist
}
}
/** Runtime policy used before forwarding notification events to a node session. */
internal data class NotificationForwardingPolicy(
val enabled: Boolean,
val mode: NotificationPackageFilterMode,
val packages: Set<String>,
val quietHoursEnabled: Boolean,
val quietStart: String,
val quietEnd: String,
val maxEventsPerMinute: Int,
val sessionKey: String?,
val selfPackageName: String = "",
)
/** Applies the operator-configured package allow/block list after trimming input. */
internal fun NotificationForwardingPolicy.allowsPackage(packageName: String): Boolean {
val normalized = packageName.trim()
if (normalized.isEmpty()) {
return false
}
val self = selfPackageName.trim()
if (self.isNotEmpty() && normalized == self) {
return false
}
// Native channel sessions own these messages. Forwarding their notifications creates an
// unbound duplicate that can be answered from the wrong conversation.
if (normalized in nativeChannelNotificationPackages) {
return false
}
return when (mode) {
NotificationPackageFilterMode.Allowlist -> packages.contains(normalized)
NotificationPackageFilterMode.Blocklist -> !packages.contains(normalized)
}
}
/** Returns true for both same-day and overnight quiet-hour windows. */
internal fun NotificationForwardingPolicy.isWithinQuietHours(
nowEpochMs: Long,
zoneId: ZoneId = ZoneId.systemDefault(),
): Boolean {
if (!quietHoursEnabled) {
return false
}
val startMinutes = parseLocalHourMinute(quietStart) ?: return false
val endMinutes = parseLocalHourMinute(quietEnd) ?: return false
if (startMinutes == endMinutes) {
return true
}
val now =
Instant
.ofEpochMilli(nowEpochMs)
.atZone(zoneId)
.toLocalTime()
val nowMinutes = now.hour * 60 + now.minute
return if (startMinutes < endMinutes) {
nowMinutes in startMinutes until endMinutes
} else {
nowMinutes >= startMinutes || nowMinutes < endMinutes
}
}
private val localHourMinuteRegex = Regex("""^([01]\d|2[0-3]):([0-5]\d)$""")
/** Normalizes persisted or user-entered local times to strict HH:mm form. */
internal fun normalizeLocalHourMinute(raw: String): String? {
val trimmed = raw.trim()
val match = localHourMinuteRegex.matchEntire(trimmed) ?: return null
return "${match.groupValues[1]}:${match.groupValues[2]}"
}
/** Converts strict local HH:mm text to minutes since midnight for window checks. */
internal fun parseLocalHourMinute(raw: String): Int? {
val normalized = normalizeLocalHourMinute(raw) ?: return null
val parts = normalized.split(':')
val hour = parts[0].toInt()
val minute = parts[1].toInt()
return hour * 60 + minute
}
/** Fixed-window limiter that bounds notification bursts per wall-clock minute. */
internal class NotificationBurstLimiter {
private val lock = Any()
private var windowStartMs: Long = -1L
private var eventsInWindow: Int = 0
/** Returns true when the current minute bucket still has forwarding capacity. */
fun allow(
nowEpochMs: Long,
maxEventsPerMinute: Int,
): Boolean {
if (maxEventsPerMinute <= 0) {
return false
}
// Align all callers to the same minute bucket so concurrent notifications
// share the quota even when they arrive with slightly different timestamps.
val currentWindow = nowEpochMs - (nowEpochMs % 60_000L)
synchronized(lock) {
if (currentWindow != windowStartMs) {
windowStartMs = currentWindow
eventsInWindow = 0
}
if (eventsInWindow >= maxEventsPerMinute) {
return false
}
eventsInWindow += 1
return true
}
}
}

View file

@ -0,0 +1,517 @@
package ai.openclaw.app
import ai.openclaw.app.i18n.nativeString
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.IdentityHashMap
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
/**
* Serializes Android runtime-permission prompts behind coroutine-friendly request calls.
*/
class PermissionRequester internal constructor(
context: Context,
private val requestCodeAllocator: PermissionRequestCodeAllocator = PermissionRequestCodeAllocator(),
) {
private data class ActivityHost(
val activity: ComponentActivity,
val permissionRequestLauncher: (Array<String>, Int) -> Unit,
)
private data class ActiveActivityHost(
val host: ActivityHost,
val activation: Long,
)
private data class PendingPermissionRequest(
val requestCode: Int,
val permissions: List<String>,
val deferred: CompletableDeferred<Map<String, Boolean>>,
)
private enum class RationaleResult {
Proceed,
Decline,
HostLost,
}
private enum class SettingsResult {
Shown,
HostLost,
}
private val appContext = context.applicationContext
private val mutex = Mutex()
private val activityHostLock = Any()
private val permissionRequestsLock = Any()
private val mainHandler = Handler(Looper.getMainLooper())
private val activityHosts = IdentityHashMap<ComponentActivity, ActivityHost>()
private val activeActivitySequences = IdentityHashMap<ComponentActivity, Long>()
private val activeActivityHost = MutableStateFlow<ActiveActivityHost?>(null)
private var nextActivityActivation = 0L
private val pendingPermissionRequests = mutableMapOf<Int, PendingPermissionRequest>()
internal fun attach(
activity: ComponentActivity,
permissionRequestLauncher: (Array<String>, Int) -> Unit = { permissions, requestCode ->
ActivityCompat.requestPermissions(activity, permissions, requestCode)
},
) {
synchronized(activityHostLock) {
activityHosts[activity] = ActivityHost(activity, permissionRequestLauncher)
publishActiveActivityHostLocked()
}
}
internal fun activate(activity: ComponentActivity) {
synchronized(activityHostLock) {
check(activityHosts.containsKey(activity)) { "permission Activity must attach before activation" }
nextActivityActivation += 1
activeActivitySequences[activity] = nextActivityActivation
publishActiveActivityHostLocked()
}
}
internal fun deactivate(activity: ComponentActivity) {
synchronized(activityHostLock) {
activeActivitySequences.remove(activity)
publishActiveActivityHostLocked()
}
}
internal fun detach(activity: ComponentActivity) {
synchronized(activityHostLock) {
activeActivitySequences.remove(activity)
activityHosts.remove(activity)
publishActiveActivityHostLocked()
}
}
/**
* Request missing Android runtime permissions and return the final grant state for every requested permission.
*/
suspend fun requestIfMissing(
permissions: List<String>,
timeoutMs: Long = 20_000,
): Map<String, Boolean> {
return mutex.withLock {
while (true) {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(appContext, perm) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
return permissions.associateWith { true }
}
if (!confirmRationaleIfNeeded(missing, timeoutMs)) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(appContext, perm) == PackageManager.PERMISSION_GRANTED
}
}
val deferred = CompletableDeferred<Map<String, Boolean>>()
val request = reservePermissionRequest(missing, deferred)
try {
launchPermissionRequest(missing, request.requestCode, timeoutMs)
} catch (err: Throwable) {
clearPermissionRequest(request)
throw err
}
val result =
try {
withTimeout(timeoutMs) { deferred.await() }
} finally {
// Timeout and caller cancellation both retire the request code before the mutex admits another prompt.
clearPermissionRequest(request)
}
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(appContext, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
showSettingsForPermanentDenials(merged, timeoutMs)
return merged
}
error("unreachable")
}
}
internal fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
): Boolean {
val request =
synchronized(permissionRequestsLock) {
pendingPermissionRequests.remove(requestCode)
} ?: return false
val grants =
permissions
.mapIndexed { index, permission ->
permission to (grantResults.getOrNull(index) == PackageManager.PERMISSION_GRANTED)
}.toMap()
request.deferred.complete(request.permissions.associateWith { permission -> grants[permission] == true })
return true
}
private fun reservePermissionRequest(
permissions: List<String>,
deferred: CompletableDeferred<Map<String, Boolean>>,
): PendingPermissionRequest =
synchronized(permissionRequestsLock) {
val requestCode = requestCodeAllocator.allocate(pendingPermissionRequests::containsKey)
val request = PendingPermissionRequest(requestCode, permissions, deferred)
pendingPermissionRequests[requestCode] = request
request
}
private fun clearPermissionRequest(
request: PendingPermissionRequest,
) {
synchronized(permissionRequestsLock) {
if (pendingPermissionRequests[request.requestCode] === request) {
pendingPermissionRequests.remove(request.requestCode)
}
}
}
private fun publishActiveActivityHostLocked() {
val active =
activeActivitySequences.entries.maxByOrNull { it.value }?.let { entry ->
activityHosts[entry.key]?.let { host ->
ActiveActivityHost(host = host, activation = entry.value)
}
}
activeActivityHost.value = active
}
private suspend fun awaitActiveActivityHost(timeoutMs: Long): ActiveActivityHost =
withTimeout(timeoutMs) {
activeActivityHost
.filterNotNull()
.first { active ->
!active.host.activity.isFinishing && !active.host.activity.isDestroyed
}
}
private suspend fun launchPermissionRequest(
permissions: List<String>,
requestCode: Int,
timeoutMs: Long,
) {
withTimeout(timeoutMs) {
var rejected: ActiveActivityHost? = null
while (true) {
val active =
activeActivityHost
.filterNotNull()
.first { candidate ->
candidate != rejected &&
!candidate.host.activity.isFinishing &&
!candidate.host.activity.isDestroyed
}
val launched =
withContext(Dispatchers.Main) {
if (activeActivityHost.value != active) return@withContext false
val host = active.host
if (host.activity.isFinishing || host.activity.isDestroyed) return@withContext false
host.permissionRequestLauncher(permissions.toTypedArray(), requestCode)
true
}
if (launched) return@withTimeout
rejected = active
}
}
}
private suspend fun confirmRationaleIfNeeded(
permissions: List<String>,
timeoutMs: Long,
): Boolean =
withTimeout(timeoutMs) {
while (true) {
val active = awaitActiveActivityHost(timeoutMs)
val needsRationale =
withContext(Dispatchers.Main) {
if (!isCurrentActiveHost(active)) return@withContext null
permissions.any { permission ->
ActivityCompat.shouldShowRequestPermissionRationale(active.host.activity, permission)
}
} ?: continue
if (!needsRationale) return@withTimeout true
when (showRationaleDialog(active, permissions)) {
RationaleResult.Proceed -> return@withTimeout true
RationaleResult.Decline -> return@withTimeout false
RationaleResult.HostLost -> Unit
}
}
error("unreachable")
}
private suspend fun showSettingsForPermanentDenials(
grants: Map<String, Boolean>,
timeoutMs: Long,
) {
if (grants.values.none { granted -> !granted }) return
withTimeout(timeoutMs) {
while (true) {
val active = awaitActiveActivityHost(timeoutMs)
val denied =
withContext(Dispatchers.Main) {
if (!isCurrentActiveHost(active)) return@withContext null
grants
.filterValues { granted -> !granted }
.keys
.filter { permission ->
!ActivityCompat.shouldShowRequestPermissionRationale(active.host.activity, permission)
}
} ?: continue
if (denied.isEmpty()) return@withTimeout
when (showSettingsDialog(active, denied)) {
SettingsResult.Shown -> return@withTimeout
SettingsResult.HostLost -> Unit
}
}
}
}
private fun isCurrentActiveHost(active: ActiveActivityHost): Boolean =
activeActivityHost.value == active &&
!active.host.activity.isFinishing &&
!active.host.activity.isDestroyed
private suspend fun showRationaleDialog(
active: ActiveActivityHost,
permissions: List<String>,
): RationaleResult =
withContext(Dispatchers.Main) {
if (!isCurrentActiveHost(active)) {
return@withContext RationaleResult.HostLost
}
val activity = active.host.activity
suspendCancellableCoroutine { cont ->
val lifecycle = activity.lifecycle
var dialog: AlertDialog? = null
var observer: LifecycleEventObserver? = null
var hostLossJob: Job? = null
val finished = AtomicBoolean(false)
val removeObserver = {
observer?.let(lifecycle::removeObserver)
observer = null
}
fun finish(result: RationaleResult?) {
if (!finished.compareAndSet(false, true)) return
hostLossJob?.cancel()
hostLossJob = null
removeObserver()
dialog?.dismiss()
if (result != null) {
cont.resume(result)
}
}
val actualObserver =
LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
finish(RationaleResult.HostLost)
}
observer = actualObserver
lifecycle.addObserver(actualObserver)
hostLossJob =
CoroutineScope(cont.context)
.launch(start = CoroutineStart.LAZY) {
activeActivityHost.first { current -> current != active }
finish(RationaleResult.HostLost)
}.also(Job::start)
cont.invokeOnCancellation {
mainHandler.post {
finish(null)
}
}
if (finished.get()) return@suspendCancellableCoroutine
dialog =
AlertDialog
.Builder(activity)
.setTitle(nativeString("Permission required"))
.setMessage(buildRationaleMessage(permissions))
.setPositiveButton(nativeString("Continue")) { _, _ -> finish(RationaleResult.Proceed) }
.setNegativeButton(nativeString("Not now")) { _, _ -> finish(RationaleResult.Decline) }
.setOnCancelListener { finish(RationaleResult.Decline) }
.show()
}
}
private suspend fun showSettingsDialog(
active: ActiveActivityHost,
permissions: List<String>,
): SettingsResult =
withContext(Dispatchers.Main) {
if (!isCurrentActiveHost(active)) {
return@withContext SettingsResult.HostLost
}
val activity = active.host.activity
suspendCancellableCoroutine { cont ->
val lifecycle = activity.lifecycle
var dialog: AlertDialog? = null
var observer: LifecycleEventObserver? = null
var hostLossJob: Job? = null
val finished = AtomicBoolean(false)
val removeObserver = {
observer?.let(lifecycle::removeObserver)
observer = null
}
fun finish(result: SettingsResult?) {
if (!finished.compareAndSet(false, true)) return
hostLossJob?.cancel()
hostLossJob = null
removeObserver()
dialog?.dismiss()
if (result != null) {
cont.resume(result)
}
}
val actualObserver =
LifecycleEventObserver { _, event ->
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
finish(SettingsResult.HostLost)
}
observer = actualObserver
lifecycle.addObserver(actualObserver)
hostLossJob =
CoroutineScope(cont.context)
.launch(start = CoroutineStart.LAZY) {
activeActivityHost.first { current -> current != active }
finish(SettingsResult.HostLost)
}.also(Job::start)
cont.invokeOnCancellation {
mainHandler.post {
finish(null)
}
}
if (finished.get()) return@suspendCancellableCoroutine
dialog =
AlertDialog
.Builder(activity)
.setTitle(nativeString("Enable permission in Settings"))
.setMessage(buildSettingsMessage(permissions))
.setPositiveButton(nativeString("Open Settings")) { _, _ ->
if (!isCurrentActiveHost(active)) {
finish(SettingsResult.HostLost)
return@setPositiveButton
}
val intent =
Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", activity.packageName, null),
)
activity.startActivity(intent)
finish(SettingsResult.Shown)
}.setNegativeButton(nativeString("Cancel")) { _, _ ->
finish(SettingsResult.Shown)
}.setOnCancelListener { finish(SettingsResult.Shown) }
.setOnDismissListener { finish(SettingsResult.Shown) }
.show()
}
}
private fun buildRationaleMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) }
return nativeString(
"OpenClaw needs \${labels.joinToString(\", \")} permissions to continue.",
labels.joinToString(", "),
)
}
private fun buildSettingsMessage(permissions: List<String>): String {
val labels = permissions.map { permissionLabel(it) }
return nativeString(
"Please enable \${labels.joinToString(\", \")} in Android Settings to continue.",
labels.joinToString(", "),
)
}
private fun permissionLabel(permission: String): String =
when (permission) {
Manifest.permission.CAMERA -> nativeString("Camera")
Manifest.permission.RECORD_AUDIO -> nativeString("Microphone")
Manifest.permission.SEND_SMS -> nativeString("Send SMS")
Manifest.permission.READ_SMS -> nativeString("Read SMS")
Manifest.permission.READ_CONTACTS -> nativeString("Read Contacts")
Manifest.permission.WRITE_CONTACTS -> nativeString("Write Contacts")
Manifest.permission.READ_CALENDAR -> nativeString("Read Calendar")
Manifest.permission.WRITE_CALENDAR -> nativeString("Write Calendar")
Manifest.permission.READ_CALL_LOG -> nativeString("Read Call Log")
Manifest.permission.ACTIVITY_RECOGNITION -> nativeString("Motion Activity")
Manifest.permission.READ_MEDIA_IMAGES -> nativeString("Photos")
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED -> nativeString("Photos")
Manifest.permission.READ_EXTERNAL_STORAGE -> nativeString("Photos")
else -> permission
}
}
internal class PermissionRequestCodeAllocator(
initialRequestCode: Int = FIRST_PERMISSION_REQUEST_CODE,
) {
private var nextRequestCode = initialRequestCode
init {
require(initialRequestCode in FIRST_PERMISSION_REQUEST_CODE..LAST_PERMISSION_REQUEST_CODE)
}
fun allocate(isInUse: (Int) -> Boolean): Int {
repeat(PERMISSION_REQUEST_CODE_COUNT) {
val requestCode = nextRequestCode
nextRequestCode =
if (requestCode == LAST_PERMISSION_REQUEST_CODE) {
FIRST_PERMISSION_REQUEST_CODE
} else {
requestCode + 1
}
if (!isInUse(requestCode)) return requestCode
}
error("permission request codes exhausted")
}
internal companion object {
// AndroidX ActivityResultRegistry reserves request codes >= 0x10000. Direct ActivityCompat
// requests stay in a disjoint 16-bit range and skip live codes when the counter wraps.
const val FIRST_PERMISSION_REQUEST_CODE = 0x4C00
const val LAST_PERMISSION_REQUEST_CODE = 0xFFFF
private const val PERMISSION_REQUEST_CODE_COUNT =
LAST_PERMISSION_REQUEST_CODE - FIRST_PERMISSION_REQUEST_CODE + 1
}
}

View file

@ -0,0 +1,23 @@
package ai.openclaw.app
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
internal fun photoReadPermissionsForRequest(): List<String> =
when {
Build.VERSION.SDK_INT >= 34 ->
listOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED,
)
Build.VERSION.SDK_INT >= 33 -> listOf(Manifest.permission.READ_MEDIA_IMAGES)
else -> listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
internal fun hasPhotoReadPermission(context: Context): Boolean =
photoReadPermissionsForRequest().any { permission ->
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}

View file

@ -0,0 +1,802 @@
@file:Suppress("DEPRECATION")
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewayCustomHeaders
import ai.openclaw.app.gateway.GatewayRegistryStore
import ai.openclaw.app.gateway.GatewayStoreMigration
import ai.openclaw.app.voice.VoiceWakePreferences
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import java.util.UUID
@Serializable
data class GatewayCredentials(
val token: String? = null,
val bootstrapToken: String? = null,
val password: String? = null,
) {
internal fun normalized(): GatewayCredentials =
copy(
token = token?.trim()?.takeIf { it.isNotEmpty() },
bootstrapToken = bootstrapToken?.trim()?.takeIf { it.isNotEmpty() },
password = password?.trim()?.takeIf { it.isNotEmpty() },
)
}
/**
* Reactive settings facade for Android node preferences and encrypted gateway credentials.
*/
class SecurePrefs(
context: Context,
private val securePrefsOverride: SharedPreferences? = null,
) {
companion object {
private const val displayNameKey = "node.displayName"
private const val locationModeKey = "location.enabledMode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
private const val notificationsForwardingEnabledKey = "notifications.forwarding.enabled"
private const val defaultNotificationForwardingEnabled = false
private const val notificationsForwardingModeKey = "notifications.forwarding.mode"
private const val notificationsForwardingPackagesKey = "notifications.forwarding.packages"
private const val notificationsForwardingQuietHoursEnabledKey =
"notifications.forwarding.quietHoursEnabled"
private const val notificationsForwardingQuietStartKey = "notifications.forwarding.quietStart"
private const val notificationsForwardingQuietEndKey = "notifications.forwarding.quietEnd"
private const val notificationsForwardingMaxEventsPerMinuteKey =
"notifications.forwarding.maxEventsPerMinute"
private const val notificationsForwardingSessionKeyPrefix = "notifications.forwarding.sessionKey"
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
private const val installedAppsDisclosureConsentVersionKey =
"device.apps.prominentDisclosure.consentVersion"
private const val currentInstalledAppsDisclosureConsentVersion = 1
private const val accessibilityControlEnabledKey = "mobileUi.accessibilityControl.enabled"
private const val cameraEnabledKey = "camera.enabled"
private const val preferredCameraFacingKey = "camera.preferredFacing"
private const val voiceMicEnabledKey = "voice.micEnabled"
private const val preferredAudioInputDeviceKey = "voice.preferredAudioInputDevice"
private const val voiceWakeEnabledKey = "voiceWake.enabled"
private const val voiceWakeWordsKey = "voiceWake.triggerWords"
private const val appearanceThemeModeKey = "appearance.themeMode"
private const val chatModelFavoritesKey = "chat.modelFavorites"
private const val chatModelRecentsKey = "chat.modelRecents"
private const val sessionCustomGroupsKey = "sessions.customGroups"
private const val maxChatModelRecents = 5
private const val gatewayCustomHeadersKeyPrefix = "gateway.customHeaders."
}
private val appContext = context.applicationContext
private val json = Json { ignoreUnknownKeys = true }
// Non-secret UI/runtime preferences stay readable for migration and backup behavior.
private val plainPrefs: SharedPreferences =
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
private val hadPlainPrefsBeforeInit = plainPrefs.all.isNotEmpty()
// Gateway credentials and arbitrary secret strings are isolated behind EncryptedSharedPreferences.
private val masterKey by lazy {
MasterKey
.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
}
private val securePrefs: SharedPreferences by lazy { securePrefsOverride ?: createSecurePrefs(appContext, securePrefsName) }
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
val instanceId: StateFlow<String> = _instanceId
// Lazy so plain-preference reads never touch the encrypted store (Robolectric
// has no AndroidKeyStore); the one-time legacy migration runs before the first
// gateway-state read, which is the earliest the registry can be observed.
val gatewayRegistry: GatewayRegistryStore by lazy {
GatewayStoreMigration(this).run()
GatewayRegistryStore(this, ::handleActiveGatewayChanged)
}
private val _displayName =
MutableStateFlow(loadOrMigrateDisplayName(context = context))
val displayName: StateFlow<String> = _displayName
private val _cameraEnabled = MutableStateFlow(loadCameraEnabled())
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode = MutableStateFlow(loadLocationMode())
val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled =
MutableStateFlow(plainPrefs.getBoolean("location.preciseEnabled", true))
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
private val _preventSleep = MutableStateFlow(plainPrefs.getBoolean("screen.preventSleep", true))
val preventSleep: StateFlow<Boolean> = _preventSleep
private val _manualEnabled =
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.enabled", false))
val manualEnabled: StateFlow<Boolean> = _manualEnabled
private val _manualHost =
MutableStateFlow(plainPrefs.getString("gateway.manual.host", "") ?: "")
val manualHost: StateFlow<String> = _manualHost
private val _manualPort =
MutableStateFlow(plainPrefs.getInt("gateway.manual.port", 18789))
val manualPort: StateFlow<Int> = _manualPort
private val _manualTls =
MutableStateFlow(plainPrefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow<Boolean> = _manualTls
private val _onboardingCompleted =
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
private val _lastDiscoveredStableId =
MutableStateFlow(
plainPrefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
)
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
private val _canvasDebugStatusEnabled =
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
private val _installedAppsSharingEnabled =
MutableStateFlow(loadInstalledAppsSharingEnabled())
val installedAppsSharingEnabled: StateFlow<Boolean> = _installedAppsSharingEnabled
private val _accessibilityControlEnabled =
MutableStateFlow(plainPrefs.getBoolean(accessibilityControlEnabledKey, false))
val accessibilityControlEnabled: StateFlow<Boolean> = _accessibilityControlEnabled
private val _notificationForwardingEnabled =
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
private val _notificationForwardingMode =
MutableStateFlow(
NotificationPackageFilterMode.fromRawValue(
plainPrefs.getString(notificationsForwardingModeKey, null),
),
)
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> = _notificationForwardingMode
private val _notificationForwardingPackages = MutableStateFlow(loadNotificationForwardingPackages())
val notificationForwardingPackages: StateFlow<Set<String>> = _notificationForwardingPackages
private val storedQuietStart =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
?: "22:00"
private val storedQuietEnd =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
?: "07:00"
private val storedQuietHoursEnabled =
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
private val _notificationForwardingQuietHoursEnabled =
MutableStateFlow(storedQuietHoursEnabled)
val notificationForwardingQuietHoursEnabled: StateFlow<Boolean> = _notificationForwardingQuietHoursEnabled
private val _notificationForwardingQuietStart = MutableStateFlow(storedQuietStart)
val notificationForwardingQuietStart: StateFlow<String> = _notificationForwardingQuietStart
private val _notificationForwardingQuietEnd = MutableStateFlow(storedQuietEnd)
val notificationForwardingQuietEnd: StateFlow<String> = _notificationForwardingQuietEnd
private val _notificationForwardingMaxEventsPerMinute =
MutableStateFlow(plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20).coerceAtLeast(1))
val notificationForwardingMaxEventsPerMinute: StateFlow<Int> = _notificationForwardingMaxEventsPerMinute
private val _notificationForwardingSessionKey by lazy {
MutableStateFlow(loadNotificationForwardingSessionKey(gatewayRegistry.activeStableId.value))
}
val notificationForwardingSessionKey: StateFlow<String?> get() = _notificationForwardingSessionKey
private val _voiceMicEnabled = MutableStateFlow(plainPrefs.getBoolean(voiceMicEnabledKey, false))
val voiceMicEnabled: StateFlow<Boolean> = _voiceMicEnabled
private val _voiceWakeEnabled = MutableStateFlow(plainPrefs.getBoolean(voiceWakeEnabledKey, false))
val voiceWakeEnabled: StateFlow<Boolean> = _voiceWakeEnabled
private val _voiceWakeWords = MutableStateFlow(loadVoiceWakeWords())
val voiceWakeWords: StateFlow<List<String>> = _voiceWakeWords
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
private val _preferredCameraFacing =
MutableStateFlow(plainPrefs.getString(preferredCameraFacingKey, null).takeIf { it == "back" } ?: "front")
val preferredCameraFacing: StateFlow<String> = _preferredCameraFacing
private val _preferredAudioInputDevice =
MutableStateFlow(plainPrefs.getString(preferredAudioInputDeviceKey, null)?.takeIf(String::isNotBlank))
val preferredAudioInputDevice: StateFlow<String?> = _preferredAudioInputDevice
private val _appearanceThemeMode =
MutableStateFlow(AppearanceThemeMode.fromRawValue(plainPrefs.getString(appearanceThemeModeKey, null)))
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = _appearanceThemeMode
private val _modelFavorites = MutableStateFlow(loadChatModelRefs(chatModelFavoritesKey))
val modelFavorites: StateFlow<List<String>> = _modelFavorites
private val _modelRecents = MutableStateFlow(loadChatModelRefs(chatModelRecentsKey))
val modelRecents: StateFlow<List<String>> = _modelRecents
// Custom session group names the user created locally; assigned groups also
// persist server-side via the session category field (mirrors web localStorage).
private val _sessionCustomGroups = MutableStateFlow(loadChatModelRefs(sessionCustomGroupsKey))
val sessionCustomGroups: StateFlow<List<String>> = _sessionCustomGroups
fun setLastDiscoveredStableId(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
_lastDiscoveredStableId.value = trimmed
}
fun setDisplayName(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString(displayNameKey, trimmed) }
_displayName.value = trimmed
}
fun setCameraEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(cameraEnabledKey, value) }
_cameraEnabled.value = value
}
fun setLocationMode(mode: LocationMode) {
plainPrefs.edit { putString(locationModeKey, mode.rawValue) }
_locationMode.value = mode
}
fun setLocationPreciseEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("location.preciseEnabled", value) }
_locationPreciseEnabled.value = value
}
fun setPreventSleep(value: Boolean) {
plainPrefs.edit { putBoolean("screen.preventSleep", value) }
_preventSleep.value = value
}
fun setManualEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("gateway.manual.enabled", value) }
_manualEnabled.value = value
}
fun setManualHost(value: String) {
val trimmed = value.trim()
plainPrefs.edit { putString("gateway.manual.host", trimmed) }
_manualHost.value = trimmed
}
fun setManualPort(value: Int) {
plainPrefs.edit { putInt("gateway.manual.port", value) }
_manualPort.value = value
}
fun setManualTls(value: Boolean) {
plainPrefs.edit { putBoolean("gateway.manual.tls", value) }
_manualTls.value = value
}
fun setOnboardingCompleted(value: Boolean) {
plainPrefs.edit { putBoolean("onboarding.completed", value) }
_onboardingCompleted.value = value
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
}
fun grantInstalledAppsDisclosureConsent() {
plainPrefs.edit {
putBoolean(installedAppsSharingEnabledKey, true)
putInt(installedAppsDisclosureConsentVersionKey, currentInstalledAppsDisclosureConsentVersion)
}
_installedAppsSharingEnabled.value = true
}
fun revokeInstalledAppsDisclosureConsent() {
plainPrefs.edit {
putBoolean(installedAppsSharingEnabledKey, false)
remove(installedAppsDisclosureConsentVersionKey)
}
_installedAppsSharingEnabled.value = false
}
fun setAccessibilityControlEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(accessibilityControlEnabledKey, value) }
_accessibilityControlEnabled.value = value
}
private fun loadInstalledAppsSharingEnabled(): Boolean {
val enabled = plainPrefs.getBoolean(installedAppsSharingEnabledKey, false)
val consentVersion = plainPrefs.getInt(installedAppsDisclosureConsentVersionKey, 0)
if (enabled && consentVersion == currentInstalledAppsDisclosureConsentVersion) return true
// A shipped opt-in without this disclosure version cannot authorize package-inventory access.
// Canonicalize both keys so every later enable starts with fresh affirmative consent.
if (enabled || consentVersion != 0) {
plainPrefs.edit {
putBoolean(installedAppsSharingEnabledKey, false)
remove(installedAppsDisclosureConsentVersionKey)
}
}
return false
}
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)
val configuredPackages = loadNotificationForwardingPackages()
val normalizedAppPackage = appPackageName.trim()
// Always block OpenClaw's own notifications in blocklist mode to prevent forwarding loops.
val defaultBlockedPackages =
if (normalizedAppPackage.isNotEmpty()) setOf(normalizedAppPackage) else emptySet()
val packages =
when (mode) {
NotificationPackageFilterMode.Allowlist -> configuredPackages
NotificationPackageFilterMode.Blocklist -> configuredPackages + defaultBlockedPackages
}
val maxEvents = plainPrefs.getInt(notificationsForwardingMaxEventsPerMinuteKey, 20)
val quietStart =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty())
?: "22:00"
val quietEnd =
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty())
?: "07:00"
// NotificationListenerService owns a separate SecurePrefs facade, so resolve the persisted
// pointer per event rather than trusting that facade's process-local registry flow.
val sessionKey = loadNotificationForwardingSessionKey(gatewayRegistry.storedActiveStableId())
val quietHoursEnabled =
plainPrefs.getBoolean(notificationsForwardingQuietHoursEnabledKey, false) &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietStartKey, "22:00").orEmpty()) != null &&
normalizeLocalHourMinute(plainPrefs.getString(notificationsForwardingQuietEndKey, "07:00").orEmpty()) != null
return NotificationForwardingPolicy(
enabled = plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled),
mode = mode,
packages = packages,
quietHoursEnabled = quietHoursEnabled,
quietStart = quietStart,
quietEnd = quietEnd,
maxEventsPerMinute = maxEvents.coerceAtLeast(1),
sessionKey = sessionKey,
selfPackageName = normalizedAppPackage,
)
}
internal fun setNotificationForwardingEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(notificationsForwardingEnabledKey, value) }
_notificationForwardingEnabled.value = value
}
internal fun setNotificationForwardingMode(mode: NotificationPackageFilterMode) {
plainPrefs.edit { putString(notificationsForwardingModeKey, mode.rawValue) }
_notificationForwardingMode.value = mode
}
internal fun setNotificationForwardingPackages(packages: List<String>) {
val sanitized =
packages
.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet()
.toList()
.sorted()
// Persist deterministic JSON so settings diffs and state restoration are stable.
val encoded = JsonArray(sanitized.map { JsonPrimitive(it) }).toString()
plainPrefs.edit { putString(notificationsForwardingPackagesKey, encoded) }
_notificationForwardingPackages.value = sanitized.toSet()
}
internal fun setNotificationForwardingQuietHours(
enabled: Boolean,
start: String,
end: String,
): Boolean {
if (!enabled) {
plainPrefs.edit { putBoolean(notificationsForwardingQuietHoursEnabledKey, false) }
_notificationForwardingQuietHoursEnabled.value = false
return true
}
val normalizedStart = normalizeLocalHourMinute(start) ?: return false
val normalizedEnd = normalizeLocalHourMinute(end) ?: return false
plainPrefs.edit {
putBoolean(notificationsForwardingQuietHoursEnabledKey, enabled)
putString(notificationsForwardingQuietStartKey, normalizedStart)
putString(notificationsForwardingQuietEndKey, normalizedEnd)
}
_notificationForwardingQuietHoursEnabled.value = enabled
_notificationForwardingQuietStart.value = normalizedStart
_notificationForwardingQuietEnd.value = normalizedEnd
return true
}
internal fun setNotificationForwardingMaxEventsPerMinute(value: Int) {
val normalized = value.coerceAtLeast(1)
plainPrefs.edit {
putInt(notificationsForwardingMaxEventsPerMinuteKey, normalized)
}
_notificationForwardingMaxEventsPerMinute.value = normalized
}
internal fun setNotificationForwardingSessionKey(value: String?) {
val stableId = gatewayRegistry.activeStableId.value ?: return
val normalized = value?.trim()?.takeIf { it.isNotEmpty() }
plainPrefs.edit {
putString(notificationForwardingSessionKeyKey(stableId), normalized.orEmpty())
}
_notificationForwardingSessionKey.value = normalized
}
fun loadGatewayCredentials(stableId: String): GatewayCredentials {
// Credential reads are gateway state; force the lazy registry so the one-time
// legacy migration has run before the first per-gateway bundle is resolved.
gatewayRegistry
val raw = securePrefs.getString(gatewayCredentialsKey(stableId), null) ?: return GatewayCredentials()
return runCatching { json.decodeFromString<GatewayCredentials>(raw).normalized() }.getOrDefault(GatewayCredentials())
}
fun saveGatewayCredentials(
stableId: String,
credentials: GatewayCredentials,
) {
securePrefs.edit {
putString(gatewayCredentialsKey(stableId), json.encodeToString(credentials.normalized()))
}
}
fun saveGatewayCredentials(
stableId: String,
token: String? = null,
bootstrapToken: String? = null,
password: String? = null,
) {
saveGatewayCredentials(stableId, GatewayCredentials(token, bootstrapToken, password))
}
fun clearGatewayCredentials(stableId: String) {
securePrefs.edit { remove(gatewayCredentialsKey(stableId)) }
}
/**
* Custom proxy headers are per-gateway credentials (Cloudflare Access-style service tokens).
* They live in the encrypted store like the other gateway secrets and are read at connect
* time; never log their values.
*/
fun loadGatewayCustomHeaders(stableId: String): Map<String, String> {
val raw = securePrefs.getString(gatewayCustomHeadersKey(stableId), null) ?: return emptyMap()
val stored =
runCatching { json.decodeFromString<Map<String, String>>(raw) }.getOrElse { return emptyMap() }
return GatewayCustomHeaders.sanitized(stored)
}
fun saveGatewayCustomHeaders(
stableId: String,
headers: Map<String, String>,
) {
val key = gatewayCustomHeadersKey(stableId)
val sanitized = GatewayCustomHeaders.sanitized(headers)
if (sanitized.isEmpty()) {
securePrefs.edit { remove(key) }
return
}
securePrefs.edit { putString(key, json.encodeToString(sanitized)) }
}
/** Forgets one gateway's proxy credentials; forgetting a gateway is the removal boundary. */
fun clearGatewayCustomHeaders(stableId: String) {
securePrefs.edit { remove(gatewayCustomHeadersKey(stableId)) }
}
private fun gatewayCustomHeadersKey(stableId: String) = "$gatewayCustomHeadersKeyPrefix${stableId.trim()}"
/** Loads the pinned gateway TLS fingerprint for a discovered/manual stable endpoint id. */
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
return plainPrefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
}
/** Persists the gateway TLS fingerprint captured through TOFU or explicit trust. */
fun saveGatewayTlsFingerprint(
stableId: String,
fingerprint: String,
) {
val key = "gateway.tls.$stableId"
plainPrefs.edit { putString(key, fingerprint.trim()) }
}
fun clearGatewayTlsFingerprint(stableId: String) {
plainPrefs.edit { remove("gateway.tls.$stableId") }
}
fun clearNotificationForwardingSessionKey(stableId: String) {
plainPrefs.edit { remove(notificationForwardingSessionKeyKey(stableId)) }
if (gatewayRegistry.activeStableId.value == stableId) {
_notificationForwardingSessionKey.value = null
}
}
fun getString(key: String): String? = securePrefs.getString(key, null)
fun putString(
key: String,
value: String,
) {
securePrefs.edit { putString(key, value) }
}
// KTX edit(commit = true) discards commit's Boolean; the identity migration fails closed on it.
@Suppress("UseKtx")
internal fun putStringSynchronously(
key: String,
value: String,
): Boolean = securePrefs.edit().putString(key, value).commit()
fun remove(key: String) {
securePrefs.edit { remove(key) }
}
internal fun containsSecureKey(key: String): Boolean = securePrefs.contains(key)
internal fun secureKeys(): Set<String> = securePrefs.all.keys
internal fun removeSecureKeys(keys: List<String>) {
securePrefs.edit { keys.forEach { remove(it) } }
}
internal fun moveSecureString(
sourceKey: String,
destinationKey: String?,
) {
val value = securePrefs.getString(sourceKey, null)
securePrefs.edit {
if (destinationKey != null && value != null) putString(destinationKey, value)
remove(sourceKey)
}
}
internal fun getPlainString(key: String): String? = plainPrefs.getString(key, null)
internal fun getPlainBoolean(
key: String,
defaultValue: Boolean,
): Boolean = plainPrefs.getBoolean(key, defaultValue)
internal fun getPlainInt(
key: String,
defaultValue: Int,
): Int = plainPrefs.getInt(key, defaultValue)
internal fun movePlainString(
sourceKey: String,
destinationKey: String?,
) {
val value = plainPrefs.getString(sourceKey, null)?.trim()?.takeIf { it.isNotEmpty() }
plainPrefs.edit(commit = true) {
if (destinationKey != null && value != null) putString(destinationKey, value)
remove(sourceKey)
}
}
private fun gatewayCredentialsKey(stableId: String): String {
val normalized = stableId.trim()
require(normalized.isNotEmpty()) { "Gateway stable id cannot be empty" }
return "gateway.credentials.$normalized"
}
private fun notificationForwardingSessionKeyKey(stableId: String): String = "$notificationsForwardingSessionKeyPrefix.$stableId"
private fun loadNotificationForwardingSessionKey(stableId: String?): String? =
stableId
?.let(::notificationForwardingSessionKeyKey)
?.let { plainPrefs.getString(it, null) }
?.trim()
?.takeIf { it.isNotEmpty() }
private fun handleActiveGatewayChanged(stableId: String?) {
_notificationForwardingSessionKey.value = loadNotificationForwardingSessionKey(stableId)
}
private fun createSecurePrefs(
context: Context,
name: String,
): SharedPreferences =
EncryptedSharedPreferences.create(
context,
name,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
private fun loadOrCreateInstanceId(): String {
val existing = plainPrefs.getString("node.instanceId", null)?.trim()
if (!existing.isNullOrBlank()) return existing
// Instance id is not secret; it scopes local credentials and survives display-name changes.
val fresh = UUID.randomUUID().toString()
plainPrefs.edit { putString("node.instanceId", fresh) }
return fresh
}
private fun loadOrMigrateDisplayName(context: Context): String {
val existing = plainPrefs.getString(displayNameKey, null)?.trim().orEmpty()
if (existing.isNotEmpty() && existing != "Android Node") return existing
// Replace the historical generic name with a device-specific default once.
val candidate = DeviceNames.bestDefaultNodeName(context).trim()
val resolved = candidate.ifEmpty { "Android Node" }
plainPrefs.edit { putString(displayNameKey, resolved) }
return resolved
}
fun setVoiceMicEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(voiceMicEnabledKey, value) }
_voiceMicEnabled.value = value
}
fun setVoiceWakeEnabled(value: Boolean) {
plainPrefs.edit { putBoolean(voiceWakeEnabledKey, value) }
_voiceWakeEnabled.value = value
}
fun setVoiceWakeWords(words: List<String>) {
val sanitized = VoiceWakePreferences.sanitizeTriggerWords(words)
plainPrefs.edit { putString(voiceWakeWordsKey, JsonArray(sanitized.map(::JsonPrimitive)).toString()) }
_voiceWakeWords.value = sanitized
}
fun setSpeakerEnabled(value: Boolean) {
plainPrefs.edit { putBoolean("voice.speakerEnabled", value) }
_speakerEnabled.value = value
}
fun setPreferredCameraFacing(value: String) {
val facing = value.takeIf { it == "back" } ?: "front"
plainPrefs.edit { putString(preferredCameraFacingKey, facing) }
_preferredCameraFacing.value = facing
}
fun setPreferredAudioInputDevice(value: String?) {
val key = value?.takeIf(String::isNotBlank)
plainPrefs.edit {
if (key == null) remove(preferredAudioInputDeviceKey) else putString(preferredAudioInputDeviceKey, key)
}
_preferredAudioInputDevice.value = key
}
private fun loadVoiceWakeWords(): List<String> {
val stored = plainPrefs.getString(voiceWakeWordsKey, null) ?: return VoiceWakePreferences.defaultTriggerWords
val decoded =
runCatching {
(json.parseToJsonElement(stored) as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.content }
}.getOrNull()
return VoiceWakePreferences.sanitizeTriggerWords(decoded.orEmpty())
}
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
plainPrefs.edit { putString(appearanceThemeModeKey, mode.rawValue) }
_appearanceThemeMode.value = mode
}
fun toggleModelFavorite(ref: String) {
val trimmed = ref.trim()
if (trimmed.isEmpty()) return
val next =
if (trimmed in _modelFavorites.value) {
_modelFavorites.value - trimmed
} else {
_modelFavorites.value + trimmed
}
persistChatModelRefs(chatModelFavoritesKey, next)
_modelFavorites.value = next
}
fun recordModelRecent(ref: String) {
val trimmed = ref.trim()
if (trimmed.isEmpty()) return
val next = (listOf(trimmed) + _modelRecents.value.filterNot { it == trimmed }).take(maxChatModelRecents)
persistChatModelRefs(chatModelRecentsKey, next)
_modelRecents.value = next
}
fun setSessionCustomGroups(groups: List<String>) {
val sanitized = groups.map(String::trim).filter { it.isNotEmpty() }.distinct()
persistChatModelRefs(sessionCustomGroupsKey, sanitized)
_sessionCustomGroups.value = sanitized
}
private fun persistChatModelRefs(
key: String,
refs: List<String>,
) {
val encoded = JsonArray(refs.map(::JsonPrimitive)).toString()
plainPrefs.edit { putString(key, encoded) }
}
private fun loadNotificationForwardingPackages(): Set<String> {
val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
if (raw.isNullOrEmpty()) {
return emptySet()
}
return try {
val element = json.parseToJsonElement(raw)
val array = element as? JsonArray ?: return emptySet()
array
.mapNotNull { item ->
when (item) {
is JsonNull -> null
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
else -> null
}
}.toSet()
} catch (_: Throwable) {
emptySet()
}
}
private fun loadLocationMode(): LocationMode {
val raw = plainPrefs.getString(locationModeKey, "off")
val stored = LocationMode.fromRawValue(raw)
val resolved =
if (stored == LocationMode.Always && !SensitiveFeatureConfig.backgroundLocationEnabled) {
LocationMode.WhileUsing
} else {
stored
}
if (resolved != stored) {
plainPrefs.edit { putString(locationModeKey, resolved.rawValue) }
}
return resolved
}
private fun loadCameraEnabled(): Boolean {
if (plainPrefs.contains(cameraEnabledKey)) {
return plainPrefs.getBoolean(cameraEnabledKey, false)
}
val migratedValue = hadPlainPrefsBeforeInit
plainPrefs.edit { putBoolean(cameraEnabledKey, migratedValue) }
return migratedValue
}
private fun loadChatModelRefs(key: String): List<String> {
val raw = plainPrefs.getString(key, null)?.trim()
if (raw.isNullOrEmpty()) return emptyList()
return try {
val array = json.parseToJsonElement(raw) as? JsonArray ?: return emptyList()
array
.mapNotNull { item ->
when (item) {
is JsonNull -> null
is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() }
else -> null
}
}.distinct()
} catch (_: Throwable) {
emptyList()
}
}
}

View file

@ -0,0 +1,37 @@
package ai.openclaw.app
/** Normalizes blank gateway session keys to the legacy main session alias. */
internal fun normalizeMainKey(raw: String?): String {
val trimmed = raw?.trim()
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
/** Extracts the agent id from canonical agent-scoped main session keys. */
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
if (!trimmed.startsWith("agent:")) return null
return trimmed
.removePrefix("agent:")
.substringBefore(':')
.trim()
.ifEmpty { null }
}
/** Builds the node session key shape consumed by gateway chat and presence APIs. */
internal fun buildNodeMainSessionKey(
deviceId: String,
agentId: String?,
): String {
val resolvedAgentId = agentId?.trim().orEmpty().ifEmpty { "main" }
return "agent:$resolvedAgentId:node-${deviceId.take(12)}"
}
/** Human-readable, device-unique label applied when Android creates or adopts its session. */
internal fun buildAndroidAppSessionLabel(
displayName: String?,
deviceId: String,
): String {
val deviceSuffix = deviceId.take(12)
val displaySuffix = displayName?.trim()?.takeUtf16Safe(96)?.takeIf { it.isNotEmpty() }
return listOfNotNull("OpenClaw App", displaySuffix, deviceSuffix).joinToString(" · ")
}

View file

@ -0,0 +1,238 @@
package ai.openclaw.app
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
private const val CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED = "clawhub_risk_acknowledgement_required"
internal const val CLAWHUB_INSTALL_REQUEST_TIMEOUT_MS = 125_000L
internal const val CLAWHUB_SKILL_GATEWAY_UNAVAILABLE = "Update the Gateway to search and install ClawHub skills from Android."
internal val CLAWHUB_SKILL_GATEWAY_METHODS = setOf("skills.search", "skills.detail", "skills.install")
data class GatewayClawHubSkillSearchState(
val query: String = "",
val searching: Boolean = false,
val results: List<GatewayClawHubSkillSummary> = emptyList(),
val reviewingSlug: String? = null,
val installReview: GatewayClawHubInstallReview? = null,
val installingSlugs: Set<String> = emptySet(),
val acknowledgeSlug: String? = null,
val acknowledgeVersion: String? = null,
val errorText: String? = null,
val messageText: String? = null,
)
data class GatewayClawHubSkillSummary(
val slug: String,
val displayName: String,
val summary: String?,
val version: String?,
)
data class GatewayClawHubInstallReview(
val slug: String,
val displayName: String,
val summary: String?,
val version: String,
val author: String,
)
internal data class GatewayClawHubInstallRejection(
val message: String,
val warning: String?,
val acknowledgeVersion: String?,
val requiresAcknowledgement: Boolean,
)
internal fun parseClawHubSearchResults(
raw: String,
json: Json,
): List<GatewayClawHubSkillSummary> {
val root = json.parseToJsonElement(raw) as? JsonObject ?: return emptyList()
return (root["results"] as? JsonArray)
?.mapNotNull { item ->
val value = item as? JsonObject ?: return@mapNotNull null
val slug = value.string("slug") ?: return@mapNotNull null
val displayName = value.string("displayName") ?: return@mapNotNull null
GatewayClawHubSkillSummary(
slug = slug,
displayName = displayName,
summary = value.string("summary"),
version = value.string("version"),
)
}.orEmpty()
}
internal fun parseClawHubInstallReview(
raw: String,
fallback: GatewayClawHubSkillSummary,
json: Json,
): GatewayClawHubInstallReview? {
val root = json.parseToJsonElement(raw) as? JsonObject ?: return null
val skill = root["skill"] as? JsonObject
val latestVersion = root["latestVersion"] as? JsonObject
val owner = root["owner"] as? JsonObject
// The detail response is the install review boundary. Prefer its current
// version over the potentially stale search result shown before review.
val version = latestVersion?.string("version") ?: fallback.version ?: return null
val ownerDisplayName = owner?.string("displayName")
val ownerHandle = owner?.string("handle")
val reviewedSlug =
canonicalClawHubSkillReference(
slug = skill?.string("slug") ?: fallback.slug,
ownerHandle = ownerHandle,
) ?: return null
val author =
when {
ownerDisplayName != null && ownerHandle != null && !ownerDisplayName.equals(ownerHandle, ignoreCase = true) ->
"$ownerDisplayName (@$ownerHandle)"
ownerDisplayName != null -> ownerDisplayName
ownerHandle != null -> "@$ownerHandle"
else -> "Unknown publisher"
}
return GatewayClawHubInstallReview(
slug = reviewedSlug,
displayName = skill?.string("displayName") ?: fallback.displayName,
summary = skill?.string("summary") ?: fallback.summary,
version = version,
author = author,
)
}
internal fun clawHubInstallRejection(
error: GatewaySession.ErrorShape,
attemptedVersion: String?,
): GatewayClawHubInstallRejection {
val details = error.details
val reviewedVersion = attemptedVersion?.trim()?.takeIf(String::isNotEmpty)
val gatewayVersion = details?.clawhubVersion?.trim()?.takeIf(String::isNotEmpty)
val acknowledgementRequested =
details?.clawhubTrustCode == CLAWHUB_RISK_ACKNOWLEDGEMENT_REQUIRED
val requiresAcknowledgement =
acknowledgementRequested && reviewedVersion != null && gatewayVersion == reviewedVersion
return GatewayClawHubInstallRejection(
message =
if (acknowledgementRequested && !requiresAcknowledgement) {
"The Gateway evaluated a different ClawHub release. Review the skill again before installing."
} else {
error.message.ifBlank { "The Gateway rejected this ClawHub install." }
},
warning = details?.clawhubWarning?.trim()?.takeIf(String::isNotEmpty),
acknowledgeVersion = reviewedVersion.takeIf { requiresAcknowledgement },
requiresAcknowledgement = requiresAcknowledgement,
)
}
internal fun supportsClawHubSkillManagement(methods: Set<String>): Boolean = methods.containsAll(CLAWHUB_SKILL_GATEWAY_METHODS)
internal fun clawHubSearchParams(query: String): String =
buildJsonObject {
query.trim().takeIf(String::isNotEmpty)?.let { put("query", JsonPrimitive(it)) }
put("limit", JsonPrimitive(25))
}.toString()
internal fun clawHubDetailParams(slug: String): String = buildJsonObject { put("slug", JsonPrimitive(slug)) }.toString()
internal fun clawHubInstallParams(
slug: String,
version: String?,
acknowledgeRisk: Boolean,
): String =
buildJsonObject {
put("source", JsonPrimitive("clawhub"))
put("slug", JsonPrimitive(slug))
version?.trim()?.takeIf(String::isNotEmpty)?.let { put("version", JsonPrimitive(it)) }
if (acknowledgeRisk) put("acknowledgeClawHubRisk", JsonPrimitive(true))
put("timeoutMs", JsonPrimitive(120_000))
}.toString()
internal fun skillEnabledParams(
skillKey: String,
enabled: Boolean,
): String =
buildJsonObject {
put("skillKey", JsonPrimitive(skillKey))
put("enabled", JsonPrimitive(enabled))
}.toString()
internal fun formatClawHubInstallMessage(
message: String,
warning: String?,
): String = if (warning.isNullOrBlank()) message else "$message\n\n$warning"
internal fun isClawHubSkillInstalled(
skills: List<GatewaySkillSummary>,
slug: String,
): Boolean {
val reference = parseClawHubSkillReference(slug) ?: return false
return skills.any { it.matchesClawHubReference(reference) }
}
internal fun isClawHubSkillInstalled(
skills: List<GatewaySkillSummary>,
slug: String,
version: String,
): Boolean =
parseClawHubSkillReference(slug)?.let { reference ->
skills.any { it.matchesClawHubReference(reference) && it.clawHubInstalledVersion == version }
} ?: false
internal fun isClawHubSkillOperationActive(
activeSlugs: Set<String>,
slug: String,
): Boolean {
val reference = parseClawHubSkillReference(slug) ?: return false
return activeSlugs.any { activeSlug ->
val active = parseClawHubSkillReference(activeSlug) ?: return@any false
active.slug.equals(reference.slug, ignoreCase = true) &&
(
active.ownerHandle == null ||
reference.ownerHandle == null ||
active.ownerHandle.equals(reference.ownerHandle, ignoreCase = true)
)
}
}
private data class ClawHubSkillReference(
val slug: String,
val ownerHandle: String?,
)
private fun parseClawHubSkillReference(rawValue: String): ClawHubSkillReference? {
val value = rawValue.trim()
if (value.isEmpty()) return null
if (!value.startsWith("@")) return ClawHubSkillReference(value, null)
val parts = value.drop(1).split("/")
if (parts.size != 2 || parts.any(String::isEmpty)) return null
return ClawHubSkillReference(slug = parts[1], ownerHandle = parts[0].lowercase())
}
private fun canonicalClawHubSkillReference(
slug: String,
ownerHandle: String?,
): String? {
val reference = parseClawHubSkillReference(slug) ?: return null
val owner = ownerHandle?.trim()?.takeIf(String::isNotEmpty)?.lowercase() ?: reference.ownerHandle
return owner?.let { "@$it/${reference.slug}" } ?: reference.slug
}
private fun GatewaySkillSummary.matchesClawHubReference(reference: ClawHubSkillReference): Boolean {
if (!clawHubValid) return false
val installedReference = clawHubSlug?.let(::parseClawHubSkillReference) ?: return false
if (!installedReference.slug.equals(reference.slug, ignoreCase = true)) return false
val requestedOwner = reference.ownerHandle ?: return true
val installedOwner = installedReference.ownerHandle ?: clawHubOwnerHandle
return installedOwner?.equals(requestedOwner, ignoreCase = true) == true
}
internal fun clawHubInstallOutcomeUnknownMessage(slug: String): String = "The result for $slug is unknown. Reconnect, refresh Skills, then retry; the Gateway safely joins a matching install that is still running."
private fun JsonObject.string(key: String): String? =
(get(key) as? JsonPrimitive)
?.contentOrNull
?.trim()
?.takeIf(String::isNotEmpty)

View file

@ -0,0 +1,31 @@
package ai.openclaw.app
import android.icu.text.BreakIterator
import java.util.Locale
// BreakIterator creation is relatively expensive, and instances are not thread-safe.
private val graphemeBreakIterator = ThreadLocal.withInitial { BreakIterator.getCharacterInstance(Locale.ROOT) }
internal fun String.firstGraphemeOrNull(): String? {
if (isEmpty()) return null
val iterator = checkNotNull(graphemeBreakIterator.get())
iterator.setText(this)
iterator.first()
val end = iterator.next()
return if (end == BreakIterator.DONE) null else substring(0, end)
}
internal fun String.uppercaseFirstGraphemeOrNull(): String? {
val grapheme = firstGraphemeOrNull() ?: return null
val firstCodePoint = grapheme.codePointAt(0)
val uppercaseCodePoint = Character.toUpperCase(firstCodePoint)
// Keep badge width stable while preserving the rest of the grapheme cluster.
return String(Character.toChars(uppercaseCodePoint)) + grapheme.substring(Character.charCount(firstCodePoint))
}
internal fun String.takeUtf16Safe(maxChars: Int): String {
if (length <= maxChars) return this
// Keep the code-unit cap without leaving a high surrogate at its boundary.
val endsOnHighSurrogate = maxChars > 0 && Character.isHighSurrogate(this[maxChars - 1])
return take(if (endsOnHighSurrogate) maxChars - 1 else maxChars)
}

View file

@ -0,0 +1,10 @@
package ai.openclaw.app
/**
* Persisted voice capture mode that controls foreground-service microphone requirements.
*/
enum class VoiceCaptureMode {
Off,
ManualMic,
TalkMode,
}

View file

@ -0,0 +1,81 @@
package ai.openclaw.app
import ai.openclaw.app.node.asObjectOrNull
import ai.openclaw.app.node.asStringOrNull
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.longOrNull
/** One entry from the read-only `agents.workspace.list` gateway RPC. */
data class GatewayWorkspaceEntry(
val path: String,
val name: String,
val isDirectory: Boolean,
val size: Long?,
val updatedAtMs: Long?,
)
/** One directory page of an agent workspace listing. */
data class GatewayWorkspaceListing(
val path: String,
val entries: List<GatewayWorkspaceEntry>,
val totalEntries: Int,
val offset: Int,
)
/** One previewable workspace file from `agents.workspace.get`. */
data class GatewayWorkspaceFile(
val path: String,
val name: String,
val size: Long,
val mimeType: String,
val isBase64: Boolean,
val content: String,
)
private fun JsonElement?.asLongOrNull(): Long? = (this as? JsonPrimitive)?.longOrNull
internal fun parseWorkspaceListing(root: JsonElement): GatewayWorkspaceListing? {
val obj = root.asObjectOrNull() ?: return null
val entries =
(obj["entries"] as? JsonArray)?.mapNotNull { item ->
val entry = item.asObjectOrNull() ?: return@mapNotNull null
// Paths/names are opaque workspace identifiers echoed back to the
// gateway; never trim them or entries with edge whitespace break.
val path = entry["path"].asStringOrNull().orEmpty()
val name = entry["name"].asStringOrNull().orEmpty()
if (path.isEmpty() || name.isEmpty()) return@mapNotNull null
GatewayWorkspaceEntry(
path = path,
name = name,
isDirectory = entry["kind"].asStringOrNull() == "directory",
size = entry["size"].asLongOrNull(),
updatedAtMs = entry["updatedAtMs"].asLongOrNull(),
)
} ?: emptyList()
return GatewayWorkspaceListing(
path = obj["path"].asStringOrNull().orEmpty(),
entries = entries,
totalEntries = obj["totalEntries"].asLongOrNull()?.toInt() ?: entries.size,
offset = obj["offset"].asLongOrNull()?.toInt() ?: 0,
)
}
internal fun parseWorkspaceFile(root: JsonElement): GatewayWorkspaceFile? {
val file = root.asObjectOrNull()?.get("file").asObjectOrNull() ?: return null
val path = file["path"].asStringOrNull().orEmpty()
if (path.isEmpty()) return null
return GatewayWorkspaceFile(
path = path,
name =
file["name"]
.asStringOrNull()
.orEmpty()
.ifEmpty { path.substringAfterLast('/') },
size = file["size"].asLongOrNull() ?: 0L,
mimeType = file["mimeType"].asStringOrNull().orEmpty().ifEmpty { "text/plain" },
isBase64 = file["encoding"].asStringOrNull() == "base64",
content = file["content"].asStringOrNull().orEmpty(),
)
}

View file

@ -0,0 +1,112 @@
package ai.openclaw.app.chat
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.time.Instant
data class BackgroundTask(
val id: String,
val status: String,
val runtime: String,
val title: String?,
val agentId: String?,
val childSessionKey: String?,
val createdAtMs: Long?,
val updatedAtMs: Long?,
val startedAtMs: Long?,
val endedAtMs: Long?,
val progress: String?,
val terminal: String?,
val error: String?,
val prompt: String?,
) {
val isActive: Boolean
get() = status == "queued" || status == "running"
val displayTitle: String
get() = title?.trim()?.takeIf { it.isNotEmpty() } ?: id
val displayStatus: BackgroundTaskDisplayStatus
get() =
when (status) {
"queued" -> BackgroundTaskDisplayStatus.Queued
"running" -> BackgroundTaskDisplayStatus.Running
"completed" -> BackgroundTaskDisplayStatus.Completed
"failed", "cancelled", "timed_out" -> BackgroundTaskDisplayStatus.Failed
else -> BackgroundTaskDisplayStatus.Failed
}
val output: String?
get() {
val candidates =
if (status == "failed" || status == "timed_out") {
listOf(error, terminal, progress)
} else {
listOf(terminal, error, progress)
}
return candidates.firstOrNull { !it.isNullOrBlank() }
}
val activityAtMs: Long
get() = updatedAtMs ?: endedAtMs ?: startedAtMs ?: createdAtMs ?: 0L
}
enum class BackgroundTaskDisplayStatus {
Queued,
Running,
Completed,
Failed,
}
internal fun parseBackgroundTasks(
json: Json,
payload: String,
): List<BackgroundTask> {
val root = json.parseToJsonElement(payload).jsonObject
return root["tasks"]?.jsonArray?.mapNotNull(::parseBackgroundTask).orEmpty()
}
internal fun parseBackgroundTask(element: JsonElement): BackgroundTask? {
val objectValue = element as? JsonObject ?: return null
fun string(key: String): String? = objectValue[key]?.jsonPrimitive?.contentOrNull
val id = string("id")?.takeIf { it.isNotBlank() } ?: return null
return BackgroundTask(
id = id,
status = string("status") ?: "running",
runtime = string("runtime") ?: "background",
title = string("title"),
agentId = string("agentId"),
childSessionKey = string("childSessionKey"),
createdAtMs = objectValue["createdAt"]?.let(::parseTaskTimestampMs),
updatedAtMs = objectValue["updatedAt"]?.let(::parseTaskTimestampMs),
startedAtMs = objectValue["startedAt"]?.let(::parseTaskTimestampMs),
endedAtMs = objectValue["endedAt"]?.let(::parseTaskTimestampMs),
progress = string("progressSummary"),
terminal = string("terminalSummary"),
error = string("error"),
prompt = string("prompt"),
)
}
internal fun mergeBackgroundTasks(vararg groups: List<BackgroundTask>): List<BackgroundTask> =
groups
.flatMap { it }
.groupBy { it.id }
.mapValues { (_, snapshots) ->
snapshots.maxWith(compareBy<BackgroundTask> { it.activityAtMs }.thenBy { !it.isActive })
}.values
.sortedWith(compareByDescending<BackgroundTask> { it.isActive }.thenByDescending { it.activityAtMs })
private fun parseTaskTimestampMs(element: JsonElement): Long? {
val primitive = element.jsonPrimitive
primitive.doubleOrNull?.let { return it.toLong() }
return primitive.contentOrNull?.let { raw -> runCatching { Instant.parse(raw).toEpochMilli() }.getOrNull() }
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,65 @@
package ai.openclaw.app.chat
import ai.openclaw.app.resolveAgentIdFromMainSessionKey
/** Identifies the gateway chat that owns transient composer state and async results. */
internal data class ChatComposerOwner(
val gatewayStableId: String?,
val agentId: String,
val sessionKey: String,
val routingVerified: Boolean = true,
)
/** Last routing owner proven for one gateway, retained while that gateway reconnects. */
internal data class GatewayDefaultAgentOwner(
val gatewayStableId: String,
val agentId: String,
)
internal fun resolveGatewayDefaultAgentId(
gatewayStableId: String?,
gatewayDefaultAgentId: String?,
lastVerifiedOwner: GatewayDefaultAgentOwner?,
): String? =
gatewayDefaultAgentId?.trim()?.takeIf { it.isNotEmpty() }
?: lastVerifiedOwner
?.takeIf { it.gatewayStableId == gatewayStableId }
?.agentId
internal fun resolveChatComposerOwner(
gatewayStableId: String?,
gatewayDefaultAgentId: String?,
lastVerifiedOwner: GatewayDefaultAgentOwner? = null,
sessionKey: String,
mainSessionKey: String,
): ChatComposerOwner {
val effectiveSessionKey = sessionKey.trim().ifEmpty { mainSessionKey.trim().ifEmpty { "main" } }
val explicitAgentId = resolveAgentIdFromMainSessionKey(effectiveSessionKey)
val effectiveDefaultAgentId = resolveGatewayDefaultAgentId(gatewayStableId, gatewayDefaultAgentId, lastVerifiedOwner)
return ChatComposerOwner(
gatewayStableId = gatewayStableId,
agentId = explicitAgentId ?: effectiveDefaultAgentId ?: "main",
sessionKey = effectiveSessionKey,
routingVerified = explicitAgentId != null || effectiveDefaultAgentId != null,
)
}
/** Returns an owner only when routing can be proven from the session key or gateway hello. */
internal fun resolveChatComposerRoutingOwner(
gatewayStableId: String?,
gatewayDefaultAgentId: String?,
sessionKey: String,
mainSessionKey: String,
): ChatComposerOwner? {
val effectiveSessionKey = sessionKey.trim().ifEmpty { mainSessionKey.trim().ifEmpty { "main" } }
val agentId =
resolveAgentIdFromMainSessionKey(effectiveSessionKey)
?: gatewayDefaultAgentId?.trim()?.takeIf(String::isNotEmpty)
?: return null
return ChatComposerOwner(
gatewayStableId = gatewayStableId,
agentId = agentId,
sessionKey = effectiveSessionKey,
routingVerified = true,
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,309 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.SessionObserverDigest
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.util.Locale
private val visibleChatMessageRoles = setOf("user", "assistant", "system", "custom")
internal const val CHAT_IMAGE_MAX_BASE64_CHARS = 300 * 1024
/** Keeps transcript rows limited to roles Android renders as user-visible chat. */
internal fun normalizeVisibleChatMessageRole(role: String?): String? =
role
?.trim()
?.lowercase(Locale.US)
?.takeIf(visibleChatMessageRoles::contains)
/**
* Chat transcript item as delivered by gateway chat history and live chat events.
*/
data class ChatMessage(
val id: String,
val role: String,
val content: List<ChatMessageContent>,
val timestampMs: Long?,
val idempotencyKey: String? = null,
/** Canonical transcript-tree identity supplied by chat.history. */
val entryId: String? = null,
)
/** One selectable transcript branch returned by sessions.branches.list. */
data class SessionBranch(
val leafEntryId: String,
val headline: String,
val messageCount: Int,
val updatedAt: String?,
val active: Boolean,
)
data class SessionRewindResult(
val editorText: String?,
val editorAttachments: List<SessionEditorAttachment>,
)
data class SessionForkResult(
val sessionKey: String,
val editorText: String?,
val editorAttachments: List<SessionEditorAttachment>,
)
data class SessionEditorAttachment(
val mimeType: String,
val data: String,
)
data class ChatTranscriptAnchorState(
val sessionKey: String,
val newestItemId: String?,
val completedEndedAt: Long?,
val completedNewestItemId: String?,
)
/**
* One content part in a chat message; media carries either bounded base64 or a managed artifact reference.
*/
data class ChatMessageContent(
val type: String = "text",
val text: String? = null,
val mimeType: String? = null,
val fileName: String? = null,
val artifactId: String? = null,
val url: String? = null,
val openUrl: String? = null,
val alt: String? = null,
val width: Int? = null,
val height: Int? = null,
val sizeBytes: Long? = null,
val base64: String? = null,
val durationMs: Long? = null,
val playback: String? = null,
val widget: ChatWidgetPreview? = null,
)
data class ChatWidgetPreview(
val title: String?,
val path: String,
val preferredHeight: Int?,
val sandbox: String,
) {
val height: Int
get() = (preferredHeight ?: 320).coerceIn(160, 1200)
}
/**
* Tool call placeholder shown while a gateway run is still streaming.
*/
data class ChatPendingToolCall(
val toolCallId: String,
val name: String,
val args: kotlinx.serialization.json.JsonObject? = null,
val startedAtMs: Long,
val isError: Boolean? = null,
)
enum class ChatPlanStepStatus {
Pending,
InProgress,
Completed,
}
data class ChatPlanStep(
val step: String,
val status: ChatPlanStepStatus,
)
/** Parses a complete gateway plan snapshot, including legacy string-only steps. */
internal fun parseChatPlanSteps(element: JsonElement?): List<ChatPlanStep> {
val entries = element as? JsonArray ?: return emptyList()
var hasInProgressStep = false
return entries.mapNotNull { entry ->
val parsed =
when (entry) {
is JsonObject -> {
val step =
(entry["step"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.trim()
?.takeIf { it.isNotEmpty() }
?: return@mapNotNull null
val status =
when ((entry["status"] as? JsonPrimitive)?.takeIf { it.isString }?.content) {
"pending" -> ChatPlanStepStatus.Pending
"in_progress" -> ChatPlanStepStatus.InProgress
"completed" -> ChatPlanStepStatus.Completed
else -> return@mapNotNull null
}
ChatPlanStep(step = step, status = status)
}
is JsonPrimitive -> {
val step =
entry
.takeIf { it.isString }
?.content
?.trim()
?.takeIf { it.isNotEmpty() }
?: return@mapNotNull null
ChatPlanStep(step = step, status = ChatPlanStepStatus.Pending)
}
else -> return@mapNotNull null
}
if (parsed.status == ChatPlanStepStatus.InProgress) {
if (hasInProgressStep) return@mapNotNull null
hasInProgressStep = true
}
parsed
}
}
/** Gateway-advertised thinking choice for the active provider/model pair. */
data class ChatThinkingLevelOption(
val id: String,
val label: String,
)
/** Thinking choices currently shown by chat, including whether the Gateway supplied them. */
data class ChatThinkingLevelSelection(
val options: List<ChatThinkingLevelOption>,
val isGatewayProvided: Boolean,
)
internal val defaultChatThinkingLevelSelection =
ChatThinkingLevelSelection(
options =
listOf(
ChatThinkingLevelOption(id = "off", label = "Off"),
ChatThinkingLevelOption(id = "low", label = "Low"),
ChatThinkingLevelOption(id = "medium", label = "Medium"),
ChatThinkingLevelOption(id = "high", label = "High"),
),
isGatewayProvided = false,
)
internal data class ChatActiveRunPresentation(
val count: Int = 0,
val runId: String? = null,
val clockKey: String? = null,
val outputTokens: Long? = null,
)
/**
* Stable session selector row; [key] is the gateway session key used in chat requests.
*/
data class ChatSessionEntry(
val key: String,
val updatedAtMs: Long?,
val ownerAgentId: String? = null,
val displayName: String? = null,
val derivedTitle: String? = null,
val label: String? = null,
val category: String? = null,
val pinned: Boolean? = null,
val archived: Boolean? = null,
val unread: Boolean? = null,
val lastReadAt: Long? = null,
val agentStatus: ChatSessionAgentStatus? = null,
val hasAgentStatusMetadata: Boolean = agentStatus != null,
val observerDigest: SessionObserverDigest? = null,
val hasObserverDigestMetadata: Boolean = observerDigest != null,
val lastActivityAt: Long? = null,
val totalTokens: Long? = null,
val totalTokensFresh: Boolean? = null,
val modelProvider: String? = null,
val model: String? = null,
val thinkingLevel: String? = null,
val thinkingLevels: List<ChatThinkingLevelOption>? = null,
val thinkingDefault: String? = null,
val contextTokens: Long? = null,
val hasContextUsageMetadata: Boolean = totalTokens != null || totalTokensFresh != null || contextTokens != null,
val hasActiveRun: Boolean? = null,
val activeRunIds: List<String>? = null,
val hasActiveRunMetadata: Boolean = hasActiveRun != null || activeRunIds != null,
val parentSessionKey: String? = null,
val spawnedBy: String? = null,
val hasActiveSubagentRun: Boolean? = null,
val subagentRunState: String? = null,
val swarmGroupId: String? = null,
val swarmPhase: String? = null,
val swarmPhaseRank: Int? = null,
val swarmLog: String? = null,
val status: String? = null,
val lastRunError: String? = null,
val startedAt: Long? = null,
val endedAt: Long? = null,
val runtimeMs: Long? = null,
val outputTokens: Long? = null,
val hasRunMetadata: Boolean =
status != null || startedAt != null || endedAt != null || runtimeMs != null || outputTokens != null,
)
data class ChatSessionAgentStatus(
val note: String,
val expiresAt: Long,
val attention: String? = null,
)
/** Local fallback for server-side `sessions.list` search over cached entries. */
fun filterSessionEntries(
sessions: List<ChatSessionEntry>,
search: String,
): List<ChatSessionEntry> {
val query = search.trim().lowercase()
if (query.isEmpty()) return sessions
return sessions.filter { session ->
listOfNotNull(session.displayName, session.label, session.key)
.any { it.lowercase().contains(query) }
}
}
/**
* Slash command metadata exposed by the gateway for text-surface chat clients.
*/
data class ChatCommandEntry(
val name: String,
val description: String,
val category: String? = null,
val textAliases: List<String> = emptyList(),
val acceptsArgs: Boolean = false,
)
/**
* Run still streaming on the gateway when a chat.history snapshot was captured;
* [text] is the assistant text buffered so far (may be empty for runs without deltas).
*/
data class ChatInFlightRun(
val runId: String,
val text: String,
val plan: ChatPlanSnapshot? = null,
)
data class ChatPlanSnapshot(
val steps: List<ChatPlanStep>,
val explanation: String? = null,
)
/**
* Snapshot of one chat session, including optional thinking level selected on the gateway.
*/
data class ChatHistory(
val sessionKey: String,
val sessionId: String?,
val thinkingLevel: String?,
val messages: List<ChatMessage>,
val sessionInfo: ChatSessionEntry? = null,
val inFlightRun: ChatInFlightRun? = null,
)
/**
* User-selected attachment payload sent to the gateway as inline base64.
*/
data class OutgoingAttachment(
val type: String,
val mimeType: String,
val fileName: String,
val base64: String,
val durationMs: Long? = null,
)

View file

@ -0,0 +1,111 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.Question
import ai.openclaw.app.gateway.QuestionRecord
enum class ChatQuestionStatus {
Pending,
Submitting,
Answered,
AnsweredElsewhere,
Expired,
Cancelled,
Unavailable,
}
data class ChatQuestionPrompt(
val record: QuestionRecord,
val submitting: Boolean = false,
val skipping: Boolean = false,
val answeredLocally: Boolean = false,
val errorText: String? = null,
val terminalObservedAtMs: Long? = null,
val recoveryUnavailable: Boolean = false,
) {
fun status(nowMs: Long = System.currentTimeMillis()): ChatQuestionStatus =
if (recoveryUnavailable) {
ChatQuestionStatus.Unavailable
} else {
when (record.status) {
"answered" -> if (answeredLocally) ChatQuestionStatus.Answered else ChatQuestionStatus.AnsweredElsewhere
"cancelled" -> ChatQuestionStatus.Cancelled
"expired" -> ChatQuestionStatus.Expired
else ->
when {
nowMs >= record.expiresAtMs -> ChatQuestionStatus.Expired
submitting -> ChatQuestionStatus.Submitting
else -> ChatQuestionStatus.Pending
}
}
}
}
data class ChatQuestionDraft(
val selectedOptions: Map<String, Set<String>> = emptyMap(),
val otherText: Map<String, String> = emptyMap(),
) {
fun toggle(
question: Question,
label: String,
): ChatQuestionDraft {
if (question.options.none { it.label == label }) return this
val selected = selectedOptions[question.questionId].orEmpty()
val next =
if (question.multiSelect == true) {
if (label in selected) selected - label else selected + label
} else if (selected == setOf(label)) {
emptySet()
} else {
setOf(label)
}
return copy(
selectedOptions = selectedOptions + (question.questionId to next),
otherText = if (question.multiSelect != true && next.isNotEmpty()) otherText + (question.questionId to "") else otherText,
)
}
fun setOther(
question: Question,
value: String,
): ChatQuestionDraft {
if (question.options.isNotEmpty() && question.isOther != true) return this
val clearOptions = question.multiSelect != true && value.isNotBlank()
return copy(
selectedOptions = if (clearOptions) selectedOptions + (question.questionId to emptySet()) else selectedOptions,
otherText = otherText + (question.questionId to value),
)
}
fun answers(questions: List<Question>): Map<String, List<String>>? {
val result = linkedMapOf<String, List<String>>()
for (question in questions) {
val selected = selectedOptions[question.questionId].orEmpty()
val values = question.options.mapNotNull { option -> option.label.takeIf { it in selected } }.toMutableList()
otherText[question.questionId]?.trim()?.takeIf { it.isNotEmpty() }?.let(values::add)
if (values.isEmpty()) return null
result[question.questionId] = values
}
return result
}
}
internal fun questionsForSession(
prompts: List<ChatQuestionPrompt>,
sessionKey: String,
mainSessionKey: String,
activeAgentId: String,
): List<ChatQuestionPrompt> {
val main = mainSessionKey.trim().ifEmpty { "main" }
val current = sessionKey.trim().let { if (it == "main") main else it }
val activeAgent = activeAgentId.trim().lowercase()
return prompts.filter { prompt ->
val key = prompt.record.sessionKey?.trim() ?: return@filter true
val sessionMatches = key == sessionKey || key == current || (key == "main" && current == main)
val promptAgent =
prompt.record.agentId
?.trim()
.orEmpty()
.lowercase()
sessionMatches && (promptAgent.isEmpty() || activeAgent.isEmpty() || promptAgent == activeAgent)
}
}

View file

@ -0,0 +1,295 @@
package ai.openclaw.app.chat
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
private const val MAX_TRACKED_SWARM_GROUPS = 10_000
private const val MAX_TRACKED_SWARM_CHILDREN = 100_000
private const val MAX_SWARM_PAGE_REQUESTS = 100
private const val MAX_RENDERED_SWARM_DOTS_PER_PHASE = 256
enum class ChatSwarmDotStatus {
Running,
Queued,
Failed,
Done,
;
val label: String
get() = name
}
data class ChatSwarmDot(
val key: String,
val label: String,
val status: ChatSwarmDotStatus,
)
data class ChatSwarmPhase(
val key: String,
val title: String?,
val dots: List<ChatSwarmDot>,
val hidden: Int,
)
data class ChatSwarmGroup(
val groupId: String,
val label: String,
val running: Int,
val done: Int,
val failed: Int,
val narrator: String?,
val phases: List<ChatSwarmPhase>,
)
internal data class ChatSwarmSessionPage(
val sessions: List<ChatSessionEntry>,
val totalCount: Int?,
val nextOffset: Int?,
val hasMore: Boolean?,
)
internal suspend fun collectChatSwarmChildSessions(
fetchPage: suspend (Int) -> ChatSwarmSessionPage,
): List<ChatSessionEntry> {
val rowsByKey = linkedMapOf<String, ChatSessionEntry>()
var remainingPageRequests = MAX_SWARM_PAGE_REQUESTS
repeat(4) {
val rowsBeforePass = rowsByKey.size
var expectedTotal: Int? = null
val seenOffsets = mutableSetOf<Int>()
var offset = 0
while (
remainingPageRequests > 0 &&
rowsByKey.size < MAX_TRACKED_SWARM_CHILDREN &&
seenOffsets.add(offset)
) {
remainingPageRequests -= 1
val page = fetchPage(offset)
expectedTotal = page.totalCount
for (row in page.sessions) {
rowsByKey[row.key] = row
if (rowsByKey.size >= MAX_TRACKED_SWARM_CHILDREN) break
}
if (rowsByKey.size >= MAX_TRACKED_SWARM_CHILDREN) return rowsByKey.values.toList()
val hasMore = page.hasMore ?: expectedTotal?.let { offset + page.sessions.size < it } ?: false
val nextOffset = page.nextOffset ?: (offset + page.sessions.size)
if (!hasMore || page.sessions.isEmpty() || nextOffset <= offset) break
offset = nextOffset
}
val added = rowsByKey.size - rowsBeforePass
if (
remainingPageRequests == 0 ||
added == 0 ||
expectedTotal?.let { rowsByKey.size >= it } != false
) {
return rowsByKey.values.toList()
}
}
return rowsByKey.values.toList()
}
internal fun chatSwarmEventBelongsToParent(
payload: JsonObject,
matchesParent: (String) -> Boolean,
): Boolean {
val source = payload["session"].asObjectOrNull() ?: payload
val explicitParent =
normalizedSwarmValue(source["parentSessionKey"].asStringOrNull())
?: normalizedSwarmValue(source["spawnedBy"].asStringOrNull())
if (explicitParent != null) return matchesParent(explicitParent)
val kind = normalizedSwarmValue(payload["kind"].asStringOrNull())
if (kind == "phase" || kind == "log") {
val sessionKey = normalizedSwarmValue(payload["sessionKey"].asStringOrNull()) ?: return false
return matchesParent(sessionKey)
}
val groupId =
normalizedSwarmValue(payload["swarmGroupId"].asStringOrNull())
?: normalizedSwarmValue(source["swarmGroupId"].asStringOrNull())
?: return false
if (!groupId.startsWith("swarm:")) return false
val generatedParent = groupId.removePrefix("swarm:").substringBeforeLast(':', missingDelimiterValue = "")
return generatedParent.isNotEmpty() && matchesParent(generatedParent)
}
private fun normalizedSwarmValue(value: String?): String? = value?.trim()?.takeIf(String::isNotEmpty)
internal class ChatSwarmActivityTracker {
private val currentPhaseByGroup = linkedMapOf<String, String>()
private val phaseRankByGroupPhase = linkedMapOf<String, Int>()
private var nextPhaseRank = 0
private val latestLogByGroup = linkedMapOf<String, String>()
private val phaseByChild = linkedMapOf<String, String>()
fun clear() {
currentPhaseByGroup.clear()
phaseRankByGroupPhase.clear()
nextPhaseRank = 0
latestLogByGroup.clear()
phaseByChild.clear()
}
fun observe(payload: JsonObject): Boolean {
val source = payload["session"].asObjectOrNull() ?: payload
val groupId = normalized(payload["swarmGroupId"].asStringOrNull()) ?: normalized(source["swarmGroupId"].asStringOrNull()) ?: return false
val kind = normalized((if ("kind" in payload) payload["kind"] else source["kind"]).asStringOrNull())
val text = normalized((if ("text" in payload) payload["text"] else source["text"]).asStringOrNull())
if ((kind == "phase" || kind == "log") && text != null) {
if (kind == "phase") {
val rankKey = phaseRankKey(groupId, text)
if (rankKey !in phaseRankByGroupPhase) {
setBounded(phaseRankByGroupPhase, rankKey, nextPhaseRank++, MAX_TRACKED_SWARM_CHILDREN)
}
setBounded(currentPhaseByGroup, groupId, text, MAX_TRACKED_SWARM_GROUPS)
} else {
setBounded(latestLogByGroup, groupId, text, MAX_TRACKED_SWARM_GROUPS)
}
return true
}
val childKey = normalized(source["key"].asStringOrNull()) ?: normalized(payload["sessionKey"].asStringOrNull()) ?: return true
val explicitPhase = normalized(source["swarmPhase"].asStringOrNull()) ?: normalized(payload["swarmPhase"].asStringOrNull())
if (explicitPhase != null) {
setBounded(phaseByChild, childKey, explicitPhase, MAX_TRACKED_SWARM_CHILDREN)
return true
}
if (payload["reason"].asStringOrNull() == "create" && childKey !in phaseByChild) {
currentPhaseByGroup[groupId]?.let { phase ->
setBounded(phaseByChild, childKey, phase, MAX_TRACKED_SWARM_CHILDREN)
}
}
return true
}
fun decorate(rows: List<ChatSessionEntry>): List<ChatSessionEntry> =
rows.map { row ->
val groupId = normalized(row.swarmGroupId) ?: return@map row
val phase = phaseByChild[row.key] ?: row.swarmPhase
row.copy(
swarmPhase = phase,
swarmPhaseRank = phase?.let { phaseRankByGroupPhase[phaseRankKey(groupId, it)] } ?: row.swarmPhaseRank,
swarmLog = latestLogByGroup[groupId] ?: row.swarmLog,
)
}
private fun phaseRankKey(
groupId: String,
phase: String,
): String = "${groupId.length}:$groupId$phase"
private fun normalized(value: String?): String? = value?.trim()?.takeIf(String::isNotEmpty)
private fun <V> setBounded(
values: LinkedHashMap<String, V>,
key: String,
value: V,
limit: Int,
) {
if (key !in values && values.size >= limit) {
values.entries
.firstOrNull()
?.key
?.let(values::remove)
}
values[key] = value
}
}
internal fun buildChatSwarmGroups(
sessions: List<ChatSessionEntry>,
matchesParent: (String) -> Boolean,
): List<ChatSwarmGroup> {
data class Entry(
val phase: String?,
val phaseRank: Int,
val log: String?,
val dot: ChatSwarmDot,
)
val byGroup = linkedMapOf<String, MutableList<Entry>>()
sessions.forEach { row ->
val groupId = row.swarmGroupId?.trim()?.takeIf(String::isNotEmpty) ?: return@forEach
if (!belongsToParent(row, groupId, matchesParent)) return@forEach
val status = swarmDotStatus(row) ?: return@forEach
val label = listOfNotNull(row.label, row.displayName, row.derivedTitle, row.key).firstOrNull { it.isNotBlank() } ?: row.key
byGroup.getOrPut(groupId, ::mutableListOf) +=
Entry(
phase = row.swarmPhase?.trim()?.takeIf(String::isNotEmpty),
phaseRank = row.swarmPhaseRank ?: Int.MAX_VALUE,
log = row.swarmLog?.trim()?.takeIf(String::isNotEmpty),
dot = ChatSwarmDot(key = row.key, label = label, status = status),
)
}
return byGroup
.map { (groupId, entries) ->
val phases = linkedMapOf<String, Triple<String?, Int, MutableList<ChatSwarmDot>>>()
entries.forEach { entry ->
val phaseKey = entry.phase ?: ""
val existing = phases[phaseKey]
if (existing == null) {
phases[phaseKey] = Triple(entry.phase, entry.phaseRank, mutableListOf(entry.dot))
} else {
existing.third += entry.dot
phases[phaseKey] = Triple(existing.first, minOf(existing.second, entry.phaseRank), existing.third)
}
}
val projectedPhases =
phases
.map { (key, bucket) ->
val dots =
if (bucket.third.size > MAX_RENDERED_SWARM_DOTS_PER_PHASE) {
bucket.third.sortedBy(ChatSwarmDot::status)
} else {
bucket.third
}
ChatSwarmPhase(
key = key,
title = bucket.first,
dots = dots.take(MAX_RENDERED_SWARM_DOTS_PER_PHASE),
hidden = (dots.size - MAX_RENDERED_SWARM_DOTS_PER_PHASE).coerceAtLeast(0),
)
}.sortedWith(compareBy<ChatSwarmPhase> { phases[it.key]?.second ?: Int.MAX_VALUE }.thenBy(ChatSwarmPhase::key))
val dots = entries.map(Entry::dot)
ChatSwarmGroup(
groupId = groupId,
label = groupId.substringAfterLast(':'),
running = dots.count { it.status == ChatSwarmDotStatus.Running },
done = dots.count { it.status == ChatSwarmDotStatus.Done },
failed = dots.count { it.status == ChatSwarmDotStatus.Failed },
narrator = entries.firstNotNullOfOrNull(Entry::log),
phases = projectedPhases,
)
}.filter { group ->
group.phases.any { phase -> phase.dots.any { it.status == ChatSwarmDotStatus.Queued || it.status == ChatSwarmDotStatus.Running } }
}.sortedBy(ChatSwarmGroup::groupId)
}
private fun swarmDotStatus(row: ChatSessionEntry): ChatSwarmDotStatus? =
when {
row.status == "running" || row.hasActiveRun == true -> ChatSwarmDotStatus.Running
row.status == "done" -> ChatSwarmDotStatus.Done
row.status == "failed" || row.status == "killed" || row.status == "timeout" -> ChatSwarmDotStatus.Failed
row.subagentRunState == "active" || row.hasActiveSubagentRun == true -> ChatSwarmDotStatus.Queued
else -> null
}
private fun belongsToParent(
row: ChatSessionEntry,
groupId: String,
matchesParent: (String) -> Boolean,
): Boolean {
if (row.parentSessionKey?.let(matchesParent) == true || row.spawnedBy?.let(matchesParent) == true) return true
val parts = groupId.split(':')
return parts.size > 2 && matchesParent(parts.drop(1).dropLast(1).joinToString(":"))
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)
?.takeIf { it.isString }
?.content

View file

@ -0,0 +1,535 @@
package ai.openclaw.app.chat
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.withTransaction
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import java.util.UUID
/** Upper bound of cached session rows per gateway across every agent owner. */
internal const val MAX_CACHED_SESSIONS = 50
/** Upper bound of cached transcript rows per session; only the newest messages are kept. */
internal const val MAX_CACHED_MESSAGES_PER_SESSION = 200
@Serializable
private data class CachedMessageContent(
val type: String,
val text: String? = null,
val mimeType: String? = null,
val fileName: String? = null,
val artifactId: String? = null,
val url: String? = null,
val openUrl: String? = null,
val alt: String? = null,
val width: Int? = null,
val height: Int? = null,
val sizeBytes: Long? = null,
val durationMs: Long? = null,
val playback: String? = null,
)
/**
* Read-only offline cache of chat sessions and transcripts.
*
* The cache is disposable: it only speeds up cold open and enables offline browsing.
* Live responses replace cached data; the active deep session may be retained outside the newest
* session-list window so its transcript remains available offline.
*/
interface ChatTranscriptCache {
suspend fun loadLastDefaultAgentId(gatewayId: String): String?
suspend fun saveLastDefaultAgentId(
gatewayId: String,
agentId: String,
)
suspend fun loadSessions(
gatewayId: String,
agentId: String,
): List<ChatSessionEntry>
suspend fun loadTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<ChatMessage>
suspend fun saveSessions(
gatewayId: String,
agentId: String,
sessions: List<ChatSessionEntry>,
retainedSessionKey: String? = null,
)
suspend fun saveTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
messages: List<ChatMessage>,
)
/** Removes one session and its transcript, so gateway-side deletes also purge offline copies. */
suspend fun deleteSession(
gatewayId: String,
agentId: String,
sessionKey: String,
)
/** Removes every cached transcript row owned by one gateway identity. */
suspend fun clearGateway(gatewayId: String)
}
@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "agentId", "sessionKey"])
internal data class CachedSessionEntity(
val gatewayId: String,
val agentId: String,
val sessionKey: String,
val displayName: String?,
val updatedAtMs: Long?,
val status: String?,
val startedAt: Long?,
val endedAt: Long?,
val runtimeMs: Long?,
val outputTokens: Long?,
val hasRunMetadata: Boolean,
// Preserves gateway list order so offline session rows render in the familiar order.
val rowOrder: Int,
)
@Entity(tableName = "cached_messages", primaryKeys = ["gatewayId", "agentId", "sessionKey", "rowOrder"])
internal data class CachedMessageEntity(
val gatewayId: String,
val agentId: String,
val sessionKey: String,
val rowOrder: Int,
val role: String,
// JSON array of text and managed-media references; attachment bytes are never persisted.
val textPartsJson: String,
val timestampMs: Long?,
// Kept so live history reconciliation can match cached rows by identity key.
val idempotencyKey: String?,
)
@Entity(tableName = "cached_gateway_owners", primaryKeys = ["gatewayId"])
internal data class CachedGatewayOwnerEntity(
val gatewayId: String,
val agentId: String,
)
@Dao
internal interface ChatCacheDao {
@Query("SELECT agentId FROM cached_gateway_owners WHERE gatewayId = :gatewayId")
suspend fun lastDefaultAgentId(gatewayId: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertGatewayOwner(row: CachedGatewayOwnerEntity)
@Query("DELETE FROM cached_gateway_owners WHERE gatewayId = :gatewayId")
suspend fun deleteGatewayOwner(gatewayId: String)
@Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId ORDER BY rowOrder ASC")
suspend fun sessions(
gatewayId: String,
agentId: String,
): List<CachedSessionEntity>
@Query("SELECT * FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey = :sessionKey")
suspend fun session(
gatewayId: String,
agentId: String,
sessionKey: String,
): CachedSessionEntity?
@Query(
"SELECT * FROM cached_messages WHERE gatewayId = :gatewayId AND agentId = :agentId " +
"AND sessionKey = :sessionKey ORDER BY rowOrder ASC",
)
suspend fun messages(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<CachedMessageEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSessions(rows: List<CachedSessionEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessages(rows: List<CachedMessageEntity>)
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId")
suspend fun deleteSessions(
gatewayId: String,
agentId: String,
)
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId")
suspend fun deleteSessionsForGateway(gatewayId: String)
@Query("DELETE FROM cached_messages WHERE gatewayId = :gatewayId")
suspend fun deleteMessages(gatewayId: String)
@Query("DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey = :sessionKey")
suspend fun deleteSessionRow(
gatewayId: String,
agentId: String,
sessionKey: String,
)
@Query("DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey = :sessionKey")
suspend fun deleteTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
)
@Query("SELECT COALESCE(MAX(rowOrder), -1) + 1 FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId")
suspend fun nextSessionRowOrder(
gatewayId: String,
agentId: String,
): Int
// Keeps the just-written session even when the cache is full: without the exclusion, a stub
// inserted at the highest rowOrder would be evicted immediately and deep-session transcripts
// could never be cached once MAX_CACHED_SESSIONS rows exist.
@Query(
"DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId " +
"AND sessionKey != :keepSessionKey AND sessionKey NOT IN " +
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey != :keepSessionKey " +
"ORDER BY rowOrder ASC LIMIT :keep)",
)
suspend fun evictSessionsBeyondKeeping(
gatewayId: String,
agentId: String,
keepSessionKey: String,
keep: Int,
)
// Owner-local cleanup runs before the gateway-wide bound below; transcripts never outlive
// their corresponding session row.
@Query(
"DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND agentId = :agentId AND sessionKey NOT IN " +
"(SELECT sessionKey FROM cached_sessions WHERE gatewayId = :gatewayId AND agentId = :agentId)",
)
suspend fun evictOrphanedTranscripts(
gatewayId: String,
agentId: String,
)
// A gateway can expose many agent owners. Cap their aggregate cache by recent writes so
// switching owners cannot grow the disposable session/transcript tables without bound.
@Query(
"DELETE FROM cached_sessions WHERE gatewayId = :gatewayId AND rowid NOT IN " +
"(SELECT rowid FROM cached_sessions WHERE gatewayId = :gatewayId ORDER BY rowid DESC LIMIT :keep)",
)
suspend fun evictGatewaySessionsBeyond(
gatewayId: String,
keep: Int,
)
@Query(
"DELETE FROM cached_messages WHERE gatewayId = :gatewayId AND NOT EXISTS " +
"(SELECT 1 FROM cached_sessions WHERE cached_sessions.gatewayId = cached_messages.gatewayId " +
"AND cached_sessions.agentId = cached_messages.agentId " +
"AND cached_sessions.sessionKey = cached_messages.sessionKey)",
)
suspend fun evictGatewayOrphanedTranscripts(gatewayId: String)
}
/**
* Room-backed [ChatTranscriptCache]. Callers bind every operation to the gateway scope captured
* before their suspend point, so a connection switch cannot re-scope an old response.
*/
class RoomChatTranscriptCache internal constructor(
private val database: GatewayCacheDatabase,
) : ChatTranscriptCache {
private val json = Json
private val cachedContentSerializer = ListSerializer(CachedMessageContent.serializer())
private val legacyTextPartsSerializer = ListSerializer(String.serializer())
override suspend fun loadLastDefaultAgentId(gatewayId: String): String? {
val gateway = scopedGatewayId(gatewayId) ?: return null
return database
.dao()
.lastDefaultAgentId(gateway)
?.trim()
?.takeIf { it.isNotEmpty() }
}
override suspend fun saveLastDefaultAgentId(
gatewayId: String,
agentId: String,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
database.dao().upsertGatewayOwner(CachedGatewayOwnerEntity(gatewayId = gateway, agentId = agent))
}
override suspend fun loadSessions(
gatewayId: String,
agentId: String,
): List<ChatSessionEntry> {
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
val agent = scopedAgentId(agentId) ?: return emptyList()
return database.dao().sessions(gateway, agent).map { row ->
ChatSessionEntry(
key = row.sessionKey,
updatedAtMs = row.updatedAtMs,
ownerAgentId = agent,
displayName = row.displayName,
status = row.status,
startedAt = row.startedAt,
endedAt = row.endedAt,
runtimeMs = row.runtimeMs,
outputTokens = row.outputTokens,
hasRunMetadata = row.hasRunMetadata,
)
}
}
override suspend fun loadTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<ChatMessage> {
val gateway = scopedGatewayId(gatewayId) ?: return emptyList()
val agent = scopedAgentId(agentId) ?: return emptyList()
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return emptyList()
return database.dao().messages(gateway, agent, key).mapNotNull { row ->
val role = normalizeVisibleChatMessageRole(row.role) ?: return@mapNotNull null
ChatMessage(
id = UUID.randomUUID().toString(),
role = role,
content =
decodeCachedContent(row.textPartsJson).map { part ->
ChatMessageContent(
type = part.type,
text = part.text,
mimeType = part.mimeType,
fileName = part.fileName,
artifactId = part.artifactId,
url = part.url,
openUrl = part.openUrl,
alt = part.alt,
width = part.width,
height = part.height,
sizeBytes = part.sizeBytes,
durationMs = part.durationMs,
playback = part.playback,
)
},
timestampMs = row.timestampMs,
idempotencyKey = row.idempotencyKey,
// Canonical tree ids stay live-only; cached rows regain actions after history refresh.
entryId = null,
)
}
}
override suspend fun saveSessions(
gatewayId: String,
agentId: String,
sessions: List<ChatSessionEntry>,
retainedSessionKey: String?,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
val retainedKey = retainedSessionKey?.trim()?.takeIf { it.isNotEmpty() }
val dao = database.dao()
database.withTransaction {
val initialSessions = sessions.take(MAX_CACHED_SESSIONS)
val needsRetainedRow = retainedKey != null && initialSessions.none { it.key == retainedKey }
val retainedEntry = if (needsRetainedRow) sessions.firstOrNull { it.key == retainedKey } else null
val retainedRow =
if (needsRetainedRow) {
retainedEntry?.let { entry ->
CachedSessionEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = entry.key,
displayName = entry.displayName,
updatedAtMs = entry.updatedAtMs,
status = entry.status,
startedAt = entry.startedAt,
endedAt = entry.endedAt,
runtimeMs = entry.runtimeMs,
outputTokens = entry.outputTokens,
hasRunMetadata = entry.hasRunMetadata,
rowOrder = 0,
)
} ?: dao.session(gateway, agent, retainedKey)
} else {
null
}
val listedSessionLimit = MAX_CACHED_SESSIONS - if (retainedRow == null) 0 else 1
val rows =
sessions.take(listedSessionLimit).mapIndexed { index, session ->
CachedSessionEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = session.key,
displayName = session.displayName,
updatedAtMs = session.updatedAtMs,
status = session.status,
startedAt = session.startedAt,
endedAt = session.endedAt,
runtimeMs = session.runtimeMs,
outputTokens = session.outputTokens,
hasRunMetadata = session.hasRunMetadata,
rowOrder = index,
)
}
dao.deleteSessions(gateway, agent)
dao.insertSessions(rows)
retainedRow?.let { dao.insertSessions(listOf(it.copy(rowOrder = rows.size))) }
dao.evictOrphanedTranscripts(gateway, agent)
dao.evictGatewaySessionsBeyond(gateway, MAX_CACHED_SESSIONS)
dao.evictGatewayOrphanedTranscripts(gateway)
}
}
override suspend fun saveTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
messages: List<ChatMessage>,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
// Persist small managed-media references, never attachment bytes. Cards remain visible offline
// even though their short-lived download capability must be reacquired after reconnecting.
val rows =
messages
.mapNotNull { message ->
val role = normalizeVisibleChatMessageRole(message.role) ?: return@mapNotNull null
val content =
message.content.mapNotNull { part ->
when {
part.type == "text" && !part.text.isNullOrBlank() ->
CachedMessageContent(type = "text", text = part.text)
part.type == "image" && !part.artifactId.isNullOrBlank() && !part.url.isNullOrBlank() ->
CachedMessageContent(
type = "image",
mimeType = part.mimeType,
fileName = part.fileName,
artifactId = part.artifactId,
url = part.url,
openUrl = part.openUrl,
alt = part.alt,
width = part.width,
height = part.height,
sizeBytes = part.sizeBytes,
)
part.type == "audio" || part.type == "video" ->
CachedMessageContent(
type = part.type,
mimeType = part.mimeType,
fileName = part.fileName,
artifactId = part.artifactId,
url = part.url,
openUrl = part.openUrl,
alt = part.alt,
width = part.width,
height = part.height,
sizeBytes = part.sizeBytes,
durationMs = part.durationMs,
playback = part.playback,
)
else -> null
}
}
if (content.isEmpty()) return@mapNotNull null
Triple(message, role, content)
}.takeLast(MAX_CACHED_MESSAGES_PER_SESSION)
.mapIndexed { index, (message, role, content) ->
CachedMessageEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = key,
rowOrder = index,
role = role,
textPartsJson = json.encodeToString(cachedContentSerializer, content),
timestampMs = message.timestampMs,
idempotencyKey = message.idempotencyKey,
)
}
val dao = database.dao()
database.withTransaction {
dao.deleteTranscript(gateway, agent, key)
dao.insertMessages(rows)
// A transcript may arrive for a session missing from the cached list (e.g. deep session
// switch); keep a stub row so the transcript stays reachable, then re-apply the bounds.
val currentSession = dao.session(gateway, agent, key)
// REPLACE refreshes SQLite rowid, making the transcript's session the most recent gateway
// row while preserving list metadata when that session was already cached.
dao.insertSessions(
listOf(
currentSession
?: CachedSessionEntity(
gatewayId = gateway,
agentId = agent,
sessionKey = key,
displayName = null,
updatedAtMs = null,
status = null,
startedAt = null,
endedAt = null,
runtimeMs = null,
outputTokens = null,
hasRunMetadata = false,
rowOrder = dao.nextSessionRowOrder(gateway, agent),
),
),
)
dao.evictSessionsBeyondKeeping(gateway, agent, keepSessionKey = key, keep = MAX_CACHED_SESSIONS - 1)
dao.evictOrphanedTranscripts(gateway, agent)
dao.evictGatewaySessionsBeyond(gateway, MAX_CACHED_SESSIONS)
dao.evictGatewayOrphanedTranscripts(gateway)
}
}
override suspend fun clearGateway(gatewayId: String) {
val gateway = scopedGatewayId(gatewayId) ?: return
val dao = database.dao()
database.withTransaction {
dao.deleteMessages(gateway)
dao.deleteSessionsForGateway(gateway)
dao.deleteGatewayOwner(gateway)
}
}
override suspend fun deleteSession(
gatewayId: String,
agentId: String,
sessionKey: String,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
val agent = scopedAgentId(agentId) ?: return
val key = sessionKey.trim().takeIf { it.isNotEmpty() } ?: return
val dao = database.dao()
database.withTransaction {
dao.deleteSessionRow(gateway, agent, key)
dao.deleteTranscript(gateway, agent, key)
}
}
private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() }
private fun scopedAgentId(agentId: String): String? = agentId.trim().takeIf { it.isNotEmpty() }
private fun decodeCachedContent(encoded: String): List<CachedMessageContent> =
runCatching { json.decodeFromString(cachedContentSerializer, encoded) }.getOrElse {
// Offline transcript browsing is shipped behavior. Keep the previous string-array rows
// readable until a live history refresh naturally rewrites this disposable cache entry.
runCatching { json.decodeFromString(legacyTextPartsSerializer, encoded) }
.getOrDefault(emptyList())
.map { CachedMessageContent(type = "text", text = it) }
}
}

View file

@ -0,0 +1,193 @@
package ai.openclaw.app.chat
import java.net.URI
import java.net.URLDecoder
internal object ChatWidgetUrlResolver {
private const val DOCUMENTS_PATH = "/__openclaw__/canvas/documents"
fun resolve(
surfaceUrl: String?,
target: String,
): String? {
val surface = parseCapabilitySurface(surfaceUrl) ?: return null
val relative = parseRelativeTarget(target) ?: return null
val joined =
buildString {
append(surface.scheme.lowercase())
append("://")
append(surface.rawAuthority)
append(surface.rawPath.trimEnd('/'))
append(relative.rawPath)
relative.rawQuery?.let { append('?').append(it) }
relative.rawFragment?.let { append('#').append(it) }
}
return runCatching { URI(joined) }.getOrNull()?.toASCIIString()
}
fun supportsTarget(target: String): Boolean = parseRelativeTarget(target) != null
private fun resolve(
surface: ChatWidgetSurface,
target: String,
role: ChatWidgetSurfaceRole,
attemptedRoles: Set<ChatWidgetSurfaceRole>,
): ChatWidgetResource? =
resolve(surface.url, target)?.let { url ->
ChatWidgetResource(
url = url,
tlsFingerprintSha256 = surface.tlsFingerprintSha256,
surfaceRole = role,
attemptedSurfaceRoles = attemptedRoles,
)
}
fun resolvePreferred(
surfaces: ChatWidgetSurfaceUrls,
target: String,
excluding: ChatWidgetResource?,
blockedRoles: Set<ChatWidgetSurfaceRole> = emptySet(),
attemptedRoles: Set<ChatWidgetSurfaceRole> = emptySet(),
): ChatWidgetResource? =
sequenceOf(
ChatWidgetSurfaceRole.NODE to surfaces.node,
ChatWidgetSurfaceRole.OPERATOR to surfaces.operator,
).filter { (role) -> role !in blockedRoles }
.mapNotNull { (role, surface) -> surface?.let { resolve(it, target, role, attemptedRoles) } }
.firstOrNull { isReplacement(it, excluding) }
suspend fun resolveAfterFailure(
target: String,
failedResource: ChatWidgetResource,
currentSurfaceUrls: () -> ChatWidgetSurfaceUrls,
refreshNodeSurface: suspend (String?) -> ChatWidgetSurface?,
refreshOperatorSurface: suspend (String?) -> ChatWidgetSurface?,
): ChatWidgetResource? {
val observed = currentSurfaceUrls()
val blockedRoles = failedResource.attemptedSurfaceRoles
if (failedResource.surfaceRole == ChatWidgetSurfaceRole.LEGACY && ChatWidgetSurfaceRole.LEGACY in blockedRoles) {
return null
}
val attemptedRoles = blockedRoles + failedResource.surfaceRole
if (ChatWidgetSurfaceRole.NODE !in blockedRoles) {
observed.node
?.let { resolve(it, target, ChatWidgetSurfaceRole.NODE, attemptedRoles) }
?.takeIf { isReplacement(it, failedResource) }
?.let { return it }
val refreshed =
refreshNodeSurface(observed.node?.url)?.let {
resolve(it, target, ChatWidgetSurfaceRole.NODE, attemptedRoles)
}
if (refreshed != null && isReplacement(refreshed, failedResource)) return refreshed
}
// A nil refresh can mean its route lease lost a reconnect race. Re-read
// both roles so a replacement connection wins over the stale observation.
val afterNodeRefresh = currentSurfaceUrls()
resolvePreferred(
afterNodeRefresh,
target,
excluding = failedResource,
blockedRoles = blockedRoles,
attemptedRoles = attemptedRoles,
)?.let { return it }
if (ChatWidgetSurfaceRole.OPERATOR !in blockedRoles) {
val refreshedOperator =
refreshOperatorSurface(afterNodeRefresh.operator?.url)?.let {
resolve(it, target, ChatWidgetSurfaceRole.OPERATOR, attemptedRoles)
}
if (refreshedOperator != null && isReplacement(refreshedOperator, failedResource)) return refreshedOperator
}
return resolvePreferred(
currentSurfaceUrls(),
target,
excluding = failedResource,
blockedRoles = blockedRoles,
attemptedRoles = attemptedRoles,
)
}
private fun isReplacement(
candidate: ChatWidgetResource,
failedResource: ChatWidgetResource?,
): Boolean {
if (failedResource == null) return true
return if (
failedResource.surfaceRole == ChatWidgetSurfaceRole.LEGACY &&
failedResource.tlsFingerprintSha256 == null
) {
candidate.url != failedResource.url
} else {
candidate.url != failedResource.url ||
candidate.tlsFingerprintSha256 != failedResource.tlsFingerprintSha256
}
}
private fun parseCapabilitySurface(raw: String?): URI? {
val parsed = raw?.trim()?.takeIf(String::isNotEmpty)?.let { runCatching { URI(it) }.getOrNull() } ?: return null
val scheme = parsed.scheme?.lowercase()
if (scheme != "http" && scheme != "https") return null
if (parsed.host.isNullOrBlank() || parsed.rawUserInfo != null || parsed.rawQuery != null || parsed.rawFragment != null) return null
val segments = parsed.rawPath.split('/').filter(String::isNotEmpty)
if (segments.size < 3 || segments[segments.lastIndex - 2] != "__openclaw__" || segments[segments.lastIndex - 1] != "cap") return null
if (decodeRepeatedly(segments.last())?.isEmpty() != false) return null
return parsed
}
private fun parseRelativeTarget(raw: String): URI? {
val target = raw.trim()
if (!target.startsWith('/')) return null
val parsed = runCatching { URI(target) }.getOrNull() ?: return null
if (parsed.isAbsolute || parsed.rawAuthority != null || !isCanonicalPath(parsed.rawPath)) return null
if (!parsed.rawPath.startsWith("$DOCUMENTS_PATH/")) return null
return parsed
}
private fun isCanonicalPath(path: String): Boolean {
val segments = path.split('/')
if (segments.firstOrNull()?.isNotEmpty() == true) return false
return segments.drop(1).all { encoded ->
if (encoded.isEmpty()) return@all false
val decoded = decodeRepeatedly(encoded) ?: return@all false
decoded != "." && decoded != ".." && !decoded.contains('/') && !decoded.contains('\\')
}
}
private fun decodeRepeatedly(raw: String): String? {
var value = raw
repeat(8) {
val decoded =
runCatching {
URLDecoder.decode(value.replace("+", "%2B"), Charsets.UTF_8.name())
}.getOrNull() ?: return null
if (decoded == value) return decoded
value = decoded
}
return null
}
}
internal data class ChatWidgetSurfaceUrls(
val node: ChatWidgetSurface?,
val operator: ChatWidgetSurface?,
)
internal data class ChatWidgetSurface(
val url: String,
val tlsFingerprintSha256: String?,
)
internal enum class ChatWidgetSurfaceRole {
NODE,
OPERATOR,
LEGACY,
}
internal data class ChatWidgetResource(
val url: String,
val tlsFingerprintSha256: String?,
val surfaceRole: ChatWidgetSurfaceRole = ChatWidgetSurfaceRole.LEGACY,
val attemptedSurfaceRoles: Set<ChatWidgetSurfaceRole> = emptySet(),
)

View file

@ -0,0 +1,772 @@
package ai.openclaw.app.chat
import android.content.Context
import android.util.Log
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.room.withTransaction
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
internal const val GATEWAY_CACHE_DB_NAME = "gateway-cache.db"
internal const val CLIENT_STATE_DB_NAME = "client-state.db"
internal const val LEGACY_CHAT_DATABASE_NAME = "chat-transcript-cache.db"
private const val LEGACY_IMPORT_KEY = "legacy-chat-transcript-cache-v8"
private const val LEGACY_IMPORT_COMPLETE = "complete"
private const val LEGACY_IMPORT_CHUNK_PAGE_ROWS = 8
private const val GATEWAY_REMOVAL_STAGED = "staged"
private const val GATEWAY_REMOVAL_COMMITTING = "committing"
private const val GATEWAY_REMOVAL_CACHE_PENDING = "cache-pending"
@Entity(tableName = "client_state_metadata")
internal data class ClientStateMetadataEntity(
@PrimaryKey val key: String,
val value: String,
)
@Entity(tableName = "gateway_removals")
internal data class GatewayRemovalEntity(
@PrimaryKey val gatewayId: String,
val phase: String,
)
@Dao
internal interface ClientStateControlDao {
@Query("SELECT value FROM client_state_metadata WHERE `key` = :key")
suspend fun metadataValue(key: String): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertMetadata(row: ClientStateMetadataEntity)
@Query("SELECT * FROM gateway_removals ORDER BY gatewayId ASC")
suspend fun gatewayRemovals(): List<GatewayRemovalEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertGatewayRemoval(row: GatewayRemovalEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertGatewayRemovalIfAbsent(row: GatewayRemovalEntity)
@Query("DELETE FROM gateway_removals WHERE gatewayId = :gatewayId AND phase = :phase")
suspend fun deleteGatewayRemovalInPhase(
gatewayId: String,
phase: String,
)
}
/** Disposable gateway-derived projections. Schema mismatches and corruption rebuild this file. */
@Database(
entities = [CachedSessionEntity::class, CachedMessageEntity::class, CachedGatewayOwnerEntity::class],
version = 2,
exportSchema = true,
)
internal abstract class GatewayCacheDatabase : RoomDatabase() {
abstract fun dao(): ChatCacheDao
companion object {
fun open(
context: Context,
name: String = GATEWAY_CACHE_DB_NAME,
): GatewayCacheDatabase {
val appContext = context.applicationContext
fun build(): GatewayCacheDatabase =
Room
.databaseBuilder(appContext, GatewayCacheDatabase::class.java, name)
// Cache rows are gateway-owned projections. A missing migration means rebuild, and
// dropAllTables also removes obsolete cache tables left by older formats.
.fallbackToDestructiveMigration(true)
.build()
var database: GatewayCacheDatabase? = null
return try {
build().also {
database = it
// Room opens lazily; force validation so corruption is repaired before publication.
it.openHelper.writableDatabase
}
} catch (_: Throwable) {
database?.close()
appContext.deleteDatabase(name)
build().also { it.openHelper.writableDatabase }
}
}
}
}
/** Durable client-owned state. Every future schema change requires an explicit Room migration. */
@Database(
entities = [
OutboxCommandEntity::class,
OutboxAttachmentEntity::class,
OutboxAttachmentChunkEntity::class,
ComposerSendAdmissionEntity::class,
ClientStateMetadataEntity::class,
GatewayRemovalEntity::class,
],
version = 1,
exportSchema = true,
)
internal abstract class ClientStateDatabase : RoomDatabase() {
abstract fun outboxDao(): ChatOutboxDao
abstract fun controlDao(): ClientStateControlDao
companion object {
fun open(
context: Context,
name: String = CLIENT_STATE_DB_NAME,
): ClientStateDatabase =
Room
.databaseBuilder(context.applicationContext, ClientStateDatabase::class.java, name)
.build()
.also {
// Fail closed and preserve the file if durable state cannot be opened or validated.
it.openHelper.writableDatabase
}
}
}
/**
* Shipped combined database, retained only as the one-time import owner.
*
* Runtime reads and writes never use this type after [AndroidClientDatabases.start] completes.
*/
@Entity(tableName = "cached_sessions", primaryKeys = ["gatewayId", "agentId", "sessionKey"])
internal data class LegacyCachedSessionEntity(
val gatewayId: String,
val agentId: String,
val sessionKey: String,
val displayName: String?,
val updatedAtMs: Long?,
val rowOrder: Int,
)
@Database(
entities = [
LegacyCachedSessionEntity::class,
CachedMessageEntity::class,
OutboxCommandEntity::class,
OutboxAttachmentEntity::class,
OutboxAttachmentChunkEntity::class,
ComposerSendAdmissionEntity::class,
CachedGatewayOwnerEntity::class,
],
version = 8,
exportSchema = false,
)
internal abstract class LegacyChatDatabase : RoomDatabase() {
abstract fun outboxDao(): ChatOutboxDao
companion object {
internal val MIGRATION_2_3 =
object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
// v2 persisted every post-dispatch exception as queued+lastError. Those rows may
// already have run, so upgrading must park them alongside crash-interrupted sends.
db.execSQL(
"UPDATE outbox_commands SET status = ?, lastError = ? " +
"WHERE status = ? OR (status = ? AND lastError IS NOT NULL)",
arrayOf<Any?>(
ChatOutboxStatus.Failed.dbValue,
OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
ChatOutboxStatus.Sending.dbValue,
ChatOutboxStatus.Queued.dbValue,
),
)
}
}
internal val MIGRATION_3_4 =
object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `gatedEpoch` INTEGER")
// Legacy queued command-shaped rows predate connection epochs; the sentinel makes
// them park for explicit retry instead of silently replaying on the next reconnect.
db.execSQL(
"UPDATE outbox_commands SET gatedEpoch = ? WHERE status = ? AND text LIKE '/%'",
arrayOf<Any?>(OUTBOX_GATED_EPOCH_NEVER, ChatOutboxStatus.Queued.dbValue),
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `outbox_attachments` (`id` TEXT NOT NULL, `commandId` TEXT NOT NULL, " +
"`position` INTEGER NOT NULL, `type` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `fileName` TEXT NOT NULL, " +
"`durationMs` INTEGER, `byteLength` INTEGER NOT NULL, PRIMARY KEY(`id`))",
)
db.execSQL("CREATE INDEX IF NOT EXISTS `index_outbox_attachments_commandId` ON `outbox_attachments` (`commandId`)")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `outbox_attachment_chunks` (`attachmentId` TEXT NOT NULL, " +
"`chunkIndex` INTEGER NOT NULL, `bytes` BLOB NOT NULL, PRIMARY KEY(`attachmentId`, `chunkIndex`))",
)
}
}
internal val MIGRATION_4_5 =
object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `outbox_commands` ADD COLUMN `ownerAgentId` TEXT")
// Agent-qualified keys carry a durable owner in the key itself. Backfill it so session
// deletion and replay keep working after upgrade without consulting mutable defaults.
db.execSQL(
"UPDATE outbox_commands SET ownerAgentId = " +
"substr(sessionKey, 7, instr(substr(sessionKey, 7), ':') - 1) " +
"WHERE sessionKey LIKE 'agent:%:%' AND instr(substr(sessionKey, 7), ':') > 1",
)
// Earlier rows did not persist the default agent that owned an unscoped key. Never
// guess after upgrade: queued input stays visible for manual resend, while accepted
// input remains delivery-ambiguous and must not be replayed under a different owner.
db.execSQL(
"UPDATE outbox_commands SET status = ?, lastError = ? " +
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
arrayOf<Any?>(
ChatOutboxStatus.Failed.dbValue,
OUTBOX_OWNER_CHANGED_ERROR,
ChatOutboxStatus.Queued.dbValue,
),
)
db.execSQL(
"UPDATE outbox_commands SET status = ?, lastError = ? " +
"WHERE status = ? AND sessionKey NOT LIKE 'agent:%'",
arrayOf<Any?>(
ChatOutboxStatus.Failed.dbValue,
OUTBOX_DELIVERY_UNCONFIRMED_ERROR,
ChatOutboxStatus.Accepted.dbValue,
),
)
}
}
internal val MIGRATION_5_6 =
object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
// Session and transcript caches are disposable, and legacy unscoped rows have no
// provable owner. Rebuild both; the durable outbox remains intact across the upgrade.
db.execSQL("DROP TABLE IF EXISTS `cached_sessions`")
db.execSQL("DROP TABLE IF EXISTS `cached_messages`")
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_sessions` " +
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
"`displayName` TEXT, `updatedAtMs` INTEGER, `rowOrder` INTEGER NOT NULL, " +
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`))",
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_messages` " +
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, `sessionKey` TEXT NOT NULL, " +
"`rowOrder` INTEGER NOT NULL, `role` TEXT NOT NULL, `textPartsJson` TEXT NOT NULL, " +
"`timestampMs` INTEGER, `idempotencyKey` TEXT, " +
"PRIMARY KEY(`gatewayId`, `agentId`, `sessionKey`, `rowOrder`))",
)
}
}
internal val MIGRATION_6_7 =
object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `cached_gateway_owners` " +
"(`gatewayId` TEXT NOT NULL, `agentId` TEXT NOT NULL, PRIMARY KEY(`gatewayId`))",
)
}
}
internal val MIGRATION_7_8 =
object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `composer_send_admissions` " +
"(`id` TEXT NOT NULL, `gatewayId` TEXT NOT NULL, `ownerAgentId` TEXT NOT NULL, " +
"`sessionKey` TEXT NOT NULL, PRIMARY KEY(`id`))",
)
}
}
fun open(
context: Context,
name: String,
): LegacyChatDatabase =
Room
.databaseBuilder(context.applicationContext, LegacyChatDatabase::class.java, name)
.addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8)
// v1 contains only disposable transcripts. Durable state starts in v2.
.fallbackToDestructiveMigrationFrom(true, 1)
.build()
.also { it.openHelper.writableDatabase }
}
}
private class OpenedAndroidClientDatabases private constructor(
private val context: Context,
val gatewayCache: GatewayCacheDatabase,
val clientState: ClientStateDatabase,
) : AutoCloseable {
companion object {
suspend fun open(
context: Context,
gatewayCacheName: String = GATEWAY_CACHE_DB_NAME,
clientStateName: String = CLIENT_STATE_DB_NAME,
legacyName: String = LEGACY_CHAT_DATABASE_NAME,
registeredGatewayIds: Set<String>? = null,
): OpenedAndroidClientDatabases {
val appContext = context.applicationContext
val state = ClientStateDatabase.open(appContext, clientStateName)
var cache: GatewayCacheDatabase? = null
return try {
cache = GatewayCacheDatabase.open(appContext, gatewayCacheName)
OpenedAndroidClientDatabases(appContext, cache, state).also { databases ->
databases.importLegacyStateIfNeeded(legacyName)
databases.resolvePendingGatewayRemovals(registeredGatewayIds)
}
} catch (error: Throwable) {
cache?.close()
state.close()
throw error
}
}
}
val transcriptCache = RoomChatTranscriptCache(gatewayCache)
val commandOutbox = RoomChatCommandOutbox(clientState)
suspend fun stageGatewayRemoval(gatewayId: String) {
val gateway = scopedGatewayId(gatewayId) ?: return
// A retry may race an interrupted committed purge. Never downgrade that irreversible marker.
clientState.controlDao().insertGatewayRemovalIfAbsent(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_STAGED))
}
suspend fun cancelGatewayRemoval(gatewayId: String) {
val gateway = scopedGatewayId(gatewayId) ?: return
clientState.controlDao().deleteGatewayRemovalInPhase(gateway, GATEWAY_REMOVAL_STAGED)
}
/**
* Marks cleanup irreversible before touching either file. A crash can leave one database ahead
* of the other, so startup resumes this idempotent operation before publishing the stores.
*/
suspend fun commitGatewayRemoval(
gatewayId: String,
requireCacheRemoval: Boolean = false,
) {
val gateway = scopedGatewayId(gatewayId) ?: return
withContext(NonCancellable) {
// State deletion and its phase advance are atomic. A rollback leaves no irreversible marker;
// after commit, startup may clear only disposable cache and must preserve any newer outbox rows.
clientState.withTransaction {
clientState.controlDao().upsertGatewayRemoval(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_COMMITTING))
commandOutbox.clearGateway(gateway)
clientState.controlDao().upsertGatewayRemoval(GatewayRemovalEntity(gateway, GATEWAY_REMOVAL_CACHE_PENDING))
}
completeCacheRemoval(gateway, propagateFailure = requireCacheRemoval)
}
}
override fun close() {
gatewayCache.close()
clientState.close()
}
private suspend fun importLegacyStateIfNeeded(legacyName: String) {
val control = clientState.controlDao()
if (control.metadataValue(LEGACY_IMPORT_KEY) == LEGACY_IMPORT_COMPLETE) {
context.deleteDatabase(legacyName)
return
}
val legacyFile = context.getDatabasePath(legacyName)
if (!legacyFile.exists()) {
control.upsertMetadata(ClientStateMetadataEntity(LEGACY_IMPORT_KEY, LEGACY_IMPORT_COMPLETE))
return
}
val legacy = LegacyChatDatabase.open(context, legacyName)
try {
val source = legacy.outboxDao()
val commands = source.allCommands()
val admissions = source.allAdmissionReceipts()
val attachments = source.allAttachments()
clientState.withTransaction {
val destination = clientState.outboxDao()
if (commands.isNotEmpty()) destination.upsertImportedCommands(commands)
if (admissions.isNotEmpty()) destination.upsertImportedAdmissionReceipts(admissions)
if (attachments.isNotEmpty()) destination.upsertImportedAttachments(attachments)
}
var afterAttachmentId: String? = null
var afterChunkIndex = -1
while (true) {
val chunks = source.attachmentChunkPage(afterAttachmentId, afterChunkIndex, LEGACY_IMPORT_CHUNK_PAGE_ROWS)
if (chunks.isEmpty()) break
clientState.withTransaction {
clientState.outboxDao().upsertImportedAttachmentChunks(chunks)
}
chunks.last().let { cursor ->
afterAttachmentId = cursor.attachmentId
afterChunkIndex = cursor.chunkIndex
}
}
clientState.withTransaction {
// Earlier page commits are idempotent. This marker publishes them only after the source
// cursor is exhausted, so a crash simply replays REPLACE inserts on the next start.
control.upsertMetadata(ClientStateMetadataEntity(LEGACY_IMPORT_KEY, LEGACY_IMPORT_COMPLETE))
}
} finally {
legacy.close()
}
// If deletion fails, the next open sees the completion marker and retries only deletion.
context.deleteDatabase(legacyName)
}
private suspend fun resolvePendingGatewayRemovals(registeredGatewayIds: Set<String>?) {
for (removal in clientState.controlDao().gatewayRemovals()) {
when {
removal.phase == GATEWAY_REMOVAL_CACHE_PENDING -> completeCacheRemoval(removal.gatewayId, propagateFailure = false)
removal.phase == GATEWAY_REMOVAL_COMMITTING -> commitGatewayRemoval(removal.gatewayId)
registeredGatewayIds != null && removal.gatewayId !in registeredGatewayIds ->
commitGatewayRemoval(removal.gatewayId)
registeredGatewayIds != null -> cancelGatewayRemoval(removal.gatewayId)
}
}
}
private suspend fun completeCacheRemoval(
gatewayId: String,
propagateFailure: Boolean,
) {
try {
transcriptCache.clearGateway(gatewayId)
} catch (error: Exception) {
if (propagateFailure) throw error
// Cache is disposable. Keep cache-pending for the next open, but the durable purge has
// committed and callers may safely retire auth without risking later outbox deletion.
Log.w("ClientDatabases", "Deferring gateway cache cleanup", error)
return
}
clientState.controlDao().deleteGatewayRemovalInPhase(gatewayId, GATEWAY_REMOVAL_CACHE_PENDING)
}
}
/**
* One installation-wide pair of multi-gateway databases initialized on a private IO scope.
* Every facade operation awaits the one-time legacy import before reaching either Room store.
*/
internal class AndroidClientDatabases private constructor(
private val scope: CoroutineScope,
private val initialization: Deferred<OpenedAndroidClientDatabases>,
private val openedReference: AtomicReference<OpenedAndroidClientDatabases?>,
private val closed: AtomicBoolean,
) : AutoCloseable {
companion object {
fun start(
context: Context,
gatewayCacheName: String = GATEWAY_CACHE_DB_NAME,
clientStateName: String = CLIENT_STATE_DB_NAME,
legacyName: String = LEGACY_CHAT_DATABASE_NAME,
registeredGatewayIds: Set<String>? = null,
): AndroidClientDatabases {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val openedReference = AtomicReference<OpenedAndroidClientDatabases?>()
val closed = AtomicBoolean(false)
val initialization =
scope.async {
val opened =
OpenedAndroidClientDatabases.open(
context = context.applicationContext,
gatewayCacheName = gatewayCacheName,
clientStateName = clientStateName,
legacyName = legacyName,
registeredGatewayIds = registeredGatewayIds,
)
if (closed.get()) {
opened.close()
throw CancellationException("Android client databases closed during initialization")
}
openedReference.set(opened)
if (closed.get() && openedReference.compareAndSet(opened, null)) {
opened.close()
throw CancellationException("Android client databases closed during initialization")
}
opened
}
return AndroidClientDatabases(scope, initialization, openedReference, closed)
}
}
private val transcriptCache = DeferredChatTranscriptCache(::ready)
private val commandOutbox = DeferredChatCommandOutbox(::ready)
fun transcriptCache(): ChatTranscriptCache = transcriptCache
fun commandOutbox(): ChatCommandOutbox = commandOutbox
suspend fun stageGatewayRemoval(gatewayId: String) = ready().stageGatewayRemoval(gatewayId)
suspend fun cancelGatewayRemoval(gatewayId: String) = ready().cancelGatewayRemoval(gatewayId)
suspend fun commitGatewayRemoval(
gatewayId: String,
requireCacheRemoval: Boolean = false,
) = ready().commitGatewayRemoval(gatewayId, requireCacheRemoval)
internal suspend fun gatewayCacheDatabase(): GatewayCacheDatabase = ready().gatewayCache
internal suspend fun clientStateDatabase(): ClientStateDatabase = ready().clientState
private suspend fun ready(): OpenedAndroidClientDatabases {
check(!closed.get()) { "Android client databases are closed" }
val opened = initialization.await()
check(!closed.get()) { "Android client databases are closed" }
return opened
}
override fun close() {
if (!closed.compareAndSet(false, true)) return
scope.cancel()
openedReference.getAndSet(null)?.close()
}
}
private class DeferredChatTranscriptCache(
private val ready: suspend () -> OpenedAndroidClientDatabases,
) : ChatTranscriptCache {
override suspend fun loadLastDefaultAgentId(gatewayId: String): String? = ready().transcriptCache.loadLastDefaultAgentId(gatewayId)
override suspend fun saveLastDefaultAgentId(
gatewayId: String,
agentId: String,
) = ready().transcriptCache.saveLastDefaultAgentId(gatewayId, agentId)
override suspend fun loadSessions(
gatewayId: String,
agentId: String,
): List<ChatSessionEntry> = ready().transcriptCache.loadSessions(gatewayId, agentId)
override suspend fun loadTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
): List<ChatMessage> = ready().transcriptCache.loadTranscript(gatewayId, agentId, sessionKey)
override suspend fun saveSessions(
gatewayId: String,
agentId: String,
sessions: List<ChatSessionEntry>,
retainedSessionKey: String?,
) = ready().transcriptCache.saveSessions(gatewayId, agentId, sessions, retainedSessionKey)
override suspend fun saveTranscript(
gatewayId: String,
agentId: String,
sessionKey: String,
messages: List<ChatMessage>,
) = ready().transcriptCache.saveTranscript(gatewayId, agentId, sessionKey, messages)
override suspend fun deleteSession(
gatewayId: String,
agentId: String,
sessionKey: String,
) = ready().transcriptCache.deleteSession(gatewayId, agentId, sessionKey)
override suspend fun clearGateway(gatewayId: String) = ready().transcriptCache.clearGateway(gatewayId)
}
private class DeferredChatCommandOutbox(
private val ready: suspend () -> OpenedAndroidClientDatabases,
) : ChatCommandOutbox {
override val supportsBranchCoordination: Boolean = true
override suspend fun load(gatewayId: String): List<ChatOutboxItem> = ready().commandOutbox.load(gatewayId)
override suspend fun wasAdmitted(id: String): Boolean = ready().commandOutbox.wasAdmitted(id)
override suspend fun enqueue(
gatewayId: String,
sessionKey: String,
text: String,
thinkingLevel: String,
nowMs: Long,
attachments: List<OutboxAttachmentPayload>,
gatedEpoch: Long?,
ownerAgentId: String,
idempotencyKey: String?,
): ChatOutboxEnqueueResult =
ready()
.commandOutbox
.enqueue(gatewayId, sessionKey, text, thinkingLevel, nowMs, attachments, gatedEpoch, ownerAgentId, idempotencyKey)
override suspend fun loadAttachments(id: String): List<LoadedOutboxAttachment> = ready().commandOutbox.loadAttachments(id)
override suspend fun updateStatus(
id: String,
status: ChatOutboxStatus,
retryCount: Int,
lastError: String?,
): Int = ready().commandOutbox.updateStatus(id, status, retryCount, lastError)
override suspend fun updateStatusIfAttempt(
id: String,
expectedAttemptVersion: Int,
status: ChatOutboxStatus,
retryCount: Int,
lastError: String?,
expectedStatus: ChatOutboxStatus?,
): Int = ready().commandOutbox.updateStatusIfAttempt(id, expectedAttemptVersion, status, retryCount, lastError, expectedStatus)
override suspend fun claimForSending(
id: String,
retryCount: Int,
lastError: String?,
): Int = ready().commandOutbox.claimForSending(id, retryCount, lastError)
override suspend fun claimForSendingIfAttempt(
id: String,
expectedAttemptVersion: Int,
retryCount: Int,
lastError: String?,
): Int = ready().commandOutbox.claimForSendingIfAttempt(id, expectedAttemptVersion, retryCount, lastError)
override suspend fun pinSessionKey(
id: String,
sessionKey: String,
) = ready().commandOutbox.pinSessionKey(id, sessionKey)
override suspend fun requeueForRetry(
gatewayId: String,
id: String,
nowMs: Long,
gatedEpoch: Long?,
ownerAgentId: String?,
): Int = ready().commandOutbox.requeueForRetry(gatewayId, id, nowMs, gatedEpoch, ownerAgentId)
override suspend fun requeueForRetryIfCurrent(
gatewayId: String,
id: String,
expectedAttemptVersion: Int,
expectedRetryCount: Int,
expectedLastError: String?,
nowMs: Long,
gatedEpoch: Long?,
ownerAgentId: String?,
replacementId: String?,
): Int =
ready()
.commandOutbox
.requeueForRetryIfCurrent(
gatewayId,
id,
expectedAttemptVersion,
expectedRetryCount,
expectedLastError,
nowMs,
gatedEpoch,
ownerAgentId,
replacementId,
)
override suspend fun delete(id: String) = ready().commandOutbox.delete(id)
override suspend fun deleteIfQueued(id: String): Boolean = ready().commandOutbox.deleteIfQueued(id)
override suspend fun confirmDelivered(ids: Set<String>): Int = ready().commandOutbox.confirmDelivered(ids)
override suspend fun confirmDeliveredAttempts(ids: Map<String, Int>): Int = ready().commandOutbox.confirmDeliveredAttempts(ids)
override suspend fun branchState(
gatewayId: String,
scope: ChatOutboxScope,
): ChatOutboxBranchState? = ready().commandOutbox.branchState(gatewayId, scope)
override suspend fun beginSessionMutation(
gatewayId: String,
scope: ChatOutboxScope,
nowMs: Long,
): ChatOutboxMutationLease? = ready().commandOutbox.beginSessionMutation(gatewayId, scope, nowMs)
override suspend fun cancelSessionMutation(
gatewayId: String,
scope: ChatOutboxScope,
lease: ChatOutboxMutationLease,
): Boolean = ready().commandOutbox.cancelSessionMutation(gatewayId, scope, lease)
override suspend fun demoteSessionMutationToReconciliation(
gatewayId: String,
scope: ChatOutboxScope,
lease: ChatOutboxMutationLease?,
): Boolean = ready().commandOutbox.demoteSessionMutationToReconciliation(gatewayId, scope, lease)
override suspend fun demoteSessionMutationToReconciliationState(
gatewayId: String,
scope: ChatOutboxScope,
lease: ChatOutboxMutationLease?,
): ChatOutboxBranchState? = ready().commandOutbox.demoteSessionMutationToReconciliationState(gatewayId, scope, lease)
override suspend fun updateLastActiveLeafEntryId(
gatewayId: String,
scope: ChatOutboxScope,
leafEntryId: String,
expectedEpoch: Int,
expectedRevision: Int,
): Boolean = ready().commandOutbox.updateLastActiveLeafEntryId(gatewayId, scope, leafEntryId, expectedEpoch, expectedRevision)
override suspend fun reconcileBranchScope(
gatewayId: String,
scope: ChatOutboxScope,
previousState: ChatOutboxBranchState,
activeLeafEntryId: String?,
branchLeafEntryIds: Set<String>,
activeTranscriptEntryIds: Set<String>,
lastError: String,
): Boolean =
ready()
.commandOutbox
.reconcileBranchScope(
gatewayId,
scope,
previousState,
activeLeafEntryId,
branchLeafEntryIds,
activeTranscriptEntryIds,
lastError,
)
override suspend fun confirmBranchChange(
gatewayId: String,
scope: ChatOutboxScope,
activeLeafEntryId: String?,
lastError: String,
lease: ChatOutboxMutationLease?,
): Boolean = ready().commandOutbox.confirmBranchChange(gatewayId, scope, activeLeafEntryId, lastError, lease)
override suspend fun deleteForSession(
gatewayId: String,
sessionKey: String,
ownerAgentId: String,
) = ready().commandOutbox.deleteForSession(gatewayId, sessionKey, ownerAgentId)
override suspend fun clearGateway(gatewayId: String) = ready().commandOutbox.clearGateway(gatewayId)
override suspend fun failSendingAfterRestart() = ready().commandOutbox.failSendingAfterRestart()
override suspend fun expireStale(
gatewayId: String,
nowMs: Long,
) = ready().commandOutbox.expireStale(gatewayId, nowMs)
}
private fun scopedGatewayId(gatewayId: String): String? = gatewayId.trim().takeIf { it.isNotEmpty() }

View file

@ -0,0 +1,303 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.voice.TalkAudioPlaying
import ai.openclaw.app.voice.TalkSpeakAudio
import android.content.Context
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.concurrent.atomic.AtomicLong
private const val TAG = "MessageSpeech"
internal enum class MessageSpeechPhase {
Preparing,
Speaking,
}
/** Playback state for the chat Listen action; null when idle. */
internal data class MessageSpeechState(
val messageId: String,
val phase: MessageSpeechPhase,
)
/** Renders message text to an audio clip; null means fall back to local TTS. */
internal fun interface MessageSpeechSynthesizing {
suspend fun synthesize(text: String): TalkSpeakAudio?
}
/** Speaks text with the on-device engine when the gateway cannot render audio. */
internal interface LocalSpeechSpeaking {
suspend fun speak(text: String)
fun stop()
}
/** Gateway tts.speak client using the general configured TTS provider chain. */
internal class MessageSpeechClient(
private val session: GatewaySession? = null,
private val json: Json = Json { ignoreUnknownKeys = true },
private val requestDetailed: (suspend (String, String, Long) -> GatewaySession.RpcResult)? = null,
) : MessageSpeechSynthesizing {
override suspend fun synthesize(text: String): TalkSpeakAudio? {
val response =
try {
performRequest(
method = "tts.speak",
paramsJson = json.encodeToString(TtsSpeakRequest(text = text)),
timeoutMs = 60_000,
)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
Log.d(TAG, "tts.speak request failed: ${err.message ?: err::class.simpleName}")
return null
}
if (!response.ok) {
// Provider/config absence and older gateways both degrade to the on-device voice.
Log.d(TAG, "tts.speak unavailable: ${response.error?.message ?: "unknown error"}")
return null
}
val payload =
try {
json.decodeFromString<TtsSpeakResponse>(response.payloadJson ?: "")
} catch (err: Throwable) {
Log.d(TAG, "tts.speak payload invalid: ${err.message ?: err::class.simpleName}")
return null
}
val bytes =
try {
android.util.Base64.decode(payload.audioBase64, android.util.Base64.DEFAULT)
} catch (err: Throwable) {
Log.d(TAG, "tts.speak audio decode failed: ${err.message ?: err::class.simpleName}")
return null
}
if (bytes.isEmpty()) return null
return TalkSpeakAudio(
bytes = bytes,
provider = payload.provider,
outputFormat = payload.outputFormat,
voiceCompatible = null,
mimeType = payload.mimeType,
fileExtension = payload.fileExtension,
)
}
private suspend fun performRequest(
method: String,
paramsJson: String,
timeoutMs: Long,
): GatewaySession.RpcResult {
requestDetailed?.let { return it(method, paramsJson, timeoutMs) }
val activeSession = session ?: throw IllegalStateException("session missing")
return activeSession.requestDetailed(method = method, paramsJson = paramsJson, timeoutMs = timeoutMs)
}
}
@Serializable
internal data class TtsSpeakRequest(
val text: String,
)
@Serializable
private data class TtsSpeakResponse(
val audioBase64: String,
val provider: String,
val outputFormat: String? = null,
val mimeType: String? = null,
val fileExtension: String? = null,
)
/** Drives one active chat Listen request, preferring gateway audio over local TTS. */
internal class MessageSpeechController(
private val scope: CoroutineScope,
private val synthesizer: MessageSpeechSynthesizing,
private val player: TalkAudioPlaying,
private val localSpeech: LocalSpeechSpeaking,
) {
private val _state = MutableStateFlow<MessageSpeechState?>(null)
val state: StateFlow<MessageSpeechState?> = _state.asStateFlow()
// A superseded playback's completion must not clear state owned by the next request.
private val generation = AtomicLong(0)
private var job: Job? = null
fun toggle(
messageId: String,
text: String,
) {
if (_state.value?.messageId == messageId) {
stop()
return
}
start(messageId = messageId, text = text)
}
fun stop() {
generation.incrementAndGet()
job?.cancel()
job = null
player.stop()
localSpeech.stop()
_state.value = null
}
private fun start(
messageId: String,
text: String,
) {
stop()
val spoken = text.trim()
if (spoken.isEmpty()) return
val token = generation.incrementAndGet()
_state.value = MessageSpeechState(messageId = messageId, phase = MessageSpeechPhase.Preparing)
job =
scope.launch {
try {
val clip = synthesizer.synthesize(spoken)
if (generation.get() != token) return@launch
_state.value = MessageSpeechState(messageId = messageId, phase = MessageSpeechPhase.Speaking)
if (!playClip(clip) && generation.get() == token) {
localSpeech.speak(spoken)
}
} finally {
if (generation.get() == token) _state.value = null
}
}
}
private suspend fun playClip(clip: TalkSpeakAudio?): Boolean {
if (clip == null) return false
return try {
player.play(clip)
true
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
Log.w(TAG, "clip playback failed: ${err.message ?: err::class.simpleName}")
false
}
}
}
/** Minimal on-device TTS wrapper for the chat Listen fallback voice. */
internal class SystemSpeechSpeaker(
private val context: Context,
) : LocalSpeechSpeaking {
private val lock = Any()
private var engine: TextToSpeech? = null
private var ready: CompletableDeferred<Boolean>? = null
private var active: CompletableDeferred<Unit>? = null
override suspend fun speak(text: String) {
val engine = ensureEngine() ?: return
val utteranceId = "chat-listen-${System.nanoTime()}"
val done = CompletableDeferred<Unit>()
synchronized(lock) {
active?.cancel()
active = done
}
withContext(Dispatchers.Main.immediate) {
engine.setOnUtteranceProgressListener(
object : UtteranceProgressListener() {
override fun onStart(id: String?) {}
override fun onDone(id: String?) {
if (id == utteranceId) done.complete(Unit)
}
@Deprecated("Deprecated in Java")
override fun onError(id: String?) {
if (id == utteranceId) done.complete(Unit)
}
override fun onError(
id: String?,
errorCode: Int,
) {
if (id == utteranceId) done.complete(Unit)
}
},
)
if (engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, utteranceId) != TextToSpeech.SUCCESS) {
done.complete(Unit)
}
}
try {
done.await()
} finally {
synchronized(lock) {
if (active === done) active = null
}
}
}
override fun stop() {
synchronized(lock) {
active?.cancel()
active = null
}
engine?.stop()
}
private suspend fun ensureEngine(): TextToSpeech? {
val current = synchronized(lock) { ready }
val pending = current ?: createEngine()
if (pending.await()) return synchronized(lock) { engine }
// A failed Android TTS service can recover later; do not cache its failed initialization.
val failedEngine =
synchronized(lock) {
if (ready !== pending) return null
ready = null
engine.also { engine = null }
}
withContext(Dispatchers.Main.immediate) { failedEngine?.shutdown() }
return null
}
private suspend fun createEngine(): CompletableDeferred<Boolean> {
val pending = CompletableDeferred<Boolean>()
val ownsInitialization =
synchronized(lock) {
if (ready != null) {
false
} else {
ready = pending
true
}
}
if (!ownsInitialization) return synchronized(lock) { checkNotNull(ready) }
val created =
try {
// Finish publishing the constructed engine even if the Listen job is stopped mid-init.
withContext(NonCancellable + Dispatchers.Main.immediate) {
TextToSpeech(context) { status -> pending.complete(status == TextToSpeech.SUCCESS) }
}
} catch (err: Throwable) {
Log.d(TAG, "system TTS initialization failed: ${err.message ?: err::class.simpleName}")
pending.complete(false)
null
}
synchronized(lock) {
if (ready === pending) engine = created else created?.shutdown()
}
return pending
}
}

View file

@ -0,0 +1,317 @@
package ai.openclaw.app.chat
import ai.openclaw.app.voice.TalkAudioLevel
import android.content.Context
import android.media.MediaRecorder
import android.os.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File
import java.io.RandomAccessFile
import java.util.UUID
internal const val VOICE_NOTE_MAX_DURATION_MS = 180_000L
internal const val VOICE_NOTE_MAX_BYTES = 3_500_000L
internal const val VOICE_NOTE_MIME_TYPE = "audio/mp4"
internal sealed interface VoiceNoteRecorderState {
data object Idle : VoiceNoteRecorderState
data class Recording(
val startedAtMillis: Long,
) : VoiceNoteRecorderState
data object Preparing : VoiceNoteRecorderState
data class Failure(
val message: String,
) : VoiceNoteRecorderState
}
internal data class VoiceNoteRecording(
val file: File,
val durationMs: Long,
val id: String = file.absolutePath,
)
internal interface VoiceNoteRecordingEngine {
fun start(outputFile: File)
fun stop(): Long
fun cancel()
/** Peak abs PCM amplitude (0..32767) since the last poll; 0 when not recording. */
fun pollAmplitude(): Int
}
/** Owns voice-note recording state and temporary-file cleanup. */
internal class VoiceNoteRecorderController(
private val scope: CoroutineScope,
private val outputDirectory: File,
private val engine: VoiceNoteRecordingEngine,
private val requestPermission: suspend () -> Boolean,
private val acquireMic: () -> Boolean,
private val releaseMic: () -> Unit,
private val onFinished: (VoiceNoteRecording) -> Unit,
private val elapsedRealtimeMillis: () -> Long = SystemClock::elapsedRealtime,
) {
private val lock = Any()
private val _state = MutableStateFlow<VoiceNoteRecorderState>(VoiceNoteRecorderState.Idle)
val state: StateFlow<VoiceNoteRecorderState> = _state.asStateFlow()
private val _elapsedMs = MutableStateFlow(0L)
val elapsedMs: StateFlow<Long> = _elapsedMs.asStateFlow()
private val _inputLevel = MutableStateFlow(0f)
val inputLevel: StateFlow<Float> = _inputLevel.asStateFlow()
private var outputFile: File? = null
private var recordingId: String? = null
private var elapsedJob: Job? = null
private var ownsMic = false
suspend fun start(id: String = UUID.randomUUID().toString()): Boolean {
synchronized(lock) {
if (_state.value !is VoiceNoteRecorderState.Idle && _state.value !is VoiceNoteRecorderState.Failure) return false
}
if (!requestPermission()) {
fail("Microphone permission is required to record a voice note.")
return false
}
return synchronized(lock) {
if (_state.value !is VoiceNoteRecorderState.Idle && _state.value !is VoiceNoteRecorderState.Failure) {
return@synchronized false
}
if (!acquireMic()) {
failLocked("Voice capture is already using the microphone.")
return@synchronized false
}
ownsMic = true
val startedAt = elapsedRealtimeMillis()
val file = File(outputDirectory, "voice-note-$id.m4a")
try {
engine.start(file)
} catch (_: Throwable) {
engine.cancel()
releaseMicLocked()
file.delete()
failLocked("Could not start voice-note recording.")
return@synchronized false
}
outputFile = file
recordingId = id
_elapsedMs.value = 0L
_state.value = VoiceNoteRecorderState.Recording(startedAtMillis = startedAt)
startElapsedUpdates(startedAt)
true
}
}
fun finish(): Boolean {
val recording =
synchronized(lock) {
if (_state.value !is VoiceNoteRecorderState.Recording) return false
val file = outputFile ?: return false
val id = recordingId ?: return false
elapsedJob?.cancel()
elapsedJob = null
val durationMs =
try {
engine.stop().coerceIn(0L, VOICE_NOTE_MAX_DURATION_MS)
} catch (_: Throwable) {
engine.cancel()
file.delete()
finishFailureLocked("Could not finish voice-note recording.")
return false
}
try {
normalizeM4aContainerBrand(file)
} catch (_: Throwable) {
file.delete()
finishFailureLocked("Could not finish voice-note recording.")
return false
}
if (file.length() > VOICE_NOTE_MAX_BYTES) {
file.delete()
finishFailureLocked("Voice note is too large. Record a shorter message.")
return false
}
// The file stays owned by the controller through Preparing: the staging
// coroutine is composition-scoped and may be cancelled before it runs,
// so cancel() must still be able to delete the handed-off recording.
_elapsedMs.value = 0L
_inputLevel.value = 0f
_state.value = VoiceNoteRecorderState.Preparing
releaseMicLocked()
VoiceNoteRecording(file = file, durationMs = durationMs, id = id)
}
onFinished(recording)
return true
}
fun completePreparation() {
synchronized(lock) {
if (_state.value is VoiceNoteRecorderState.Preparing) {
outputFile = null
recordingId = null
_state.value = VoiceNoteRecorderState.Idle
}
}
}
fun canCommitPreparation(id: String): Boolean =
synchronized(lock) {
_state.value is VoiceNoteRecorderState.Preparing && recordingId == id
}
fun cancel() {
synchronized(lock) {
elapsedJob?.cancel()
elapsedJob = null
if (_state.value is VoiceNoteRecorderState.Recording) {
engine.cancel()
}
releaseMicLocked()
outputFile?.delete()
outputFile = null
recordingId = null
_elapsedMs.value = 0L
_inputLevel.value = 0f
_state.value = VoiceNoteRecorderState.Idle
}
}
fun reportFailure(message: String) {
synchronized(lock) {
outputFile?.delete()
outputFile = null
recordingId = null
failLocked(message)
}
}
private fun startElapsedUpdates(startedAt: Long) {
elapsedJob?.cancel()
elapsedJob =
scope.launch {
while (isActive && state.value is VoiceNoteRecorderState.Recording) {
val elapsed = (elapsedRealtimeMillis() - startedAt).coerceIn(0L, VOICE_NOTE_MAX_DURATION_MS)
_elapsedMs.value = elapsed
// MediaRecorder exposes only the peak since the last poll; running it
// through the shared dB window keeps this wave on the same visual
// scale as the RMS-metered talk and dictation waves.
val rawLevel = TalkAudioLevel.normalized((engine.pollAmplitude().coerceIn(0, 32_767)) / 32_767.0)
_inputLevel.value = TalkAudioLevel.smoothed(_inputLevel.value, rawLevel)
// MediaRecorder's duration callback races its asynchronous auto-stop.
// Own the cap here so every successful finish calls stop() exactly once.
if (elapsed >= VOICE_NOTE_MAX_DURATION_MS) {
finish()
return@launch
}
delay(100L)
}
}
}
private fun fail(message: String) {
synchronized(lock) { failLocked(message) }
}
private fun failLocked(message: String) {
_state.value = VoiceNoteRecorderState.Failure(message)
}
private fun finishFailureLocked(message: String) {
releaseMicLocked()
outputFile = null
recordingId = null
_elapsedMs.value = 0L
_inputLevel.value = 0f
_state.value = VoiceNoteRecorderState.Failure(message)
}
private fun releaseMicLocked() {
if (!ownsMic) return
ownsMic = false
releaseMic()
}
}
/** Marks AAC-only MPEG-4 output as audio so gateway byte sniffing cannot classify it as video. */
internal fun normalizeM4aContainerBrand(file: File) {
RandomAccessFile(file, "rw").use { output ->
if (output.length() < 12L) return
output.seek(4L)
val boxType = ByteArray(4)
output.readFully(boxType)
if (!boxType.contentEquals("ftyp".toByteArray(Charsets.US_ASCII))) return
output.seek(8L)
output.write("M4A ".toByteArray(Charsets.US_ASCII))
}
}
/** Android AAC/m4a engine kept behind [VoiceNoteRecordingEngine] for JVM tests. */
internal class AndroidVoiceNoteRecordingEngine(
private val context: Context,
private val elapsedRealtime: () -> Long = SystemClock::elapsedRealtime,
) : VoiceNoteRecordingEngine {
private var recorder: MediaRecorder? = null
private var startedAtElapsedMs = 0L
override fun start(outputFile: File) {
check(recorder == null)
val next = MediaRecorder(context)
try {
next.setAudioSource(MediaRecorder.AudioSource.MIC)
next.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
next.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
next.setAudioChannels(1)
next.setAudioEncodingBitRate(32_000)
next.setAudioSamplingRate(44_100)
next.setOutputFile(outputFile.absolutePath)
next.prepare()
next.start()
startedAtElapsedMs = elapsedRealtime()
recorder = next
} catch (error: Throwable) {
next.release()
throw error
}
}
override fun stop(): Long {
val active = checkNotNull(recorder)
recorder = null
return try {
active.stop()
(elapsedRealtime() - startedAtElapsedMs).coerceAtLeast(0L)
} finally {
active.release()
}
}
override fun cancel() {
val active = recorder ?: return
recorder = null
runCatching { active.stop() }
active.release()
}
// maxAmplitude throws until sampling starts on some OEMs; a dead meter beats
// killing the recording.
override fun pollAmplitude(): Int = recorder?.let { active -> runCatching { active.maxAmplitude }.getOrDefault(0) } ?: 0
}

View file

@ -0,0 +1,40 @@
package ai.openclaw.app.gateway
/**
* Decoder for Bonjour DNS-SD service names returned with decimal byte escapes.
*/
object BonjourEscapes {
/** Decodes Bonjour DNS-SD decimal escapes while preserving ordinary UTF-8. */
fun decode(input: String): String {
if (input.isEmpty()) return input
val bytes = mutableListOf<Byte>()
var i = 0
while (i < input.length) {
if (input[i] == '\\' && i + 3 < input.length) {
val d0 = input[i + 1]
val d1 = input[i + 2]
val d2 = input[i + 3]
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
val value =
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
if (value in 0..255) {
// Bonjour escape bytes are decimal octets, not Unicode code points.
bytes.add(value.toByte())
i += 4
continue
}
}
}
val codePoint = Character.codePointAt(input, i)
val charBytes = String(Character.toChars(codePoint)).toByteArray(Charsets.UTF_8)
for (b in charBytes) {
bytes.add(b)
}
i += Character.charCount(codePoint)
}
return String(bytes.toByteArray(), Charsets.UTF_8)
}
}

View file

@ -0,0 +1,46 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal data class ChatSendAck(
val runId: String?,
val status: String?,
) {
val normalizedStatus: String
get() = status?.trim()?.lowercase().orEmpty()
val isTerminalSuccess: Boolean
get() = normalizedStatus == "ok"
val isTerminalFailure: Boolean
get() = normalizedStatus == "timeout" || normalizedStatus == "error"
val isTerminal: Boolean
get() = isTerminalSuccess || isTerminalFailure
}
internal fun chatSendAckHistorySinceSeconds(
ack: ChatSendAck,
startedAtSeconds: Double,
): Double? = if (ack.isTerminalSuccess) null else startedAtSeconds
internal fun parseChatSendAck(
json: Json,
responseJson: String,
): ChatSendAck =
try {
val obj = json.parseToJsonElement(responseJson).asObjectOrNull()
ChatSendAck(
runId = obj?.get("runId").asStringOrNull(),
status = obj?.get("status").asStringOrNull(),
)
} catch (_: Throwable) {
ChatSendAck(runId = null, status = null)
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content

View file

@ -0,0 +1,57 @@
package ai.openclaw.app.gateway
/**
* Canonical device-auth payload builder shared with gateway verification rules.
*/
internal object DeviceAuthPayload {
/** Builds the canonical v3 auth string signed by device registration flows. */
fun buildV3(
deviceId: String,
clientId: String,
clientMode: String,
role: String,
scopes: List<String>,
signedAtMs: Long,
token: String?,
nonce: String,
platform: String?,
deviceFamily: String?,
): String {
val scopeString = scopes.joinToString(",")
val authToken = token.orEmpty()
val platformNorm = normalizeMetadataField(platform)
val deviceFamilyNorm = normalizeMetadataField(deviceFamily)
return listOf(
"v3",
deviceId,
clientId,
clientMode,
role,
scopeString,
signedAtMs.toString(),
authToken,
nonce,
platformNorm,
deviceFamilyNorm,
).joinToString("|")
}
/** Normalizes signed metadata fields without locale-sensitive lowercasing. */
internal fun normalizeMetadataField(value: String?): String {
val trimmed = value?.trim().orEmpty()
if (trimmed.isEmpty()) {
return ""
}
// Keep cross-runtime normalization deterministic (TS/Swift/Kotlin):
// lowercase ASCII A-Z only for auth payload metadata fields.
val out = StringBuilder(trimmed.length)
for (ch in trimmed) {
if (ch in 'A'..'Z') {
out.append((ch.code + 32).toChar())
} else {
out.append(ch)
}
}
return out.toString()
}
}

View file

@ -0,0 +1,155 @@
package ai.openclaw.app.gateway
import ai.openclaw.app.SecurePrefs
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/** Stored gateway device-token material scoped by gateway, device id, and role. */
data class DeviceAuthEntry(
val token: String,
val role: String,
val scopes: List<String>,
val updatedAtMs: Long,
)
@Serializable
private data class PersistedDeviceAuthMetadata(
val scopes: List<String> = emptyList(),
val updatedAtMs: Long = 0L,
)
/** Persistence interface used by gateway pairing/session code for role tokens. */
interface DeviceAuthTokenStore {
/** Loads the stored token plus metadata for one device/role pair. */
fun loadEntry(
gatewayId: String,
deviceId: String,
role: String,
): DeviceAuthEntry?
/** Loads only the bearer token when callers do not need scope metadata. */
fun loadToken(
gatewayId: String,
deviceId: String,
role: String,
): String? = loadEntry(gatewayId, deviceId, role)?.token
/** Persists a role token and deterministic scope metadata under normalized keys. */
fun saveToken(
gatewayId: String,
deviceId: String,
role: String,
token: String,
scopes: List<String> = emptyList(),
)
/** Removes both token and metadata for the normalized device/role pair. */
fun clearToken(
gatewayId: String,
deviceId: String,
role: String,
)
}
/** SecurePrefs-backed implementation of Android gateway device-token storage. */
class DeviceAuthStore(
private val prefs: SecurePrefs,
) : DeviceAuthTokenStore {
private val json = Json { ignoreUnknownKeys = true }
override fun loadEntry(
gatewayId: String,
deviceId: String,
role: String,
): DeviceAuthEntry? {
val key = tokenKey(gatewayId, deviceId, role)
val token = prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val normalizedRole = normalizeRole(role)
val metadata =
prefs
.getString(metadataKey(gatewayId, deviceId, role))
?.let { raw ->
runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull()
}
return DeviceAuthEntry(
token = token,
role = normalizedRole,
scopes = metadata?.scopes ?: emptyList(),
updatedAtMs = metadata?.updatedAtMs ?: 0L,
)
}
override fun saveToken(
gatewayId: String,
deviceId: String,
role: String,
token: String,
scopes: List<String>,
) {
val normalizedScopes = normalizeScopes(scopes)
val key = tokenKey(gatewayId, deviceId, role)
prefs.putString(key, token.trim())
prefs.putString(
metadataKey(gatewayId, deviceId, role),
json.encodeToString(
PersistedDeviceAuthMetadata(
scopes = normalizedScopes,
updatedAtMs = System.currentTimeMillis(),
),
),
)
}
override fun clearToken(
gatewayId: String,
deviceId: String,
role: String,
) {
val key = tokenKey(gatewayId, deviceId, role)
prefs.remove(key)
prefs.remove(metadataKey(gatewayId, deviceId, role))
}
private fun tokenKey(
gatewayId: String,
deviceId: String,
role: String,
): String {
val normalizedGateway = normalizeGatewayId(gatewayId)
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
// Keep key normalization shared with metadata keys so token and metadata
// are added/removed as one logical auth entry.
return "gateway.deviceToken.$normalizedGateway.$normalizedDevice.$normalizedRole"
}
private fun metadataKey(
gatewayId: String,
deviceId: String,
role: String,
): String {
val normalizedGateway = normalizeGatewayId(gatewayId)
val normalizedDevice = normalizeDeviceId(deviceId)
val normalizedRole = normalizeRole(role)
return "gateway.deviceTokenMeta.$normalizedGateway.$normalizedDevice.$normalizedRole"
}
private fun normalizeGatewayId(gatewayId: String): String = gatewayId.trim().also { require(it.isNotEmpty()) }
/** Normalizes device ids before they become encrypted preference key segments. */
private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase()
/** Normalizes role names so node/operator token slots are stable across callers. */
private fun normalizeRole(role: String): String = role.trim().lowercase()
/** Stores scopes in deterministic order for display and restart comparisons. */
private fun normalizeScopes(scopes: List<String>): List<String> =
scopes
.map { it.trim() }
.filter { it.isNotEmpty() }
// Persist deterministic scope lists because they are displayed and may be
// compared across process restarts.
.distinct()
.sorted()
}

View file

@ -0,0 +1,238 @@
package ai.openclaw.app.gateway
import ai.openclaw.app.SecurePrefs
import android.content.Context
import android.util.Base64
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
/** Persistent Ed25519 identity used to register this Android node with gateways. */
@Serializable
data class DeviceIdentity(
val deviceId: String,
val publicKeyRawBase64: String,
val privateKeyPkcs8Base64: String,
val createdAtMs: Long,
)
/** Owns device identity generation, persistence, and auth payload signatures. */
class DeviceIdentityStore private constructor(
context: Context,
private val prefs: SecurePrefs,
) {
constructor(context: Context) : this(context, SecurePrefs(context))
private val json = Json { ignoreUnknownKeys = true }
private val legacyIdentityFile = File(context.filesDir, "openclaw/identity/device.json")
@Volatile private var cachedIdentity: DeviceIdentity? = null
/** Loads the persisted identity or creates one, repairing old device-id drift. */
@Synchronized
fun loadOrCreate(): DeviceIdentity {
cachedIdentity?.let { return it }
migrateLegacyIdentity()
val existing = load()
if (existing != null) {
val derived = deriveDeviceId(existing.publicKeyRawBase64)
if (derived != null && derived != existing.deviceId) {
val updated = existing.copy(deviceId = derived)
save(updated)
cachedIdentity = updated
return updated
}
cachedIdentity = existing
return existing
}
val fresh = generate()
save(fresh)
cachedIdentity = fresh
return fresh
}
/** Signs gateway connect payload text with the persisted Ed25519 private key. */
fun signPayload(
payload: String,
identity: DeviceIdentity,
): String? =
try {
// Use BC lightweight API directly; R8 can break JCA provider registration.
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
val pkInfo =
org.bouncycastle.asn1.pkcs.PrivateKeyInfo
.getInstance(privateKeyBytes)
val parsed = pkInfo.parsePrivateKey()
val rawPrivate =
org.bouncycastle.asn1.DEROctetString
.getInstance(parsed)
.octets
val privateKey =
org.bouncycastle.crypto.params
.Ed25519PrivateKeyParameters(rawPrivate, 0)
val signer =
org.bouncycastle.crypto.signers
.Ed25519Signer()
signer.init(true, privateKey)
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
signer.update(payloadBytes, 0, payloadBytes.size)
base64UrlEncode(signer.generateSignature())
} catch (e: Throwable) {
android.util.Log.e("DeviceAuth", "signPayload FAILED: ${e.javaClass.simpleName}: ${e.message}", e)
null
}
/** Verifies a signature against the persisted public key for debug diagnostics. */
fun verifySelfSignature(
payload: String,
signatureBase64Url: String,
identity: DeviceIdentity,
): Boolean =
try {
val rawPublicKey = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
val pubKey =
org.bouncycastle.crypto.params
.Ed25519PublicKeyParameters(rawPublicKey, 0)
val sigBytes = base64UrlDecode(signatureBase64Url)
val verifier =
org.bouncycastle.crypto.signers
.Ed25519Signer()
verifier.init(false, pubKey)
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
verifier.update(payloadBytes, 0, payloadBytes.size)
verifier.verifySignature(sigBytes)
} catch (e: Throwable) {
android.util.Log.e("DeviceAuth", "self-verify exception: ${e.message}", e)
false
}
/** Decodes gateway URL-safe base64 signatures, accepting unpadded input. */
private fun base64UrlDecode(input: String): ByteArray {
val normalized = input.replace('-', '+').replace('_', '/')
// Android Base64 expects padded input; gateway signatures are URL-safe
// unpadded strings.
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
return Base64.decode(padded, Base64.DEFAULT)
}
/** Returns the public key in the gateway's unpadded URL-safe base64 format. */
fun publicKeyBase64Url(identity: DeviceIdentity): String? =
try {
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
base64UrlEncode(raw)
} catch (_: Throwable) {
null
}
private fun load(): DeviceIdentity? = readIdentity(prefs.getString(identityKey))
private fun readIdentity(raw: String?): DeviceIdentity? {
return try {
if (raw == null) return null
val decoded = json.decodeFromString(DeviceIdentity.serializer(), raw)
if (decoded.deviceId.isBlank() ||
decoded.publicKeyRawBase64.isBlank() ||
decoded.privateKeyPkcs8Base64.isBlank()
) {
null
} else {
decoded
}
} catch (_: Throwable) {
null
}
}
private fun migrateLegacyIdentity() {
if (!legacyIdentityFile.exists()) return
val legacy =
runCatching { legacyIdentityFile.readText(Charsets.UTF_8) }
.getOrNull()
?.let(::readIdentity)
if (legacy == null) {
legacyIdentityFile.delete()
return
}
save(legacy)
check(load() == legacy) { "Failed to migrate device identity to secure storage" }
// Delete plaintext after verified import so secure prefs remain the only identity owner.
// A fallback would expose the key again and can restore stale identity, breaking gateway pairing.
check(legacyIdentityFile.delete() || !legacyIdentityFile.exists()) {
"Failed to delete legacy device identity"
}
}
private fun save(identity: DeviceIdentity) {
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
check(prefs.putStringSynchronously(identityKey, encoded)) {
"Failed to persist device identity"
}
}
private fun generate(): DeviceIdentity {
// Use BC lightweight API directly to avoid JCA provider issues with R8.
val kpGen =
org.bouncycastle.crypto.generators
.Ed25519KeyPairGenerator()
kpGen.init(
org.bouncycastle.crypto.params
.Ed25519KeyGenerationParameters(java.security.SecureRandom()),
)
val kp = kpGen.generateKeyPair()
val pubKey = kp.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
val rawPublic = pubKey.encoded // 32 bytes
val deviceId = sha256Hex(rawPublic)
// Store private key as PKCS8 so signPayload can parse the same persisted
// shape after app restarts and upgrades.
val privKeyInfo =
org.bouncycastle.crypto.util.PrivateKeyInfoFactory
.createPrivateKeyInfo(privKey)
val pkcs8Bytes = privKeyInfo.encoded
return DeviceIdentity(
deviceId = deviceId,
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
privateKeyPkcs8Base64 = Base64.encodeToString(pkcs8Bytes, Base64.NO_WRAP),
createdAtMs = System.currentTimeMillis(),
)
}
/** Re-derives the stable device id from the raw Ed25519 public key bytes. */
private fun deriveDeviceId(publicKeyRawBase64: String): String? =
try {
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
sha256Hex(raw)
} catch (_: Throwable) {
null
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = CharArray(digest.size * 2)
var i = 0
for (byte in digest) {
val v = byte.toInt() and 0xff
out[i++] = HEX[v ushr 4]
out[i++] = HEX[v and 0x0f]
}
return String(out)
}
private fun base64UrlEncode(data: ByteArray): String =
Base64.encodeToString(
data,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING,
)
companion object {
private const val identityKey = "device.identity"
private val HEX = "0123456789abcdef".toCharArray()
internal fun withPrefs(
context: Context,
prefs: SecurePrefs,
): DeviceIdentityStore = DeviceIdentityStore(context, prefs)
}
}

View file

@ -0,0 +1,43 @@
package ai.openclaw.app.gateway
/**
* Operator-defined HTTP headers attached to gateway connections so gateways fronted by
* authenticating reverse proxies (Cloudflare Access-style service tokens) stay reachable.
* Header values are credentials: persist them only in SecurePrefs and never log them.
*/
object GatewayCustomHeaders {
// Connection-management headers the WebSocket upgrade owns. Operator overrides here would
// corrupt the handshake or duplicate fields OkHttp sets itself.
private val reservedNames =
setOf("connection", "content-length", "host", "proxy-connection", "upgrade")
private const val RESERVED_PREFIX = "sec-websocket-"
private const val TOKEN_PUNCTUATION = "!#$%&'*+-.^_`|~"
fun isReservedName(name: String): Boolean {
val normalized = name.trim().lowercase()
return normalized in reservedNames || normalized.startsWith(RESERVED_PREFIX)
}
/**
* Drops entries that cannot travel as a single well-formed header: empty, reserved, or
* non-token names, and values outside printable ASCII. Dropping invalid entries keeps one bad
* stored value from wedging every reconnect or being interpreted differently by a proxy.
*/
fun sanitized(headers: Map<String, String>): Map<String, String> {
val result = LinkedHashMap<String, String>()
for ((rawName, value) in headers) {
val name = rawName.trim()
if (name.isEmpty() || isReservedName(name)) continue
if (!name.all(::isTokenCharacter)) continue
if (!value.all { it in ' '..'~' }) continue
result[name] = value
}
return result
}
private fun isTokenCharacter(character: Char): Boolean =
character in '0'..'9' ||
character in 'A'..'Z' ||
character in 'a'..'z' ||
character in TOKEN_PUNCTUATION
}

View file

@ -0,0 +1,704 @@
package ai.openclaw.app.gateway
import android.content.Context
import android.net.ConnectivityManager
import android.net.DnsResolver
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.CancellationSignal
import android.util.Log
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import org.xbill.DNS.AAAARecord
import org.xbill.DNS.ARecord
import org.xbill.DNS.DClass
import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.Message
import org.xbill.DNS.Name
import org.xbill.DNS.PTRRecord
import org.xbill.DNS.Rcode
import org.xbill.DNS.Record
import org.xbill.DNS.Resolver
import org.xbill.DNS.SRVRecord
import org.xbill.DNS.Section
import org.xbill.DNS.SimpleResolver
import org.xbill.DNS.TXTRecord
import org.xbill.DNS.TextParseException
import org.xbill.DNS.Type
import java.io.IOException
import java.net.InetAddress
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private fun createDnsResolver(context: Context): DnsResolver =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
createContextDnsResolver(context)
} else {
createLegacyDnsResolver()
}
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
@Suppress("DEPRECATION")
private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance()
internal fun gatewayDiscoveryStatusText(
localCount: Int,
wideAreaRcode: Int?,
wideAreaCount: Int,
): String {
val wide =
when (wideAreaRcode) {
null -> "Wide: ?"
Rcode.NOERROR -> "Wide: $wideAreaCount"
Rcode.NXDOMAIN -> "Wide: NXDOMAIN"
else -> "Wide: ${Rcode.string(wideAreaRcode)}"
}
return when {
localCount == 0 && wideAreaRcode == null -> "Searching for gateways…"
localCount == 0 -> wide
else -> "Local: $localCount$wide"
}
}
/**
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
*/
class GatewayDiscovery(
context: Context,
private val scope: CoroutineScope,
) {
private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val dns = createDnsResolver(context)
private val serviceType = "_openclaw-gw._tcp."
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
private val logTag = "OpenClaw/GatewayDiscovery"
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
/** Current discovered gateway list, merged from local DNS-SD and optional wide-area DNS-SD. */
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
private val _statusText = MutableStateFlow("Searching…")
/** Short diagnostic text shown by connect UI while discovery is running. */
val statusText: StateFlow<String> = _statusText.asStateFlow()
private var unicastJob: Job? = null
private val dnsExecutor: Executor = Executors.newCachedThreadPool()
private val availableNetworks = ConcurrentHashMap.newKeySet<Network>()
private val serviceInfoCallbacks = ConcurrentHashMap<String, Any>()
@Volatile private var lastWideAreaRcode: Int? = null
@Volatile private var lastWideAreaCount: Int = 0
private val networkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
availableNetworks.add(network)
}
override fun onLost(network: Network) {
availableNetworks.remove(network)
}
}
private val discoveryListener =
object : NsdManager.DiscoveryListener {
override fun onStartDiscoveryFailed(
serviceType: String,
errorCode: Int,
) {}
override fun onStopDiscoveryFailed(
serviceType: String,
errorCode: Int,
) {}
override fun onDiscoveryStarted(serviceType: String) {}
override fun onDiscoveryStopped(serviceType: String) {}
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
if (serviceInfo.serviceType != this@GatewayDiscovery.serviceType) return
resolve(serviceInfo)
}
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")
localById.remove(id)
unregisterServiceInfoCallback(id)
publish()
}
}
init {
startNetworkTracking()
startLocalDiscovery()
if (!wideAreaDomain.isNullOrBlank()) {
startUnicastDiscovery(wideAreaDomain)
}
}
private fun startNetworkTracking() {
val cm = connectivity ?: return
cm.activeNetwork?.let(availableNetworks::add)
try {
// Track all networks so wide-area DNS can prefer VPN/split-DNS answers
// even when Android's active network is not the VPN.
cm.registerNetworkCallback(NetworkRequest.Builder().build(), networkCallback)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startLocalDiscovery() {
try {
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startUnicastDiscovery(domain: String) {
unicastJob =
scope.launch(Dispatchers.IO) {
while (true) {
try {
refreshUnicast(domain)
} catch (_: Throwable) {
// ignore (best-effort)
}
delay(5000)
}
}
}
private fun resolve(serviceInfo: NsdServiceInfo) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// Android 14+ streams service updates; older releases require one-shot resolve calls.
resolveWithServiceInfoCallback(serviceInfo)
} else {
resolveLegacy(serviceInfo)
}
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private fun resolveWithServiceInfoCallback(serviceInfo: NsdServiceInfo) {
val serviceName = BonjourEscapes.decode(serviceInfo.serviceName)
val id = stableId(serviceName, "local.")
if (serviceInfoCallbacks.containsKey(id)) return
val callback =
object : NsdManager.ServiceInfoCallback {
override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
serviceInfoCallbacks.remove(id, this)
}
override fun onServiceInfoCallbackUnregistered() {
serviceInfoCallbacks.remove(id, this)
}
override fun onServiceLost() {
localById.remove(id)
publish()
}
override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
upsertResolvedService(serviceInfo)
}
}
serviceInfoCallbacks[id] = callback
try {
nsd.registerServiceInfoCallback(serviceInfo, dnsExecutor, callback)
} catch (_: Throwable) {
serviceInfoCallbacks.remove(id, callback)
}
}
private fun unregisterServiceInfoCallback(id: String) {
val callback = serviceInfoCallbacks.remove(id) ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return
try {
nsd.unregisterServiceInfoCallback(callback as NsdManager.ServiceInfoCallback)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun resolveLegacy(serviceInfo: NsdServiceInfo) {
val listener =
object : NsdManager.ResolveListener {
override fun onResolveFailed(
serviceInfo: NsdServiceInfo,
errorCode: Int,
) {}
override fun onServiceResolved(resolved: NsdServiceInfo) {
upsertResolvedService(resolved)
}
}
try {
NsdManager::class.java
.getMethod("resolveService", NsdServiceInfo::class.java, NsdManager.ResolveListener::class.java)
.invoke(nsd, serviceInfo, listener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun upsertResolvedService(resolved: NsdServiceInfo) {
val host = resolvedHostAddress(resolved) ?: return
val port = resolved.port
if (port <= 0) return
val rawServiceName = resolved.serviceName
val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
val lanHost = txt(resolved, "lanHost")
val tailnetDns = txt(resolved, "tailnetDns")
val gatewayPort = txtInt(resolved, "gatewayPort")
val canvasPort = txtInt(resolved, "canvasPort")
val tlsEnabled = txtBool(resolved, "gatewayTls")
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
val id = stableId(serviceName, "local.")
// Local NSD gives the socket host/port; TXT ports are retained as gateway metadata only.
localById[id] =
GatewayEndpoint(
stableId = id,
name = displayName,
host = host,
port = port,
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
)
publish()
}
private fun resolvedHostAddress(resolved: NsdServiceInfo): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return resolved.hostAddresses.firstOrNull()?.hostAddress
}
return legacyHostAddress(resolved)
}
private fun legacyHostAddress(resolved: NsdServiceInfo): String? =
try {
val host = NsdServiceInfo::class.java.getMethod("getHost").invoke(resolved) as? InetAddress
host?.hostAddress
} catch (_: Throwable) {
null
}
private fun publish() {
_gateways.value =
// Merge local and wide-area results deterministically for stable UI selection.
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
_statusText.value =
gatewayDiscoveryStatusText(
localCount = localById.size,
wideAreaRcode = lastWideAreaRcode,
wideAreaCount = lastWideAreaCount,
)
}
private fun stableId(
serviceName: String,
domain: String,
): String = "$serviceType|$domain|${normalizeName(serviceName)}"
private fun normalizeName(raw: String): String = raw.trim().split(Regex("\\s+")).joinToString(" ")
private fun txt(
info: NsdServiceInfo,
key: String,
): String? {
val bytes = info.attributes[key] ?: return null
return try {
String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
} catch (_: Throwable) {
null
}
}
private fun txtInt(
info: NsdServiceInfo,
key: String,
): Int? = txt(info, key)?.toIntOrNull()
private fun txtBool(
info: NsdServiceInfo,
key: String,
): Boolean {
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private suspend fun refreshUnicast(domain: String) {
val ptrName = "${serviceType}$domain"
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
val ptrRecords = records(ptrMsg, Section.ANSWER).mapNotNull { it as? PTRRecord }
val next = LinkedHashMap<String, GatewayEndpoint>()
for (ptr in ptrRecords) {
val instanceFqdn = ptr.target.toString()
val srv =
recordByName(ptrMsg, instanceFqdn, Type.SRV) as? SRVRecord
?: run {
val msg = lookupUnicastMessage(instanceFqdn, Type.SRV) ?: return@run null
recordByName(msg, instanceFqdn, Type.SRV) as? SRVRecord
}
?: continue
val port = srv.port
if (port <= 0) continue
val targetFqdn = srv.target.toString()
val host =
resolveHostFromMessage(ptrMsg, targetFqdn)
?: resolveHostFromMessage(lookupUnicastMessage(instanceFqdn, Type.SRV), targetFqdn)
?: resolveHostUnicast(targetFqdn)
?: continue
// Wide-area DNS-SD may put TXT in additional records; fall back to a direct TXT query.
val txtFromPtr =
recordsByName(ptrMsg, Section.ADDITIONAL)[keyName(instanceFqdn)]
.orEmpty()
.mapNotNull { it as? TXTRecord }
val txt =
if (txtFromPtr.isNotEmpty()) {
txtFromPtr
} else {
val msg = lookupUnicastMessage(instanceFqdn, Type.TXT)
records(msg, Section.ANSWER).mapNotNull { it as? TXTRecord }
}
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
val lanHost = txtValue(txt, "lanHost")
val tailnetDns = txtValue(txt, "tailnetDns")
val gatewayPort = txtIntValue(txt, "gatewayPort")
val canvasPort = txtIntValue(txt, "canvasPort")
val tlsEnabled = txtBoolValue(txt, "gatewayTls")
val tlsFingerprint = txtValue(txt, "gatewayTlsSha256")
val id = stableId(instanceName, domain)
next[id] =
GatewayEndpoint(
stableId = id,
name = displayName,
host = host,
port = port,
lanHost = lanHost,
tailnetDns = tailnetDns,
gatewayPort = gatewayPort,
canvasPort = canvasPort,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = tlsFingerprint,
)
}
unicastById.clear()
unicastById.putAll(next)
lastWideAreaRcode = ptrMsg.header.rcode
lastWideAreaCount = next.size
publish()
if (next.isEmpty()) {
Log.d(
logTag,
"wide-area discovery: 0 results for $ptrName (rcode=${Rcode.string(ptrMsg.header.rcode)})",
)
}
}
private fun decodeInstanceName(
instanceFqdn: String,
domain: String,
): String {
val suffix = "${serviceType}$domain"
val withoutSuffix =
if (instanceFqdn.endsWith(suffix)) {
instanceFqdn.removeSuffix(suffix)
} else {
instanceFqdn.substringBefore(serviceType)
}
return normalizeName(stripTrailingDot(withoutSuffix))
}
private fun stripTrailingDot(raw: String): String = raw.removeSuffix(".")
private suspend fun lookupUnicastMessage(
name: String,
type: Int,
): Message? {
val query =
try {
Message.newQuery(
org.xbill.DNS.Record.newRecord(
Name.fromString(name),
type,
DClass.IN,
),
)
} catch (_: TextParseException) {
return null
}
val system = queryViaSystemDns(query)
if (records(system, Section.ANSWER).any { it.type == type }) return system
// Android's DnsResolver can miss split-DNS answers; retry with dnsjava against network DNS servers.
val direct = createDirectResolver() ?: return system
return try {
val msg = direct.send(query)
if (records(msg, Section.ANSWER).any { it.type == type }) msg else system
} catch (_: Throwable) {
system
}
}
private suspend fun queryViaSystemDns(query: Message): Message? {
val network = preferredDnsNetwork()
val bytes =
try {
rawQuery(network, query.toWire())
} catch (_: Throwable) {
return null
}
return try {
Message(bytes)
} catch (_: IOException) {
null
}
}
private fun records(
msg: Message?,
section: Int,
): List<Record> = msg?.getSection(section).orEmpty()
private fun keyName(raw: String): String = raw.trim().lowercase()
private fun recordsByName(
msg: Message,
section: Int,
): Map<String, List<Record>> {
val next = LinkedHashMap<String, MutableList<Record>>()
for (r in records(msg, section)) {
val name = r.name?.toString() ?: continue
next.getOrPut(keyName(name)) { mutableListOf() }.add(r)
}
return next
}
private fun recordByName(
msg: Message,
fqdn: String,
type: Int,
): Record? {
val key = keyName(fqdn)
val byNameAnswer = recordsByName(msg, Section.ANSWER)
val fromAnswer = byNameAnswer[key].orEmpty().firstOrNull { it.type == type }
if (fromAnswer != null) return fromAnswer
val byNameAdditional = recordsByName(msg, Section.ADDITIONAL)
return byNameAdditional[key].orEmpty().firstOrNull { it.type == type }
}
private fun resolveHostFromMessage(
msg: Message?,
hostname: String,
): String? {
val m = msg ?: return null
val key = keyName(hostname)
val additional = recordsByName(m, Section.ADDITIONAL)[key].orEmpty()
val a = additional.mapNotNull { it as? ARecord }.mapNotNull { it.address?.hostAddress }
val aaaa = additional.mapNotNull { it as? AAAARecord }.mapNotNull { it.address?.hostAddress }
return a.firstOrNull() ?: aaaa.firstOrNull()
}
private fun preferredDnsNetwork(): android.net.Network? {
val cm = connectivity ?: return null
// Prefer VPN (Tailscale) when present; otherwise use the active network.
trackedNetworks(cm)
.firstOrNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
}?.let { return it }
return cm.activeNetwork
}
private fun trackedNetworks(cm: ConnectivityManager): List<Network> =
buildList {
cm.activeNetwork?.let(::add)
addAll(availableNetworks)
}.distinct()
private fun createDirectResolver(): Resolver? {
val cm = connectivity ?: return null
val candidateNetworks =
buildList {
// Put VPN DNS first so Tailscale split-horizon names win over public DNS.
trackedNetworks(cm)
.firstOrNull { n ->
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
}?.let(::add)
cm.activeNetwork?.let(::add)
}.distinct()
val servers =
candidateNetworks
.asSequence()
.flatMap { n ->
cm.getLinkProperties(n)?.dnsServers?.asSequence() ?: emptySequence()
}.distinctBy { it.hostAddress ?: it.toString() }
.toList()
if (servers.isEmpty()) return null
return try {
val resolvers =
servers.mapNotNull { addr ->
try {
SimpleResolver().apply {
setAddress(InetSocketAddress(addr, 53))
setTimeout(Duration.ofSeconds(3))
}
} catch (_: Throwable) {
null
}
}
if (resolvers.isEmpty()) return null
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(Duration.ofSeconds(3)) }
} catch (_: Throwable) {
null
}
}
private suspend fun rawQuery(
network: android.net.Network?,
wireQuery: ByteArray,
): ByteArray =
suspendCancellableCoroutine { cont ->
val signal = CancellationSignal()
cont.invokeOnCancellation { signal.cancel() }
dns.rawQuery(
network,
wireQuery,
DnsResolver.FLAG_EMPTY,
dnsExecutor,
signal,
object : DnsResolver.Callback<ByteArray> {
override fun onAnswer(
answer: ByteArray,
rcode: Int,
) {
cont.resume(answer)
}
override fun onError(error: DnsResolver.DnsException) {
cont.resumeWithException(error)
}
},
)
}
private fun txtValue(
records: List<TXTRecord>,
key: String,
): String? {
val prefix = "$key="
for (r in records) {
val strings: List<String> =
try {
r.strings
} catch (_: Throwable) {
emptyList()
}
for (s in strings) {
val trimmed = decodeDnsTxtString(s).trim()
if (trimmed.startsWith(prefix)) {
return trimmed.removePrefix(prefix).trim().ifEmpty { null }
}
}
}
return null
}
private fun txtIntValue(
records: List<TXTRecord>,
key: String,
): Int? = txtValue(records, key)?.toIntOrNull()
private fun txtBoolValue(
records: List<TXTRecord>,
key: String,
): Boolean {
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
return raw == "1" || raw == "true" || raw == "yes"
}
private fun decodeDnsTxtString(raw: String): String {
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
val bytes = raw.toByteArray(Charsets.ISO_8859_1)
val decoder =
Charsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
return try {
decoder.decode(ByteBuffer.wrap(bytes)).toString()
} catch (_: Throwable) {
raw
}
}
private suspend fun resolveHostUnicast(hostname: String): String? {
val a =
records(lookupUnicastMessage(hostname, Type.A), Section.ANSWER)
.mapNotNull { it as? ARecord }
.mapNotNull { it.address?.hostAddress }
val aaaa =
records(lookupUnicastMessage(hostname, Type.AAAA), Section.ANSWER)
.mapNotNull { it as? AAAARecord }
.mapNotNull { it.address?.hostAddress }
return a.firstOrNull() ?: aaaa.firstOrNull()
}
}

View file

@ -0,0 +1,32 @@
package ai.openclaw.app.gateway
/** Resolved gateway address and optional metadata discovered from Bonjour/manual entry. */
data class GatewayEndpoint(
val stableId: String,
val name: String,
val host: String,
val port: Int,
val lanHost: String? = null,
val tailnetDns: String? = null,
val gatewayPort: Int? = null,
val canvasPort: Int? = null,
val tlsEnabled: Boolean = false,
val tlsFingerprintSha256: String? = null,
) {
companion object {
/** Builds a stable manual endpoint key that survives display-name changes. */
fun manual(
host: String,
port: Int,
tlsEnabled: Boolean = false,
): GatewayEndpoint =
GatewayEndpoint(
stableId = "manual|${host.lowercase()}|$port",
name = "$host:$port",
host = host,
port = port,
tlsEnabled = tlsEnabled,
tlsFingerprintSha256 = null,
)
}
}

View file

@ -0,0 +1,148 @@
package ai.openclaw.app.gateway
import android.os.Build
import java.net.InetAddress
import java.util.Locale
/** Returns true only for loopback hosts safe to treat as local gateway origins. */
internal fun isLoopbackGatewayHost(
rawHost: String?,
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
): Boolean {
var host =
rawHost
?.trim()
?.lowercase(Locale.US)
?.trim('[', ']')
.orEmpty()
if (host.endsWith(".")) {
host = host.dropLast(1)
}
val zoneIndex = host.indexOf('%')
// Scoped IPv6 literals are not stable origin identifiers; reject them for
// loopback trust instead of guessing which interface the zone names.
if (zoneIndex >= 0) return false
if (host.isEmpty()) return false
if (host == "localhost") return true
// Android emulator maps host loopback through this bridge alias.
if (allowEmulatorBridgeAlias && host == "10.0.2.2") return true
parseIpv4Address(host)?.let { ipv4 ->
return ipv4.first() == 127.toByte()
}
if (!host.contains(':') || !host.all(::isIpv6LiteralChar)) return false
val address = runCatching { InetAddress.getByName(host) }.getOrNull()?.address ?: return false
if (address.size == 4) {
return address[0] == 127.toByte()
}
if (address.size != 16) return false
// `::1` is 15 zero bytes followed by `0x01`.
val isIpv6Loopback = address.copyOfRange(0, 15).all { it == 0.toByte() } && address[15] == 1.toByte()
if (isIpv6Loopback) return true
val isMappedIpv4 =
address.copyOfRange(0, 10).all { it == 0.toByte() } &&
address[10] == 0xFF.toByte() &&
address[11] == 0xFF.toByte()
return isMappedIpv4 && address[12] == 127.toByte()
}
/** Allows cleartext only for loopback, `.local`, and private/link-local network ranges. */
internal fun isLocalCleartextGatewayHost(
rawHost: String?,
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
): Boolean {
var host =
rawHost
?.trim()
?.lowercase(Locale.US)
?.trim('[', ']')
.orEmpty()
if (host.endsWith(".")) {
host = host.dropLast(1)
}
if (host.isEmpty()) return false
if (isLoopbackGatewayHost(host, allowEmulatorBridgeAlias = allowEmulatorBridgeAlias)) return true
if (isMdnsLocalHostname(host)) return true
val zoneIndex = host.indexOf('%')
if (zoneIndex >= 0) {
// Link-local cleartext policy is about the address range; strip the
// interface zone before InetAddress parsing rejects otherwise valid hosts.
host = host.substring(0, zoneIndex)
}
if (host.isEmpty()) return false
parseIpv4Address(host)?.let { ipv4 ->
val first = ipv4[0].toInt() and 0xff
val second = ipv4[1].toInt() and 0xff
return when {
first == 10 -> true
first == 172 && second in 16..31 -> true
first == 192 && second == 168 -> true
first == 169 && second == 254 -> true
else -> false
}
}
if (!host.contains(':') || !host.all(::isIpv6LiteralChar)) return false
val address = runCatching { InetAddress.getByName(host) }.getOrNull() ?: return false
return when {
address.isLinkLocalAddress -> true
address.isSiteLocalAddress -> true
else -> {
val bytes = address.address
bytes.size == 16 && (bytes[0].toInt() and 0xfe) == 0xfc
}
}
}
private fun isAndroidEmulatorRuntime(): Boolean {
val fingerprint = Build.FINGERPRINT?.lowercase(Locale.US).orEmpty()
val model = Build.MODEL?.lowercase(Locale.US).orEmpty()
val manufacturer = Build.MANUFACTURER?.lowercase(Locale.US).orEmpty()
val brand = Build.BRAND?.lowercase(Locale.US).orEmpty()
val device = Build.DEVICE?.lowercase(Locale.US).orEmpty()
val product = Build.PRODUCT?.lowercase(Locale.US).orEmpty()
return fingerprint.contains("generic") ||
fingerprint.contains("robolectric") ||
model.contains("emulator") ||
model.contains("sdk_gphone") ||
manufacturer.contains("genymotion") ||
(brand.contains("generic") && device.contains("generic")) ||
product.contains("sdk_gphone") ||
product.contains("emulator") ||
product.contains("simulator")
}
/** Parses strict dotted-quad IPv4, rejecting shorthand and out-of-range octets. */
private fun parseIpv4Address(host: String): ByteArray? {
val parts = host.split('.')
if (parts.size != 4) return null
val bytes = ByteArray(4)
for ((index, part) in parts.withIndex()) {
val value = part.toIntOrNull() ?: return null
if (value !in 0..255) return null
bytes[index] = value.toByte()
}
return bytes
}
private fun isMdnsLocalHostname(host: String): Boolean {
if (host.length > 253) return false
if (!host.endsWith(".local")) return false
val labels = host.split('.')
if (labels.size < 2 || labels.last() != "local") return false
return labels.dropLast(1).all(::isDnsHostnameLabel)
}
private fun isDnsHostnameLabel(label: String): Boolean {
if (label.isEmpty() || label.length > 63) return false
if (label.first() == '-' || label.last() == '-') return false
return label.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
}
/** Cheap prefilter before handing potential IPv6 literals to InetAddress. */
private fun isIpv6LiteralChar(char: Char): Boolean = char in '0'..'9' || char in 'a'..'f' || char == ':' || char == '.'

View file

@ -0,0 +1,558 @@
// Generated by scripts/protocol-gen-kotlin.ts — do not edit by hand.
package ai.openclaw.app.gateway
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
const val GATEWAY_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 3
@Serializable
data class GatewayProtocolError(
val code: String,
val message: String,
val details: JsonElement? = null,
val retryable: Boolean? = null,
val retryAfterMs: Long? = null,
)
@Serializable
data class GatewayRequestFrame(
val type: String = "req",
val id: String,
val method: String,
val params: JsonElement? = null,
val traceparent: String? = null,
)
@Serializable
data class GatewayResponseFrame(
val type: String = "res",
val id: String,
val ok: Boolean,
val payload: JsonElement? = null,
val error: GatewayProtocolError? = null,
)
@Serializable
data class GatewayEventFrame(
val type: String = "event",
val event: String,
val payload: JsonElement? = null,
val seq: Long? = null,
val stateVersion: GatewayEventFrameStateVersion? = null,
)
@Serializable
data class GatewayNodeEventParams(
val event: String,
val payload: JsonElement? = null,
@SerialName("payloadJSON")
val payloadJson: String? = null,
)
@Serializable
data class GatewayNodeInvokeResultParams(
val id: String,
val nodeId: String,
val ok: Boolean,
val payload: JsonElement? = null,
@SerialName("payloadJSON")
val payloadJson: String? = null,
val error: GatewayNodeInvokeResultParamsError? = null,
)
@Serializable
data class GatewayNodeInvokeRequest(
val id: String,
val nodeId: String,
val command: String,
@SerialName("paramsJSON")
val paramsJson: String? = null,
val timeoutMs: Long? = null,
val idempotencyKey: String? = null,
)
@Serializable
data class QuestionOption(
val label: String,
val description: String? = null,
)
@Serializable
data class Question(
val questionId: String,
val header: String,
val question: String,
val options: List<QuestionOption>,
val multiSelect: Boolean? = null,
val isOther: Boolean? = null,
val isSecret: Boolean? = null,
)
@Serializable
data class QuestionAnswers(
val answers: Map<String, List<String>>,
)
@Serializable
data class QuestionRecord(
val id: String,
val questions: List<Question>,
val agentId: String? = null,
val sessionKey: String? = null,
val runId: String? = null,
val createdAtMs: Long,
val expiresAtMs: Long,
val status: String,
val answers: QuestionAnswers? = null,
val resolvedBy: String? = null,
)
@Serializable
data class QuestionGetResult(
val question: QuestionRecord,
)
@Serializable
data class QuestionListResult(
val questions: List<QuestionRecord>,
)
@Serializable
data class SessionObserverPlanProgress(
val completed: Long,
val total: Long,
)
@Serializable
data class SessionObserverDigest(
val sessionKey: String,
val agentId: String? = null,
val runId: String? = null,
val revision: Long,
val updatedAt: Long,
val headline: String,
val assessment: String? = null,
val health: String,
val planProgress: SessionObserverPlanProgress? = null,
)
@Serializable
data class GatewayEventFrameStateVersion(
val presence: Long,
val health: Long,
)
@Serializable
data class GatewayNodeInvokeResultParamsError(
val code: String? = null,
val message: String? = null,
)
enum class GatewayMethod(
val rawValue: String,
) {
Health("health"),
DiagnosticsStability("diagnostics.stability"),
DoctorMemoryStatus("doctor.memory.status"),
DoctorMemoryDreamDiary("doctor.memory.dreamDiary"),
DoctorMemoryBackfillDreamDiary("doctor.memory.backfillDreamDiary"),
DoctorMemoryResetDreamDiary("doctor.memory.resetDreamDiary"),
DoctorMemoryResetGroundedShortTerm("doctor.memory.resetGroundedShortTerm"),
DoctorMemoryRepairDreamingArtifacts("doctor.memory.repairDreamingArtifacts"),
DoctorMemoryDedupeDreamDiary("doctor.memory.dedupeDreamDiary"),
DoctorMemoryRemHarness("doctor.memory.remHarness"),
LogsTail("logs.tail"),
ChannelsStatus("channels.status"),
ChannelsStart("channels.start"),
ChannelsStop("channels.stop"),
ChannelsLogout("channels.logout"),
Status("status"),
UsageStatus("usage.status"),
UsageCost("usage.cost"),
TtsStatus("tts.status"),
TtsProviders("tts.providers"),
TtsPersonas("tts.personas"),
TtsEnable("tts.enable"),
TtsDisable("tts.disable"),
TtsConvert("tts.convert"),
TtsSetProvider("tts.setProvider"),
TtsSetPersona("tts.setPersona"),
ConfigGet("config.get"),
ConfigSet("config.set"),
ConfigApply("config.apply"),
ConfigPatch("config.patch"),
ConfigSchema("config.schema"),
ConfigSchemaLookup("config.schema.lookup"),
ExecApprovalsGet("exec.approvals.get"),
ExecApprovalsSet("exec.approvals.set"),
ExecApprovalsNodeGet("exec.approvals.node.get"),
ExecApprovalsNodeSet("exec.approvals.node.set"),
ExecApprovalGet("exec.approval.get"),
ExecApprovalList("exec.approval.list"),
ExecApprovalRequest("exec.approval.request"),
ExecApprovalWaitDecision("exec.approval.waitDecision"),
ExecApprovalResolve("exec.approval.resolve"),
QuestionRequest("question.request"),
QuestionWaitAnswer("question.waitAnswer"),
QuestionResolve("question.resolve"),
QuestionGet("question.get"),
QuestionList("question.list"),
PluginApprovalList("plugin.approval.list"),
PluginApprovalRequest("plugin.approval.request"),
PluginApprovalWaitDecision("plugin.approval.waitDecision"),
PluginApprovalResolve("plugin.approval.resolve"),
PluginsUiDescriptors("plugins.uiDescriptors"),
PluginsSessionAction("plugins.sessionAction"),
OpenclawChat("openclaw.chat"),
OpenclawChatHistory("openclaw.chat.history"),
OpenclawChangesList("openclaw.changes.list"),
OpenclawApprovalList("openclaw.approval.list"),
OpenclawSetupDetect("openclaw.setup.detect"),
OpenclawSetupActivate("openclaw.setup.activate"),
OpenclawSetupAuthStart("openclaw.setup.auth.start"),
OpenclawSetupPrepareStart("openclaw.setup.prepare.start"),
WizardStart("wizard.start"),
WizardNext("wizard.next"),
WizardCancel("wizard.cancel"),
WizardStatus("wizard.status"),
TalkCatalog("talk.catalog"),
TalkConfig("talk.config"),
TalkClientCreate("talk.client.create"),
TalkClientTranscript("talk.client.transcript"),
TalkClientClose("talk.client.close"),
TalkClientToolCall("talk.client.toolCall"),
TalkClientSteer("talk.client.steer"),
TalkSessionCreate("talk.session.create"),
TalkSessionJoin("talk.session.join"),
TalkSessionAppendAudio("talk.session.appendAudio"),
TalkSessionStartTurn("talk.session.startTurn"),
TalkSessionEndTurn("talk.session.endTurn"),
TalkSessionCancelTurn("talk.session.cancelTurn"),
TalkSessionCancelOutput("talk.session.cancelOutput"),
TalkSessionAcknowledgeMark("talk.session.acknowledgeMark"),
TalkSessionSubmitToolResult("talk.session.submitToolResult"),
TalkSessionSteer("talk.session.steer"),
TalkSessionClose("talk.session.close"),
TalkSpeak("talk.speak"),
TalkMode("talk.mode"),
CommandsList("commands.list"),
ModelsList("models.list"),
ModelsAuthStatus("models.authStatus"),
ModelsAuthLogout("models.authLogout"),
ToolsCatalog("tools.catalog"),
ToolsEffective("tools.effective"),
ToolsInvoke("tools.invoke"),
McpAppView("mcp.app.view"),
McpAppListTools("mcp.app.listTools"),
McpAppListResources("mcp.app.listResources"),
McpAppListResourceTemplates("mcp.app.listResourceTemplates"),
McpAppReadResource("mcp.app.readResource"),
McpAppCallTool("mcp.app.callTool"),
McpAppUpdateModelContext("mcp.app.updateModelContext"),
BoardGet("board.get"),
BoardUpdate("board.update"),
BoardWidgetPut("board.widget.put"),
BoardWidgetGrant("board.widget.grant"),
BoardWidgetAppView("board.widget.appView"),
BoardEvent("board.event"),
AuditList("audit.list"),
AuditActivityList("audit.activity.list"),
UsersList("users.list"),
UsersSelf("users.self"),
UsersLinkEmail("users.linkEmail"),
UsersSetDisplayName("users.setDisplayName"),
UsersSetAvatar("users.setAvatar"),
TasksList("tasks.list"),
TasksGet("tasks.get"),
TasksCancel("tasks.cancel"),
TaskSuggestionsList("taskSuggestions.list"),
TaskSuggestionsCreate("taskSuggestions.create"),
TaskSuggestionsAccept("taskSuggestions.accept"),
TaskSuggestionsDismiss("taskSuggestions.dismiss"),
EnvironmentsList("environments.list"),
EnvironmentsStatus("environments.status"),
WorktreesList("worktrees.list"),
WorktreesBranches("worktrees.branches"),
FsListDir("fs.listDir"),
WorktreesCreate("worktrees.create"),
WorktreesRemove("worktrees.remove"),
WorktreesRestore("worktrees.restore"),
WorktreesGc("worktrees.gc"),
AgentsList("agents.list"),
AgentsCreate("agents.create"),
AgentsUpdate("agents.update"),
AgentsDelete("agents.delete"),
AgentsFilesList("agents.files.list"),
AgentsFilesGet("agents.files.get"),
AgentsFilesSet("agents.files.set"),
SessionsFilesList("sessions.files.list"),
SessionsFilesGet("sessions.files.get"),
SessionsFilesSet("sessions.files.set"),
SessionsFilesReveal("sessions.files.reveal"),
ArtifactsList("artifacts.list"),
ArtifactsGet("artifacts.get"),
ArtifactsDownload("artifacts.download"),
SkillsStatus("skills.status"),
SkillsSearch("skills.search"),
SkillsDetail("skills.detail"),
SkillsSecurityVerdicts("skills.securityVerdicts"),
SkillsSkillCard("skills.skillCard"),
SkillsBins("skills.bins"),
SkillsUploadBegin("skills.upload.begin"),
SkillsUploadChunk("skills.upload.chunk"),
SkillsUploadCommit("skills.upload.commit"),
SkillsInstall("skills.install"),
SkillsUpdate("skills.update"),
SkillsCuratorStatus("skills.curator.status"),
SkillsCuratorPin("skills.curator.pin"),
SkillsCuratorUnpin("skills.curator.unpin"),
SkillsCuratorRestore("skills.curator.restore"),
SkillsProposalsList("skills.proposals.list"),
SkillsProposalsInspect("skills.proposals.inspect"),
SkillsProposalsHistoryStatus("skills.proposals.historyStatus"),
SkillsProposalsHistoryScan("skills.proposals.historyScan"),
SkillsProposalsCreate("skills.proposals.create"),
SkillsProposalsUpdate("skills.proposals.update"),
SkillsProposalsRevise("skills.proposals.revise"),
SkillsProposalsRequestRevision("skills.proposals.requestRevision"),
SkillsProposalsApply("skills.proposals.apply"),
SkillsProposalsReject("skills.proposals.reject"),
SkillsProposalsQuarantine("skills.proposals.quarantine"),
UpdateStatus("update.status"),
UpdateRun("update.run"),
VoicewakeGet("voicewake.get"),
VoicewakeSet("voicewake.set"),
SecretsReload("secrets.reload"),
SecretsResolve("secrets.resolve"),
VoicewakeRoutingGet("voicewake.routing.get"),
VoicewakeRoutingSet("voicewake.routing.set"),
SessionsList("sessions.list"),
SessionsSubscribe("sessions.subscribe"),
SessionsUnsubscribe("sessions.unsubscribe"),
SessionsMessagesSubscribe("sessions.messages.subscribe"),
SessionsMessagesUnsubscribe("sessions.messages.unsubscribe"),
SessionsViewersSet("sessions.viewers.set"),
SessionsPreview("sessions.preview"),
SessionsDescribe("sessions.describe"),
SessionsCompactionList("sessions.compaction.list"),
SessionsCompactionGet("sessions.compaction.get"),
SessionsCompactionBranch("sessions.compaction.branch"),
SessionsCompactionRestore("sessions.compaction.restore"),
SessionsBranchesList("sessions.branches.list"),
SessionsBranchesSwitch("sessions.branches.switch"),
SessionsRewind("sessions.rewind"),
SessionsFork("sessions.fork"),
SessionsCreate("sessions.create"),
SessionsSend("sessions.send"),
SessionsAbort("sessions.abort"),
SessionsPatch("sessions.patch"),
SessionsPluginPatch("sessions.pluginPatch"),
SessionsCleanup("sessions.cleanup"),
SessionsReset("sessions.reset"),
SessionsDelete("sessions.delete"),
SessionsCompact("sessions.compact"),
SessionsGroupsList("sessions.groups.list"),
SessionsGroupsPut("sessions.groups.put"),
SessionsGroupsRename("sessions.groups.rename"),
SessionsGroupsDelete("sessions.groups.delete"),
LastHeartbeat("last-heartbeat"),
SetHeartbeats("set-heartbeats"),
Wake("wake"),
NodePairList("node.pair.list"),
NodePairApprove("node.pair.approve"),
NodePairReject("node.pair.reject"),
NodePairRemove("node.pair.remove"),
DevicePairList("device.pair.list"),
DevicePairApprove("device.pair.approve"),
DevicePairReject("device.pair.reject"),
DevicePairRemove("device.pair.remove"),
DevicePairRename("device.pair.rename"),
DeviceTokenRotate("device.token.rotate"),
DeviceTokenRevoke("device.token.revoke"),
DevicePairSetupCode("device.pair.setupCode"),
NodeRename("node.rename"),
NodeList("node.list"),
NodeDescribe("node.describe"),
NodePluginSurfaceRefresh("node.pluginSurface.refresh"),
NodePluginToolsUpdate("node.pluginTools.update"),
NodeSkillsUpdate("node.skills.update"),
NodePendingDrain("node.pending.drain"),
NodePendingEnqueue("node.pending.enqueue"),
NodeInvoke("node.invoke"),
NodePendingPull("node.pending.pull"),
NodePendingAck("node.pending.ack"),
NodeInvokeProgress("node.invoke.progress"),
NodeInvokeResult("node.invoke.result"),
NodeEvent("node.event"),
CronGet("cron.get"),
CronList("cron.list"),
CronStatus("cron.status"),
CronScratchGet("cron.scratch.get"),
CronScratchSet("cron.scratch.set"),
CronAdd("cron.add"),
CronUpdate("cron.update"),
CronRemove("cron.remove"),
CronRun("cron.run"),
CronRuns("cron.runs"),
GatewayIdentityGet("gateway.identity.get"),
GatewayRestartPreflight("gateway.restart.preflight"),
GatewayRestartRequest("gateway.restart.request"),
SystemPresence("system-presence"),
SystemEvent("system-event"),
MessageAction("message.action"),
ConversationsSend("conversations.send"),
ConversationsTurn("conversations.turn"),
ConversationsTurnCancel("conversations.turn.cancel"),
Send("send"),
Agent("agent"),
AgentIdentityGet("agent.identity.get"),
AgentWait("agent.wait"),
ChatHistory("chat.history"),
ChatStartup("chat.startup"),
ChatMetadata("chat.metadata"),
ChatMessageGet("chat.message.get"),
ChatAbort("chat.abort"),
ChatSend("chat.send"),
TerminalOpen("terminal.open"),
TerminalInput("terminal.input"),
TerminalResize("terminal.resize"),
TerminalClose("terminal.close"),
ChannelsPairingList("channels.pairing.list"),
ChannelsPairingApprove("channels.pairing.approve"),
ChannelsPairingDismiss("channels.pairing.dismiss"),
AssistantMediaGet("assistant.media.get"),
SessionsGet("sessions.get"),
SessionsResolve("sessions.resolve"),
SessionsUsage("sessions.usage"),
SessionsUsageTimeseries("sessions.usage.timeseries"),
SessionsUsageLogs("sessions.usage.logs"),
Poll("poll"),
SessionsSteer("sessions.steer"),
PushTest("push.test"),
AttachGrant("attach.grant"),
AttachRevoke("attach.revoke"),
PushWebVapidPublicKey("push.web.vapidPublicKey"),
PushWebSubscribe("push.web.subscribe"),
PushWebUnsubscribe("push.web.unsubscribe"),
PushWebTest("push.web.test"),
ConfigOpenFile("config.openFile"),
Connect("connect"),
ChatInject("chat.inject"),
NativeHookInvoke("nativeHook.invoke"),
WebLoginStart("web.login.start"),
WebLoginWait("web.login.wait"),
TerminalAttach("terminal.attach"),
TerminalList("terminal.list"),
TerminalText("terminal.text"),
ControlUiGithubPreview("controlUi.githubPreview"),
SystemInfo("system.info"),
AgentsWorkspaceList("agents.workspace.list"),
AgentsWorkspaceGet("agents.workspace.get"),
TtsSpeak("tts.speak"),
PluginsList("plugins.list"),
PluginsSearch("plugins.search"),
PluginsInstall("plugins.install"),
PluginsSetEnabled("plugins.setEnabled"),
PluginsUninstall("plugins.uninstall"),
PluginsRefresh("plugins.refresh"),
ControlUiSessionPullRequestsSubscribe("controlUi.sessionPullRequests.subscribe"),
GatewaySuspendPrepare("gateway.suspend.prepare"),
GatewaySuspendStatus("gateway.suspend.status"),
GatewaySuspendResume("gateway.suspend.resume"),
ChatToolTitles("chat.toolTitles"),
SessionsDiff("sessions.diff"),
OpenclawSetupVerify("openclaw.setup.verify"),
EnvironmentsCreate("environments.create"),
EnvironmentsDestroy("environments.destroy"),
SessionsCatalogList("sessions.catalog.list"),
SessionsCatalogRead("sessions.catalog.read"),
TerminalUpload("terminal.upload"),
SessionsCatalogContinue("sessions.catalog.continue"),
SessionsCatalogArchive("sessions.catalog.archive"),
ApprovalGet("approval.get"),
ApprovalResolve("approval.resolve"),
SessionsSearch("sessions.search"),
SessionsDispatch("sessions.dispatch"),
SessionsReclaim("sessions.reclaim"),
ModelsProbe("models.probe"),
MigrationsMemoryPlan("migrations.memory.plan"),
MigrationsMemoryApply("migrations.memory.apply"),
UiCommand("ui.command"),
ApprovalHistory("approval.history"),
PluginSurfaceRefresh("plugin.surface.refresh"),
ConversationsList("conversations.list"),
SessionDiscussionInfo("session.discussion.info"),
SessionDiscussionOpen("session.discussion.open"),
BoardPromptAuthorize("board.prompt.authorize"),
BoardDataRead("board.data.read"),
BoardAction("board.action"),
SessionsObserverVisibility("sessions.observer.visibility"),
SessionVisibilitySet("session.visibility.set"),
SessionMembersList("session.members.list"),
SessionMembersAdd("session.members.add"),
SessionMembersRemove("session.members.remove"),
SessionSuggestionsAdd("session.suggestions.add"),
SessionSuggestionsList("session.suggestions.list"),
SessionSuggestionsResolve("session.suggestions.resolve"),
SessionTyping("session.typing"),
SessionsCompanionAsk("sessions.companion.ask"),
SessionsCompanionState("sessions.companion.state"),
SessionsCompanionReset("sessions.companion.reset"),
MemorySearch("memory.search"),
SkillsProposalsEventsList("skills.proposals.events.list"),
SkillsProposalsEvaluate("skills.proposals.evaluate"),
}
enum class GatewayEvent(
val rawValue: String,
) {
ConnectChallenge("connect.challenge"),
Agent("agent"),
Chat("chat"),
UiCommand("ui.command"),
SessionApproval("session.approval"),
SessionMessage("session.message"),
SessionObserver("session.observer"),
SessionOperation("session.operation"),
SessionSharing("session.sharing"),
SessionSuggestion("session.suggestion"),
SessionTyping("session.typing"),
SessionTool("session.tool"),
SessionsChanged("sessions.changed"),
ControlUiSessionPullRequestsChanged("controlUi.sessionPullRequests.changed"),
Presence("presence"),
Tick("tick"),
TalkMode("talk.mode"),
TalkEvent("talk.event"),
Shutdown("shutdown"),
Health("health"),
Heartbeat("heartbeat"),
Cron("cron"),
Task("task"),
TaskSuggestion("task.suggestion"),
NodePairRequested("node.pair.requested"),
NodePairResolved("node.pair.resolved"),
NodePresence("node.presence"),
NodeInvokeCancel("node.invoke.cancel"),
NodeInvokeInput("node.invoke.input"),
NodeInvokeRequest("node.invoke.request"),
DevicePairRequested("device.pair.requested"),
DevicePairResolved("device.pair.resolved"),
SkillsChanged("skills.changed"),
VoicewakeChanged("voicewake.changed"),
VoicewakeRoutingChanged("voicewake.routing.changed"),
ExecApprovalRequested("exec.approval.requested"),
ExecApprovalResolved("exec.approval.resolved"),
QuestionRequested("question.requested"),
QuestionResolved("question.resolved"),
PluginApprovalRequested("plugin.approval.requested"),
PluginApprovalResolved("plugin.approval.resolved"),
OpenclawApprovalRequested("openclaw.approval.requested"),
OpenclawApprovalResolved("openclaw.approval.resolved"),
TerminalData("terminal.data"),
TerminalExit("terminal.exit"),
UpdateAvailable("update.available"),
}

View file

@ -0,0 +1,245 @@
package ai.openclaw.app.gateway
import ai.openclaw.app.SecurePrefs
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
enum class GatewayRegistryEntryKind {
@SerialName("manual")
MANUAL,
@SerialName("discovered")
DISCOVERED,
}
@Serializable
data class GatewayRegistryEntry(
val stableId: String,
val kind: GatewayRegistryEntryKind,
val name: String,
val host: String? = null,
val port: Int? = null,
val tls: Boolean = true,
val lastConnectedAtMs: Long = 0L,
)
@Serializable
internal data class PersistedGatewayRegistry(
val version: Int = 1,
val activeStableId: String? = null,
val connectedStableIds: List<String>? = null,
val entries: List<GatewayRegistryEntry> = emptyList(),
)
@Serializable
private data class PersistedGatewayRegistryVersion(
val version: Int,
)
class GatewayRegistryStore(
private val prefs: SecurePrefs,
private val onActiveChanged: ((String?) -> Unit)? = null,
) {
companion object {
internal const val STORAGE_KEY = "gateway.registry"
}
private val json =
Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val mutationLock = Any()
private val initialRaw = prefs.getString(STORAGE_KEY)
private val initialDecode = decode(initialRaw)
private val initial = initialDecode.registry
private val mutationsAllowed = initialRaw == null || initialDecode.canRewrite
private val _entries = MutableStateFlow(initial.entries.sortedForStorage())
val entries: StateFlow<List<GatewayRegistryEntry>> = _entries.asStateFlow()
private val _activeStableId = MutableStateFlow(initial.activeStableId)
val activeStableId: StateFlow<String?> = _activeStableId.asStateFlow()
private val _connectedStableIds = MutableStateFlow(initial.connectedStableIds.orEmpty())
val connectedStableIds: StateFlow<List<String>> = _connectedStableIds.asStateFlow()
init {
if (initialDecode.canRewrite && initialRaw != encodedRegistry()) persist()
}
fun upsert(entry: GatewayRegistryEntry): Unit =
synchronized(mutationLock) {
if (!mutationsAllowed) return@synchronized
val stableId = entry.stableId.trim()
require(stableId.isNotEmpty()) { "Gateway stable id cannot be empty" }
val existing = _entries.value.firstOrNull { it.stableId == stableId }
val normalized =
entry.copy(
stableId = stableId,
name = entry.name.trim().ifEmpty { stableId },
host = entry.host?.trim()?.takeIf { it.isNotEmpty() },
lastConnectedAtMs =
if (entry.lastConnectedAtMs == 0L) {
existing?.lastConnectedAtMs ?: 0L
} else {
entry.lastConnectedAtMs
},
)
_entries.value = (_entries.value.filterNot { it.stableId == stableId } + normalized).sortedForStorage()
persist()
}
fun setActive(stableId: String?): Unit =
synchronized(mutationLock) {
if (!mutationsAllowed) return@synchronized
val normalized = stableId?.trim()?.takeIf { it.isNotEmpty() }
require(normalized == null || _entries.value.any { it.stableId == normalized }) {
"Active gateway must exist in the registry"
}
_activeStableId.value = normalized
if (normalized != null && normalized !in _connectedStableIds.value) {
_connectedStableIds.value = _connectedStableIds.value + normalized
}
persist()
onActiveChanged?.invoke(normalized)
}
fun setConnectionEnabled(
stableId: String,
enabled: Boolean,
): Unit =
synchronized(mutationLock) {
if (!mutationsAllowed) return@synchronized
val normalized = stableId.trim()
require(_entries.value.any { it.stableId == normalized }) {
"Connected gateway must exist in the registry"
}
_connectedStableIds.value =
if (enabled) {
(_connectedStableIds.value + normalized).distinct()
} else {
_connectedStableIds.value.filterNot { it == normalized }
}
persist()
}
fun connectedEntries(): List<GatewayRegistryEntry> =
synchronized(mutationLock) {
_connectedStableIds.value.mapNotNull { connectedId ->
_entries.value.firstOrNull { it.stableId == connectedId }
}
}
fun markConnected(
stableId: String,
atMs: Long,
): Unit =
synchronized(mutationLock) {
if (!mutationsAllowed) return@synchronized
val existing = _entries.value.firstOrNull { it.stableId == stableId } ?: return
upsert(existing.copy(lastConnectedAtMs = atMs))
}
fun remove(stableId: String): Boolean =
synchronized(mutationLock) {
if (!mutationsAllowed) return@synchronized false
val normalized = stableId.trim()
val nextEntries = _entries.value.filterNot { it.stableId == normalized }
val previousActiveStableId = _activeStableId.value
val nextActiveStableId = previousActiveStableId?.takeUnless { it == normalized }
val nextConnectedStableIds = _connectedStableIds.value.filterNot { it == normalized }
if (!persistSynchronously(nextEntries, nextActiveStableId, nextConnectedStableIds)) return@synchronized false
// Publish only after the durable commit. Notification is post-commit and cannot turn a
// successful removal into a failure that would cancel the database recovery marker.
_entries.value = nextEntries
_activeStableId.value = nextActiveStableId
_connectedStableIds.value = nextConnectedStableIds
if (previousActiveStableId != nextActiveStableId) {
runCatching { onActiveChanged?.invoke(nextActiveStableId) }
.onFailure { Log.e("GatewayRegistry", "Active-gateway observer failed after durable removal", it) }
}
true
}
fun activeEntry(): GatewayRegistryEntry? =
synchronized(mutationLock) {
val activeId = _activeStableId.value ?: return@synchronized null
_entries.value.firstOrNull { it.stableId == activeId }
}
internal fun storedActiveStableId(): String? = decode(prefs.getString(STORAGE_KEY)).registry.activeStableId
private fun persist() {
if (!mutationsAllowed) return
prefs.putString(STORAGE_KEY, encodedRegistry())
}
private fun persistSynchronously(
entries: List<GatewayRegistryEntry>,
activeStableId: String?,
connectedStableIds: List<String>,
): Boolean =
mutationsAllowed &&
prefs.putStringSynchronously(
STORAGE_KEY,
encodedRegistry(entries, activeStableId, connectedStableIds),
)
private fun encodedRegistry(
entries: List<GatewayRegistryEntry> = _entries.value,
activeStableId: String? = _activeStableId.value,
connectedStableIds: List<String> = _connectedStableIds.value,
): String =
json.encodeToString(
PersistedGatewayRegistry(
activeStableId = activeStableId,
connectedStableIds =
connectedStableIds
.distinct()
.filter { connectedId -> entries.any { it.stableId == connectedId } },
entries = entries.sortedForStorage(),
),
)
private data class DecodedRegistry(
val registry: PersistedGatewayRegistry,
val canRewrite: Boolean,
)
private fun decode(rawValue: String?): DecodedRegistry {
val raw = rawValue ?: return DecodedRegistry(PersistedGatewayRegistry(), canRewrite = false)
val version =
runCatching { json.decodeFromString<PersistedGatewayRegistryVersion>(raw) }
.getOrNull()
?.version
?.takeIf { it in 1..2 }
?: return DecodedRegistry(PersistedGatewayRegistry(), canRewrite = false)
val decoded =
runCatching { json.decodeFromString<PersistedGatewayRegistry>(raw) }.getOrNull()
?: return DecodedRegistry(PersistedGatewayRegistry(), canRewrite = false)
val entries = decoded.entries.sortedForStorage()
val active = decoded.activeStableId?.takeIf { activeId -> entries.any { it.stableId == activeId } }
val connected =
(decoded.connectedStableIds ?: if (version == 1) listOfNotNull(active) else emptyList())
.distinct()
.filter { connectedId -> entries.any { it.stableId == connectedId } }
return DecodedRegistry(
registry =
PersistedGatewayRegistry(
version = 1,
activeStableId = active,
connectedStableIds = connected,
entries = entries,
),
canRewrite = true,
)
}
}
internal fun List<GatewayRegistryEntry>.sortedForStorage(): List<GatewayRegistryEntry> = sortedWith(compareBy<GatewayRegistryEntry>({ it.name.lowercase() }, { it.stableId }))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,103 @@
package ai.openclaw.app.gateway
import ai.openclaw.app.GatewayCredentials
import ai.openclaw.app.SecurePrefs
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
internal class GatewayStoreMigration(
private val prefs: SecurePrefs,
) {
private val json = Json { encodeDefaults = true }
fun run() {
if (prefs.containsSecureKey(GatewayRegistryStore.STORAGE_KEY)) return
val activeEntry = legacyActiveEntry()
migrateCredentials(activeEntry?.stableId)
migrateDeviceTokens(activeEntry?.stableId)
migrateNotificationSessionKey(activeEntry?.stableId)
prefs.putString(
GatewayRegistryStore.STORAGE_KEY,
json.encodeToString(
PersistedGatewayRegistry(
activeStableId = activeEntry?.stableId,
connectedStableIds = listOfNotNull(activeEntry?.stableId),
entries = listOfNotNull(activeEntry),
),
),
)
}
private fun legacyActiveEntry(): GatewayRegistryEntry? {
if (prefs.getPlainBoolean("gateway.manual.enabled", false)) {
val host = prefs.getPlainString("gateway.manual.host").orEmpty().trim()
val port = prefs.getPlainInt("gateway.manual.port", 18789)
if (host.isNotEmpty() && port in 1..65535) {
val endpoint = GatewayEndpoint.manual(host, port)
return GatewayRegistryEntry(
stableId = endpoint.stableId,
kind = GatewayRegistryEntryKind.MANUAL,
name = "$host:$port",
host = host,
port = port,
tls = prefs.getPlainBoolean("gateway.manual.tls", true),
)
}
}
val stableId = prefs.getPlainString("gateway.lastDiscoveredStableID").orEmpty().trim()
return stableId
.takeIf { it.isNotEmpty() }
?.let {
GatewayRegistryEntry(
stableId = it,
kind = GatewayRegistryEntryKind.DISCOVERED,
name = it,
)
}
}
private fun migrateCredentials(activeStableId: String?) {
val instanceId = prefs.instanceId.value
val legacyKeys =
listOf(
"gateway.manual.token",
"gateway.token.$instanceId",
"gateway.bootstrapToken.$instanceId",
"gateway.password.$instanceId",
)
if (activeStableId != null) {
val legacyToken =
sequenceOf(prefs.getString(legacyKeys[0]), prefs.getString(legacyKeys[1]))
.mapNotNull { it?.trim()?.takeIf(String::isNotEmpty) }
.firstOrNull()
val credentials =
GatewayCredentials(
token = legacyToken,
bootstrapToken = prefs.getString(legacyKeys[2]),
password = prefs.getString(legacyKeys[3]),
).normalized()
if (credentials != GatewayCredentials()) {
prefs.saveGatewayCredentials(activeStableId, credentials)
}
}
prefs.removeSecureKeys(legacyKeys)
}
private fun migrateDeviceTokens(activeStableId: String?) {
val legacyPrefixes = listOf("gateway.deviceToken.", "gateway.deviceTokenMeta.")
val keys = prefs.secureKeys()
for (key in keys) {
val prefix = legacyPrefixes.firstOrNull(key::startsWith) ?: continue
val suffix = key.removePrefix(prefix)
if (suffix.split('.').size != 2) continue
prefs.moveSecureString(key, activeStableId?.let { "$prefix$it.$suffix" })
}
}
private fun migrateNotificationSessionKey(activeStableId: String?) {
val legacyKey = "notifications.forwarding.sessionKey"
prefs.movePlainString(legacyKey, activeStableId?.let { "$legacyKey.$it" })
}
}

View file

@ -0,0 +1,541 @@
package ai.openclaw.app.gateway
import android.annotation.SuppressLint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.EOFException
import java.net.ConnectException
import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.Locale
import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SNIHostName
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLException
import javax.net.ssl.SSLParameters
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager
/** TLS pinning inputs for a discovered or manually configured gateway endpoint. */
data class GatewayTlsParams(
val required: Boolean,
val expectedFingerprint: String?,
val allowTOFU: Boolean,
val stableId: String,
)
/** SSL primitives and accepted route trust installed into OkHttp. */
class GatewayTlsConfig internal constructor(
val sslSocketFactory: SSLSocketFactory,
val trustManager: X509TrustManager,
val hostnameVerifier: HostnameVerifier,
private val effectiveFingerprint: AtomicReference<String?>,
) {
val effectiveFingerprintSha256: String?
get() = effectiveFingerprint.get()
}
/** Distinguishes non-TLS endpoints from unreachable endpoints during probing. */
enum class GatewayTlsProbeFailure {
TLS_UNAVAILABLE,
TLS_HANDSHAKE_TIMEOUT,
ENDPOINT_UNREACHABLE,
}
/** Result of probing a gateway TLS endpoint for first-use fingerprint capture. */
data class GatewayTlsProbeResult(
val fingerprintSha256: String? = null,
val failure: GatewayTlsProbeFailure? = null,
val systemTrusted: Boolean = false,
)
/** Final trust policy selected before opening gateway sessions. */
sealed interface GatewayTlsTrustDecision {
data object SystemTrusted : GatewayTlsTrustDecision
data class PinnedTrust(
val fingerprintSha256: String,
) : GatewayTlsTrustDecision
data class PromptRequired(
val fingerprintSha256: String?,
val previousFingerprintSha256: String?,
val probeFailure: GatewayTlsProbeFailure? = null,
val systemTrustAvailable: Boolean = false,
) : GatewayTlsTrustDecision
data class Failed(
val reason: GatewayTlsProbeFailure,
) : GatewayTlsTrustDecision
}
internal const val GATEWAY_TLS_PROBE_CONNECT_TIMEOUT_MS = 3_000
internal const val GATEWAY_TLS_PROBE_HANDSHAKE_TIMEOUT_MS = 10_000
private const val GATEWAY_TLS_FALLBACK_TIMEOUT_FLOOR_MS = 250
internal data class GatewayTlsProbeTimeouts(
val connectTimeoutMs: Int,
val handshakeTimeoutMs: Int,
)
internal fun splitGatewayTlsFallbackProbeTimeouts(
connectTimeoutMs: Int,
handshakeTimeoutMs: Int,
elapsedMs: Long,
): GatewayTlsProbeTimeouts? {
val totalBudgetMs = connectTimeoutMs.toLong() + handshakeTimeoutMs.toLong()
val remainingBudgetMs = (totalBudgetMs - elapsedMs).coerceIn(0, totalBudgetMs)
val minimumBudgetMs = GATEWAY_TLS_FALLBACK_TIMEOUT_FLOOR_MS.toLong() * 2
if (remainingBudgetMs < minimumBudgetMs) return null
val remainingMs = remainingBudgetMs.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
val proportionalConnectMs = ((remainingMs.toLong() * connectTimeoutMs) / totalBudgetMs).toInt()
val fallbackConnectMs =
proportionalConnectMs.coerceIn(
GATEWAY_TLS_FALLBACK_TIMEOUT_FLOOR_MS,
remainingMs - GATEWAY_TLS_FALLBACK_TIMEOUT_FLOOR_MS,
)
return GatewayTlsProbeTimeouts(
connectTimeoutMs = fallbackConnectMs,
handshakeTimeoutMs = remainingMs - fallbackConnectMs,
)
}
/** Public-DNS candidates may use Android's CA store and HTTPS hostname validation. */
internal fun isGatewayTlsSystemTrustCandidate(rawHost: String): Boolean = normalizedGatewayTlsDnsHost(rawHost) != null
/** Resolves probe evidence and any stored pin into one exhaustive trust decision. */
internal fun decideGatewayTlsTrust(
storedFingerprint: String?,
systemTrustCandidate: Boolean,
probeResult: GatewayTlsProbeResult,
): GatewayTlsTrustDecision {
val stored =
storedFingerprint
?.takeIf { it.isNotBlank() }
?.let(::normalizeGatewayTlsFingerprintInput)
?: if (storedFingerprint.isNullOrBlank()) {
null
} else {
return GatewayTlsTrustDecision.Failed(probeResult.failure ?: GatewayTlsProbeFailure.TLS_UNAVAILABLE)
}
val observed =
probeResult.fingerprintSha256?.let { raw ->
normalizeGatewayTlsFingerprintInput(raw)
?: return GatewayTlsTrustDecision.Failed(probeResult.failure ?: GatewayTlsProbeFailure.TLS_UNAVAILABLE)
}
if (stored == null && systemTrustCandidate && probeResult.systemTrusted) {
return GatewayTlsTrustDecision.SystemTrusted
}
if (observed != null) {
return if (stored == observed) {
GatewayTlsTrustDecision.PinnedTrust(observed)
} else {
GatewayTlsTrustDecision.PromptRequired(
fingerprintSha256 = observed,
previousFingerprintSha256 = stored,
systemTrustAvailable = stored != null && systemTrustCandidate && probeResult.systemTrusted,
)
}
}
if (stored != null) return GatewayTlsTrustDecision.PinnedTrust(stored)
return GatewayTlsTrustDecision.PromptRequired(
fingerprintSha256 = null,
previousFingerprintSha256 = null,
probeFailure = probeResult.failure,
)
}
/** Builds a TLS config that supports pinned fingerprints and trust-on-first-use. */
fun buildGatewayTlsConfig(
params: GatewayTlsParams?,
onStore: ((String) -> Unit)? = null,
): GatewayTlsConfig? {
if (params == null) return null
return buildGatewayTlsConfig(
params = params,
defaultTrust = defaultTrustManager(),
onStore = onStore,
)
}
internal fun buildGatewayTlsConfig(
params: GatewayTlsParams,
defaultTrust: X509TrustManager,
onStore: ((String) -> Unit)? = null,
): GatewayTlsConfig {
val expectedInput = params.expectedFingerprint?.takeIf { it.isNotBlank() }
val expected =
expectedInput
?.let(::normalizeGatewayTlsFingerprint)
?.takeIf { it.isNotBlank() }
val effectiveFingerprint = AtomicReference(expected)
val usesPlatformTrust = expectedInput == null && !params.allowTOFU
fun recordAcceptedFingerprint(chain: Array<X509Certificate>) {
val certificate = chain.firstOrNull() ?: return
effectiveFingerprint.set(sha256Hex(certificate.encoded))
}
@SuppressLint("CustomX509TrustManager")
val trustManager =
object : X509ExtendedTrustManager() {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
defaultTrust.checkClientTrusted(chain, authType)
}
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) {
if (defaultTrust is X509ExtendedTrustManager) {
defaultTrust.checkClientTrusted(chain, authType, socket)
} else {
checkClientTrusted(chain, authType)
}
}
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) {
if (defaultTrust is X509ExtendedTrustManager) {
defaultTrust.checkClientTrusted(chain, authType, engine)
} else {
checkClientTrusted(chain, authType)
}
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
val fingerprint = sha256Hex(chain[0].encoded)
if (expectedInput != null) {
if (expected == null) {
throw CertificateException("invalid gateway TLS fingerprint")
}
if (fingerprint != expected) {
throw CertificateException("gateway TLS fingerprint mismatch")
}
effectiveFingerprint.set(fingerprint)
return
}
if (params.allowTOFU) {
// Store only after the TLS stack presents a concrete server cert; the
// caller persists the fingerprint against the endpoint's stable id,
// and later connects must come back through the pinned branch above.
onStore?.invoke(fingerprint)
effectiveFingerprint.set(fingerprint)
return
}
defaultTrust.checkServerTrusted(chain, authType)
effectiveFingerprint.set(fingerprint)
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) {
if (usesPlatformTrust && defaultTrust is X509ExtendedTrustManager) {
// Preserve the connected hostname for Android's domain-aware platform trust manager.
defaultTrust.checkServerTrusted(chain, authType, socket)
recordAcceptedFingerprint(chain)
} else {
checkServerTrusted(chain, authType)
}
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) {
if (usesPlatformTrust && defaultTrust is X509ExtendedTrustManager) {
defaultTrust.checkServerTrusted(chain, authType, engine)
recordAcceptedFingerprint(chain)
} else {
checkServerTrusted(chain, authType)
}
}
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
}
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(trustManager), SecureRandom())
val verifier =
if (expectedInput != null || params.allowTOFU) {
// When pinning, we intentionally ignore hostname mismatch (service discovery often yields IPs).
HostnameVerifier { _, _ -> true }
} else {
HttpsURLConnection.getDefaultHostnameVerifier()
}
return GatewayTlsConfig(
sslSocketFactory = context.socketFactory,
trustManager = trustManager,
hostnameVerifier = verifier,
effectiveFingerprint = effectiveFingerprint,
)
}
/** Uses platform trust for public DNS, otherwise captures the presented cert hash. */
suspend fun probeGatewayTlsFingerprint(
host: String,
port: Int,
): GatewayTlsProbeResult =
probeGatewayTlsFingerprint(
host = host,
port = port,
connectTimeoutMs = GATEWAY_TLS_PROBE_CONNECT_TIMEOUT_MS,
handshakeTimeoutMs = GATEWAY_TLS_PROBE_HANDSHAKE_TIMEOUT_MS,
)
internal suspend fun probeGatewayTlsFingerprint(
host: String,
port: Int,
connectTimeoutMs: Int,
handshakeTimeoutMs: Int,
): GatewayTlsProbeResult {
val trimmedHost = host.trim()
if (trimmedHost.isEmpty()) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
if (port !in 1..65535) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
if (connectTimeoutMs <= 0 || handshakeTimeoutMs <= 0) return GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE)
return withContext(Dispatchers.IO) {
val probeDeadlineNanos =
System.nanoTime() +
(connectTimeoutMs.toLong() + handshakeTimeoutMs.toLong()) * 1_000_000L
var fallbackTimeouts = GatewayTlsProbeTimeouts(connectTimeoutMs, handshakeTimeoutMs)
if (isGatewayTlsSystemTrustCandidate(trimmedHost)) {
val fingerprintSha256 =
probeGatewayTlsSystemTrust(
host = trimmedHost,
port = port,
connectTimeoutMs = connectTimeoutMs,
handshakeTimeoutMs = handshakeTimeoutMs,
)
if (fingerprintSha256 != null) {
return@withContext GatewayTlsProbeResult(fingerprintSha256 = fingerprintSha256, systemTrusted = true)
}
// One probe budget total, not one budget for each trust attempt.
val totalBudgetMs = connectTimeoutMs.toLong() + handshakeTimeoutMs.toLong()
val remainingBudgetMs = ((probeDeadlineNanos - System.nanoTime()) / 1_000_000L).coerceAtLeast(0)
fallbackTimeouts =
splitGatewayTlsFallbackProbeTimeouts(
connectTimeoutMs = connectTimeoutMs,
handshakeTimeoutMs = handshakeTimeoutMs,
elapsedMs = totalBudgetMs - remainingBudgetMs,
) ?: return@withContext GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT)
}
val fingerprintRef = AtomicReference<String?>(null)
val probeTrustManager =
@SuppressLint("CustomX509TrustManager")
object : X509ExtendedTrustManager() {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
): Unit = throw CertificateException("gateway TLS probe does not accept client certificates")
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) = checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) = checkClientTrusted(chain, authType)
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
fingerprintRef.set(sha256Hex(chain[0].encoded))
// Abort validation after capture; the probe is not deciding trust.
throw CertificateException("gateway TLS probe captured fingerprint")
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket,
) = checkServerTrusted(chain, authType)
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine,
) = checkServerTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(probeTrustManager), SecureRandom())
val socket = (context.socketFactory.createSocket() as SSLSocket)
var connected = false
try {
// TCP reachability and TLS handshake progress fail differently on mobile
// tailnets; keep the budgets separate so a reachable-but-slow secure
// endpoint does not collapse into generic gateway unreachable guidance.
socket.soTimeout = fallbackTimeouts.handshakeTimeoutMs
socket.connect(InetSocketAddress(trimmedHost, port), fallbackTimeouts.connectTimeoutMs)
connected = true
// Best-effort SNI for hostnames (avoid crashing on IP literals).
try {
if (trimmedHost.any { it.isLetter() }) {
val params = SSLParameters()
params.serverNames = listOf(SNIHostName(trimmedHost))
socket.sslParameters = params
}
} catch (_: Throwable) {
// SNI is only a probe hint. IP literals and odd Bonjour names should
// still be probed instead of failing before the TLS handshake.
}
socket.startHandshake()
val cert =
socket.session.peerCertificates.firstOrNull() as? X509Certificate
?: return@withContext GatewayTlsProbeResult(failure = GatewayTlsProbeFailure.TLS_UNAVAILABLE)
GatewayTlsProbeResult(fingerprintSha256 = sha256Hex(cert.encoded))
} catch (err: Throwable) {
fingerprintRef.get()?.let { return@withContext GatewayTlsProbeResult(fingerprintSha256 = it) }
val failure =
when (err) {
is SSLException,
is EOFException,
-> GatewayTlsProbeFailure.TLS_UNAVAILABLE
is SocketTimeoutException ->
if (connected) {
GatewayTlsProbeFailure.TLS_HANDSHAKE_TIMEOUT
} else {
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
}
is ConnectException,
is UnknownHostException,
-> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
is SocketException ->
if (connected) {
GatewayTlsProbeFailure.TLS_UNAVAILABLE
} else {
GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
}
else -> GatewayTlsProbeFailure.ENDPOINT_UNREACHABLE
}
GatewayTlsProbeResult(failure = failure)
} finally {
try {
socket.close()
} catch (_: Throwable) {
// ignore
}
}
}
}
private fun probeGatewayTlsSystemTrust(
host: String,
port: Int,
connectTimeoutMs: Int,
handshakeTimeoutMs: Int,
): String? {
val dnsHost = normalizedGatewayTlsDnsHost(host) ?: return null
val context = SSLContext.getInstance("TLS")
context.init(null, arrayOf(defaultTrustManager()), SecureRandom())
val socket = context.socketFactory.createSocket() as SSLSocket
return try {
socket.soTimeout = handshakeTimeoutMs
val parameters = socket.sslParameters
parameters.endpointIdentificationAlgorithm = "HTTPS"
parameters.serverNames = listOf(SNIHostName(dnsHost))
socket.sslParameters = parameters
socket.connect(InetSocketAddress(dnsHost, port), connectTimeoutMs)
socket.startHandshake()
val certificate = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return null
sha256Hex(certificate.encoded)
} catch (_: Exception) {
null
} finally {
runCatching { socket.close() }
}
}
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as java.security.KeyStore?)
val trust =
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
return trust ?: throw IllegalStateException("No default X509TrustManager found")
}
private fun sha256Hex(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256").digest(data)
val out = StringBuilder(digest.size * 2)
for (byte in digest) {
out.append(String.format(Locale.US, "%02x", byte))
}
return out.toString()
}
/** Normalizes accepted fingerprint text to lowercase bare SHA-256 hex. */
fun normalizeGatewayTlsFingerprintInput(raw: String): String? {
val stripped =
raw
.trim()
.replace(Regex("^sha-?256\\s*:\\s*", RegexOption.IGNORE_CASE), "")
val compact =
stripped
.filterNot { it == ':' || it.isWhitespace() }
.lowercase(Locale.US)
return compact.takeIf { value ->
value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
}
}
/** Normalizes internal fingerprint text; invalid values become empty. */
fun normalizeGatewayTlsFingerprint(raw: String): String = normalizeGatewayTlsFingerprintInput(raw).orEmpty()
private fun normalizedGatewayTlsDnsHost(rawHost: String): String? {
val trimmed = rawHost.trim()
if (trimmed.startsWith('[') || trimmed.endsWith(']')) return null
val host = trimmed.trimEnd('.').lowercase(Locale.US)
if (host.isEmpty() || host.length > 253 || host.endsWith(".local")) return null
if (host.contains(':')) return null
val labels = host.split('.')
if (labels.size < 2 || labels.any { !isGatewayTlsDnsLabel(it) }) return null
if (labels.all { label -> label.all { it in '0'..'9' } }) return null
return host
}
private fun isGatewayTlsDnsLabel(label: String): Boolean {
if (label.isEmpty() || label.length > 63 || label.first() == '-' || label.last() == '-') return false
return label.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
}

View file

@ -0,0 +1,47 @@
package ai.openclaw.app.gateway
private val invokeErrorCodePattern = Regex("^[A-Z][A-Z0-9_]*$")
data class ParsedInvokeError(
val code: String,
val message: String,
val hadExplicitCode: Boolean,
) {
/** Gateway-facing form expected by UI and retry copy. */
val prefixedMessage: String
get() = "$code: $message"
}
/**
* Parses gateway invoke errors encoded as CODE: message while preserving legacy
* plain-text errors as UNAVAILABLE.
*/
fun parseInvokeErrorMessage(raw: String): ParsedInvokeError {
val trimmed = raw.trim()
if (trimmed.isEmpty()) {
return ParsedInvokeError(code = "UNAVAILABLE", message = "error", hadExplicitCode = false)
}
val parts = trimmed.split(":", limit = 2)
if (parts.size == 2) {
val code = parts[0].trim()
val rest = parts[1].trim()
if (invokeErrorCodePattern.matches(code)) {
return ParsedInvokeError(
code = code,
message = rest.ifEmpty { trimmed },
hadExplicitCode = true,
)
}
}
return ParsedInvokeError(code = "UNAVAILABLE", message = trimmed, hadExplicitCode = false)
}
/** Extracts an invoke error from a throwable without exposing blank messages. */
fun parseInvokeErrorFromThrowable(
err: Throwable,
fallbackMessage: String = "error",
): ParsedInvokeError {
val raw = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: fallbackMessage
return parseInvokeErrorMessage(raw)
}

View file

@ -0,0 +1,110 @@
package ai.openclaw.app.gateway
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
/**
* Listens for Android transport restores and signals [onValidatedNetworkAvailable] when the device
* regains a validated internet connection. Used to trigger an immediate gateway
* reconnect instead of waiting out the time-based backoff slot in [GatewaySession].
*
* This monitor only reports "transport came back". Each gateway session still owns
* desired-connection and auth-pause decisions. The application context keeps this
* process-lifetime callback aligned with the process-lifetime NodeRuntime.
*/
internal class NetworkMonitor(
context: Context,
private val onValidatedNetworkAvailable: () -> Unit,
) {
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val logTag = "OpenClaw/NetworkMonitor"
// Tracks the last emitted transport state so capability churn (e.g. signal strength
// changes) does not re-fire the reconnect path. Only a lost->validated transition
// should signal.
private val validatedNetworks = ValidatedNetworkState<Network>()
private val callback =
object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
if (validatedNetworks.update(network, isTransportValidated(capabilities))) {
notifyValidatedNetworkAvailable()
}
}
override fun onLost(network: Network) {
validatedNetworks.update(network, isValidated = false)
}
}
init {
// Register first so a network lost during initial seeding still has an owning callback.
// The seed suppresses the initial snapshot when it wins; session guards handle the other race.
start()
seedActiveValidatedNetwork()
}
private fun start() {
val cm = connectivity ?: return
try {
// Equivalent to the default request used by GatewayDiscovery: match any network.
cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback)
} catch (err: Throwable) {
Log.w(logTag, "registerNetworkCallback failed: ${err.message ?: err::class.java.simpleName}")
}
}
private fun notifyValidatedNetworkAvailable() {
try {
onValidatedNetworkAvailable()
} catch (err: Throwable) {
Log.w(logTag, "network restore callback threw: ${err.message ?: err::class.java.simpleName}")
}
}
private fun seedActiveValidatedNetwork() {
try {
val cm = connectivity ?: return
val active = cm.activeNetwork ?: return
val caps = cm.getNetworkCapabilities(active) ?: return
if (isTransportValidated(caps)) {
validatedNetworks.update(active, isValidated = true)
}
} catch (_: Throwable) {
// Callback delivery remains the source of truth when the initial snapshot races.
}
}
}
internal class ValidatedNetworkState<T>(
initialValidatedNetworks: Set<T> = emptySet(),
) {
private val validatedNetworks = initialValidatedNetworks.toMutableSet()
@Synchronized
fun update(
network: T,
isValidated: Boolean,
): Boolean {
val wasOnline = validatedNetworks.isNotEmpty()
if (isValidated) {
validatedNetworks.add(network)
} else {
validatedNetworks.remove(network)
}
return !wasOnline && validatedNetworks.isNotEmpty()
}
}
/**
* True when the network reports a validated internet capability. Exposed internal so the
* predicate can be unit-tested without a Robolectric ConnectivityManager shadow.
*/
internal fun isTransportValidated(capabilities: NetworkCapabilities): Boolean = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,336 @@
package ai.openclaw.app.i18n
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.util.Xml
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.core.app.LocaleManagerCompat
import androidx.core.os.ConfigurationCompat
import androidx.core.os.LocaleListCompat
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import org.xmlpull.v1.XmlPullParser
sealed interface NativeText {
data class Resource(
val source: String,
val formatArgs: List<Any>,
) : NativeText
data class Verbatim(
val value: String,
) : NativeText
data class Composite(
val parts: List<NativeText>,
val separator: String,
) : NativeText
}
private val nativeLocaleRevision = MutableStateFlow(0L)
internal val nativeLocaleChanges: StateFlow<Long> = nativeLocaleRevision.asStateFlow()
internal fun nativeText(
source: String,
vararg formatArgs: Any,
): NativeText.Resource = NativeText.Resource(source = source, formatArgs = formatArgs.toList())
internal fun verbatimText(value: String): NativeText = NativeText.Verbatim(value)
internal fun joinedNativeText(
separator: String,
parts: List<NativeText>,
): NativeText = NativeText.Composite(parts = parts, separator = separator)
internal fun NativeText.resolveNativeText(): String =
when (this) {
is NativeText.Resource -> nativeString(source, *formatArgs.map(::resolveNativeFormatArg).toTypedArray())
is NativeText.Verbatim -> value
is NativeText.Composite -> parts.joinToString(separator, transform = NativeText::resolveNativeText)
}
@Composable
internal fun NativeText.resolveNativeTextResource(): String =
when (this) {
is NativeText.Resource -> {
val resolvedArgs = mutableListOf<Any>()
for (formatArg in formatArgs) {
resolvedArgs += if (formatArg is NativeText) formatArg.resolveNativeTextResource() else formatArg
}
nativeStringResource(source, *resolvedArgs.toTypedArray())
}
is NativeText.Verbatim -> value
is NativeText.Composite -> {
val resolvedParts = mutableListOf<String>()
for (part in parts) {
resolvedParts += part.resolveNativeTextResource()
}
resolvedParts.joinToString(separator)
}
}
private fun resolveNativeFormatArg(value: Any): Any = if (value is NativeText) value.resolveNativeText() else value
internal fun notifyNativeLocaleChanged() {
NativeStringResources.invalidateLocalizedContext()
nativeLocaleRevision.update { it + 1 }
}
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
private class LocaleResolvingStateFlow<T, R>(
private val source: StateFlow<T>,
private val transform: (T) -> R,
) : StateFlow<R> {
override val value: R
get() = transform(source.value)
override val replayCache: List<R>
get() = listOf(value)
override suspend fun collect(collector: FlowCollector<R>): Nothing {
combine(source, nativeLocaleRevision) { value, _ -> transform(value) }
.distinctUntilChanged()
.collect(collector)
error("locale-resolving state flow completed unexpectedly")
}
}
internal fun StateFlow<NativeText>.resolveNativeText(): StateFlow<String> = LocaleResolvingStateFlow(this, NativeText::resolveNativeText)
internal fun StateFlow<NativeText?>.resolveOptionalNativeText(): StateFlow<String?> = LocaleResolvingStateFlow(this) { text -> text?.resolveNativeText() }
@SuppressLint("StaticFieldLeak")
internal object NativeStringResources {
private sealed interface ApplicationLocaleMode {
val locales: LocaleListCompat
data class Pinned(
override val locales: LocaleListCompat,
) : ApplicationLocaleMode
data class System(
override val locales: LocaleListCompat,
) : ApplicationLocaleMode
}
@Volatile
private var applicationContext: Context? = null
@Volatile
private var applicationLocaleMode: ApplicationLocaleMode? = null
@Volatile
private var localizedContext: Context? = null
@Synchronized
fun install(context: Context) {
val appContext = context.applicationContext
applicationContext = appContext
val liveLocales =
if (Build.VERSION.SDK_INT >= 33) {
LocaleManagerCompat.getApplicationLocales(appContext)
} else {
AppCompatDelegate.getApplicationLocales()
}
val requestedLocales = liveLocales.takeUnless { it.isEmpty } ?: appContext.readStoredAppLocales()
applicationLocaleMode =
if (requestedLocales.isEmpty) {
ApplicationLocaleMode.System(ConfigurationCompat.getLocales(appContext.resources.configuration))
} else {
ApplicationLocaleMode.Pinned(requestedLocales)
}
localizedContext = null
}
@Synchronized
fun setApplicationLocales(locales: LocaleListCompat) {
applicationLocaleMode =
if (locales.isEmpty) {
val context = applicationContext
ApplicationLocaleMode.System(
context?.let { ConfigurationCompat.getLocales(it.resources.configuration) }
?: LocaleListCompat.getEmptyLocaleList(),
)
} else {
ApplicationLocaleMode.Pinned(locales)
}
localizedContext = null
}
@Synchronized
fun setConfigurationLocales(configuration: Configuration) {
val previousMode = applicationLocaleMode
val context = applicationContext
val liveLocales =
when {
context == null -> LocaleListCompat.getEmptyLocaleList()
Build.VERSION.SDK_INT >= 33 -> LocaleManagerCompat.getApplicationLocales(context)
else -> AppCompatDelegate.getApplicationLocales()
}
applicationLocaleMode =
when {
!liveLocales.isEmpty -> ApplicationLocaleMode.Pinned(liveLocales)
Build.VERSION.SDK_INT < 33 && previousMode is ApplicationLocaleMode.Pinned -> previousMode
else -> ApplicationLocaleMode.System(ConfigurationCompat.getLocales(configuration))
}
localizedContext = null
}
@Synchronized
fun invalidateLocalizedContext() {
localizedContext = null
}
fun resolve(
source: String,
vararg formatArgs: Any,
): String {
val context = applicationContext ?: return formatNativeSource(source, formatArgs)
val localized =
localizedContext
?: synchronized(this) {
localizedContext
?: context
.localizedContext(
applicationLocaleMode
?.locales
?: LocaleManagerCompat
.getApplicationLocales(context)
.takeUnless { it.isEmpty }
?: context.readStoredAppLocales(),
).also { localizedContext = it }
}
return localized.nativeString(source, *formatArgs)
}
}
private fun Context.localizedContext(locales: LocaleListCompat): Context =
if (locales.isEmpty) {
this
} else {
val configuration = Configuration(resources.configuration)
ConfigurationCompat.setLocales(configuration, locales)
createConfigurationContext(configuration)
}
private fun Context.readStoredAppLocales(): LocaleListCompat {
if (Build.VERSION.SDK_INT >= 33) return LocaleListCompat.getEmptyLocaleList()
// AppCompat only hydrates auto-stored locales when a delegate attaches. A cold service
// has no delegate, so mirror AndroidX's XML read until the platform owns app locales.
val languageTags =
runCatching {
openFileInput(APP_LOCALES_FILE).use { input ->
val parser = Xml.newPullParser()
parser.setInput(input, "UTF-8")
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG && parser.name == APP_LOCALES_TAG) {
return@use parser.getAttributeValue(null, APP_LOCALES_ATTRIBUTE).orEmpty()
}
}
""
}
}.getOrDefault("")
return LocaleListCompat.forLanguageTags(languageTags)
}
internal fun nativeString(
source: String,
vararg formatArgs: Any,
): String = NativeStringResources.resolve(source, *formatArgs)
@Composable
internal fun nativeStringResource(
source: String,
vararg formatArgs: Any,
): String {
val resourceId = nativeStringResourceIds[source] ?: return formatNativeSource(source, formatArgs)
return if (formatArgs.isEmpty()) stringResource(resourceId) else stringResource(resourceId, *formatArgs)
}
internal fun Context.nativeString(
source: String,
vararg formatArgs: Any,
): String {
val resourceId = nativeStringResourceIds[source] ?: return formatNativeSource(source, formatArgs)
return if (formatArgs.isEmpty()) getString(resourceId) else getString(resourceId, *formatArgs)
}
private fun formatNativeSource(
source: String,
formatArgs: Array<out Any>,
): String {
if (formatArgs.isEmpty()) return source
val rendered = StringBuilder(source.length)
var argumentIndex = 0
var cursor = 0
while (cursor < source.length) {
val start = source.indexOf('$', startIndex = cursor)
if (start < 0) {
rendered.append(source, cursor, source.length)
break
}
rendered.append(source, cursor, start)
val end = source.kotlinInterpolationEnd(start)
if (end == null) {
rendered.append('$')
cursor = start + 1
continue
}
val argument = formatArgs.getOrNull(argumentIndex++)
if (argument == null) {
rendered.append(source, start, end)
} else {
rendered.append(argument)
}
cursor = end
}
return rendered.toString()
}
private fun String.kotlinInterpolationEnd(start: Int): Int? {
val next = getOrNull(start + 1) ?: return null
if (next != '{') {
if (next != '_' && !next.isLetter()) return null
var end = start + 2
while (getOrNull(end)?.let { it == '_' || it.isLetterOrDigit() } == true) {
end += 1
}
return end
}
var depth = 1
var quote: Char? = null
var escaped = false
var end = start + 2
while (end < length) {
val character = this[end]
when {
escaped -> escaped = false
quote != null && character == '\\' -> escaped = true
character == quote -> quote = null
quote == null && (character == '"' || character == '\'') -> quote = character
quote == null && character == '{' -> depth += 1
quote == null && character == '}' -> {
depth -= 1
if (depth == 0) return end + 1
}
}
end += 1
}
return null
}
private const val APP_LOCALES_FILE =
"androidx.appcompat.app.AppCompatDelegate.application_locales_record_file"
private const val APP_LOCALES_TAG = "locales"
private const val APP_LOCALES_ATTRIBUTE = "application_locales"

View file

@ -0,0 +1,149 @@
package ai.openclaw.app.node
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Android bridge for applying gateway A2UI messages to the canvas WebView.
*/
class A2UIHandler(
private val canvas: CanvasController,
private val json: Json,
) {
fun isTrustedCanvasActionUrl(rawUrl: String?): Boolean = CanvasActionTrust.isTrustedCanvasActionUrl(rawUrl)
suspend fun ensureA2uiReady(): Boolean {
val alreadyOnA2uiHost = canvas.currentUrl()?.trim() == CanvasActionTrust.localA2uiAssetUrl
if (!canvas.showAndAwaitHost()) return false
if (!alreadyOnA2uiHost) {
canvas.showLocalA2ui()
}
if (alreadyOnA2uiHost && isA2uiReady()) {
return true
}
// The bundled A2UI host bootstraps asynchronously after navigation; poll briefly before failing the command.
repeat(50) {
if (isA2uiReady()) return true
delay(120)
}
return false
}
private suspend fun isA2uiReady(): Boolean =
try {
canvas.eval(a2uiReadyCheckJS) == "true"
} catch (_: Throwable) {
false
}
fun decodeA2uiMessages(
command: String,
paramsJson: String?,
): String {
val raw = paramsJson?.trim().orEmpty()
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
val obj =
json.parseToJsonElement(raw) as? JsonObject
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
val hasMessagesArray = obj["messages"] is JsonArray
if (command == "canvas.a2ui.pushJSONL" || (!hasMessagesArray && jsonlField.isNotBlank())) {
val jsonl = jsonlField
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
// JSONL keeps large A2UI streams model-friendly while still validating each message.
val messages =
jsonl
.lineSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.mapIndexed { idx, line ->
val el = json.parseToJsonElement(line)
val msg =
el as? JsonObject
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
validateA2uiV0_8(msg, idx + 1)
msg
}.toList()
return JsonArray(messages).toString()
}
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
val out =
arr.mapIndexed { idx, el ->
val msg =
el as? JsonObject
?: throw IllegalArgumentException("A2UI messages[$idx]: expected a JSON object")
validateA2uiV0_8(msg, idx + 1)
msg
}
return JsonArray(out).toString()
}
private fun validateA2uiV0_8(
msg: JsonObject,
lineNumber: Int,
) {
if (msg.containsKey("createSurface")) {
// Android scaffold currently implements A2UI v0.8, not the v0.9 createSurface shape.
throw IllegalArgumentException(
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
)
}
val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface")
val matched = msg.keys.filter { allowed.contains(it) }
if (matched.size != 1) {
val found = msg.keys.sorted().joinToString(", ")
throw IllegalArgumentException(
"A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found",
)
}
}
companion object {
const val a2uiReadyCheckJS: String =
"""
(() => {
try {
const host = globalThis.openclawA2UI;
return !!host && typeof host.applyMessages === 'function';
} catch (_) {
return false;
}
})()
"""
const val a2uiResetJS: String =
"""
(() => {
try {
const host = globalThis.openclawA2UI;
if (!host) return { ok: false, error: "missing openclawA2UI" };
return host.reset();
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
})()
"""
fun a2uiApplyMessagesJS(messagesJson: String): String =
"""
(() => {
try {
const host = globalThis.openclawA2UI;
if (!host) return { ok: false, error: "missing openclawA2UI" };
const messages = $messagesJson;
return host.applyMessages(messages);
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
})()
""".trimIndent()
}
}

View file

@ -0,0 +1,91 @@
package ai.openclaw.app.node
import ai.openclaw.app.hasPhotoReadPermission
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
/**
* Canonical Android authority snapshot shared by node approval and device.permissions.
*/
internal data class AndroidPermissionSnapshot(
val camera: Boolean,
val microphone: Boolean,
val location: Boolean,
val locationPrecise: Boolean,
val locationBackground: Boolean,
val smsSend: Boolean,
val smsRead: Boolean,
val notificationListener: Boolean,
val notifications: Boolean,
val photos: Boolean,
val contactsRead: Boolean,
val contactsWrite: Boolean,
val calendarRead: Boolean,
val calendarWrite: Boolean,
val callLog: Boolean,
val motion: Boolean,
) {
/**
* Keep independently grantable authority separate so any widening requires node reapproval.
*/
fun gatewayPermissions(): Map<String, Boolean> =
linkedMapOf(
"camera" to camera,
"microphone" to microphone,
"location" to location,
"locationPrecise" to locationPrecise,
"locationBackground" to locationBackground,
"smsSend" to smsSend,
"smsRead" to smsRead,
"notificationListener" to notificationListener,
"notifications" to notifications,
"photos" to photos,
"contactsRead" to contactsRead,
"contactsWrite" to contactsWrite,
"calendarRead" to calendarRead,
"calendarWrite" to calendarWrite,
"callLog" to callLog,
"motion" to motion,
)
}
internal fun readAndroidPermissionSnapshot(
context: Context,
smsEnabled: Boolean,
callLogEnabled: Boolean,
photosEnabled: Boolean,
backgroundLocationEnabled: Boolean,
): AndroidPermissionSnapshot {
fun hasPermission(permission: String): Boolean = ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
val locationFine = hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
val locationCoarse = hasPermission(Manifest.permission.ACCESS_COARSE_LOCATION)
val telephonyAvailable = context.packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)
return AndroidPermissionSnapshot(
camera = hasPermission(Manifest.permission.CAMERA),
microphone = hasPermission(Manifest.permission.RECORD_AUDIO),
location = locationFine || locationCoarse,
locationPrecise = locationFine,
locationBackground =
backgroundLocationEnabled &&
(locationFine || locationCoarse) &&
hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
smsSend = smsEnabled && telephonyAvailable && hasPermission(Manifest.permission.SEND_SMS),
smsRead = smsEnabled && telephonyAvailable && hasPermission(Manifest.permission.READ_SMS),
notificationListener = DeviceNotificationListenerService.isAccessEnabled(context),
notifications =
Build.VERSION.SDK_INT < 33 ||
hasPermission(Manifest.permission.POST_NOTIFICATIONS),
photos = photosEnabled && hasPhotoReadPermission(context),
contactsRead = hasPermission(Manifest.permission.READ_CONTACTS),
contactsWrite = hasPermission(Manifest.permission.WRITE_CONTACTS),
calendarRead = hasPermission(Manifest.permission.READ_CALENDAR),
calendarWrite = hasPermission(Manifest.permission.WRITE_CALENDAR),
callLog = callLogEnabled && hasPermission(Manifest.permission.READ_CALL_LOG),
motion = hasPermission(Manifest.permission.ACTIVITY_RECOGNITION),
)
}

View file

@ -0,0 +1,459 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.TimeZone
private const val DEFAULT_CALENDAR_LIMIT = 50
/**
* Parsed calendar.events request; times are epoch millis for CalendarContract queries.
*/
internal data class CalendarEventsRequest(
val startMs: Long,
val endMs: Long,
val limit: Int,
)
/**
* Parsed calendar.add request before resolving the target Android calendar.
*/
internal data class CalendarAddRequest(
val title: String,
val startMs: Long,
val endMs: Long,
val isAllDay: Boolean,
val timeZoneId: String,
val location: String?,
val notes: String?,
val calendarId: Long?,
val calendarTitle: String?,
)
private data class CalendarAddRange(
val start: Instant,
val end: Instant,
)
/**
* Normalized calendar event returned through gateway calendar commands.
*/
internal data class CalendarEventRecord(
val identifier: String,
val title: String,
val startISO: String,
val endISO: String,
val isAllDay: Boolean,
val location: String?,
val calendarTitle: String?,
)
/**
* Injectable CalendarProvider facade for command tests and Android runtime access.
*/
internal interface CalendarDataSource {
fun hasReadPermission(context: Context): Boolean
fun hasWritePermission(context: Context): Boolean
fun events(
context: Context,
request: CalendarEventsRequest,
): List<CalendarEventRecord>
fun add(
context: Context,
request: CalendarAddRequest,
): CalendarEventRecord
}
private object SystemCalendarDataSource : CalendarDataSource {
override fun hasReadPermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
override fun hasWritePermission(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
override fun events(
context: Context,
request: CalendarEventsRequest,
): List<CalendarEventRecord> {
val resolver = context.contentResolver
val builder = CalendarContract.Instances.CONTENT_URI.buildUpon()
// Instances expands recurring events inside the requested time window.
ContentUris.appendId(builder, request.startMs)
ContentUris.appendId(builder, request.endMs)
val projection =
arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.ALL_DAY,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
)
val sortOrder = "${CalendarContract.Instances.BEGIN} ASC LIMIT ${request.limit}"
resolver.query(builder.build(), projection, null, null, sortOrder).use { cursor ->
if (cursor == null) return emptyList()
val out = mutableListOf<CalendarEventRecord>()
while (cursor.moveToNext() && out.size < request.limit) {
val id = cursor.getLong(0)
val title =
cursor
.getString(1)
?.trim()
.orEmpty()
.ifEmpty { "(untitled)" }
val beginMs = cursor.getLong(2)
val endMs = cursor.getLong(3)
val isAllDay = cursor.getInt(4) == 1
val location = cursor.getString(5)?.trim()?.ifEmpty { null }
val calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null }
out +=
CalendarEventRecord(
identifier = id.toString(),
title = title,
startISO = Instant.ofEpochMilli(beginMs).toString(),
endISO = Instant.ofEpochMilli(endMs).toString(),
isAllDay = isAllDay,
location = location,
calendarTitle = calendarTitle,
)
}
return out
}
}
override fun add(
context: Context,
request: CalendarAddRequest,
): CalendarEventRecord {
val resolver = context.contentResolver
val resolvedCalendarId = resolveCalendarId(resolver, request.calendarId, request.calendarTitle)
val values =
ContentValues().apply {
put(CalendarContract.Events.CALENDAR_ID, resolvedCalendarId)
put(CalendarContract.Events.TITLE, request.title)
put(CalendarContract.Events.DTSTART, request.startMs)
put(CalendarContract.Events.DTEND, request.endMs)
put(CalendarContract.Events.ALL_DAY, if (request.isAllDay) 1 else 0)
put(CalendarContract.Events.EVENT_TIMEZONE, request.timeZoneId)
request.location?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
request.notes?.let { put(CalendarContract.Events.DESCRIPTION, it) }
}
val uri =
resolver.insert(CalendarContract.Events.CONTENT_URI, values)
?: throw IllegalStateException("calendar insert failed")
val eventId =
uri.lastPathSegment?.toLongOrNull()
?: throw IllegalStateException("calendar insert failed")
return loadEventById(resolver, eventId)
?: throw IllegalStateException("calendar insert failed")
}
private fun resolveCalendarId(
resolver: ContentResolver,
calendarId: Long?,
calendarTitle: String?,
): Long {
if (calendarId != null) {
// Explicit id wins over title/default selection and must already exist.
if (calendarExists(resolver, calendarId)) return calendarId
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar id $calendarId")
}
if (!calendarTitle.isNullOrEmpty()) {
// Title lookup is exact to avoid adding events to a similarly named calendar.
findCalendarByTitle(resolver, calendarTitle)?.let { return it }
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no calendar named $calendarTitle")
}
findDefaultCalendarId(resolver)?.let { return it }
throw IllegalArgumentException("CALENDAR_NOT_FOUND: no default calendar")
}
private fun calendarExists(
resolver: ContentResolver,
id: Long,
): Boolean {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver
.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars._ID}=?",
arrayOf(id.toString()),
null,
).use { cursor ->
return cursor != null && cursor.moveToFirst()
}
}
private fun findCalendarByTitle(
resolver: ContentResolver,
title: String,
): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver
.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME}=?",
arrayOf(title),
"${CalendarContract.Calendars.IS_PRIMARY} DESC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun findDefaultCalendarId(resolver: ContentResolver): Long? {
val projection = arrayOf(CalendarContract.Calendars._ID)
resolver
.query(
CalendarContract.Calendars.CONTENT_URI,
projection,
"${CalendarContract.Calendars.VISIBLE}=1",
null,
// Prefer Android's primary visible calendar, then lowest id for deterministic fallback.
"${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars._ID} ASC",
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return cursor.getLong(0)
}
}
private fun loadEventById(
resolver: ContentResolver,
eventId: Long,
): CalendarEventRecord? {
val projection =
arrayOf(
CalendarContract.Events._ID,
CalendarContract.Events.TITLE,
CalendarContract.Events.DTSTART,
CalendarContract.Events.DTEND,
CalendarContract.Events.ALL_DAY,
CalendarContract.Events.EVENT_LOCATION,
CalendarContract.Events.CALENDAR_DISPLAY_NAME,
)
resolver
.query(
CalendarContract.Events.CONTENT_URI,
projection,
"${CalendarContract.Events._ID}=?",
arrayOf(eventId.toString()),
null,
).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
return CalendarEventRecord(
identifier = cursor.getLong(0).toString(),
title =
cursor
.getString(1)
?.trim()
.orEmpty()
.ifEmpty { "(untitled)" },
startISO = Instant.ofEpochMilli(cursor.getLong(2)).toString(),
endISO = Instant.ofEpochMilli(cursor.getLong(3)).toString(),
isAllDay = cursor.getInt(4) == 1,
location = cursor.getString(5)?.trim()?.ifEmpty { null },
calendarTitle = cursor.getString(6)?.trim()?.ifEmpty { null },
)
}
}
}
class CalendarHandler private constructor(
private val appContext: Context,
private val dataSource: CalendarDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCalendarDataSource)
fun handleCalendarEvents(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasReadPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_PERMISSION_REQUIRED",
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
)
}
val request =
parseEventsRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val events = dataSource.events(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put(
"events",
buildJsonArray { events.forEach { add(eventJson(it)) } },
)
}.toString(),
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CALENDAR_UNAVAILABLE",
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar query failed"}",
)
}
}
fun handleCalendarAdd(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasWritePermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_PERMISSION_REQUIRED",
message = "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
)
}
val request =
parseAddRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
if (request.title.isEmpty()) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_INVALID",
message = "CALENDAR_INVALID: title required",
)
}
if (request.endMs <= request.startMs) {
return GatewaySession.InvokeResult.error(
code = "CALENDAR_INVALID",
message = "CALENDAR_INVALID: endISO must be after startISO",
)
}
return try {
val event = dataSource.add(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put("event", eventJson(event))
}.toString(),
)
} catch (err: IllegalArgumentException) {
val msg = err.message ?: "CALENDAR_INVALID: invalid request"
val code = if (msg.startsWith("CALENDAR_NOT_FOUND")) "CALENDAR_NOT_FOUND" else "CALENDAR_INVALID"
GatewaySession.InvokeResult.error(code = code, message = msg)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CALENDAR_UNAVAILABLE",
message = "CALENDAR_UNAVAILABLE: ${err.message ?: "calendar add failed"}",
)
}
}
private fun parseEventsRequest(paramsJson: String?): CalendarEventsRequest? {
if (paramsJson.isNullOrBlank()) {
val start = Instant.now()
val end = start.plus(7, ChronoUnit.DAYS)
// Default calendar read is a one-week window, not the full calendar store.
return CalendarEventsRequest(startMs = start.toEpochMilli(), endMs = end.toEpochMilli(), limit = DEFAULT_CALENDAR_LIMIT)
}
val params =
try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val start = parseISO((params["startISO"] as? JsonPrimitive)?.content)
val end = parseISO((params["endISO"] as? JsonPrimitive)?.content)
val resolvedStart = start ?: Instant.now()
val resolvedEnd = end ?: resolvedStart.plus(7, ChronoUnit.DAYS)
// Keep model-driven calendar reads bounded.
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALENDAR_LIMIT).coerceIn(1, 500)
return CalendarEventsRequest(
startMs = resolvedStart.toEpochMilli(),
endMs = resolvedEnd.toEpochMilli(),
limit = limit,
)
}
private fun parseAddRequest(paramsJson: String?): CalendarAddRequest? {
val params =
try {
paramsJson?.let { Json.parseToJsonElement(it).asObjectOrNull() }
} catch (_: Throwable) {
null
} ?: return null
val start =
parseISO((params["startISO"] as? JsonPrimitive)?.content)
?: return null
val end =
parseISO((params["endISO"] as? JsonPrimitive)?.content)
?: return null
val isAllDay = (params["isAllDay"] as? JsonPrimitive)?.content?.toBooleanStrictOrNull() ?: false
val addRange = normalizeAddRange(start, end, isAllDay)
return CalendarAddRequest(
title = (params["title"] as? JsonPrimitive)?.content?.trim().orEmpty(),
startMs = addRange.start.toEpochMilli(),
endMs = addRange.end.toEpochMilli(),
isAllDay = isAllDay,
timeZoneId = if (isAllDay) "UTC" else TimeZone.getDefault().id,
location = (params["location"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
notes = (params["notes"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
calendarId = (params["calendarId"] as? JsonPrimitive)?.content?.toLongOrNull(),
calendarTitle = (params["calendarTitle"] as? JsonPrimitive)?.content?.trim()?.ifEmpty { null },
)
}
private fun normalizeAddRange(
start: Instant,
end: Instant,
isAllDay: Boolean,
): CalendarAddRange {
if (!isAllDay || end <= start) return CalendarAddRange(start = start, end = end)
val dayStart = start.truncatedTo(ChronoUnit.DAYS)
val dayEnd = end.truncatedTo(ChronoUnit.DAYS)
return CalendarAddRange(
start = dayStart,
end = if (dayEnd > dayStart) dayEnd else dayStart.plus(1, ChronoUnit.DAYS),
)
}
private fun parseISO(raw: String?): Instant? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
// Gateway calendar payloads use UTC ISO-8601 instants for unambiguous Android storage.
return try {
Instant.parse(value)
} catch (_: Throwable) {
null
}
}
private fun eventJson(event: CalendarEventRecord): JsonObject =
buildJsonObject {
put("identifier", JsonPrimitive(event.identifier))
put("title", JsonPrimitive(event.title))
put("startISO", JsonPrimitive(event.startISO))
put("endISO", JsonPrimitive(event.endISO))
put("isAllDay", JsonPrimitive(event.isAllDay))
event.location?.let { put("location", JsonPrimitive(it)) }
event.calendarTitle?.let { put("calendarTitle", JsonPrimitive(it)) }
}
companion object {
internal fun forTesting(
appContext: Context,
dataSource: CalendarDataSource,
): CalendarHandler = CalendarHandler(appContext = appContext, dataSource = dataSource)
}
}

View file

@ -0,0 +1,482 @@
package ai.openclaw.app.node
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.hardware.camera2.CameraCharacteristics
import android.util.Base64
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.core.CameraInfo
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.FallbackStrategy
import androidx.camera.video.FileOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.checkSelfPermission
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.math.roundToInt
/**
* CameraX-backed capture service used by gateway camera commands.
*/
internal class CameraClipSession(
private val unbind: () -> Unit,
private val deleteTemporaryFile: (File) -> Unit,
) : AutoCloseable {
private var recording: AutoCloseable? = null
private var temporaryFile: File? = null
private var closed = false
fun ownRecording(recording: AutoCloseable) {
check(!closed) { "camera clip session is closed" }
this.recording = recording
}
fun ownFile(file: File): File {
check(!closed) { "camera clip session is closed" }
check(temporaryFile == null) { "camera clip session already owns a file" }
temporaryFile = file
return file
}
fun transferFile(): File {
check(!closed) { "camera clip session is closed" }
return checkNotNull(temporaryFile) { "camera clip session has no file" }
.also { temporaryFile = null }
}
override fun close() {
if (closed) return
closed = true
var failure: Throwable? = null
fun cleanup(action: () -> Unit) {
try {
action()
} catch (err: Throwable) {
failure?.addSuppressed(err) ?: run { failure = err }
}
}
// Keep teardown symmetric across bind, warmup, recording, finalize, and success exits.
cleanup { recording?.close() }
cleanup(unbind)
temporaryFile?.let { file -> cleanup { deleteTemporaryFile(file) } }
failure?.let { throw it }
}
}
class CameraCaptureManager(
private val context: Context,
private val defaultFacing: () -> String = { "front" },
) {
/** Base64 JSON response for camera.snap after resize and JPEG budget enforcement. */
data class Payload(
val payloadJson: String,
)
/** Temporary MP4 response for camera.clip before CameraHandler validates invoke size. */
data class FilePayload(
val file: File,
val durationMs: Long,
val hasAudio: Boolean,
)
/** Camera device metadata exposed through camera.list. */
data class CameraDeviceInfo(
val id: String,
val name: String,
val position: String,
val deviceType: String,
)
@Volatile private var lifecycleOwner: LifecycleOwner? = null
/** Supplies the foreground Activity lifecycle required by CameraX use-case binding. */
fun attachLifecycleOwner(owner: LifecycleOwner) {
// CameraX binds use cases to an Activity lifecycle; background services cannot capture alone.
lifecycleOwner = owner
}
/** Lists CameraX devices with stable Camera2 ids where available. */
suspend fun listDevices(): List<CameraDeviceInfo> =
withContext(Dispatchers.Main) {
val provider = context.cameraProvider()
provider.availableCameraInfos
.mapNotNull { info -> cameraDeviceInfoOrNull(info) }
.sortedBy { it.id }
}
private fun ensureCameraPermission() {
val granted = checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (granted) return
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
}
private fun ensureMicPermission() {
val granted = checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (granted) return
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
}
/** Captures one still image and returns a gateway-sized JPEG payload. */
suspend fun snap(paramsJson: String?): Payload =
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val params = parseJsonParamsObject(paramsJson)
val facing = resolveCameraFacing(parseFacing(params), defaultFacing())
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(params) ?: 1600
val deviceId = parseDeviceId(params)
val provider = context.cameraProvider()
val capture = ImageCapture.Builder().build()
val selector = resolveCameraSelector(provider, facing, deviceId)
provider.unbindAll()
// Bind only the still capture use case; CameraX owns camera open/close through the lifecycle owner.
provider.bindToLifecycle(owner, selector, capture)
val (bytes, orientation) =
try {
capture.takeJpegWithExif(context.mainExecutor(), context.cacheDir)
} finally {
// The JPEG bytes are self-contained; release CameraX before decoding and recompressing them.
provider.unbind(capture)
}
val decoded =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
val rotated = rotateBitmapByExif(decoded, orientation)
val scaled =
if (maxWidth > 0 && rotated.width > maxWidth) {
val h =
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
.toInt()
.coerceAtLeast(1)
val s = rotated.scale(maxWidth, h)
if (s !== rotated) rotated.recycle()
s
} else {
rotated
}
try {
val maxPayloadBytes = 5 * 1024 * 1024
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
val result =
JpegSizeLimiter.compressToLimit(
initialWidth = scaled.width,
initialHeight = scaled.height,
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
maxBytes = maxEncodedBytes,
encode = { width, height, q ->
val bitmap =
if (width == scaled.width && height == scaled.height) {
scaled
} else {
scaled.scale(width, height)
}
val out = ByteArrayOutputStream()
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
if (bitmap !== scaled) bitmap.recycle()
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
}
if (bitmap !== scaled) {
bitmap.recycle()
}
out.toByteArray()
},
)
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
Payload(
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
)
} finally {
scaled.recycle()
}
}
/** Records a short MP4 clip into a temporary cache file for the caller to encode/delete. */
@SuppressLint("MissingPermission")
suspend fun clip(paramsJson: String?): FilePayload =
withContext(Dispatchers.Main) {
ensureCameraPermission()
val params = parseJsonParamsObject(paramsJson)
val facing = resolveCameraFacing(parseFacing(params), defaultFacing())
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
val includeAudio = parseIncludeAudio(params) ?: true
val deviceId = parseDeviceId(params)
if (includeAudio) ensureMicPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
val provider = context.cameraProvider()
// Use LOWEST quality for smallest files over WebSocket
val recorder =
Recorder
.Builder()
.setQualitySelector(
QualitySelector.from(Quality.LOWEST, FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST)),
).build()
val videoCapture = VideoCapture.withOutput(recorder)
val selector = resolveCameraSelector(provider, facing, deviceId)
// CameraX requires a Preview use case for the camera to start producing frames;
// without it, the encoder may get no data (ERROR_NO_VALID_DATA).
val preview =
androidx.camera.core.Preview
.Builder()
.build()
// Allocate the dummy preview surface only after CameraX requests it; its result owns release.
preview.setSurfaceProvider { request ->
val surfaceTexture = android.graphics.SurfaceTexture(0)
surfaceTexture.setDefaultBufferSize(640, 480)
val surface = android.view.Surface(surfaceTexture)
request.provideSurface(surface, context.mainExecutor()) {
surface.release()
surfaceTexture.release()
}
}
provider.unbindAll()
CameraClipSession(
unbind = { provider.unbind(preview, videoCapture) },
deleteTemporaryFile = { file ->
check(!file.exists() || file.delete()) { "failed to delete temporary camera clip" }
},
).use { session ->
provider.bindToLifecycle(owner, selector, preview, videoCapture)
// Give camera pipeline time to initialize before recording
kotlinx.coroutines.delay(1_500)
val clipFile = session.ownFile(File.createTempFile("openclaw-clip-", ".mp4", context.cacheDir))
val outputOptions = FileOutputOptions.Builder(clipFile).build()
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
val recording =
videoCapture.output
.prepareRecording(context, outputOptions)
.apply {
if (includeAudio) withAudioEnabled()
}.start(context.mainExecutor()) { event ->
if (event is VideoRecordEvent.Finalize) {
finalized.complete(event)
}
}
session.ownRecording(recording)
kotlinx.coroutines.delay(durationMs.toLong())
recording.close()
val finalizeEvent =
try {
withTimeout(15_000) { finalized.await() }
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
}
if (finalizeEvent.hasError()) {
throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})")
}
FilePayload(
file = session.transferFile(),
durationMs = durationMs.toLong(),
hasAudio = includeAudio,
)
}
}
private fun rotateBitmapByExif(
bitmap: Bitmap,
orientation: Int,
): Bitmap {
val matrix = Matrix()
// CameraX JPEG bytes keep sensor orientation in EXIF; normalize before resizing/encoding.
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.postRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.postRotate(-90f)
matrix.postScale(-1f, 1f)
}
else -> return bitmap
}
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (rotated !== bitmap) {
bitmap.recycle()
}
return rotated
}
private fun parseFacing(params: JsonObject?): String? {
val value = parseJsonString(params, "facing")?.trim()?.lowercase() ?: return null
return when (value) {
"front", "back" -> value
else -> null
}
}
private fun parseQuality(params: JsonObject?): Double? = parseJsonDouble(params, "quality")
private fun parseMaxWidth(params: JsonObject?): Int? =
parseJsonInt(params, "maxWidth")
?.takeIf { it > 0 }
private fun parseDurationMs(params: JsonObject?): Int? = parseJsonInt(params, "durationMs")
private fun parseDeviceId(params: JsonObject?): String? =
parseJsonString(params, "deviceId")
?.trim()
?.takeIf { it.isNotEmpty() }
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
private fun resolveCameraSelector(
provider: ProcessCameraProvider,
facing: String,
deviceId: String?,
): CameraSelector {
if (deviceId.isNullOrEmpty()) {
return if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
}
val availableIds = provider.availableCameraInfos.mapNotNull { cameraIdOrNull(it) }.toSet()
if (!availableIds.contains(deviceId)) {
throw IllegalStateException("INVALID_REQUEST: unknown camera deviceId '$deviceId'")
}
return CameraSelector
.Builder()
// CameraX selectors are filters over CameraInfo; pin by Camera2 id for stable device selection.
.addCameraFilter { infos -> infos.filter { cameraIdOrNull(it) == deviceId } }
.build()
}
@SuppressLint("UnsafeOptInUsageError")
private fun cameraDeviceInfoOrNull(info: CameraInfo): CameraDeviceInfo? {
val cameraId = cameraIdOrNull(info) ?: return null
val lensFacing =
runCatching {
Camera2CameraInfo.from(info).getCameraCharacteristic(CameraCharacteristics.LENS_FACING)
}.getOrNull()
val position =
when (lensFacing) {
CameraCharacteristics.LENS_FACING_FRONT -> "front"
CameraCharacteristics.LENS_FACING_BACK -> "back"
CameraCharacteristics.LENS_FACING_EXTERNAL -> "external"
else -> "unspecified"
}
val deviceType =
if (lensFacing == CameraCharacteristics.LENS_FACING_EXTERNAL) "external" else "builtIn"
val name =
when (position) {
"front" -> "Front Camera"
"back" -> "Back Camera"
"external" -> "External Camera"
else -> "Camera $cameraId"
}
return CameraDeviceInfo(
id = cameraId,
name = name,
position = position,
deviceType = deviceType,
)
}
@SuppressLint("UnsafeOptInUsageError")
private fun cameraIdOrNull(info: CameraInfo): String? = runCatching { Camera2CameraInfo.from(info).cameraId }.getOrNull()
}
internal fun resolveCameraFacing(
explicitFacing: String?,
preferredFacing: String,
): String = explicitFacing ?: preferredFacing.takeIf { it == "back" } ?: "front"
private suspend fun Context.cameraProvider(): ProcessCameraProvider =
suspendCancellableCoroutine { cont ->
val future = ProcessCameraProvider.getInstance(this)
future.addListener(
{
try {
cont.resume(future.get())
} catch (e: Exception) {
cont.resumeWithException(e)
}
},
ContextCompat.getMainExecutor(this),
)
}
/**
* Returns JPEG bytes plus EXIF orientation so callers can normalize the decoded bitmap.
*/
private suspend fun ImageCapture.takeJpegWithExif(
executor: Executor,
tempDir: File,
): Pair<ByteArray, Int> =
suspendCancellableCoroutine { cont ->
val file = File.createTempFile("openclaw-snap-", ".jpg", tempDir)
val options = ImageCapture.OutputFileOptions.Builder(file).build()
takePicture(
options,
executor,
object : ImageCapture.OnImageSavedCallback {
override fun onError(exception: ImageCaptureException) {
file.delete()
cont.resumeWithException(exception)
}
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
try {
val exif = ExifInterface(file.absolutePath)
val orientation =
exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val bytes = file.readBytes()
cont.resume(Pair(bytes, orientation))
} catch (e: Exception) {
cont.resumeWithException(e)
} finally {
file.delete()
}
}
},
)
}

View file

@ -0,0 +1,184 @@
package ai.openclaw.app.node
import ai.openclaw.app.BuildConfig
import ai.openclaw.app.CameraHudKind
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.takeUtf16Safe
import android.content.Context
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
internal const val CAMERA_CLIP_MAX_RAW_BYTES: Long = 18L * 1024L * 1024L
private const val CAMERA_DEBUG_STACK_TRACE_MAX_CHARS = 2_000
/**
* Raw MP4 size guard before base64 encoding the clip into a node.invoke response.
*/
internal fun isCameraClipWithinPayloadLimit(rawBytes: Long): Boolean = rawBytes in 0L..CAMERA_CLIP_MAX_RAW_BYTES
/**
* Gateway camera command adapter that adds HUD feedback and payload-size enforcement.
*/
class CameraHandler(
private val appContext: Context,
private val camera: CameraCaptureManager,
private val setCameraAudioCaptureActive: (Boolean) -> Boolean,
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
) {
/** Handles camera.list by exposing CameraX devices through gateway metadata. */
suspend fun handleList(_paramsJson: String?): GatewaySession.InvokeResult =
try {
val devices = camera.listDevices()
val payload =
buildJsonObject {
put(
"devices",
buildJsonArray {
devices.forEach { device ->
add(
buildJsonObject {
put("id", JsonPrimitive(device.id))
put("name", JsonPrimitive(device.name))
put("position", JsonPrimitive(device.position))
put("deviceType", JsonPrimitive(device.deviceType))
},
)
}
},
)
}.toString()
GatewaySession.InvokeResult.ok(payload)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
GatewaySession.InvokeResult.error(code = code, message = message)
}
/** Handles camera.snap with HUD progress, flash feedback, and normalized invoke errors. */
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
fun camLog(msg: String) {
if (!BuildConfig.DEBUG) return
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
logFile?.appendText("[$ts] $msg\n")
android.util.Log.w("openclaw", "camera.snap: $msg")
}
try {
logFile?.writeText("") // clear
camLog("starting, params=$paramsJson")
camLog("calling showCameraHud")
showCameraHud("Taking photo…", CameraHudKind.Photo, null)
val res =
try {
camLog("calling camera.snap()")
val r = camera.snap(paramsJson)
camLog("success, payload size=${r.payloadJson.length}")
r
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
camLog("inner error: ${err::class.java.simpleName}: ${err.message}")
camLog("stack: ${err.stackTraceToString().takeUtf16Safe(CAMERA_DEBUG_STACK_TRACE_MAX_CHARS)}")
val (code, message) = invokeErrorFromThrowable(err)
showCameraHud(message, CameraHudKind.Error, 2200)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
camLog("returning result")
showCameraHud("Photo captured", CameraHudKind.Success, 1600)
return GatewaySession.InvokeResult.ok(res.payloadJson)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
camLog("outer error: ${err::class.java.simpleName}: ${err.message}")
camLog("stack: ${err.stackTraceToString().takeUtf16Safe(CAMERA_DEBUG_STACK_TRACE_MAX_CHARS)}")
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera snap failed")
}
}
/** Handles camera.clip and keeps external audio capture paused while camera audio is active. */
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
fun clipLog(msg: String) {
if (!BuildConfig.DEBUG) return
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
clipLogFile?.appendText("[CLIP $ts] $msg\n")
android.util.Log.w("openclaw", "camera.clip: $msg")
}
val includeAudio = parseIncludeAudio(paramsJson) ?: true
val ownsAudioCapture = includeAudio && setCameraAudioCaptureActive(true)
if (includeAudio && !ownsAudioCapture) {
return GatewaySession.InvokeResult.error(
code = "MIC_BUSY",
message = "MIC_BUSY: another audio capture is active",
)
}
try {
clipLogFile?.writeText("") // clear
clipLog("starting, params=$paramsJson includeAudio=$includeAudio")
clipLog("calling showCameraHud")
showCameraHud("Recording…", CameraHudKind.Recording, null)
val filePayload =
try {
clipLog("calling camera.clip()")
val r = camera.clip(paramsJson)
clipLog("success, file size=${r.file.length()}")
r
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
clipLog("inner error: ${err::class.java.simpleName}: ${err.message}")
clipLog("stack: ${err.stackTraceToString().takeUtf16Safe(CAMERA_DEBUG_STACK_TRACE_MAX_CHARS)}")
val (code, message) = invokeErrorFromThrowable(err)
showCameraHud(message, CameraHudKind.Error, 2400)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
val rawBytes = filePayload.file.length()
if (!isCameraClipWithinPayloadLimit(rawBytes)) {
clipLog("payload too large: bytes=$rawBytes max=$CAMERA_CLIP_MAX_RAW_BYTES")
// Delete oversized clips before returning so cache files do not accumulate after failed invokes.
withContext(Dispatchers.IO) { filePayload.file.delete() }
showCameraHud("Clip too large", CameraHudKind.Error, 2400)
return GatewaySession.InvokeResult.error(
code = "PAYLOAD_TOO_LARGE",
message =
"PAYLOAD_TOO_LARGE: camera clip is $rawBytes bytes; max is $CAMERA_CLIP_MAX_RAW_BYTES bytes. Reduce durationMs and retry.",
)
}
val bytes =
withContext(Dispatchers.IO) {
try {
filePayload.file.readBytes()
} finally {
filePayload.file.delete()
}
}
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
clipLog("returning base64 payload")
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
return GatewaySession.InvokeResult.ok(
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}""",
)
} catch (err: CancellationException) {
throw err
} catch (err: Throwable) {
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")
clipLog("stack: ${err.stackTraceToString().takeUtf16Safe(CAMERA_DEBUG_STACK_TRACE_MAX_CHARS)}")
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed")
} finally {
// Prevent talk/transcription capture from competing with camera audio after every exit path.
if (ownsAudioCapture) setCameraAudioCaptureActive(false)
}
}
private fun parseIncludeAudio(paramsJson: String?): Boolean? = parseJsonBooleanFlag(parseJsonParamsObject(paramsJson), "includeAudio")
}

Some files were not shown because too many files have changed in this diff Show more