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) // Material Components (XML theme + resources)
implementation("com.google.android.material:material:1.13.0") 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-coroutines-android:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") 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 instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName val displayName: StateFlow<String> = runtime.displayName
val deviceIdentityStatusText: StateFlow<String> = runtime.deviceIdentityStatusText
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
val locationMode: StateFlow<LocationMode> = runtime.locationMode val locationMode: StateFlow<LocationMode> = runtime.locationMode
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
@ -51,6 +52,9 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val manualHost: StateFlow<String> = runtime.manualHost val manualHost: StateFlow<String> = runtime.manualHost
val manualPort: StateFlow<Int> = runtime.manualPort val manualPort: StateFlow<Int> = runtime.manualPort
val manualTls: StateFlow<Boolean> = runtime.manualTls 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 canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
@ -104,6 +108,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.setManualTls(value) runtime.setManualTls(value)
} }
fun setGatewayToken(value: String) {
runtime.setGatewayToken(value)
}
fun setGatewayPassword(value: String) {
runtime.setGatewayPassword(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
runtime.setCanvasDebugStatusEnabled(value) runtime.setCanvasDebugStatusEnabled(value)
} }
@ -140,6 +152,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
runtime.disconnect() runtime.disconnect()
} }
fun requestNodePairing() {
runtime.requestNodePairing()
}
fun resetDeviceIdentity() {
runtime.resetDeviceIdentity()
}
fun handleCanvasA2UIActionFromWebView(payloadJson: String) { fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
runtime.handleCanvasA2UIActionFromWebView(payloadJson) runtime.handleCanvasA2UIActionFromWebView(payloadJson)
} }

View File

