fix(android): device identity + pairing + a2ui controls

- Register BouncyCastle provider for Ed25519 device identity
- Add identity status + reset button in Settings
- Fix operator client id/mode handshake
- Add Request Pairing UI + gateway node.pair.request support
- Improve SettingsSheet connect/disconnect visibility
- Add A2UI bridge compat shim + direct action->node.invoke wiring (camera/screen/location)
This commit is contained in:
Deano 2026-01-30 09:10:04 +00:00
parent 6af205a13a
commit 176768770e
8 changed files with 407 additions and 71 deletions

View File

@ -98,6 +98,9 @@ dependencies {
// Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0")
// Crypto provider for Ed25519 on Android (some OEM builds lack reliable support).
implementation("org.bouncycastle:bcprov-jdk15to18:1.80")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")

View File

@ -35,6 +35,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName
val deviceIdentityStatusText: StateFlow<String> = runtime.deviceIdentityStatusText
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
@ -51,6 +52,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls
val gatewayToken: StateFlow<String> = runtime.gatewayToken
val gatewayPassword: StateFlow<String> = runtime.gatewayPassword
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
@ -104,6 +108,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualTls(value)
}
fun setGatewayToken(value: String) {
runtime.setGatewayToken(value)
}
fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value)
}
@ -140,6 +152,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.disconnect()
}
fun requestNodePairing() {
runtime.requestNodePairing()
}
fun resetDeviceIdentity() {
runtime.resetDeviceIdentity()
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
}

View File

