mirror of
https://github.com/BrendanGreenlee/openclaw-android-heartbeat.git
synced 2026-08-17 16:49:14 +00:00
Import official OpenClaw Android app (apps/android @ 71a59512ba476df3328cf485d84748121cf341f2)
Pristine fork source for PROJ-0088. v1 will turn this into a WebView web shell; the WebSocket node infrastructure stays intact for v2 heartbeat.
This commit is contained in:
commit
7a380c40ed
656 changed files with 209982 additions and 0 deletions
478
app/build.gradle.kts
Normal file
478
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
import com.android.build.api.variant.impl.VariantOutputImpl
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Properties
|
||||
|
||||
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
|
||||
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
|
||||
val thirdPartyLicensesDir = rootProject.file("THIRD_PARTY_LICENSES")
|
||||
val openClawAndroidVersionProperties =
|
||||
Properties().apply {
|
||||
if (!openClawAndroidVersionFile.isFile) {
|
||||
error("Missing Android version properties. Run `pnpm android:version:sync`.")
|
||||
}
|
||||
openClawAndroidVersionFile.inputStream().use(::load)
|
||||
}
|
||||
|
||||
fun requireOpenClawAndroidVersionProperty(name: String): String =
|
||||
openClawAndroidVersionProperties.getProperty(name)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: error("Missing $name in Config/Version.properties. Run `pnpm android:version:sync`.")
|
||||
|
||||
val openClawAndroidVersionName = requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_NAME")
|
||||
val openClawAndroidVersionCode =
|
||||
requireOpenClawAndroidVersionProperty("OPENCLAW_ANDROID_VERSION_CODE").toIntOrNull()
|
||||
?: error("OPENCLAW_ANDROID_VERSION_CODE must be an integer in Config/Version.properties.")
|
||||
|
||||
fun optionalOpenClawBuildProperty(name: String): String? =
|
||||
providers
|
||||
.gradleProperty(name)
|
||||
.orNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
val fullGitCommitPattern = Regex("^[a-f0-9]{40}$")
|
||||
val buildTimestampFormatter =
|
||||
DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS'Z'").withZone(ZoneOffset.UTC)
|
||||
val explicitOpenClawBuildCommit =
|
||||
optionalOpenClawBuildProperty("openclawBuildCommit")
|
||||
?.lowercase()
|
||||
?.also { commit ->
|
||||
if (!fullGitCommitPattern.matches(commit)) {
|
||||
error("openclawBuildCommit must be a full 40-character hexadecimal Git commit.")
|
||||
}
|
||||
}
|
||||
|
||||
val explicitOpenClawBuildTimestamp =
|
||||
optionalOpenClawBuildProperty("openclawBuildTimestamp")
|
||||
?.let { timestamp ->
|
||||
if (!Regex("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$").matches(timestamp)) {
|
||||
error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.")
|
||||
}
|
||||
val instant =
|
||||
runCatching { Instant.parse(timestamp) }
|
||||
.getOrElse { error("openclawBuildTimestamp must be an ISO-8601 UTC timestamp.") }
|
||||
buildTimestampFormatter.format(instant)
|
||||
}
|
||||
|
||||
val repositoryBuildCommit =
|
||||
if (explicitOpenClawBuildCommit == null) {
|
||||
runCatching {
|
||||
providers
|
||||
.exec {
|
||||
workingDir(rootProject.projectDir)
|
||||
commandLine("git", "rev-parse", "HEAD")
|
||||
}.standardOutput
|
||||
.asText
|
||||
.get()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.takeIf(fullGitCommitPattern::matches)
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val openClawBuildCommit = explicitOpenClawBuildCommit ?: repositoryBuildCommit ?: "unknown"
|
||||
// Keep every variant generated by one Gradle invocation on the same build instant.
|
||||
val invocationBuildTimestamp =
|
||||
providers.provider { buildTimestampFormatter.format(Instant.now()) }.get()
|
||||
val openClawBuildTimestamp = explicitOpenClawBuildTimestamp ?: invocationBuildTimestamp
|
||||
|
||||
val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() }
|
||||
val androidKeyPassword = providers.gradleProperty("OPENCLAW_ANDROID_KEY_PASSWORD").orNull?.takeIf { it.isNotBlank() }
|
||||
val resolvedAndroidStoreFile =
|
||||
androidStoreFile?.let { storeFilePath ->
|
||||
if (storeFilePath.startsWith("~/")) {
|
||||
"${System.getProperty("user.home")}/${storeFilePath.removePrefix("~/")}"
|
||||
} else {
|
||||
storeFilePath
|
||||
}
|
||||
}
|
||||
|
||||
val hasAndroidReleaseSigning =
|
||||
listOf(resolvedAndroidStoreFile, androidStorePassword, androidKeyAlias, androidKeyPassword).all { it != null }
|
||||
|
||||
val wantsAndroidReleaseBuild =
|
||||
gradle.startParameter.taskNames.any { taskName ->
|
||||
taskName.contains("Release", ignoreCase = true) ||
|
||||
Regex("""(^|:)(bundle|assemble)$""").containsMatchIn(taskName)
|
||||
}
|
||||
val missingAndroidBuildMetadata =
|
||||
explicitOpenClawBuildCommit == null || explicitOpenClawBuildTimestamp == null
|
||||
|
||||
if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
|
||||
error(
|
||||
"Missing Android release signing properties. Set OPENCLAW_ANDROID_STORE_FILE, " +
|
||||
"OPENCLAW_ANDROID_STORE_PASSWORD, OPENCLAW_ANDROID_KEY_ALIAS, and " +
|
||||
"OPENCLAW_ANDROID_KEY_PASSWORD in ~/.gradle/gradle.properties.",
|
||||
)
|
||||
}
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app"
|
||||
// AndroidX Core 1.19 and Lifecycle 2.11 require API 37 compilation.
|
||||
// targetSdk stays separate so runtime behavior changes remain an explicit migration.
|
||||
compileSdk = 37
|
||||
|
||||
// Release signing is local-only; keep the keystore path and passwords out of the repo.
|
||||
signingConfigs {
|
||||
if (hasAndroidReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = project.file(checkNotNull(resolvedAndroidStoreFile))
|
||||
storePassword = checkNotNull(androidStorePassword)
|
||||
keyAlias = checkNotNull(androidKeyAlias)
|
||||
keyPassword = checkNotNull(androidKeyPassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
assets.directories.add("../../shared/OpenClawKit/Sources/OpenClawKit/Resources")
|
||||
assets.directories.add(thirdPartyLicensesDir.path)
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
versionCode = openClawAndroidVersionCode
|
||||
versionName = openClawAndroidVersionName
|
||||
buildConfigField("String", "GIT_COMMIT", "\"$openClawBuildCommit\"")
|
||||
buildConfigField("String", "BUILD_TIMESTAMP", "\"$openClawBuildTimestamp\"")
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "store"
|
||||
|
||||
productFlavors {
|
||||
create("play") {
|
||||
dimension = "store"
|
||||
manifestPlaceholders["nodeForegroundServiceType"] = "connectedDevice|microphone"
|
||||
}
|
||||
create("thirdParty") {
|
||||
dimension = "store"
|
||||
manifestPlaceholders["nodeForegroundServiceType"] = "connectedDevice|microphone|location"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
if (hasAndroidReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
ndk {
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
bundle {
|
||||
language {
|
||||
// The in-app picker can select a locale outside the device language list.
|
||||
// Without a Play Core download path, every translated resource must stay installed.
|
||||
enableSplit = false
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
localeFilters +=
|
||||
listOf(
|
||||
"ar",
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fa",
|
||||
"fr",
|
||||
"hi",
|
||||
"in",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt-rBR",
|
||||
"ru",
|
||||
"sv",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
"zh-rCN",
|
||||
"zh-rTW",
|
||||
)
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes +=
|
||||
setOf(
|
||||
"/META-INF/{AL2.0,LGPL2.1}",
|
||||
"/META-INF/*.version",
|
||||
"/META-INF/LICENSE*.txt",
|
||||
"DebugProbesKt.bin",
|
||||
"kotlin-tooling-metadata.json",
|
||||
"org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties",
|
||||
"org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties",
|
||||
"org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties",
|
||||
"org/bouncycastle/x509/CertPathReviewerMessages*.properties",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
lintConfig = file("lint.xml")
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.outputs
|
||||
.filterIsInstance<VariantOutputImpl>()
|
||||
.forEach { output ->
|
||||
val versionName = output.versionName.orNull ?: "0"
|
||||
val buildType = variant.buildType
|
||||
val flavorName = variant.flavorName?.takeIf { it.isNotBlank() }
|
||||
val outputFileName =
|
||||
if (flavorName == null) {
|
||||
"openclaw-$versionName-$buildType.apk"
|
||||
} else {
|
||||
"openclaw-$versionName-$flavorName-$buildType.apk"
|
||||
}
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
allWarningsAsErrors.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
ktlint {
|
||||
android.set(true)
|
||||
ignoreFailures.set(false)
|
||||
filter {
|
||||
exclude("**/build/**")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation(project(":wear-shared"))
|
||||
implementation(libs.play.services.wearable)
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
// AppCompat owns per-app locale persistence and Activity recreation on API 31-32.
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
|
||||
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
|
||||
// R8 will tree-shake unused icons when minify is enabled on release builds.
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Material Components (XML theme + resources)
|
||||
implementation(libs.material)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(libs.androidx.security.crypto)
|
||||
// Room owns separate disposable gateway cache and durable client-state databases.
|
||||
implementation(libs.androidx.room.runtime)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.bcprov)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.svg)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.commonmark.ext.autolink)
|
||||
implementation(libs.commonmark.ext.gfm.strikethrough)
|
||||
implementation(libs.commonmark.ext.gfm.tables)
|
||||
implementation(libs.commonmark.ext.task.list.items)
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.view)
|
||||
implementation(libs.androidx.camera.video)
|
||||
implementation(libs.barcode.scanning)
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation(libs.dnsjava)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kotest.runner.junit5)
|
||||
testImplementation(libs.kotest.assertions.core)
|
||||
testImplementation(libs.mockwebserver)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
testRuntimeOnly(libs.junit.vintage.engine)
|
||||
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.androidx.uiautomator)
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
events("failed")
|
||||
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||
}
|
||||
}
|
||||
|
||||
val validateOpenClawReleaseBuildMetadata =
|
||||
tasks.register("validateOpenClawReleaseBuildMetadata") {
|
||||
doLast {
|
||||
if (missingAndroidBuildMetadata) {
|
||||
error(
|
||||
"Android release builds require -PopenclawBuildCommit and -PopenclawBuildTimestamp. " +
|
||||
"Use the repository Android release helper.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val validateThirdPartyLicenseAssets =
|
||||
tasks.register("validateThirdPartyLicenseAssets") {
|
||||
inputs.dir(thirdPartyLicensesDir)
|
||||
doLast {
|
||||
if (!thirdPartyLicensesDir.isDirectory) {
|
||||
error("Missing Android third-party license directory: ${thirdPartyLicensesDir.relativeTo(rootProject.projectDir)}")
|
||||
}
|
||||
val invalidFiles =
|
||||
thirdPartyLicensesDir
|
||||
.walkTopDown()
|
||||
.filter { file -> file.isFile && file.extension.lowercase() != "txt" }
|
||||
.map { file -> file.relativeTo(thirdPartyLicensesDir).path }
|
||||
.toList()
|
||||
|
||||
if (invalidFiles.isNotEmpty()) {
|
||||
error(
|
||||
"Android third-party license assets must be .txt files:\n" +
|
||||
invalidFiles.joinToString(separator = "\n") { path -> "- $path" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { task -> task.name == "preBuild" }.configureEach {
|
||||
dependsOn(validateThirdPartyLicenseAssets)
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
val variantName = variant.name
|
||||
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
|
||||
val preBuildTaskName = "pre${variantNameCapitalized}Build"
|
||||
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
|
||||
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
|
||||
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
|
||||
val mergedJar =
|
||||
layout.buildDirectory.file(
|
||||
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
|
||||
)
|
||||
|
||||
tasks.matching { task -> task.name == preBuildTaskName }.configureEach {
|
||||
dependsOn(validateOpenClawReleaseBuildMetadata)
|
||||
}
|
||||
|
||||
val stripTask =
|
||||
tasks.register(stripTaskName) {
|
||||
inputs.file(mergedJar)
|
||||
outputs.file(mergedJar)
|
||||
|
||||
doLast {
|
||||
val jarFile = mergedJar.get().asFile
|
||||
if (!jarFile.exists()) {
|
||||
return@doLast
|
||||
}
|
||||
|
||||
val unpackDir = temporaryDir.resolve("merged-java-res")
|
||||
delete(unpackDir)
|
||||
copy {
|
||||
from(zipTree(jarFile))
|
||||
into(unpackDir)
|
||||
exclude(dnsjavaInetAddressResolverService)
|
||||
}
|
||||
delete(jarFile)
|
||||
ant.invokeMethod(
|
||||
"zip",
|
||||
mapOf(
|
||||
"destfile" to jarFile.absolutePath,
|
||||
"basedir" to unpackDir.absolutePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == mergeTaskName }.configureEach {
|
||||
finalizedBy(stripTask)
|
||||
}
|
||||
tasks.matching { it.name == minifyTaskName }.configureEach {
|
||||
dependsOn(stripTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/lint.xml
Normal file
13
app/lint.xml
Normal 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
8
app/proguard-rules.pro
vendored
Normal 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
|
||||
273
app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json
Normal file
273
app/schemas/ai.openclaw.app.chat.ClientStateDatabase/1.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
146
app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json
Normal file
146
app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/1.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
177
app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json
Normal file
177
app/schemas/ai.openclaw.app.chat.GatewayCacheDatabase/2.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
32
app/src/debug/AndroidManifest.xml
Normal file
32
app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
242
app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt
Normal file
242
app/src/debug/java/ai/openclaw/app/VoiceE2eReceiver.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
160
app/src/main/AndroidManifest.xml
Normal file
160
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
37
app/src/main/assets/katex/README.md
Normal file
37
app/src/main/assets/katex/README.md
Normal 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
|
||||
```
|
||||
BIN
app/src/main/assets/katex/fonts/KaTeX_AMS-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_AMS-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Fraktur-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Fraktur-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Bold.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Bold.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-BoldItalic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Italic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Italic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Main-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Math-Italic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Math-Italic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Bold.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Bold.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_SansSerif-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Script-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Script-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Size1-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Size1-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Size2-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Size2-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Size3-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Size3-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Size4-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Size4-Regular.woff2
Normal file
Binary file not shown.
BIN
app/src/main/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2
Normal file
BIN
app/src/main/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2
Normal file
Binary file not shown.
20
app/src/main/assets/katex/index.html
Normal file
20
app/src/main/assets/katex/index.html
Normal 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>
|
||||
1
app/src/main/assets/katex/katex.min.css
vendored
Normal file
1
app/src/main/assets/katex/katex.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
app/src/main/assets/katex/katex.min.js
vendored
Normal file
1
app/src/main/assets/katex/katex.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
34
app/src/main/assets/katex/renderer.js
Normal file
34
app/src/main/assets/katex/renderer.js
Normal 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 }),
|
||||
);
|
||||
}
|
||||
};
|
||||
40
app/src/main/java/ai/openclaw/app/AndroidLicenseNotices.kt
Normal file
40
app/src/main/java/ai/openclaw/app/AndroidLicenseNotices.kt
Normal 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" }
|
||||
436
app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt
Normal file
436
app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt
Normal 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 {
|
||||
"I’ll 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()
|
||||
}
|
||||
33
app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt
Normal file
33
app/src/main/java/ai/openclaw/app/AndroidScreenshotMode.kt
Normal 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))
|
||||
}
|
||||
98
app/src/main/java/ai/openclaw/app/AppLanguage.kt
Normal file
98
app/src/main/java/ai/openclaw/app/AppLanguage.kt
Normal 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)
|
||||
}
|
||||
25
app/src/main/java/ai/openclaw/app/AppearanceThemeMode.kt
Normal file
25
app/src/main/java/ai/openclaw/app/AppearanceThemeMode.kt
Normal 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
|
||||
}
|
||||
}
|
||||
223
app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
Normal file
223
app/src/main/java/ai/openclaw/app/AssistantLaunch.kt
Normal 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
|
||||
16
app/src/main/java/ai/openclaw/app/CameraHudState.kt
Normal file
16
app/src/main/java/ai/openclaw/app/CameraHudState.kt
Normal 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,
|
||||
)
|
||||
316
app/src/main/java/ai/openclaw/app/CronJobDetail.kt
Normal file
316
app/src/main/java/ai/openclaw/app/CronJobDetail.kt
Normal 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
|
||||
671
app/src/main/java/ai/openclaw/app/CronJobManagement.kt
Normal file
671
app/src/main/java/ai/openclaw/app/CronJobManagement.kt
Normal 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
|
||||
28
app/src/main/java/ai/openclaw/app/DeviceNames.kt
Normal file
28
app/src/main/java/ai/openclaw/app/DeviceNames.kt
Normal 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" }
|
||||
}
|
||||
}
|
||||
41
app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt
Normal file
41
app/src/main/java/ai/openclaw/app/GatewayAgentSummary.kt
Normal 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() }
|
||||
587
app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt
Normal file
587
app/src/main/java/ai/openclaw/app/GatewayExecApprovals.kt
Normal 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",
|
||||
)
|
||||
237
app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt
Normal file
237
app/src/main/java/ai/openclaw/app/GatewayTalkSetupReadiness.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
34
app/src/main/java/ai/openclaw/app/LocationMode.kt
Normal file
34
app/src/main/java/ai/openclaw/app/LocationMode.kt
Normal 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
|
||||
}
|
||||
382
app/src/main/java/ai/openclaw/app/MainActivity.kt
Normal file
382
app/src/main/java/ai/openclaw/app/MainActivity.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
1967
app/src/main/java/ai/openclaw/app/MainViewModel.kt
Normal file
1967
app/src/main/java/ai/openclaw/app/MainViewModel.kt
Normal file
File diff suppressed because it is too large
Load diff
123
app/src/main/java/ai/openclaw/app/NodeApp.kt
Normal file
123
app/src/main/java/ai/openclaw/app/NodeApp.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
469
app/src/main/java/ai/openclaw/app/NodeForegroundService.kt
Normal file
469
app/src/main/java/ai/openclaw/app/NodeForegroundService.kt
Normal 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)
|
||||
}
|
||||
9054
app/src/main/java/ai/openclaw/app/NodeRuntime.kt
Normal file
9054
app/src/main/java/ai/openclaw/app/NodeRuntime.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
517
app/src/main/java/ai/openclaw/app/PermissionRequester.kt
Normal file
517
app/src/main/java/ai/openclaw/app/PermissionRequester.kt
Normal 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
|
||||
}
|
||||
}
|
||||
23
app/src/main/java/ai/openclaw/app/PhotoPermissions.kt
Normal file
23
app/src/main/java/ai/openclaw/app/PhotoPermissions.kt
Normal 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
|
||||
}
|
||||
802
app/src/main/java/ai/openclaw/app/SecurePrefs.kt
Normal file
802
app/src/main/java/ai/openclaw/app/SecurePrefs.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/src/main/java/ai/openclaw/app/SessionKey.kt
Normal file
37
app/src/main/java/ai/openclaw/app/SessionKey.kt
Normal 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(" · ")
|
||||
}
|
||||
238
app/src/main/java/ai/openclaw/app/SkillManagement.kt
Normal file
238
app/src/main/java/ai/openclaw/app/SkillManagement.kt
Normal 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)
|
||||
31
app/src/main/java/ai/openclaw/app/Utf16Text.kt
Normal file
31
app/src/main/java/ai/openclaw/app/Utf16Text.kt
Normal 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)
|
||||
}
|
||||
10
app/src/main/java/ai/openclaw/app/VoiceCaptureMode.kt
Normal file
10
app/src/main/java/ai/openclaw/app/VoiceCaptureMode.kt
Normal 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,
|
||||
}
|
||||
81
app/src/main/java/ai/openclaw/app/WorkspaceFiles.kt
Normal file
81
app/src/main/java/ai/openclaw/app/WorkspaceFiles.kt
Normal 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(),
|
||||
)
|
||||
}
|
||||
112
app/src/main/java/ai/openclaw/app/chat/BackgroundTask.kt
Normal file
112
app/src/main/java/ai/openclaw/app/chat/BackgroundTask.kt
Normal 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() }
|
||||
}
|
||||
1703
app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt
Normal file
1703
app/src/main/java/ai/openclaw/app/chat/ChatCommandOutbox.kt
Normal file
File diff suppressed because it is too large
Load diff
65
app/src/main/java/ai/openclaw/app/chat/ChatComposerOwner.kt
Normal file
65
app/src/main/java/ai/openclaw/app/chat/ChatComposerOwner.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
7320
app/src/main/java/ai/openclaw/app/chat/ChatController.kt
Normal file
7320
app/src/main/java/ai/openclaw/app/chat/ChatController.kt
Normal file
File diff suppressed because it is too large
Load diff
309
app/src/main/java/ai/openclaw/app/chat/ChatModels.kt
Normal file
309
app/src/main/java/ai/openclaw/app/chat/ChatModels.kt
Normal 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,
|
||||
)
|
||||
111
app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt
Normal file
111
app/src/main/java/ai/openclaw/app/chat/ChatQuestion.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
295
app/src/main/java/ai/openclaw/app/chat/ChatSwarmProgress.kt
Normal file
295
app/src/main/java/ai/openclaw/app/chat/ChatSwarmProgress.kt
Normal 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
|
||||
535
app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt
Normal file
535
app/src/main/java/ai/openclaw/app/chat/ChatTranscriptCache.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
193
app/src/main/java/ai/openclaw/app/chat/ChatWidgetUrlResolver.kt
Normal file
193
app/src/main/java/ai/openclaw/app/chat/ChatWidgetUrlResolver.kt
Normal 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(),
|
||||
)
|
||||
772
app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt
Normal file
772
app/src/main/java/ai/openclaw/app/chat/ClientDatabases.kt
Normal 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() }
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
40
app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt
Normal file
40
app/src/main/java/ai/openclaw/app/gateway/BonjourEscapes.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
46
app/src/main/java/ai/openclaw/app/gateway/ChatSendAck.kt
Normal file
46
app/src/main/java/ai/openclaw/app/gateway/ChatSendAck.kt
Normal 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
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
155
app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt
Normal file
155
app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt
Normal 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()
|
||||
}
|
||||
238
app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt
Normal file
238
app/src/main/java/ai/openclaw/app/gateway/DeviceIdentityStore.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
704
app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt
Normal file
704
app/src/main/java/ai/openclaw/app/gateway/GatewayDiscovery.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
32
app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt
Normal file
32
app/src/main/java/ai/openclaw/app/gateway/GatewayEndpoint.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
148
app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt
Normal file
148
app/src/main/java/ai/openclaw/app/gateway/GatewayHostSecurity.kt
Normal 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 == '.'
|
||||
558
app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
Normal file
558
app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
Normal 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"),
|
||||
}
|
||||
245
app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt
Normal file
245
app/src/main/java/ai/openclaw/app/gateway/GatewayRegistry.kt
Normal 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 }))
|
||||
2266
app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
Normal file
2266
app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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" })
|
||||
}
|
||||
}
|
||||
541
app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt
Normal file
541
app/src/main/java/ai/openclaw/app/gateway/GatewayTls.kt
Normal 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 == '-' }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
110
app/src/main/java/ai/openclaw/app/gateway/NetworkMonitor.kt
Normal file
110
app/src/main/java/ai/openclaw/app/gateway/NetworkMonitor.kt
Normal 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)
|
||||
1639
app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt
Normal file
1639
app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt
Normal file
File diff suppressed because it is too large
Load diff
336
app/src/main/java/ai/openclaw/app/i18n/NativeStrings.kt
Normal file
336
app/src/main/java/ai/openclaw/app/i18n/NativeStrings.kt
Normal 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"
|
||||
149
app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt
Normal file
149
app/src/main/java/ai/openclaw/app/node/A2UIHandler.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
459
app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt
Normal file
459
app/src/main/java/ai/openclaw/app/node/CalendarHandler.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
482
app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt
Normal file
482
app/src/main/java/ai/openclaw/app/node/CameraCaptureManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
184
app/src/main/java/ai/openclaw/app/node/CameraHandler.kt
Normal file
184
app/src/main/java/ai/openclaw/app/node/CameraHandler.kt
Normal 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
Loading…
Add table
Add a link
Reference in a new issue