@ -2,12 +2,23 @@ package ai.openclaw.android
import android.app.Application import android.app.Application
import android.os.StrictMode import android.os.StrictMode
import java.security.Security
import org.bouncycastle.jce.provider.BouncyCastleProvider
class NodeApp : Application() { class NodeApp : Application() {
val runtime: NodeRuntime by lazy { NodeRuntime(this) } val runtime: NodeRuntime by lazy { NodeRuntime(this) }
override fun onCreate() { override fun onCreate() {
super.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) { if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy( StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder() StrictMode.ThreadPolicy.Builder()

View File

@ -113,6 +113,15 @@ class NodeRuntime(context: Context) {
private val identityStore = DeviceIdentityStore(appContext) 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) private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -170,8 +179,11 @@ class NodeRuntime(context: Context) {
onDisconnected = { message -> onDisconnected = { message ->
operatorConnected = false operatorConnected = false
operatorStatusText = message operatorStatusText = message
_serverName.value = null // If the node session is still connected, keep server/remote around so Settings stays usable.
_remoteAddress.value = null if (!nodeConnected) {
_serverName.value = null
_remoteAddress.value = null
}
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB _seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) { if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
_mainSessionKey.value = "main" _mainSessionKey.value = "main"
@ -192,15 +204,22 @@ class NodeRuntime(context: Context) {
scope = scope, scope = scope,
identityStore = identityStore, identityStore = identityStore,
deviceAuthStore = deviceAuthStore, deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ -> onConnected = { name, remote, _ ->
nodeConnected = true nodeConnected = true
nodeStatusText = "Connected" 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() updateStatus()
maybeNavigateToA2uiOnConnect() maybeNavigateToA2uiOnConnect()
}, },
onDisconnected = { message -> onDisconnected = { message ->
nodeConnected = false nodeConnected = false
nodeStatusText = message nodeStatusText = message
if (!operatorConnected) {
_serverName.value = null
_remoteAddress.value = null
}
updateStatus() updateStatus()
showLocalCanvasOnDisconnect() showLocalCanvasOnDisconnect()
}, },
@ -241,7 +260,7 @@ class NodeRuntime(context: Context) {
} }
private fun updateStatus() { private fun updateStatus() {
_isConnected.value = operatorConnected _isConnected.value = operatorConnected || nodeConnected
_statusText.value = _statusText.value =
when { when {
operatorConnected && nodeConnected -> "Connected" operatorConnected && nodeConnected -> "Connected"
@ -284,6 +303,9 @@ class NodeRuntime(context: Context) {
val manualHost: StateFlow<String> = prefs.manualHost val manualHost: StateFlow<String> = prefs.manualHost
val manualPort: StateFlow<Int> = prefs.manualPort val manualPort: StateFlow<Int> = prefs.manualPort
val manualTls: StateFlow<Boolean> = prefs.manualTls 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 lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
@ -428,6 +450,14 @@ class NodeRuntime(context: Context) {
prefs.setManualTls(value) prefs.setManualTls(value)
} }
fun setGatewayToken(value: String) {
prefs.setGatewayToken(value)
}
fun setGatewayPassword(value: String) {
prefs.setGatewayPassword(value)
}
fun setCanvasDebugStatusEnabled(value: Boolean) { fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.setCanvasDebugStatusEnabled(value) prefs.setCanvasDebugStatusEnabled(value)
} }
@ -529,25 +559,52 @@ class NodeRuntime(context: Context) {
caps = buildCapabilities(), caps = buildCapabilities(),
commands = buildInvokeCommands(), commands = buildInvokeCommands(),
permissions = emptyMap(), permissions = emptyMap(),
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"), client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
userAgent = buildUserAgent(), 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 { 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( return GatewayConnectOptions(
role = "operator", role = "operator",
scopes = emptyList(), scopes = emptyList(),
caps = emptyList(), caps = emptyList(),
commands = emptyList(), commands = emptyList(),
permissions = emptyMap(), 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(), userAgent = buildUserAgent(),
) )
} }
fun refreshGatewayConnection() { fun refreshGatewayConnection() {
val endpoint = connectedEndpoint ?: return val endpoint = connectedEndpoint ?: return
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
val token = prefs.loadGatewayToken() val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword() val password = prefs.loadGatewayPassword()
val tls = resolveTlsParams(endpoint) val tls = resolveTlsParams(endpoint)
@ -562,6 +619,7 @@ class NodeRuntime(context: Context) {
operatorStatusText = "Connecting…" operatorStatusText = "Connecting…"
nodeStatusText = "Connecting…" nodeStatusText = "Connecting…"
updateStatus() updateStatus()
_deviceIdentityStatusText.value = computeDeviceIdentityStatusText()
val token = prefs.loadGatewayToken() val token = prefs.loadGatewayToken()
val password = prefs.loadGatewayPassword() val password = prefs.loadGatewayPassword()
val tls = resolveTlsParams(endpoint) val tls = resolveTlsParams(endpoint)
@ -613,6 +671,52 @@ class NodeRuntime(context: Context) {
nodeSession.disconnect() 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? { private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId) val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank() val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
@ -667,51 +771,123 @@ class NodeRuntime(context: Context) {
} }
val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch val name = OpenClawCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
val surfaceId = // Extract context as key/value map (supports literal* values).
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" } fun contextString(key: String): String? {
val sourceComponentId = val ctx = userActionObj["context"] as? JsonObject ?: return null
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" } val entry = ctx[key] as? JsonPrimitive ?: return null
val contextJson = (userActionObj["context"] as? JsonObject)?.toString() return entry.content.trim().takeIf { it.isNotEmpty() }
}
val sessionKey = resolveMainSessionKey() fun contextNumber(key: String): Double? {
val message = val ctx = userActionObj["context"] as? JsonObject ?: return null
OpenClawCanvasA2UIAction.formatAgentMessage( val entry = ctx[key] as? JsonPrimitive ?: return null
actionName = name, return entry.content.toDoubleOrNull()
sessionKey = sessionKey, }
surfaceId = surfaceId,
sourceComponentId = sourceComponentId,
host = displayName.value,
instanceId = instanceId.value.lowercase(),
contextJson = contextJson,
)
val connected = nodeConnected val nodeId = contextString("nodeId")
var ok = false
var error: String? = null var error: String? = null
if (connected) {
try { // If the action name matches a known node command, invoke it directly via the operator session.
nodeSession.sendNodeEvent( // (Do not require operatorConnected here; if it's offline we'll return a visible error.)
event = "agent.request", val (command, params) =
payloadJson = when (name) {
"camera.snap" -> "camera.snap" to buildJsonObject { }
"camera.clip" -> {
val durationSec = (contextNumber("durationSec") ?: 5.0).toLong().coerceIn(1, 60)
"camera.clip" to
buildJsonObject { buildJsonObject {
put("message", JsonPrimitive(message)) put("durationMs", JsonPrimitive(durationSec * 1000))
put("sessionKey", JsonPrimitive(sessionKey)) put("includeAudio", JsonPrimitive(true))
put("thinking", JsonPrimitive("low")) }
put("deliver", JsonPrimitive(false)) }
put("key", JsonPrimitive(actionId)) "screen.record" -> {
}.toString(), val durationSec = (contextNumber("durationSec") ?: 15.0).toLong().coerceIn(1, 180)
) "screen.record" to
} catch (e: Throwable) { buildJsonObject {
error = e.message ?: "send failed" 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 { try {
canvas.eval( canvas.eval(
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus( OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
actionId = actionId, actionId = actionId,
ok = connected && error == null, ok = ok && error == null,
error = error, error = error,
), ),
) )
@ -1115,7 +1291,7 @@ class NodeRuntime(context: Context) {
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
if (raw.isBlank()) return null if (raw.isBlank()) return null
val base = raw.trimEnd('/') val base = raw.trimEnd('/')
return "${base}/__openclaw__/a2ui/?platform=android" return "${base}/__clawdbot__/a2ui/?platform=android"
} }
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean { private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
@ -1207,8 +1383,7 @@ private const val a2uiReadyCheckJS: String =
""" """
(() => { (() => {
try { try {
const host = globalThis.openclawA2UI; return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
return !!host && typeof host.applyMessages === 'function';
} catch (_) { } catch (_) {
return false; return false;
} }
@ -1219,9 +1394,8 @@ private const val a2uiResetJS: String =
""" """
(() => { (() => {
try { try {
const host = globalThis.openclawA2UI; if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
if (!host) return { ok: false, error: "missing openclawA2UI" }; return globalThis.clawdbotA2UI.reset();
return host.reset();
} catch (e) { } catch (e) {
return { ok: false, error: String(e?.message ?? e) }; return { ok: false, error: String(e?.message ?? e) };
} }
@ -1232,10 +1406,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
return """ return """
(() => { (() => {
try { try {
const host = globalThis.openclawA2UI; if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
if (!host) return { ok: false, error: "missing openclawA2UI" };
const messages = $messagesJson; const messages = $messagesJson;
return host.applyMessages(messages); return globalThis.clawdbotA2UI.applyMessages(messages);
} catch (e) { } catch (e) {
return { ok: false, error: String(e?.message ?? 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)) MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
val manualTls: StateFlow<Boolean> = _manualTls 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 = private val _lastDiscoveredStableId =
MutableStateFlow( MutableStateFlow(
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "", prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
@ -143,6 +153,26 @@ class SecurePrefs(context: Context) {
_manualTls.value = value _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) { fun setCanvasDebugStatusEnabled(value: Boolean) {
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) } prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
_canvasDebugStatusEnabled.value = value _canvasDebugStatusEnabled.value = value
@ -157,6 +187,7 @@ class SecurePrefs(context: Context) {
fun saveGatewayToken(token: String) { fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}" val key = "gateway.token.${_instanceId.value}"
prefs.edit { putString(key, token.trim()) } prefs.edit { putString(key, token.trim()) }
_gatewayToken.value = token.trim()
} }
fun loadGatewayPassword(): String? { fun loadGatewayPassword(): String? {
@ -168,8 +199,11 @@ class SecurePrefs(context: Context) {
fun saveGatewayPassword(password: String) { fun saveGatewayPassword(password: String) {
val key = "gateway.password.${_instanceId.value}" val key = "gateway.password.${_instanceId.value}"
prefs.edit { putString(key, password.trim()) } prefs.edit { putString(key, password.trim()) }
_gatewayPassword.value = password.trim()
} }
// node token loader removed (see note above)
fun loadGatewayTlsFingerprint(stableId: String): String? { fun loadGatewayTlsFingerprint(stableId: String): String? {
val key = "gateway.tls.$stableId" val key = "gateway.tls.$stableId"
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() } 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.KeyPairGenerator
import java.security.MessageDigest import java.security.MessageDigest
import java.security.Signature import java.security.Signature
import java.security.Security
import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.PKCS8EncodedKeySpec
import java.util.UUID
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -23,6 +25,14 @@ class DeviceIdentityStore(context: Context) {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val identityFile = File(context.filesDir, "openclaw/identity/device.json") private val identityFile = File(context.filesDir, "openclaw/identity/device.json")
fun resetIdentity() {
try {
if (identityFile.exists()) identityFile.delete()
} catch (_: Throwable) {
// best-effort
}
}
@Synchronized @Synchronized
fun loadOrCreate(): DeviceIdentity { fun loadOrCreate(): DeviceIdentity {
val existing = load() val existing = load()
@ -44,9 +54,11 @@ class DeviceIdentityStore(context: Context) {
return try { return try {
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT) val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes) 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 privateKey = keyFactory.generatePrivate(keySpec)
val signature = Signature.getInstance("Ed25519") val signature = runCatching { Signature.getInstance("Ed25519", "BC") }.getOrNull()
?: Signature.getInstance("Ed25519")
signature.initSign(privateKey) signature.initSign(privateKey)
signature.update(payload.toByteArray(Charsets.UTF_8)) signature.update(payload.toByteArray(Charsets.UTF_8))
base64UrlEncode(signature.sign()) base64UrlEncode(signature.sign())
@ -97,17 +109,33 @@ class DeviceIdentityStore(context: Context) {
} }
private fun generate(): DeviceIdentity { private fun generate(): DeviceIdentity {
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair() // Some Android builds/devices can be missing Ed25519 support at runtime (or have
val spki = keyPair.public.encoded // broken providers). The gateway can still accept a token-only connection, so
val rawPublic = stripSpkiPrefix(spki) // we fall back to a "no-signature" identity rather than crashing.
val deviceId = sha256Hex(rawPublic) return try {
val privateKey = keyPair.private.encoded // Prefer BC provider when available.
return DeviceIdentity( val kpg = runCatching { KeyPairGenerator.getInstance("Ed25519", "BC") }.getOrNull()
deviceId = deviceId, ?: KeyPairGenerator.getInstance("Ed25519")
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP), val keyPair = kpg.generateKeyPair()
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP), val spki = keyPair.public.encoded
createdAtMs = System.currentTimeMillis(), 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? { private fun deriveDeviceId(publicKeyRawBase64: String): String? {

View File

@ -68,6 +68,7 @@ class CanvasController {
fun onPageFinished() { fun onPageFinished() {
applyDebugStatus() applyDebugStatus()
injectA2uiActionBridgeCompat()
} }
private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { 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 = suspend fun eval(javaScript: String): String =
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview") val wv = webView ?: throw IllegalStateException("no webview")

View File

@ -70,6 +70,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
val context = LocalContext.current val context = LocalContext.current
val instanceId by viewModel.instanceId.collectAsState() val instanceId by viewModel.instanceId.collectAsState()
val displayName by viewModel.displayName.collectAsState() val displayName by viewModel.displayName.collectAsState()
val deviceIdentityStatusText by viewModel.deviceIdentityStatusText.collectAsState()
val cameraEnabled by viewModel.cameraEnabled.collectAsState() val cameraEnabled by viewModel.cameraEnabled.collectAsState()
val locationMode by viewModel.locationMode.collectAsState() val locationMode by viewModel.locationMode.collectAsState()
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState() val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
@ -82,6 +83,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val manualHost by viewModel.manualHost.collectAsState() val manualHost by viewModel.manualHost.collectAsState()
val manualPort by viewModel.manualPort.collectAsState() val manualPort by viewModel.manualPort.collectAsState()
val manualTls by viewModel.manualTls.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 canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
val statusText by viewModel.statusText.collectAsState() val statusText by viewModel.statusText.collectAsState()
val serverName by viewModel.serverName.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("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("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
item { Text("Version: $appVersion", 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 { ListItem(headlineContent = { Text("Address") }, supportingContent = { Text(remoteAddress!!) }) }
} }
item { item {
// UI sanity: "Disconnect" only when we have an active remote. // Keep Disconnect/Pairing visible even if only the node session is connected.
if (isConnected && remoteAddress != null) { if (isConnected) {
Button( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
onClick = { Button(
viewModel.disconnect() onClick = {
NodeForegroundService.stop(context) viewModel.disconnect()
}, NodeForegroundService.stop(context)
) { },
Text("Disconnect") ) {
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(), modifier = Modifier.fillMaxWidth(),
enabled = manualEnabled, 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( ListItem(
headlineContent = { Text("Require TLS") }, headlineContent = { Text("Require TLS") },
supportingContent = { Text("Pin the gateway certificate on first connect.") }, supportingContent = { Text("Pin the gateway certificate on first connect.") },