@ -2,12 +2,23 @@ package ai.openclaw.android
import android.app.Application
import android.os.StrictMode
import java.security.Security
import org.bouncycastle.jce.provider.BouncyCastleProvider
class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() {
super.onCreate()
// Ensure a working provider for Ed25519/EdDSA. Some Android builds ship broken or missing support.
try {
Security.removeProvider("BC")
Security.insertProviderAt(BouncyCastleProvider(), 1)
} catch (_: Throwable) {
// best-effort
}
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()

View File

@ -113,6 +113,15 @@ class NodeRuntime(context: Context) {
private val identityStore = DeviceIdentityStore(appContext)
fun resetDeviceIdentity() {
identityStore.resetIdentity()
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
refreshGatewayConnection()
}
private val _deviceIdentityStatusText = MutableStateFlow(computeDeviceIdentityStatusText())
val deviceIdentityStatusText: StateFlow<String> = _deviceIdentityStatusText.asStateFlow()
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -170,8 +179,11 @@ class NodeRuntime(context: Context) {
onDisconnected = { message ->
operatorConnected = false
operatorStatusText = message
_serverName.value = null
_remoteAddress.value = null
// If the node session is still connected, keep server/remote around so Settings stays usable.
if (!nodeConnected) {
_serverName.value = null
_remoteAddress.value = null
}
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main"
@ -192,15 +204,22 @@ class NodeRuntime(context: Context) {
scope = scope,
identityStore = identityStore,
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
onConnected = { name, remote, _ ->
nodeConnected = true
nodeStatusText = "Connected"
// When operator auth is failing, node can still connect; keep UI usable.
if (_serverName.value == null) _serverName.value = name
if (_remoteAddress.value == null) _remoteAddress.value = remote
updateStatus()
maybeNavigateToA2uiOnConnect()
},
onDisconnected = { message ->
nodeConnected = false
nodeStatusText = message
if (!operatorConnected) {
_serverName.value = null
_remoteAddress.value = null
}
updateStatus()
showLocalCanvasOnDisconnect()
},
@ -241,7 +260,7 @@ class NodeRuntime(context: Context) {
}
private fun updateStatus() {
_isConnected.value = operatorConnected
_isConnected.value = operatorConnected || nodeConnected
_statusText.value =
when {
operatorConnected && nodeConnected -> "Connected"
@ -284,6 +303,9 @@ class NodeRuntime(context: Context) {
val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls
val gatewayToken: StateFlow<String> = prefs.gatewayToken
val gatewayPassword: StateFlow<String> = prefs.gatewayPassword
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
@ -428,6 +450,14 @@ class NodeRuntime(context: Context) {
prefs.setManualTls(value)
}
fun setGatewayToken(value: String) {
prefs.setGatewayToken(value)
}
fun setGatewayPassword(value: String) {
prefs.setGatewayPassword(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value)
}
@ -529,25 +559,52 @@ class NodeRuntime(context: Context) {
caps = buildCapabilities(),
commands = buildInvokeCommands(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"),
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
userAgent = buildUserAgent(),
)
}
private fun computeDeviceIdentityStatusText(): String {
return try {
val identity = identityStore.loadOrCreate()
val publicKey = identityStore.publicKeyBase64Url(identity)?.trim().orEmpty()
val signature = identityStore.signPayload("clawdbot:identity-probe", identity)?.trim().orEmpty()
// Fallback identity uses a placeholder public key (Base64 of a single 0 byte is "AA==").
val placeholder = identity.publicKeyRawBase64.trim() == "AA==" || identity.privateKeyPkcs8Base64.trim() == "AA=="
when {
placeholder -> "Missing (crypto fallback)"
publicKey.isEmpty() -> "Missing (no public key)"
signature.isEmpty() -> "Missing (cannot sign)"
else -> "OK"
}
} catch (err: Throwable) {
"Missing (${err::class.java.simpleName})"
}
}
private fun buildOperatorConnectOptions(): GatewayConnectOptions {
// IMPORTANT:
// Do NOT use the CONTROL_UI client id here. The gateway enforces extra "secure context"
// rules for the Control UI (HTTPS/localhost), and our Android operator session is a native
// client over WS/WSS.
return GatewayConnectOptions(
role = "operator",
scopes = emptyList(),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"),
// Use a known client id/mode so the gateway accepts the handshake.
// Avoid CONTROL_UI (web control panel) which enforces secure-context rules.
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "ui"),
userAgent = buildUserAgent(),
)
}
fun refreshGatewayConnection() {
val endpoint = connectedEndpoint ?: return
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
val tls = resolveTlsParams(endpoint)
@ -562,6 +619,7 @@ class NodeRuntime(context: Context) {
operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…"
updateStatus()
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword()
val tls = resolveTlsParams(endpoint)
@ -613,6 +671,52 @@ class NodeRuntime(context: Context) {
nodeSession.disconnect()
}
fun requestNodePairing() {
val endpoint = connectedEndpoint
if (!_isConnected.value || endpoint == null) {
_statusText.value = "Pairing failed: not connected"
return
}
scope.launch {
try {
val params =
buildJsonObject {
// IMPORTANT: Pair the actual nodeId used by the gateway for this device.
// With device identity enabled, this will be the deviceId (sha256 of public key).
val nodeId = identityStore.loadOrCreate().deviceId
put("nodeId", JsonPrimitive(nodeId))
put("displayName", JsonPrimitive(displayName.value))
put("platform", JsonPrimitive("android"))
put("version", JsonPrimitive(resolvedVersionName()))
put("deviceFamily", JsonPrimitive("Android"))
resolveModelIdentifier()?.let { put("modelIdentifier", JsonPrimitive(it)) }
put("caps", JsonArray(buildCapabilities().map(::JsonPrimitive)))
put("commands", JsonArray(buildInvokeCommands().map(::JsonPrimitive)))
put("silent", JsonPrimitive(false))
}
val res = operatorSession.request("node.pair.request", params.toString(), timeoutMs = 15_000)
val requestId =
try {
val obj = json.parseToJsonElement(res).asObjectOrNull()
obj?.get("request")?.asObjectOrNull()?.get("requestId")?.asStringOrNull()
} catch (_: Throwable) {
null
}
_statusText.value =
if (requestId.isNullOrBlank()) {
"Pairing requested"
} else {
"Pairing requested: $requestId"
}
} catch (err: Throwable) {
_statusText.value = "Pairing failed: ${err.message ?: err::class.java.simpleName}"
}
}
}
private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
@ -667,51 +771,123 @@ class NodeRuntime(context: Context) {
}
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
val sourceComponentId =
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
// Extract context as key/value map (supports literal* values).
fun contextString(key: String): String? {
val ctx = userActionObj["context"] as? JsonObject ?: return null
val entry = ctx[key] as? JsonPrimitive ?: return null
return entry.content.trim().takeIf { it.isNotEmpty() }
}
val sessionKey = resolveMainSessionKey()
val message =
OpenClawCanvasA2UIAction.formatAgentMessage(
actionName = name,
sessionKey = sessionKey,
surfaceId = surfaceId,
sourceComponentId = sourceComponentId,
host = displayName.value,
instanceId = instanceId.value.lowercase(),
contextJson = contextJson,
)
fun contextNumber(key: String): Double? {
val ctx = userActionObj["context"] as? JsonObject ?: return null
val entry = ctx[key] as? JsonPrimitive ?: return null
return entry.content.toDoubleOrNull()
}
val connected = nodeConnected
val nodeId = contextString("nodeId")
var ok = false
var error: String? = null
if (connected) {
try {
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
// If the action name matches a known node command, invoke it directly via the operator session.
// (Do not require operatorConnected here; if it's offline we'll return a visible error.)
val (command, params) =
when (name) {
"camera.snap" -> "camera.snap" to buildJsonObject { }
"camera.clip" -> {
val durationSec = (contextNumber("durationSec") ?: 5.0).toLong().coerceIn(1, 60)
"camera.clip" to
buildJsonObject {
put("message", JsonPrimitive(message))
put("sessionKey", JsonPrimitive(sessionKey))
put("thinking", JsonPrimitive("low"))
put("deliver", JsonPrimitive(false))
put("key", JsonPrimitive(actionId))
}.toString(),
)
} catch (e: Throwable) {
error = e.message ?: "send failed"
put("durationMs", JsonPrimitive(durationSec * 1000))
put("includeAudio", JsonPrimitive(true))
}
}
"screen.record" -> {
val durationSec = (contextNumber("durationSec") ?: 15.0).toLong().coerceIn(1, 180)
"screen.record" to
buildJsonObject {
put("durationMs", JsonPrimitive(durationSec * 1000))
put("includeAudio", JsonPrimitive(false))
}
}
"location.get" ->
"location.get" to
buildJsonObject {
put("timeoutMs", JsonPrimitive(10_000))
put("desiredAccuracy", JsonPrimitive("balanced"))
}
else -> null
} ?: (null to null)
val isDirectNodeCommand = command != null && params != null
if (isDirectNodeCommand) {
if (nodeId == null) {
error = "missing nodeId in action context"
} else {
try {
val invokeParams =
buildJsonObject {
put("nodeId", JsonPrimitive(nodeId))
put("command", JsonPrimitive(command!!))
put("params", params!!)
put("timeoutMs", JsonPrimitive(30_000))
put("idempotencyKey", JsonPrimitive(actionId))
}
operatorSession.request("node.invoke", invokeParams.toString(), timeoutMs = 35_000)
ok = true
} catch (e: Throwable) {
error = e.message ?: "invoke failed"
}
}
}
// Fall back to agent request only for unknown action names.
if (!ok && error == null && !isDirectNodeCommand) {
val surfaceId =
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
val sourceComponentId =
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
val sessionKey = resolveMainSessionKey()
val message =
OpenClawCanvasA2UIAction.formatAgentMessage(
actionName = name,
sessionKey = sessionKey,
surfaceId = surfaceId,
sourceComponentId = sourceComponentId,
host = displayName.value,
instanceId = instanceId.value.lowercase(),
contextJson = contextJson,
)
if (nodeConnected) {
try {
nodeSession.sendNodeEvent(
event = "agent.request",
payloadJson =
buildJsonObject {
put("message", JsonPrimitive(message))
put("sessionKey", JsonPrimitive(sessionKey))
put("thinking", JsonPrimitive("low"))
put("deliver", JsonPrimitive(false))
put("key", JsonPrimitive(actionId))
}.toString(),
)
ok = true
} catch (e: Throwable) {
error = e.message ?: "send failed"
}
} else {
error = "gateway not connected"
}
} else {
error = "gateway not connected"
}
try {
canvas.eval(
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId = actionId,
ok = connected && error == null,
ok = ok && error == null,
error = error,
),
)
@ -1115,7 +1291,7 @@ class NodeRuntime(context: Context) {
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null
val base = raw.trimEnd('/')
return "${base}/__openclaw__/a2ui/?platform=android"
return "${base}/__clawdbot__/a2ui/?platform=android"
}
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
@ -1207,8 +1383,7 @@ private const val a2uiReadyCheckJS: String =
"""
(() => {
try {
const host = globalThis.openclawA2UI;
return !!host && typeof host.applyMessages === 'function';
return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
} catch (_) {
return false;
}
@ -1219,9 +1394,8 @@ private const val a2uiResetJS: String =
"""
(() => {
try {
const host = globalThis.openclawA2UI;
if (!host) return { ok: false, error: "missing openclawA2UI" };
return host.reset();
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
return globalThis.clawdbotA2UI.reset();
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}
@ -1232,10 +1406,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
return """
(() => {
try {
const host = globalThis.openclawA2UI;
if (!host) return { ok: false, error: "missing openclawA2UI" };
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
const messages = $messagesJson;
return host.applyMessages(messages);
return globalThis.clawdbotA2UI.applyMessages(messages);
} catch (e) {
return { ok: false, error: String(e?.message ?? e) };
}

View File

@ -71,6 +71,16 @@ class SecurePrefs(context: Context) {
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow<Boolean> = _manualTls
// Gateway auth (used for both discovery + manual connects)
private val _gatewayToken = MutableStateFlow(loadGatewayToken().orEmpty())
val gatewayToken: StateFlow<String> = _gatewayToken
private val _gatewayPassword = MutableStateFlow(loadGatewayPassword().orEmpty())
val gatewayPassword: StateFlow<String> = _gatewayPassword
// NOTE: node pairing tokens are not valid gateway connect auth tokens.
// Keep node pairing state on the gateway; do not store a separate node token here.
private val _lastDiscoveredStableId =
MutableStateFlow(
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
@ -143,6 +153,26 @@ class SecurePrefs(context: Context) {
_manualTls.value = value
}
fun setGatewayToken(value: String) {
val trimmed = value.trim()
val key = "gateway.token.${_instanceId.value}"
prefs.edit {
if (trimmed.isEmpty()) remove(key) else putString(key, trimmed)
}
_gatewayToken.value = trimmed
}
fun setGatewayPassword(value: String) {
val trimmed = value.trim()
val key = "gateway.password.${_instanceId.value}"
prefs.edit {
if (trimmed.isEmpty()) remove(key) else putString(key, trimmed)
}
_gatewayPassword.value = trimmed
}
// node token setter removed (see note above)
fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value
@ -157,6 +187,7 @@ class SecurePrefs(context: Context) {
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
prefs.edit { putString(key, token.trim()) }
_gatewayToken.value = token.trim()
}
fun loadGatewayPassword(): String? {
@ -168,8 +199,11 @@ class SecurePrefs(context: Context) {
fun saveGatewayPassword(password: String) {
val key = "gateway.password.${_instanceId.value}"
prefs.edit { putString(key, password.trim()) }
_gatewayPassword.value = password.trim()
}
// node token loader removed (see note above)
fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId"
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }

View File

@ -7,7 +7,9 @@ import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.Signature
import java.security.Security
import java.security.spec.PKCS8EncodedKeySpec
import java.util.UUID
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -23,6 +25,14 @@ class DeviceIdentityStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
fun resetIdentity() {
try {
if (identityFile.exists()) identityFile.delete()
} catch (_: Throwable) {
// best-effort
}
}
@Synchronized
fun loadOrCreate(): DeviceIdentity {
val existing = load()
@ -44,9 +54,11 @@ class DeviceIdentityStore(context: Context) {
return try {
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
val keyFactory = KeyFactory.getInstance("Ed25519")
val keyFactory = runCatching { KeyFactory.getInstance("Ed25519", "BC") }.getOrNull()
?: KeyFactory.getInstance("Ed25519")
val privateKey = keyFactory.generatePrivate(keySpec)
val signature = Signature.getInstance("Ed25519")
val signature = runCatching { Signature.getInstance("Ed25519", "BC") }.getOrNull()
?: Signature.getInstance("Ed25519")
signature.initSign(privateKey)
signature.update(payload.toByteArray(Charsets.UTF_8))
base64UrlEncode(signature.sign())
@ -97,17 +109,33 @@ class DeviceIdentityStore(context: Context) {
}
private fun generate(): DeviceIdentity {
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
val spki = keyPair.public.encoded
val rawPublic = stripSpkiPrefix(spki)
val deviceId = sha256Hex(rawPublic)
val privateKey = keyPair.private.encoded
return DeviceIdentity(
deviceId = deviceId,
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP),
createdAtMs = System.currentTimeMillis(),
)
// Some Android builds/devices can be missing Ed25519 support at runtime (or have
// broken providers). The gateway can still accept a token-only connection, so
// we fall back to a "no-signature" identity rather than crashing.
return try {
// Prefer BC provider when available.
val kpg = runCatching { KeyPairGenerator.getInstance("Ed25519", "BC") }.getOrNull()
?: KeyPairGenerator.getInstance("Ed25519")
val keyPair = kpg.generateKeyPair()
val spki = keyPair.public.encoded
val rawPublic = stripSpkiPrefix(spki)
val deviceId = sha256Hex(rawPublic)
val privateKey = keyPair.private.encoded
DeviceIdentity(
deviceId = deviceId,
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP),
createdAtMs = System.currentTimeMillis(),
)
} catch (_: Throwable) {
val placeholder = Base64.encodeToString(byteArrayOf(0), Base64.NO_WRAP)
DeviceIdentity(
deviceId = UUID.randomUUID().toString(),
publicKeyRawBase64 = placeholder,
privateKeyPkcs8Base64 = placeholder,
createdAtMs = System.currentTimeMillis(),
)
}
}
private fun deriveDeviceId(publicKeyRawBase64: String): String? {

View File

@ -68,6 +68,7 @@ class CanvasController {
fun onPageFinished() {
applyDebugStatus()
injectA2uiActionBridgeCompat()
}
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) {
@ -122,6 +123,37 @@ class CanvasController {
}
}
// Some WebView/JS-bridge combos don't reliably preserve `handler === globalThis.clawdbotCanvasA2UIAction`,
// which makes the A2UI bundle try to post an object instead of a string. Android JS interfaces only
// accept primitives, so clicks appear to do nothing. This shim forces string payloads.
private fun injectA2uiActionBridgeCompat() {
withWebViewOnMain { wv ->
val js =
"""
(() => {
try {
const native = globalThis.clawdbotCanvasA2UIAction;
if (!native || typeof native.postMessage !== 'function') return;
if (native.__clawdbotWrapped) return;
const wrapper = {
__clawdbotWrapped: true,
postMessage: (payload) => {
try {
if (typeof payload === 'string') return native.postMessage(payload);
return native.postMessage(JSON.stringify(payload));
} catch (e) {
try { native.postMessage(String(payload)); } catch (_) {}
}
}
};
globalThis.clawdbotCanvasA2UIAction = wrapper;
} catch (_) {}
})();
""".trimIndent()
wv.evaluateJavascript(js, null)
}
}
suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")

View File

@ -70,6 +70,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val context = LocalContext.current
val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState()
val deviceIdentityStatusText by viewModel.deviceIdentityStatusText.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val locationMode by viewModel.locationMode.collectAsState()
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
@ -82,6 +83,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.collectAsState()
val gatewayToken by viewModel.gatewayToken.collectAsState()
val gatewayPassword by viewModel.gatewayPassword.collectAsState()
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.collectAsState()
@ -277,6 +280,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { Text("Identity: $deviceIdentityStatusText", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { Button(onClick = viewModel::resetDeviceIdentity) { Text("Reset identity") } }
item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) }
@ -292,15 +297,26 @@ fun SettingsSheet(viewModel: MainViewModel) {
item { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
}
item {
// UI sanity: "Disconnect" only when we have an active remote.
if (isConnected && remoteAddress != null) {
Button(
onClick = {
viewModel.disconnect()
NodeForegroundService.stop(context)
},
) {
Text("Disconnect")
// Keep Disconnect/Pairing visible even if only the node session is connected.
if (isConnected) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(
onClick = {
viewModel.disconnect()
NodeForegroundService.stop(context)
},
) {
Text("Disconnect")
}
Button(
onClick = {
// Triggers a node pairing request (so it appears in `clawdbot nodes pending`).
viewModel.requestNodePairing()
},
) {
Text("Request Pairing")
}
}
}
}
@ -403,6 +419,25 @@ fun SettingsSheet(viewModel: MainViewModel) {
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
OutlinedTextField(
value = gatewayToken,
onValueChange = viewModel::setGatewayToken,
label = { Text("Gateway token") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
OutlinedTextField(
value = gatewayPassword,
onValueChange = viewModel::setGatewayPassword,
label = { Text("Gateway password") },
modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled,
)
// Node pairing token removed: gateway does not accept node pairing tokens as connect auth.
ListItem(
headlineContent = { Text("Require TLS") },
supportingContent = { Text("Pin the gateway certificate on first connect.") },