feat: migrate android node to gateway ws
This commit is contained in:
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.19-2
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.
|
||||||
|
- Docs: refresh Android node discovery docs for the Gateway WS service type.
|
||||||
|
|
||||||
## 2026.1.19-1
|
## 2026.1.19-1
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
## Clawdbot Node (Android) (internal)
|
## Clawdbot Node (Android) (internal)
|
||||||
|
|
||||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
Modern Android node app: connects to the **Gateway WebSocket** (`_clawdbot-gateway._tcp`) and exposes **Canvas + Chat + Camera**.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||||
@@ -30,7 +30,7 @@ pnpm clawdbot gateway --port 18789 --verbose
|
|||||||
|
|
||||||
2) In the Android app:
|
2) In the Android app:
|
||||||
- Open **Settings**
|
- Open **Settings**
|
||||||
- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).
|
- Either select a discovered gateway under **Discovered Gateways**, or use **Advanced → Manual Gateway** (host + port).
|
||||||
|
|
||||||
3) Approve pairing (on the gateway machine):
|
3) Approve pairing (on the gateway machine):
|
||||||
```bash
|
```bash
|
||||||
@@ -38,7 +38,7 @@ clawdbot nodes pending
|
|||||||
clawdbot nodes approve <requestId>
|
clawdbot nodes approve <requestId>
|
||||||
```
|
```
|
||||||
|
|
||||||
More details: `docs/android/connect.md`.
|
More details: `docs/platforms/android.md`.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation("androidx.security:security-crypto:1.1.0")
|
implementation("androidx.security:security-crypto:1.1.0")
|
||||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
|
||||||
// CameraX (for node.invoke camera.* parity)
|
// CameraX (for node.invoke camera.* parity)
|
||||||
implementation("androidx.camera:camera-core:1.5.2")
|
implementation("androidx.camera:camera-core:1.5.2")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.clawdbot.android
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
import com.clawdbot.android.gateway.GatewayEndpoint
|
||||||
import com.clawdbot.android.chat.OutgoingAttachment
|
import com.clawdbot.android.chat.OutgoingAttachment
|
||||||
import com.clawdbot.android.node.CameraCaptureManager
|
import com.clawdbot.android.node.CameraCaptureManager
|
||||||
import com.clawdbot.android.node.CanvasController
|
import com.clawdbot.android.node.CanvasController
|
||||||
@@ -18,7 +18,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||||
val sms: SmsManager = runtime.sms
|
val sms: SmsManager = runtime.sms
|
||||||
|
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
|
||||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||||
|
|
||||||
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||||
@@ -50,6 +50,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||||
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 canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||||
|
|
||||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||||
@@ -99,6 +100,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setManualPort(value)
|
runtime.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setManualTls(value: Boolean) {
|
||||||
|
runtime.setManualTls(value)
|
||||||
|
}
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||||
runtime.setCanvasDebugStatusEnabled(value)
|
runtime.setCanvasDebugStatusEnabled(value)
|
||||||
}
|
}
|
||||||
@@ -119,11 +124,11 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setTalkEnabled(enabled)
|
runtime.setTalkEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshBridgeHello() {
|
fun refreshGatewayConnection() {
|
||||||
runtime.refreshBridgeHello()
|
runtime.refreshGatewayConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: GatewayEndpoint) {
|
||||||
runtime.connect(endpoint)
|
runtime.connect(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import com.clawdbot.android.chat.ChatMessage
|
|||||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
import com.clawdbot.android.chat.ChatPendingToolCall
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
import com.clawdbot.android.chat.ChatSessionEntry
|
||||||
import com.clawdbot.android.chat.OutgoingAttachment
|
import com.clawdbot.android.chat.OutgoingAttachment
|
||||||
import com.clawdbot.android.bridge.BridgeDiscovery
|
import com.clawdbot.android.gateway.DeviceIdentityStore
|
||||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
import com.clawdbot.android.gateway.GatewayConnectOptions
|
||||||
import com.clawdbot.android.bridge.BridgePairingClient
|
import com.clawdbot.android.gateway.GatewayDiscovery
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.clawdbot.android.gateway.GatewayEndpoint
|
||||||
import com.clawdbot.android.bridge.BridgeTlsParams
|
import com.clawdbot.android.gateway.GatewaySession
|
||||||
|
import com.clawdbot.android.gateway.GatewayTlsParams
|
||||||
import com.clawdbot.android.node.CameraCaptureManager
|
import com.clawdbot.android.node.CameraCaptureManager
|
||||||
import com.clawdbot.android.node.LocationCaptureManager
|
import com.clawdbot.android.node.LocationCaptureManager
|
||||||
import com.clawdbot.android.BuildConfig
|
import com.clawdbot.android.BuildConfig
|
||||||
@@ -74,7 +75,7 @@ class NodeRuntime(context: Context) {
|
|||||||
context = appContext,
|
context = appContext,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onCommand = { command ->
|
onCommand = { command ->
|
||||||
session.sendEvent(
|
nodeSession.sendNodeEvent(
|
||||||
event = "agent.request",
|
event = "agent.request",
|
||||||
payloadJson =
|
payloadJson =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
@@ -103,10 +104,12 @@ class NodeRuntime(context: Context) {
|
|||||||
val talkIsSpeaking: StateFlow<Boolean>
|
val talkIsSpeaking: StateFlow<Boolean>
|
||||||
get() = talkMode.isSpeaking
|
get() = talkMode.isSpeaking
|
||||||
|
|
||||||
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
private val discovery = GatewayDiscovery(appContext, scope = scope)
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
|
||||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||||
|
|
||||||
|
private val identityStore = DeviceIdentityStore(appContext)
|
||||||
|
|
||||||
private val _isConnected = MutableStateFlow(false)
|
private val _isConnected = MutableStateFlow(false)
|
||||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
@@ -139,52 +142,87 @@ class NodeRuntime(context: Context) {
|
|||||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||||
|
|
||||||
private var lastAutoA2uiUrl: String? = null
|
private var lastAutoA2uiUrl: String? = null
|
||||||
|
private var operatorConnected = false
|
||||||
|
private var nodeConnected = false
|
||||||
|
private var operatorStatusText: String = "Offline"
|
||||||
|
private var nodeStatusText: String = "Offline"
|
||||||
|
private var connectedEndpoint: GatewayEndpoint? = null
|
||||||
|
|
||||||
private val session =
|
private val operatorSession =
|
||||||
BridgeSession(
|
GatewaySession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
|
identityStore = identityStore,
|
||||||
onConnected = { name, remote, mainSessionKey ->
|
onConnected = { name, remote, mainSessionKey ->
|
||||||
_statusText.value = "Connected"
|
operatorConnected = true
|
||||||
|
operatorStatusText = "Connected"
|
||||||
_serverName.value = name
|
_serverName.value = name
|
||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_isConnected.value = true
|
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
applyMainSessionKey(mainSessionKey)
|
applyMainSessionKey(mainSessionKey)
|
||||||
|
updateStatus()
|
||||||
scope.launch { refreshBrandingFromGateway() }
|
scope.launch { refreshBrandingFromGateway() }
|
||||||
scope.launch { refreshWakeWordsFromGateway() }
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
|
},
|
||||||
|
onDisconnected = { message ->
|
||||||
|
operatorConnected = false
|
||||||
|
operatorStatusText = message
|
||||||
|
_serverName.value = null
|
||||||
|
_remoteAddress.value = null
|
||||||
|
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||||
|
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
||||||
|
_mainSessionKey.value = "main"
|
||||||
|
}
|
||||||
|
val mainKey = resolveMainSessionKey()
|
||||||
|
talkMode.setMainSessionKey(mainKey)
|
||||||
|
chat.applyMainSessionKey(mainKey)
|
||||||
|
chat.onDisconnected(message)
|
||||||
|
updateStatus()
|
||||||
|
},
|
||||||
|
onEvent = { event, payloadJson ->
|
||||||
|
handleGatewayEvent(event, payloadJson)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private val nodeSession =
|
||||||
|
GatewaySession(
|
||||||
|
scope = scope,
|
||||||
|
identityStore = identityStore,
|
||||||
|
onConnected = { _, _, _ ->
|
||||||
|
nodeConnected = true
|
||||||
|
nodeStatusText = "Connected"
|
||||||
|
updateStatus()
|
||||||
maybeNavigateToA2uiOnConnect()
|
maybeNavigateToA2uiOnConnect()
|
||||||
},
|
},
|
||||||
onDisconnected = { message -> handleSessionDisconnected(message) },
|
onDisconnected = { message ->
|
||||||
onEvent = { event, payloadJson ->
|
nodeConnected = false
|
||||||
handleBridgeEvent(event, payloadJson)
|
nodeStatusText = message
|
||||||
|
updateStatus()
|
||||||
|
showLocalCanvasOnDisconnect()
|
||||||
},
|
},
|
||||||
|
onEvent = { _, _ -> },
|
||||||
onInvoke = { req ->
|
onInvoke = { req ->
|
||||||
handleInvoke(req.command, req.paramsJson)
|
handleInvoke(req.command, req.paramsJson)
|
||||||
},
|
},
|
||||||
onTlsFingerprint = { stableId, fingerprint ->
|
onTlsFingerprint = { stableId, fingerprint ->
|
||||||
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
|
prefs.saveGatewayTlsFingerprint(stableId, fingerprint)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
private val chat =
|
||||||
|
ChatController(
|
||||||
|
scope = scope,
|
||||||
|
session = operatorSession,
|
||||||
|
json = json,
|
||||||
|
supportsChatSubscribe = false,
|
||||||
|
)
|
||||||
private val talkMode: TalkModeManager by lazy {
|
private val talkMode: TalkModeManager by lazy {
|
||||||
TalkModeManager(context = appContext, scope = scope).also { it.attachSession(session) }
|
TalkModeManager(
|
||||||
}
|
context = appContext,
|
||||||
|
scope = scope,
|
||||||
private fun handleSessionDisconnected(message: String) {
|
session = operatorSession,
|
||||||
_statusText.value = message
|
supportsChatSubscribe = false,
|
||||||
_serverName.value = null
|
isConnected = { operatorConnected },
|
||||||
_remoteAddress.value = null
|
)
|
||||||
_isConnected.value = false
|
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
|
||||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
|
||||||
_mainSessionKey.value = "main"
|
|
||||||
}
|
|
||||||
val mainKey = resolveMainSessionKey()
|
|
||||||
talkMode.setMainSessionKey(mainKey)
|
|
||||||
chat.applyMainSessionKey(mainKey)
|
|
||||||
chat.onDisconnected(message)
|
|
||||||
showLocalCanvasOnDisconnect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyMainSessionKey(candidate: String?) {
|
private fun applyMainSessionKey(candidate: String?) {
|
||||||
@@ -197,6 +235,18 @@ class NodeRuntime(context: Context) {
|
|||||||
chat.applyMainSessionKey(trimmed)
|
chat.applyMainSessionKey(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateStatus() {
|
||||||
|
_isConnected.value = operatorConnected
|
||||||
|
_statusText.value =
|
||||||
|
when {
|
||||||
|
operatorConnected && nodeConnected -> "Connected"
|
||||||
|
operatorConnected && !nodeConnected -> "Connected (node offline)"
|
||||||
|
!operatorConnected && nodeConnected -> "Connected (operator offline)"
|
||||||
|
operatorStatusText.isNotBlank() && operatorStatusText != "Offline" -> operatorStatusText
|
||||||
|
else -> nodeStatusText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun resolveMainSessionKey(): String {
|
private fun resolveMainSessionKey(): String {
|
||||||
val trimmed = _mainSessionKey.value.trim()
|
val trimmed = _mainSessionKey.value.trim()
|
||||||
return if (trimmed.isEmpty()) "main" else trimmed
|
return if (trimmed.isEmpty()) "main" else trimmed
|
||||||
@@ -228,6 +278,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||||
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 lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||||
|
|
||||||
@@ -288,24 +339,21 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
bridges.collect { list ->
|
gateways.collect { list ->
|
||||||
if (list.isNotEmpty()) {
|
if (list.isNotEmpty()) {
|
||||||
// Persist the last discovered bridge (best-effort UX parity with iOS).
|
// Persist the last discovered gateway (best-effort UX parity with iOS).
|
||||||
prefs.setLastDiscoveredStableId(list.last().stableId)
|
prefs.setLastDiscoveredStableId(list.last().stableId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (didAutoConnect) return@collect
|
if (didAutoConnect) return@collect
|
||||||
if (_isConnected.value) return@collect
|
if (_isConnected.value) return@collect
|
||||||
|
|
||||||
val token = prefs.loadBridgeToken()
|
|
||||||
if (token.isNullOrBlank()) return@collect
|
|
||||||
|
|
||||||
if (manualEnabled.value) {
|
if (manualEnabled.value) {
|
||||||
val host = manualHost.value.trim()
|
val host = manualHost.value.trim()
|
||||||
val port = manualPort.value
|
val port = manualPort.value
|
||||||
if (host.isNotEmpty() && port in 1..65535) {
|
if (host.isNotEmpty() && port in 1..65535) {
|
||||||
didAutoConnect = true
|
didAutoConnect = true
|
||||||
connect(BridgeEndpoint.manual(host = host, port = port))
|
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||||
}
|
}
|
||||||
return@collect
|
return@collect
|
||||||
}
|
}
|
||||||
@@ -371,6 +419,10 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setManualPort(value)
|
prefs.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setManualTls(value: Boolean) {
|
||||||
|
prefs.setManualTls(value)
|
||||||
|
}
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||||
prefs.setCanvasDebugStatusEnabled(value)
|
prefs.setCanvasDebugStatusEnabled(value)
|
||||||
}
|
}
|
||||||
@@ -429,99 +481,78 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildPairingHello(token: String?): BridgePairingClient.Hello {
|
private fun resolvedVersionName(): String {
|
||||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
|
||||||
.joinToString(" ")
|
|
||||||
.trim()
|
|
||||||
.ifEmpty { null }
|
|
||||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||||
val advertisedVersion =
|
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
"$versionName-dev"
|
||||||
"$versionName-dev"
|
} else {
|
||||||
} else {
|
versionName
|
||||||
versionName
|
|
||||||
}
|
|
||||||
return BridgePairingClient.Hello(
|
|
||||||
nodeId = instanceId.value,
|
|
||||||
displayName = displayName.value,
|
|
||||||
token = token,
|
|
||||||
platform = "Android",
|
|
||||||
version = advertisedVersion,
|
|
||||||
deviceFamily = "Android",
|
|
||||||
modelIdentifier = modelIdentifier,
|
|
||||||
caps = buildCapabilities(),
|
|
||||||
commands = buildInvokeCommands(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildSessionHello(token: String?): BridgeSession.Hello {
|
|
||||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
|
||||||
.joinToString(" ")
|
|
||||||
.trim()
|
|
||||||
.ifEmpty { null }
|
|
||||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
|
||||||
val advertisedVersion =
|
|
||||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
|
||||||
"$versionName-dev"
|
|
||||||
} else {
|
|
||||||
versionName
|
|
||||||
}
|
|
||||||
return BridgeSession.Hello(
|
|
||||||
nodeId = instanceId.value,
|
|
||||||
displayName = displayName.value,
|
|
||||||
token = token,
|
|
||||||
platform = "Android",
|
|
||||||
version = advertisedVersion,
|
|
||||||
deviceFamily = "Android",
|
|
||||||
modelIdentifier = modelIdentifier,
|
|
||||||
caps = buildCapabilities(),
|
|
||||||
commands = buildInvokeCommands(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshBridgeHello() {
|
|
||||||
scope.launch {
|
|
||||||
if (!_isConnected.value) return@launch
|
|
||||||
val token = prefs.loadBridgeToken()
|
|
||||||
if (token.isNullOrBlank()) return@launch
|
|
||||||
session.updateHello(buildSessionHello(token))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
private fun resolveModelIdentifier(): String? {
|
||||||
scope.launch {
|
return listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
_statusText.value = "Connecting…"
|
.joinToString(" ")
|
||||||
val storedToken = prefs.loadBridgeToken()
|
.trim()
|
||||||
val tls = resolveTlsParams(endpoint)
|
.ifEmpty { null }
|
||||||
val resolved =
|
}
|
||||||
if (storedToken.isNullOrBlank()) {
|
|
||||||
_statusText.value = "Pairing…"
|
|
||||||
BridgePairingClient().pairAndHello(
|
|
||||||
endpoint = endpoint,
|
|
||||||
hello = buildPairingHello(token = null),
|
|
||||||
tls = tls,
|
|
||||||
onTlsFingerprint = { fingerprint ->
|
|
||||||
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
private fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo {
|
||||||
val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
|
return GatewayClientInfo(
|
||||||
_statusText.value = "Failed: $errorMessage"
|
id = clientId,
|
||||||
return@launch
|
displayName = displayName.value,
|
||||||
}
|
version = resolvedVersionName(),
|
||||||
|
platform = "android",
|
||||||
|
mode = clientMode,
|
||||||
|
instanceId = instanceId.value,
|
||||||
|
deviceFamily = "Android",
|
||||||
|
modelIdentifier = resolveModelIdentifier(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val authToken = requireNotNull(resolved.token).trim()
|
private fun buildNodeConnectOptions(): GatewayConnectOptions {
|
||||||
prefs.saveBridgeToken(authToken)
|
return GatewayConnectOptions(
|
||||||
session.connect(
|
role = "node",
|
||||||
endpoint = endpoint,
|
scopes = emptyList(),
|
||||||
hello = buildSessionHello(token = authToken),
|
caps = buildCapabilities(),
|
||||||
tls = tls,
|
commands = buildInvokeCommands(),
|
||||||
)
|
permissions = emptyMap(),
|
||||||
}
|
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildOperatorConnectOptions(): GatewayConnectOptions {
|
||||||
|
return GatewayConnectOptions(
|
||||||
|
role = "operator",
|
||||||
|
scopes = emptyList(),
|
||||||
|
caps = emptyList(),
|
||||||
|
commands = emptyList(),
|
||||||
|
permissions = emptyMap(),
|
||||||
|
client = buildClientInfo(clientId = "clawdbot-control-ui", clientMode = "ui"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshGatewayConnection() {
|
||||||
|
val endpoint = connectedEndpoint ?: return
|
||||||
|
val token = prefs.loadGatewayToken()
|
||||||
|
val password = prefs.loadGatewayPassword()
|
||||||
|
val tls = resolveTlsParams(endpoint)
|
||||||
|
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls)
|
||||||
|
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls)
|
||||||
|
operatorSession.reconnect()
|
||||||
|
nodeSession.reconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(endpoint: GatewayEndpoint) {
|
||||||
|
connectedEndpoint = endpoint
|
||||||
|
operatorStatusText = "Connecting…"
|
||||||
|
nodeStatusText = "Connecting…"
|
||||||
|
updateStatus()
|
||||||
|
val token = prefs.loadGatewayToken()
|
||||||
|
val password = prefs.loadGatewayPassword()
|
||||||
|
val tls = resolveTlsParams(endpoint)
|
||||||
|
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls)
|
||||||
|
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasRecordAudioPermission(): Boolean {
|
private fun hasRecordAudioPermission(): Boolean {
|
||||||
@@ -559,20 +590,32 @@ class NodeRuntime(context: Context) {
|
|||||||
_statusText.value = "Failed: invalid manual host/port"
|
_statusText.value = "Failed: invalid manual host/port"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
connect(BridgeEndpoint.manual(host = host, port = port))
|
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
session.disconnect()
|
connectedEndpoint = null
|
||||||
|
operatorSession.disconnect()
|
||||||
|
nodeSession.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
|
private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
||||||
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
|
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||||
val manual = endpoint.stableId.startsWith("manual|")
|
val manual = endpoint.stableId.startsWith("manual|")
|
||||||
|
|
||||||
|
if (manual) {
|
||||||
|
if (!manualTls.value) return null
|
||||||
|
return GatewayTlsParams(
|
||||||
|
required = true,
|
||||||
|
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||||
|
allowTOFU = stored == null,
|
||||||
|
stableId = endpoint.stableId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (hinted) {
|
if (hinted) {
|
||||||
return BridgeTlsParams(
|
return GatewayTlsParams(
|
||||||
required = true,
|
required = true,
|
||||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||||
allowTOFU = stored == null,
|
allowTOFU = stored == null,
|
||||||
@@ -581,7 +624,7 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!stored.isNullOrBlank()) {
|
if (!stored.isNullOrBlank()) {
|
||||||
return BridgeTlsParams(
|
return GatewayTlsParams(
|
||||||
required = true,
|
required = true,
|
||||||
expectedFingerprint = stored,
|
expectedFingerprint = stored,
|
||||||
allowTOFU = false,
|
allowTOFU = false,
|
||||||
@@ -589,15 +632,6 @@ class NodeRuntime(context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (manual) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = false,
|
|
||||||
expectedFingerprint = null,
|
|
||||||
allowTOFU = true,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,11 +671,11 @@ class NodeRuntime(context: Context) {
|
|||||||
contextJson = contextJson,
|
contextJson = contextJson,
|
||||||
)
|
)
|
||||||
|
|
||||||
val connected = isConnected.value
|
val connected = nodeConnected
|
||||||
var error: String? = null
|
var error: String? = null
|
||||||
if (connected) {
|
if (connected) {
|
||||||
try {
|
try {
|
||||||
session.sendEvent(
|
nodeSession.sendNodeEvent(
|
||||||
event = "agent.request",
|
event = "agent.request",
|
||||||
payloadJson =
|
payloadJson =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
@@ -656,7 +690,7 @@ class NodeRuntime(context: Context) {
|
|||||||
error = e.message ?: "send failed"
|
error = e.message ?: "send failed"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error = "bridge not connected"
|
error = "gateway not connected"
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -702,7 +736,7 @@ class NodeRuntime(context: Context) {
|
|||||||
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
chat.sendMessage(message = message, thinkingLevel = thinking, attachments = attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
private fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||||
if (event == "voicewake.changed") {
|
if (event == "voicewake.changed") {
|
||||||
if (payloadJson.isNullOrBlank()) return
|
if (payloadJson.isNullOrBlank()) return
|
||||||
try {
|
try {
|
||||||
@@ -716,8 +750,8 @@ class NodeRuntime(context: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
talkMode.handleBridgeEvent(event, payloadJson)
|
talkMode.handleGatewayEvent(event, payloadJson)
|
||||||
chat.handleBridgeEvent(event, payloadJson)
|
chat.handleGatewayEvent(event, payloadJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyWakeWordsFromGateway(words: List<String>) {
|
private fun applyWakeWordsFromGateway(words: List<String>) {
|
||||||
@@ -738,7 +772,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
|
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
|
||||||
val params = """{"triggers":[$jsonList]}"""
|
val params = """{"triggers":[$jsonList]}"""
|
||||||
try {
|
try {
|
||||||
session.request("voicewake.set", params)
|
operatorSession.request("voicewake.set", params)
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -748,7 +782,7 @@ class NodeRuntime(context: Context) {
|
|||||||
private suspend fun refreshWakeWordsFromGateway() {
|
private suspend fun refreshWakeWordsFromGateway() {
|
||||||
if (!_isConnected.value) return
|
if (!_isConnected.value) return
|
||||||
try {
|
try {
|
||||||
val res = session.request("voicewake.get", "{}")
|
val res = operatorSession.request("voicewake.get", "{}")
|
||||||
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||||
val array = payload["triggers"] as? JsonArray ?: return
|
val array = payload["triggers"] as? JsonArray ?: return
|
||||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||||
@@ -761,7 +795,7 @@ class NodeRuntime(context: Context) {
|
|||||||
private suspend fun refreshBrandingFromGateway() {
|
private suspend fun refreshBrandingFromGateway() {
|
||||||
if (!_isConnected.value) return
|
if (!_isConnected.value) return
|
||||||
try {
|
try {
|
||||||
val res = session.request("config.get", "{}")
|
val res = operatorSession.request("config.get", "{}")
|
||||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||||
val config = root?.get("config").asObjectOrNull()
|
val config = root?.get("config").asObjectOrNull()
|
||||||
val ui = config?.get("ui").asObjectOrNull()
|
val ui = config?.get("ui").asObjectOrNull()
|
||||||
@@ -777,7 +811,7 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
private suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||||
if (
|
if (
|
||||||
command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
|
command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
|
||||||
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
|
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
|
||||||
@@ -785,14 +819,14 @@ class NodeRuntime(context: Context) {
|
|||||||
command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
|
command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
|
||||||
) {
|
) {
|
||||||
if (!isForeground.value) {
|
if (!isForeground.value) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "CAMERA_DISABLED",
|
code = "CAMERA_DISABLED",
|
||||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||||
)
|
)
|
||||||
@@ -800,7 +834,7 @@ class NodeRuntime(context: Context) {
|
|||||||
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
|
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
|
||||||
locationMode.value == LocationMode.Off
|
locationMode.value == LocationMode.Off
|
||||||
) {
|
) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "LOCATION_DISABLED",
|
code = "LOCATION_DISABLED",
|
||||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||||
)
|
)
|
||||||
@@ -810,18 +844,18 @@ class NodeRuntime(context: Context) {
|
|||||||
ClawdbotCanvasCommand.Present.rawValue -> {
|
ClawdbotCanvasCommand.Present.rawValue -> {
|
||||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
canvas.navigate(url)
|
canvas.navigate(url)
|
||||||
BridgeSession.InvokeResult.ok(null)
|
GatewaySession.InvokeResult.ok(null)
|
||||||
}
|
}
|
||||||
ClawdbotCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
ClawdbotCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null)
|
||||||
ClawdbotCanvasCommand.Navigate.rawValue -> {
|
ClawdbotCanvasCommand.Navigate.rawValue -> {
|
||||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
canvas.navigate(url)
|
canvas.navigate(url)
|
||||||
BridgeSession.InvokeResult.ok(null)
|
GatewaySession.InvokeResult.ok(null)
|
||||||
}
|
}
|
||||||
ClawdbotCanvasCommand.Eval.rawValue -> {
|
ClawdbotCanvasCommand.Eval.rawValue -> {
|
||||||
val js =
|
val js =
|
||||||
CanvasController.parseEvalJs(paramsJson)
|
CanvasController.parseEvalJs(paramsJson)
|
||||||
?: return BridgeSession.InvokeResult.error(
|
?: return GatewaySession.InvokeResult.error(
|
||||||
code = "INVALID_REQUEST",
|
code = "INVALID_REQUEST",
|
||||||
message = "INVALID_REQUEST: javaScript required",
|
message = "INVALID_REQUEST: javaScript required",
|
||||||
)
|
)
|
||||||
@@ -829,12 +863,12 @@ class NodeRuntime(context: Context) {
|
|||||||
try {
|
try {
|
||||||
canvas.eval(js)
|
canvas.eval(js)
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||||
}
|
}
|
||||||
ClawdbotCanvasCommand.Snapshot.rawValue -> {
|
ClawdbotCanvasCommand.Snapshot.rawValue -> {
|
||||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||||
@@ -846,51 +880,51 @@ class NodeRuntime(context: Context) {
|
|||||||
maxWidth = snapshotParams.maxWidth,
|
maxWidth = snapshotParams.maxWidth,
|
||||||
)
|
)
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||||
}
|
}
|
||||||
ClawdbotCanvasA2UICommand.Reset.rawValue -> {
|
ClawdbotCanvasA2UICommand.Reset.rawValue -> {
|
||||||
val a2uiUrl = resolveA2uiHostUrl()
|
val a2uiUrl = resolveA2uiHostUrl()
|
||||||
?: return BridgeSession.InvokeResult.error(
|
?: return GatewaySession.InvokeResult.error(
|
||||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||||
)
|
)
|
||||||
val ready = ensureA2uiReady(a2uiUrl)
|
val ready = ensureA2uiReady(a2uiUrl)
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "A2UI_HOST_UNAVAILABLE",
|
code = "A2UI_HOST_UNAVAILABLE",
|
||||||
message = "A2UI host not reachable",
|
message = "A2UI host not reachable",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val res = canvas.eval(a2uiResetJS)
|
val res = canvas.eval(a2uiResetJS)
|
||||||
BridgeSession.InvokeResult.ok(res)
|
GatewaySession.InvokeResult.ok(res)
|
||||||
}
|
}
|
||||||
ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> {
|
ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||||
val messages =
|
val messages =
|
||||||
try {
|
try {
|
||||||
decodeA2uiMessages(command, paramsJson)
|
decodeA2uiMessages(command, paramsJson)
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
return BridgeSession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
|
return GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
|
||||||
}
|
}
|
||||||
val a2uiUrl = resolveA2uiHostUrl()
|
val a2uiUrl = resolveA2uiHostUrl()
|
||||||
?: return BridgeSession.InvokeResult.error(
|
?: return GatewaySession.InvokeResult.error(
|
||||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||||
)
|
)
|
||||||
val ready = ensureA2uiReady(a2uiUrl)
|
val ready = ensureA2uiReady(a2uiUrl)
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "A2UI_HOST_UNAVAILABLE",
|
code = "A2UI_HOST_UNAVAILABLE",
|
||||||
message = "A2UI host not reachable",
|
message = "A2UI host not reachable",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val js = a2uiApplyMessagesJS(messages)
|
val js = a2uiApplyMessagesJS(messages)
|
||||||
val res = canvas.eval(js)
|
val res = canvas.eval(js)
|
||||||
BridgeSession.InvokeResult.ok(res)
|
GatewaySession.InvokeResult.ok(res)
|
||||||
}
|
}
|
||||||
ClawdbotCameraCommand.Snap.rawValue -> {
|
ClawdbotCameraCommand.Snap.rawValue -> {
|
||||||
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||||
@@ -901,10 +935,10 @@ class NodeRuntime(context: Context) {
|
|||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
val (code, message) = invokeErrorFromThrowable(err)
|
val (code, message) = invokeErrorFromThrowable(err)
|
||||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
|
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
|
||||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||||
}
|
}
|
||||||
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||||
}
|
}
|
||||||
ClawdbotCameraCommand.Clip.rawValue -> {
|
ClawdbotCameraCommand.Clip.rawValue -> {
|
||||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||||
@@ -917,10 +951,10 @@ class NodeRuntime(context: Context) {
|
|||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
val (code, message) = invokeErrorFromThrowable(err)
|
val (code, message) = invokeErrorFromThrowable(err)
|
||||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
|
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
|
||||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||||
}
|
}
|
||||||
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
|
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||||
} finally {
|
} finally {
|
||||||
if (includeAudio) externalAudioCaptureActive.value = false
|
if (includeAudio) externalAudioCaptureActive.value = false
|
||||||
}
|
}
|
||||||
@@ -928,19 +962,19 @@ class NodeRuntime(context: Context) {
|
|||||||
ClawdbotLocationCommand.Get.rawValue -> {
|
ClawdbotLocationCommand.Get.rawValue -> {
|
||||||
val mode = locationMode.value
|
val mode = locationMode.value
|
||||||
if (!isForeground.value && mode != LocationMode.Always) {
|
if (!isForeground.value && mode != LocationMode.Always) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "LOCATION_BACKGROUND_UNAVAILABLE",
|
code = "LOCATION_BACKGROUND_UNAVAILABLE",
|
||||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
|
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "LOCATION_PERMISSION_REQUIRED",
|
code = "LOCATION_PERMISSION_REQUIRED",
|
||||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
|
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return GatewaySession.InvokeResult.error(
|
||||||
code = "LOCATION_PERMISSION_REQUIRED",
|
code = "LOCATION_PERMISSION_REQUIRED",
|
||||||
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
|
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
|
||||||
)
|
)
|
||||||
@@ -967,15 +1001,15 @@ class NodeRuntime(context: Context) {
|
|||||||
timeoutMs = timeoutMs,
|
timeoutMs = timeoutMs,
|
||||||
isPrecise = accuracy == "precise",
|
isPrecise = accuracy == "precise",
|
||||||
)
|
)
|
||||||
BridgeSession.InvokeResult.ok(payload.payloadJson)
|
GatewaySession.InvokeResult.ok(payload.payloadJson)
|
||||||
} catch (err: TimeoutCancellationException) {
|
} catch (err: TimeoutCancellationException) {
|
||||||
BridgeSession.InvokeResult.error(
|
GatewaySession.InvokeResult.error(
|
||||||
code = "LOCATION_TIMEOUT",
|
code = "LOCATION_TIMEOUT",
|
||||||
message = "LOCATION_TIMEOUT: no fix in time",
|
message = "LOCATION_TIMEOUT: no fix in time",
|
||||||
)
|
)
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
|
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
|
||||||
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClawdbotScreenCommand.Record.rawValue -> {
|
ClawdbotScreenCommand.Record.rawValue -> {
|
||||||
@@ -987,9 +1021,9 @@ class NodeRuntime(context: Context) {
|
|||||||
screenRecorder.record(paramsJson)
|
screenRecorder.record(paramsJson)
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
val (code, message) = invokeErrorFromThrowable(err)
|
val (code, message) = invokeErrorFromThrowable(err)
|
||||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||||
}
|
}
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||||
} finally {
|
} finally {
|
||||||
_screenRecordActive.value = false
|
_screenRecordActive.value = false
|
||||||
}
|
}
|
||||||
@@ -997,16 +1031,16 @@ class NodeRuntime(context: Context) {
|
|||||||
ClawdbotSmsCommand.Send.rawValue -> {
|
ClawdbotSmsCommand.Send.rawValue -> {
|
||||||
val res = sms.send(paramsJson)
|
val res = sms.send(paramsJson)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||||
} else {
|
} else {
|
||||||
val error = res.error ?: "SMS_SEND_FAILED"
|
val error = res.error ?: "SMS_SEND_FAILED"
|
||||||
val idx = error.indexOf(':')
|
val idx = error.indexOf(':')
|
||||||
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
|
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
|
||||||
BridgeSession.InvokeResult.error(code = code, message = error)
|
GatewaySession.InvokeResult.error(code = code, message = error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else ->
|
else ->
|
||||||
BridgeSession.InvokeResult.error(
|
GatewaySession.InvokeResult.error(
|
||||||
code = "INVALID_REQUEST",
|
code = "INVALID_REQUEST",
|
||||||
message = "INVALID_REQUEST: unknown command",
|
message = "INVALID_REQUEST: unknown command",
|
||||||
)
|
)
|
||||||
@@ -1062,7 +1096,9 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveA2uiHostUrl(): String? {
|
private fun resolveA2uiHostUrl(): String? {
|
||||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
val nodeRaw = nodeSession.currentCanvasHostUrl()?.trim().orEmpty()
|
||||||
|
val operatorRaw = operatorSession.currentCanvasHostUrl()?.trim().orEmpty()
|
||||||
|
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}/__clawdbot__/a2ui/?platform=android"
|
return "${base}/__clawdbot__/a2ui/?platform=android"
|
||||||
|
|||||||
@@ -58,17 +58,30 @@ class SecurePrefs(context: Context) {
|
|||||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||||
|
|
||||||
private val _manualEnabled = MutableStateFlow(prefs.getBoolean("bridge.manual.enabled", false))
|
private val _manualEnabled =
|
||||||
|
MutableStateFlow(readBoolWithMigration("gateway.manual.enabled", "bridge.manual.enabled", false))
|
||||||
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
val manualEnabled: StateFlow<Boolean> = _manualEnabled
|
||||||
|
|
||||||
private val _manualHost = MutableStateFlow(prefs.getString("bridge.manual.host", "")!!)
|
private val _manualHost =
|
||||||
|
MutableStateFlow(readStringWithMigration("gateway.manual.host", "bridge.manual.host", ""))
|
||||||
val manualHost: StateFlow<String> = _manualHost
|
val manualHost: StateFlow<String> = _manualHost
|
||||||
|
|
||||||
private val _manualPort = MutableStateFlow(prefs.getInt("bridge.manual.port", 18790))
|
private val _manualPort =
|
||||||
|
MutableStateFlow(readIntWithMigration("gateway.manual.port", "bridge.manual.port", 18789))
|
||||||
val manualPort: StateFlow<Int> = _manualPort
|
val manualPort: StateFlow<Int> = _manualPort
|
||||||
|
|
||||||
|
private val _manualTls =
|
||||||
|
MutableStateFlow(readBoolWithMigration("gateway.manual.tls", null, true))
|
||||||
|
val manualTls: StateFlow<Boolean> = _manualTls
|
||||||
|
|
||||||
private val _lastDiscoveredStableId =
|
private val _lastDiscoveredStableId =
|
||||||
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
MutableStateFlow(
|
||||||
|
readStringWithMigration(
|
||||||
|
"gateway.lastDiscoveredStableID",
|
||||||
|
"bridge.lastDiscoveredStableId",
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
)
|
||||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||||
|
|
||||||
private val _canvasDebugStatusEnabled =
|
private val _canvasDebugStatusEnabled =
|
||||||
@@ -86,7 +99,7 @@ class SecurePrefs(context: Context) {
|
|||||||
|
|
||||||
fun setLastDiscoveredStableId(value: String) {
|
fun setLastDiscoveredStableId(value: String) {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
prefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||||
_lastDiscoveredStableId.value = trimmed
|
_lastDiscoveredStableId.value = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,43 +130,62 @@ class SecurePrefs(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setManualEnabled(value: Boolean) {
|
fun setManualEnabled(value: Boolean) {
|
||||||
prefs.edit { putBoolean("bridge.manual.enabled", value) }
|
prefs.edit { putBoolean("gateway.manual.enabled", value) }
|
||||||
_manualEnabled.value = value
|
_manualEnabled.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualHost(value: String) {
|
fun setManualHost(value: String) {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
prefs.edit { putString("bridge.manual.host", trimmed) }
|
prefs.edit { putString("gateway.manual.host", trimmed) }
|
||||||
_manualHost.value = trimmed
|
_manualHost.value = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualPort(value: Int) {
|
fun setManualPort(value: Int) {
|
||||||
prefs.edit { putInt("bridge.manual.port", value) }
|
prefs.edit { putInt("gateway.manual.port", value) }
|
||||||
_manualPort.value = value
|
_manualPort.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setManualTls(value: Boolean) {
|
||||||
|
prefs.edit { putBoolean("gateway.manual.tls", value) }
|
||||||
|
_manualTls.value = value
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBridgeToken(): String? {
|
fun loadGatewayToken(): String? {
|
||||||
val key = "bridge.token.${_instanceId.value}"
|
val key = "gateway.token.${_instanceId.value}"
|
||||||
return prefs.getString(key, null)
|
val stored = prefs.getString(key, null)?.trim()
|
||||||
|
if (!stored.isNullOrEmpty()) return stored
|
||||||
|
val legacy = prefs.getString("bridge.token.${_instanceId.value}", null)?.trim()
|
||||||
|
return legacy?.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBridgeToken(token: String) {
|
fun saveGatewayToken(token: String) {
|
||||||
val key = "bridge.token.${_instanceId.value}"
|
val key = "gateway.token.${_instanceId.value}"
|
||||||
prefs.edit { putString(key, token.trim()) }
|
prefs.edit { putString(key, token.trim()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBridgeTlsFingerprint(stableId: String): String? {
|
fun loadGatewayPassword(): String? {
|
||||||
val key = "bridge.tls.$stableId"
|
val key = "gateway.password.${_instanceId.value}"
|
||||||
|
val stored = prefs.getString(key, null)?.trim()
|
||||||
|
return stored?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveGatewayPassword(password: String) {
|
||||||
|
val key = "gateway.password.${_instanceId.value}"
|
||||||
|
prefs.edit { putString(key, password.trim()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadGatewayTlsFingerprint(stableId: String): String? {
|
||||||
|
val key = "gateway.tls.$stableId"
|
||||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
|
fun saveGatewayTlsFingerprint(stableId: String, fingerprint: String) {
|
||||||
val key = "bridge.tls.$stableId"
|
val key = "gateway.tls.$stableId"
|
||||||
prefs.edit { putString(key, fingerprint.trim()) }
|
prefs.edit { putString(key, fingerprint.trim()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,4 +257,40 @@ class SecurePrefs(context: Context) {
|
|||||||
defaultWakeWords
|
defaultWakeWords
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun readBoolWithMigration(newKey: String, oldKey: String?, defaultValue: Boolean): Boolean {
|
||||||
|
if (prefs.contains(newKey)) {
|
||||||
|
return prefs.getBoolean(newKey, defaultValue)
|
||||||
|
}
|
||||||
|
if (oldKey != null && prefs.contains(oldKey)) {
|
||||||
|
val value = prefs.getBoolean(oldKey, defaultValue)
|
||||||
|
prefs.edit { putBoolean(newKey, value) }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readStringWithMigration(newKey: String, oldKey: String?, defaultValue: String): String {
|
||||||
|
if (prefs.contains(newKey)) {
|
||||||
|
return prefs.getString(newKey, defaultValue) ?: defaultValue
|
||||||
|
}
|
||||||
|
if (oldKey != null && prefs.contains(oldKey)) {
|
||||||
|
val value = prefs.getString(oldKey, defaultValue) ?: defaultValue
|
||||||
|
prefs.edit { putString(newKey, value) }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readIntWithMigration(newKey: String, oldKey: String?, defaultValue: Int): Int {
|
||||||
|
if (prefs.contains(newKey)) {
|
||||||
|
return prefs.getInt(newKey, defaultValue)
|
||||||
|
}
|
||||||
|
if (oldKey != null && prefs.contains(oldKey)) {
|
||||||
|
val value = prefs.getInt(oldKey, defaultValue)
|
||||||
|
prefs.edit { putInt(newKey, value) }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.clawdbot.android.chat
|
package com.clawdbot.android.chat
|
||||||
|
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.clawdbot.android.gateway.GatewaySession
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -20,8 +20,9 @@ import kotlinx.serialization.json.buildJsonObject
|
|||||||
|
|
||||||
class ChatController(
|
class ChatController(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val session: BridgeSession,
|
private val session: GatewaySession,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
|
private val supportsChatSubscribe: Boolean,
|
||||||
) {
|
) {
|
||||||
private val _sessionKey = MutableStateFlow("main")
|
private val _sessionKey = MutableStateFlow("main")
|
||||||
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
|
||||||
@@ -224,7 +225,7 @@ class ChatController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleBridgeEvent(event: String, payloadJson: String?) {
|
fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||||
when (event) {
|
when (event) {
|
||||||
"tick" -> {
|
"tick" -> {
|
||||||
scope.launch { pollHealthIfNeeded(force = false) }
|
scope.launch { pollHealthIfNeeded(force = false) }
|
||||||
@@ -259,10 +260,12 @@ class ChatController(
|
|||||||
|
|
||||||
val key = _sessionKey.value
|
val key = _sessionKey.value
|
||||||
try {
|
try {
|
||||||
try {
|
if (supportsChatSubscribe) {
|
||||||
session.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
try {
|
||||||
} catch (_: Throwable) {
|
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||||
// best-effort
|
} catch (_: Throwable) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.clawdbot.android.gateway
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Base64
|
||||||
|
import java.io.File
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeviceIdentity(
|
||||||
|
val deviceId: String,
|
||||||
|
val publicKeyRawBase64: String,
|
||||||
|
val privateKeyPkcs8Base64: String,
|
||||||
|
val createdAtMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DeviceIdentityStore(context: Context) {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val identityFile = File(context.filesDir, "clawdbot/identity/device.json")
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun loadOrCreate(): DeviceIdentity {
|
||||||
|
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)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
val fresh = generate()
|
||||||
|
save(fresh)
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signPayload(payload: String, identity: DeviceIdentity): String? {
|
||||||
|
return try {
|
||||||
|
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||||
|
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
|
||||||
|
val keyFactory = KeyFactory.getInstance("Ed25519")
|
||||||
|
val privateKey = keyFactory.generatePrivate(keySpec)
|
||||||
|
val signature = Signature.getInstance("Ed25519")
|
||||||
|
signature.initSign(privateKey)
|
||||||
|
signature.update(payload.toByteArray(Charsets.UTF_8))
|
||||||
|
base64UrlEncode(signature.sign())
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun publicKeyBase64Url(identity: DeviceIdentity): String? {
|
||||||
|
return try {
|
||||||
|
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||||
|
base64UrlEncode(raw)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(): DeviceIdentity? {
|
||||||
|
return try {
|
||||||
|
if (!identityFile.exists()) return null
|
||||||
|
val raw = identityFile.readText(Charsets.UTF_8)
|
||||||
|
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 save(identity: DeviceIdentity) {
|
||||||
|
try {
|
||||||
|
identityFile.parentFile?.mkdirs()
|
||||||
|
val encoded = json.encodeToString(DeviceIdentity.serializer(), identity)
|
||||||
|
identityFile.writeText(encoded, Charsets.UTF_8)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// best-effort only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deriveDeviceId(publicKeyRawBase64: String): String? {
|
||||||
|
return try {
|
||||||
|
val raw = Base64.decode(publicKeyRawBase64, Base64.DEFAULT)
|
||||||
|
sha256Hex(raw)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripSpkiPrefix(spki: ByteArray): ByteArray {
|
||||||
|
if (spki.size == ED25519_SPKI_PREFIX.size + 32 &&
|
||||||
|
spki.copyOfRange(0, ED25519_SPKI_PREFIX.size).contentEquals(ED25519_SPKI_PREFIX)
|
||||||
|
) {
|
||||||
|
return spki.copyOfRange(ED25519_SPKI_PREFIX.size, spki.size)
|
||||||
|
}
|
||||||
|
return spki
|
||||||
|
}
|
||||||
|
|
||||||
|
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("%02x", byte))
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base64UrlEncode(data: ByteArray): String {
|
||||||
|
return Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val ED25519_SPKI_PREFIX =
|
||||||
|
byteArrayOf(
|
||||||
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
package com.clawdbot.android.gateway
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.DnsResolver
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.os.CancellationSignal
|
||||||
|
import android.util.Log
|
||||||
|
import com.clawdbot.android.bridge.BonjourEscapes
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.charset.CodingErrorAction
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
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.Record
|
||||||
|
import org.xbill.DNS.Rcode
|
||||||
|
import org.xbill.DNS.Resolver
|
||||||
|
import org.xbill.DNS.SRVRecord
|
||||||
|
import org.xbill.DNS.Section
|
||||||
|
import org.xbill.DNS.SimpleResolver
|
||||||
|
import org.xbill.DNS.TextParseException
|
||||||
|
import org.xbill.DNS.TXTRecord
|
||||||
|
import org.xbill.DNS.Type
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
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 = DnsResolver.getInstance()
|
||||||
|
private val serviceType = "_clawdbot-gateway._tcp."
|
||||||
|
private val wideAreaDomain = "clawdbot.internal."
|
||||||
|
private val logTag = "Clawdbot/GatewayDiscovery"
|
||||||
|
|
||||||
|
private val localById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||||
|
private val unicastById = ConcurrentHashMap<String, GatewayEndpoint>()
|
||||||
|
private val _gateways = MutableStateFlow<List<GatewayEndpoint>>(emptyList())
|
||||||
|
val gateways: StateFlow<List<GatewayEndpoint>> = _gateways.asStateFlow()
|
||||||
|
|
||||||
|
private val _statusText = MutableStateFlow("Searching…")
|
||||||
|
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||||
|
|
||||||
|
private var unicastJob: Job? = null
|
||||||
|
private val dnsExecutor: Executor = Executors.newCachedThreadPool()
|
||||||
|
|
||||||
|
@Volatile private var lastWideAreaRcode: Int? = null
|
||||||
|
@Volatile private var lastWideAreaCount: Int = 0
|
||||||
|
|
||||||
|
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)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
startLocalDiscovery()
|
||||||
|
startUnicastDiscovery(wideAreaDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLocalDiscovery() {
|
||||||
|
try {
|
||||||
|
nsd.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore (best-effort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLocalDiscovery() {
|
||||||
|
try {
|
||||||
|
nsd.stopServiceDiscovery(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) {
|
||||||
|
nsd.resolveService(
|
||||||
|
serviceInfo,
|
||||||
|
object : NsdManager.ResolveListener {
|
||||||
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||||
|
|
||||||
|
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||||
|
val host = resolved.host?.hostAddress ?: 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.")
|
||||||
|
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 publish() {
|
||||||
|
_gateways.value =
|
||||||
|
(localById.values + unicastById.values).sortedBy { it.name.lowercase() }
|
||||||
|
_statusText.value = buildStatusText()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildStatusText(): String {
|
||||||
|
val localCount = localById.size
|
||||||
|
val wideRcode = lastWideAreaRcode
|
||||||
|
val wideCount = lastWideAreaCount
|
||||||
|
|
||||||
|
val wide =
|
||||||
|
when (wideRcode) {
|
||||||
|
null -> "Wide: ?"
|
||||||
|
Rcode.NOERROR -> "Wide: $wideCount"
|
||||||
|
Rcode.NXDOMAIN -> "Wide: NXDOMAIN"
|
||||||
|
else -> "Wide: ${Rcode.string(wideRcode)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
localCount == 0 && wideRcode == null -> "Searching for gateways…"
|
||||||
|
localCount == 0 -> "$wide"
|
||||||
|
else -> "Local: $localCount • $wide"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stableId(serviceName: String, domain: String): String {
|
||||||
|
return "${serviceType}|${domain}|${normalizeName(serviceName)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeName(raw: String): String {
|
||||||
|
return 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? {
|
||||||
|
return 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
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return 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
|
||||||
|
|
||||||
|
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> {
|
||||||
|
return msg?.getSectionArray(section)?.toList() ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keyName(raw: String): String {
|
||||||
|
return 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.
|
||||||
|
cm.allNetworks.firstOrNull { n ->
|
||||||
|
val caps = cm.getNetworkCapabilities(n) ?: return@firstOrNull false
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
}?.let { return it }
|
||||||
|
|
||||||
|
return cm.activeNetwork
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createDirectResolver(): Resolver? {
|
||||||
|
val cm = connectivity ?: return null
|
||||||
|
|
||||||
|
val candidateNetworks =
|
||||||
|
buildList {
|
||||||
|
cm.allNetworks
|
||||||
|
.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(3)
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resolvers.isEmpty()) return null
|
||||||
|
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(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.mapNotNull { it as? String }
|
||||||
|
} 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? {
|
||||||
|
return 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.clawdbot.android.gateway
|
||||||
|
|
||||||
|
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 {
|
||||||
|
fun manual(host: String, port: Int): GatewayEndpoint =
|
||||||
|
GatewayEndpoint(
|
||||||
|
stableId = "manual|${host.lowercase()}|$port",
|
||||||
|
name = "$host:$port",
|
||||||
|
host = host,
|
||||||
|
port = port,
|
||||||
|
tlsEnabled = false,
|
||||||
|
tlsFingerprintSha256 = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.clawdbot.android.gateway
|
||||||
|
|
||||||
|
const val GATEWAY_PROTOCOL_VERSION = 3
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
package com.clawdbot.android.gateway
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
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.buildJsonObject
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
|
||||||
|
data class GatewayClientInfo(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val version: String,
|
||||||
|
val platform: String,
|
||||||
|
val mode: String,
|
||||||
|
val instanceId: String?,
|
||||||
|
val deviceFamily: String?,
|
||||||
|
val modelIdentifier: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GatewayConnectOptions(
|
||||||
|
val role: String,
|
||||||
|
val scopes: List<String>,
|
||||||
|
val caps: List<String>,
|
||||||
|
val commands: List<String>,
|
||||||
|
val permissions: Map<String, Boolean>,
|
||||||
|
val client: GatewayClientInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
class GatewaySession(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val identityStore: DeviceIdentityStore,
|
||||||
|
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
||||||
|
private val onDisconnected: (message: String) -> Unit,
|
||||||
|
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||||
|
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
|
||||||
|
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
data class InvokeRequest(
|
||||||
|
val id: String,
|
||||||
|
val nodeId: String,
|
||||||
|
val command: String,
|
||||||
|
val paramsJson: String?,
|
||||||
|
val timeoutMs: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InvokeResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?) {
|
||||||
|
companion object {
|
||||||
|
fun ok(payloadJson: String?) = InvokeResult(ok = true, payloadJson = payloadJson, error = null)
|
||||||
|
fun error(code: String, message: String) =
|
||||||
|
InvokeResult(ok = false, payloadJson = null, error = ErrorShape(code = code, message = message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ErrorShape(val code: String, val message: String)
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val writeLock = Mutex()
|
||||||
|
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||||
|
|
||||||
|
@Volatile private var canvasHostUrl: String? = null
|
||||||
|
@Volatile private var mainSessionKey: String? = null
|
||||||
|
|
||||||
|
private data class DesiredConnection(
|
||||||
|
val endpoint: GatewayEndpoint,
|
||||||
|
val token: String?,
|
||||||
|
val password: String?,
|
||||||
|
val options: GatewayConnectOptions,
|
||||||
|
val tls: GatewayTlsParams?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private var desired: DesiredConnection? = null
|
||||||
|
private var job: Job? = null
|
||||||
|
@Volatile private var currentConnection: Connection? = null
|
||||||
|
|
||||||
|
fun connect(
|
||||||
|
endpoint: GatewayEndpoint,
|
||||||
|
token: String?,
|
||||||
|
password: String?,
|
||||||
|
options: GatewayConnectOptions,
|
||||||
|
tls: GatewayTlsParams? = null,
|
||||||
|
) {
|
||||||
|
desired = DesiredConnection(endpoint, token, password, options, tls)
|
||||||
|
if (job == null) {
|
||||||
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
desired = null
|
||||||
|
currentConnection?.closeQuietly()
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
job?.cancelAndJoin()
|
||||||
|
job = null
|
||||||
|
canvasHostUrl = null
|
||||||
|
mainSessionKey = null
|
||||||
|
onDisconnected("Offline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reconnect() {
|
||||||
|
currentConnection?.closeQuietly()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||||
|
fun currentMainSessionKey(): String? = mainSessionKey
|
||||||
|
|
||||||
|
suspend fun sendNodeEvent(event: String, payloadJson: String?) {
|
||||||
|
val conn = currentConnection ?: return
|
||||||
|
val params =
|
||||||
|
buildJsonObject {
|
||||||
|
put("event", JsonPrimitive(event))
|
||||||
|
if (payloadJson != null) put("payloadJSON", JsonPrimitive(payloadJson)) else put("payloadJSON", JsonNull)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
conn.request("node.event", params, timeoutMs = 8_000)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
Log.w("ClawdbotGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
|
||||||
|
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||||
|
val params =
|
||||||
|
if (paramsJson.isNullOrBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
json.parseToJsonElement(paramsJson)
|
||||||
|
}
|
||||||
|
val res = conn.request(method, params, timeoutMs)
|
||||||
|
if (res.ok) return res.payloadJson ?: ""
|
||||||
|
val err = res.error
|
||||||
|
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RpcResponse(val id: String, val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||||
|
|
||||||
|
private inner class Connection(
|
||||||
|
private val endpoint: GatewayEndpoint,
|
||||||
|
private val token: String?,
|
||||||
|
private val password: String?,
|
||||||
|
private val options: GatewayConnectOptions,
|
||||||
|
private val tls: GatewayTlsParams?,
|
||||||
|
) {
|
||||||
|
private val connectDeferred = CompletableDeferred<Unit>()
|
||||||
|
private val closedDeferred = CompletableDeferred<Unit>()
|
||||||
|
private val isClosed = AtomicBoolean(false)
|
||||||
|
private val client: OkHttpClient = buildClient()
|
||||||
|
private var socket: WebSocket? = null
|
||||||
|
private val loggerTag = "ClawdbotGateway"
|
||||||
|
|
||||||
|
val remoteAddress: String =
|
||||||
|
if (endpoint.host.contains(":")) {
|
||||||
|
"[${endpoint.host}]:${endpoint.port}"
|
||||||
|
} else {
|
||||||
|
"${endpoint.host}:${endpoint.port}"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connect() {
|
||||||
|
val scheme = if (tls != null) "wss" else "ws"
|
||||||
|
val url = "$scheme://${endpoint.host}:${endpoint.port}"
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
socket = client.newWebSocket(request, Listener())
|
||||||
|
try {
|
||||||
|
connectDeferred.await()
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun request(method: String, params: JsonElement?, timeoutMs: Long): RpcResponse {
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
val deferred = CompletableDeferred<RpcResponse>()
|
||||||
|
pending[id] = deferred
|
||||||
|
val frame =
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("req"))
|
||||||
|
put("id", JsonPrimitive(id))
|
||||||
|
put("method", JsonPrimitive(method))
|
||||||
|
if (params != null) put("params", params)
|
||||||
|
}
|
||||||
|
sendJson(frame)
|
||||||
|
return try {
|
||||||
|
withTimeout(timeoutMs) { deferred.await() }
|
||||||
|
} catch (err: TimeoutCancellationException) {
|
||||||
|
pending.remove(id)
|
||||||
|
throw IllegalStateException("request timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendJson(obj: JsonObject) {
|
||||||
|
val jsonString = obj.toString()
|
||||||
|
writeLock.withLock {
|
||||||
|
socket?.send(jsonString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun awaitClose() = closedDeferred.await()
|
||||||
|
|
||||||
|
fun closeQuietly() {
|
||||||
|
if (isClosed.compareAndSet(false, true)) {
|
||||||
|
socket?.close(1000, "bye")
|
||||||
|
socket = null
|
||||||
|
closedDeferred.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildClient(): OkHttpClient {
|
||||||
|
val builder = OkHttpClient.Builder()
|
||||||
|
val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint ->
|
||||||
|
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||||
|
}
|
||||||
|
if (tlsConfig != null) {
|
||||||
|
builder.sslSocketFactory(tlsConfig.sslSocketFactory, tlsConfig.trustManager)
|
||||||
|
builder.hostnameVerifier(tlsConfig.hostnameVerifier)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class Listener : WebSocketListener() {
|
||||||
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
sendConnect()
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
connectDeferred.completeExceptionally(err)
|
||||||
|
closeQuietly()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
scope.launch { handleMessage(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
if (!connectDeferred.isCompleted) {
|
||||||
|
connectDeferred.completeExceptionally(t)
|
||||||
|
}
|
||||||
|
if (isClosed.compareAndSet(false, true)) {
|
||||||
|
failPending()
|
||||||
|
closedDeferred.complete(Unit)
|
||||||
|
onDisconnected("Gateway error: ${t.message ?: t::class.java.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
if (!connectDeferred.isCompleted) {
|
||||||
|
connectDeferred.completeExceptionally(IllegalStateException("Gateway closed: $reason"))
|
||||||
|
}
|
||||||
|
if (isClosed.compareAndSet(false, true)) {
|
||||||
|
failPending()
|
||||||
|
closedDeferred.complete(Unit)
|
||||||
|
onDisconnected("Gateway closed: $reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendConnect() {
|
||||||
|
val payload = buildConnectParams()
|
||||||
|
val res = request("connect", payload, timeoutMs = 8_000)
|
||||||
|
if (!res.ok) {
|
||||||
|
val msg = res.error?.message ?: "connect failed"
|
||||||
|
throw IllegalStateException(msg)
|
||||||
|
}
|
||||||
|
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
||||||
|
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
||||||
|
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
||||||
|
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||||
|
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint)
|
||||||
|
val sessionDefaults =
|
||||||
|
obj["snapshot"].asObjectOrNull()
|
||||||
|
?.get("sessionDefaults").asObjectOrNull()
|
||||||
|
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
|
||||||
|
onConnected(serverName, remoteAddress, mainSessionKey)
|
||||||
|
connectDeferred.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildConnectParams(): JsonObject {
|
||||||
|
val client = options.client
|
||||||
|
val locale = Locale.getDefault().toLanguageTag()
|
||||||
|
val clientObj =
|
||||||
|
buildJsonObject {
|
||||||
|
put("id", JsonPrimitive(client.id))
|
||||||
|
client.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
put("version", JsonPrimitive(client.version))
|
||||||
|
put("platform", JsonPrimitive(client.platform))
|
||||||
|
put("mode", JsonPrimitive(client.mode))
|
||||||
|
client.instanceId?.let { put("instanceId", JsonPrimitive(it)) }
|
||||||
|
client.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val params =
|
||||||
|
buildJsonObject {
|
||||||
|
put("minProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
|
||||||
|
put("maxProtocol", JsonPrimitive(GATEWAY_PROTOCOL_VERSION))
|
||||||
|
put("client", clientObj)
|
||||||
|
if (options.caps.isNotEmpty()) put("caps", JsonArray(options.caps.map(::JsonPrimitive)))
|
||||||
|
if (options.commands.isNotEmpty()) put("commands", JsonArray(options.commands.map(::JsonPrimitive)))
|
||||||
|
if (options.permissions.isNotEmpty()) {
|
||||||
|
put(
|
||||||
|
"permissions",
|
||||||
|
buildJsonObject {
|
||||||
|
options.permissions.forEach { (key, value) ->
|
||||||
|
put(key, JsonPrimitive(value))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
put("role", JsonPrimitive(options.role))
|
||||||
|
if (options.scopes.isNotEmpty()) put("scopes", JsonArray(options.scopes.map(::JsonPrimitive)))
|
||||||
|
put("locale", JsonPrimitive(locale))
|
||||||
|
}
|
||||||
|
|
||||||
|
val authToken = token?.trim().orEmpty()
|
||||||
|
val authPassword = password?.trim().orEmpty()
|
||||||
|
if (authToken.isNotEmpty()) {
|
||||||
|
params["auth"] =
|
||||||
|
buildJsonObject {
|
||||||
|
put("token", JsonPrimitive(authToken))
|
||||||
|
}
|
||||||
|
} else if (authPassword.isNotEmpty()) {
|
||||||
|
params["auth"] =
|
||||||
|
buildJsonObject {
|
||||||
|
put("password", JsonPrimitive(authPassword))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val identity = identityStore.loadOrCreate()
|
||||||
|
val signedAtMs = System.currentTimeMillis()
|
||||||
|
val payload =
|
||||||
|
buildDeviceAuthPayload(
|
||||||
|
deviceId = identity.deviceId,
|
||||||
|
clientId = client.id,
|
||||||
|
clientMode = client.mode,
|
||||||
|
role = options.role,
|
||||||
|
scopes = options.scopes,
|
||||||
|
signedAtMs = signedAtMs,
|
||||||
|
token = if (authToken.isNotEmpty()) authToken else null,
|
||||||
|
)
|
||||||
|
val signature = identityStore.signPayload(payload, identity)
|
||||||
|
val publicKey = identityStore.publicKeyBase64Url(identity)
|
||||||
|
if (!signature.isNullOrBlank() && !publicKey.isNullOrBlank()) {
|
||||||
|
params["device"] =
|
||||||
|
buildJsonObject {
|
||||||
|
put("id", JsonPrimitive(identity.deviceId))
|
||||||
|
put("publicKey", JsonPrimitive(publicKey))
|
||||||
|
put("signature", JsonPrimitive(signature))
|
||||||
|
put("signedAt", JsonPrimitive(signedAtMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleMessage(text: String) {
|
||||||
|
val frame = json.parseToJsonElement(text).asObjectOrNull() ?: return
|
||||||
|
when (frame["type"].asStringOrNull()) {
|
||||||
|
"res" -> handleResponse(frame)
|
||||||
|
"event" -> handleEvent(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleResponse(frame: JsonObject) {
|
||||||
|
val id = frame["id"].asStringOrNull() ?: return
|
||||||
|
val ok = frame["ok"].asBooleanOrNull() ?: false
|
||||||
|
val payloadJson = frame["payload"]?.let { payload -> payload.toString() }
|
||||||
|
val error =
|
||||||
|
frame["error"]?.asObjectOrNull()?.let { obj ->
|
||||||
|
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
||||||
|
ErrorShape(code, msg)
|
||||||
|
}
|
||||||
|
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleEvent(frame: JsonObject) {
|
||||||
|
val event = frame["event"].asStringOrNull() ?: return
|
||||||
|
val payloadJson = frame["payload"]?.let { it.toString() }
|
||||||
|
if (event == "node.invoke.request" && payloadJson != null && onInvoke != null) {
|
||||||
|
handleInvokeEvent(payloadJson)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onEvent(event, payloadJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleInvokeEvent(payloadJson: String) {
|
||||||
|
val payload =
|
||||||
|
try {
|
||||||
|
json.parseToJsonElement(payloadJson).asObjectOrNull()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
} ?: return
|
||||||
|
val id = payload["id"].asStringOrNull() ?: return
|
||||||
|
val nodeId = payload["nodeId"].asStringOrNull() ?: return
|
||||||
|
val command = payload["command"].asStringOrNull() ?: return
|
||||||
|
val params = payload["paramsJSON"].asStringOrNull()
|
||||||
|
val timeoutMs = payload["timeoutMs"].asLongOrNull()
|
||||||
|
scope.launch {
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
onInvoke?.invoke(InvokeRequest(id, nodeId, command, params, timeoutMs))
|
||||||
|
?: InvokeResult.error("UNAVAILABLE", "invoke handler missing")
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
invokeErrorFromThrowable(err)
|
||||||
|
}
|
||||||
|
sendInvokeResult(id, nodeId, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendInvokeResult(id: String, nodeId: String, result: InvokeResult) {
|
||||||
|
val params =
|
||||||
|
buildJsonObject {
|
||||||
|
put("id", JsonPrimitive(id))
|
||||||
|
put("nodeId", JsonPrimitive(nodeId))
|
||||||
|
put("ok", JsonPrimitive(result.ok))
|
||||||
|
if (result.payloadJson != null) put("payloadJSON", JsonPrimitive(result.payloadJson))
|
||||||
|
result.error?.let { err ->
|
||||||
|
put(
|
||||||
|
"error",
|
||||||
|
buildJsonObject {
|
||||||
|
put("code", JsonPrimitive(err.code))
|
||||||
|
put("message", JsonPrimitive(err.message))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
request("node.invoke.result", params, timeoutMs = 15_000)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
Log.w(loggerTag, "node.invoke.result failed: ${err.message ?: err::class.java.simpleName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invokeErrorFromThrowable(err: Throwable): InvokeResult {
|
||||||
|
val msg = err.message?.trim().takeIf { !it.isNullOrEmpty() } ?: err::class.java.simpleName
|
||||||
|
val parts = msg.split(":", limit = 2)
|
||||||
|
if (parts.size == 2) {
|
||||||
|
val code = parts[0].trim()
|
||||||
|
val rest = parts[1].trim()
|
||||||
|
if (code.isNotEmpty() && code.all { it.isUpperCase() || it == '_' }) {
|
||||||
|
return InvokeResult.error(code = code, message = rest.ifEmpty { msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failPending() {
|
||||||
|
for ((_, waiter) in pending) {
|
||||||
|
waiter.cancel()
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
var attempt = 0
|
||||||
|
while (scope.isActive) {
|
||||||
|
val target = desired
|
||||||
|
if (target == null) {
|
||||||
|
currentConnection?.closeQuietly()
|
||||||
|
currentConnection = null
|
||||||
|
delay(250)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||||
|
connectOnce(target)
|
||||||
|
attempt = 0
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
attempt += 1
|
||||||
|
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
|
||||||
|
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
||||||
|
delay(sleepMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
|
||||||
|
val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls)
|
||||||
|
currentConnection = conn
|
||||||
|
try {
|
||||||
|
conn.connect()
|
||||||
|
conn.awaitClose()
|
||||||
|
} finally {
|
||||||
|
currentConnection = null
|
||||||
|
canvasHostUrl = null
|
||||||
|
mainSessionKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildDeviceAuthPayload(
|
||||||
|
deviceId: String,
|
||||||
|
clientId: String,
|
||||||
|
clientMode: String,
|
||||||
|
role: String,
|
||||||
|
scopes: List<String>,
|
||||||
|
signedAtMs: Long,
|
||||||
|
token: String?,
|
||||||
|
): String {
|
||||||
|
val scopeString = scopes.joinToString(",")
|
||||||
|
val authToken = token.orEmpty()
|
||||||
|
return listOf(
|
||||||
|
"v1",
|
||||||
|
deviceId,
|
||||||
|
clientId,
|
||||||
|
clientMode,
|
||||||
|
role,
|
||||||
|
scopeString,
|
||||||
|
signedAtMs.toString(),
|
||||||
|
authToken,
|
||||||
|
).joinToString("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeCanvasHostUrl(raw: String?, endpoint: GatewayEndpoint): String? {
|
||||||
|
val trimmed = raw?.trim().orEmpty()
|
||||||
|
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { java.net.URI(it) }.getOrNull() }
|
||||||
|
val host = parsed?.host?.trim().orEmpty()
|
||||||
|
val port = parsed?.port ?: -1
|
||||||
|
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
|
||||||
|
|
||||||
|
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
val fallbackHost =
|
||||||
|
endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() }
|
||||||
|
?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() }
|
||||||
|
?: endpoint.host.trim()
|
||||||
|
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
|
||||||
|
|
||||||
|
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
|
||||||
|
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
||||||
|
return "$scheme://$formattedHost:$fallbackPort"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isLoopbackHost(raw: String?): Boolean {
|
||||||
|
val host = raw?.trim()?.lowercase().orEmpty()
|
||||||
|
if (host.isEmpty()) return false
|
||||||
|
if (host == "localhost") return true
|
||||||
|
if (host == "::1") return true
|
||||||
|
if (host == "0.0.0.0" || host == "::") return true
|
||||||
|
return host.startsWith("127.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asBooleanOrNull(): Boolean? =
|
||||||
|
when (this) {
|
||||||
|
is JsonPrimitive -> {
|
||||||
|
val c = content.trim()
|
||||||
|
when {
|
||||||
|
c.equals("true", ignoreCase = true) -> true
|
||||||
|
c.equals("false", ignoreCase = true) -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asLongOrNull(): Long? =
|
||||||
|
when (this) {
|
||||||
|
is JsonPrimitive -> content.toLongOrNull()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.clawdbot.android.gateway
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.HostnameVerifier
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
data class GatewayTlsParams(
|
||||||
|
val required: Boolean,
|
||||||
|
val expectedFingerprint: String?,
|
||||||
|
val allowTOFU: Boolean,
|
||||||
|
val stableId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class GatewayTlsConfig(
|
||||||
|
val sslSocketFactory: SSLSocketFactory,
|
||||||
|
val trustManager: X509TrustManager,
|
||||||
|
val hostnameVerifier: HostnameVerifier,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun buildGatewayTlsConfig(
|
||||||
|
params: GatewayTlsParams?,
|
||||||
|
onStore: ((String) -> Unit)? = null,
|
||||||
|
): GatewayTlsConfig? {
|
||||||
|
if (params == null) return null
|
||||||
|
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
||||||
|
val defaultTrust = defaultTrustManager()
|
||||||
|
@SuppressLint("CustomX509TrustManager")
|
||||||
|
val trustManager =
|
||||||
|
object : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
defaultTrust.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 (expected != null) {
|
||||||
|
if (fingerprint != expected) {
|
||||||
|
throw CertificateException("gateway TLS fingerprint mismatch")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (params.allowTOFU) {
|
||||||
|
onStore?.invoke(fingerprint)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaultTrust.checkServerTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
||||||
|
}
|
||||||
|
|
||||||
|
val context = SSLContext.getInstance("TLS")
|
||||||
|
context.init(null, arrayOf(trustManager), SecureRandom())
|
||||||
|
return GatewayTlsConfig(
|
||||||
|
sslSocketFactory = context.socketFactory,
|
||||||
|
trustManager = trustManager,
|
||||||
|
hostnameVerifier = HostnameVerifier { _, _ -> true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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("%02x", byte))
|
||||||
|
}
|
||||||
|
return out.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeFingerprint(raw: String): String {
|
||||||
|
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
contentDescription = "Approval pending",
|
contentDescription = "Approval pending",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||||
|
|
||||||
if (screenRecordActive) {
|
if (screenRecordActive) {
|
||||||
return@remember StatusActivity(
|
return@remember StatusActivity(
|
||||||
@@ -179,14 +179,14 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val bridgeState =
|
val gatewayState =
|
||||||
remember(serverName, statusText) {
|
remember(serverName, statusText) {
|
||||||
when {
|
when {
|
||||||
serverName != null -> BridgeState.Connected
|
serverName != null -> GatewayState.Connected
|
||||||
statusText.contains("connecting", ignoreCase = true) ||
|
statusText.contains("connecting", ignoreCase = true) ||
|
||||||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
statusText.contains("reconnecting", ignoreCase = true) -> GatewayState.Connecting
|
||||||
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
statusText.contains("error", ignoreCase = true) -> GatewayState.Error
|
||||||
else -> BridgeState.Disconnected
|
else -> GatewayState.Disconnected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ fun RootScreen(viewModel: MainViewModel) {
|
|||||||
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
||||||
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
||||||
StatusPill(
|
StatusPill(
|
||||||
bridge = bridgeState,
|
gateway = gatewayState,
|
||||||
voiceEnabled = voiceEnabled,
|
voiceEnabled = voiceEnabled,
|
||||||
activity = activity,
|
activity = activity,
|
||||||
onClick = { sheet = Sheet.Settings },
|
onClick = { sheet = Sheet.Settings },
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -74,11 +75,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
val manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||||
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 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()
|
||||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||||
val bridges by viewModel.bridges.collectAsState()
|
val gateways by viewModel.gateways.collectAsState()
|
||||||
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
@@ -163,7 +165,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val smsPermissionLauncher =
|
val smsPermissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
smsPermissionGranted = granted
|
smsPermissionGranted = granted
|
||||||
viewModel.refreshBridgeHello()
|
viewModel.refreshGatewayConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCameraEnabledChecked(checked: Boolean) {
|
fun setCameraEnabledChecked(checked: Boolean) {
|
||||||
@@ -223,20 +225,20 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val visibleBridges =
|
val visibleGateways =
|
||||||
if (isConnected && remoteAddress != null) {
|
if (isConnected && remoteAddress != null) {
|
||||||
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
gateways.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||||
} else {
|
} else {
|
||||||
bridges
|
gateways
|
||||||
}
|
}
|
||||||
|
|
||||||
val bridgeDiscoveryFooterText =
|
val gatewayDiscoveryFooterText =
|
||||||
if (visibleBridges.isEmpty()) {
|
if (visibleGateways.isEmpty()) {
|
||||||
discoveryStatusText
|
discoveryStatusText
|
||||||
} else if (isConnected) {
|
} else if (isConnected) {
|
||||||
"Discovery active • ${visibleBridges.size} other bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
"Discovery active • ${visibleGateways.size} other gateway${if (visibleGateways.size == 1) "" else "s"} found"
|
||||||
} else {
|
} else {
|
||||||
"Discovery active • ${visibleBridges.size} bridge${if (visibleBridges.size == 1) "" else "s"} found"
|
"Discovery active • ${visibleGateways.size} gateway${if (visibleGateways.size == 1) "" else "s"} found"
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -250,7 +252,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
// Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
|
// Order parity: Node → Gateway → Voice → Camera → Messaging → Location → Screen.
|
||||||
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||||
item {
|
item {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -266,8 +268,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
item { HorizontalDivider() }
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
// Bridge
|
// Gateway
|
||||||
item { Text("Bridge", style = MaterialTheme.typography.titleSmall) }
|
item { Text("Gateway", style = MaterialTheme.typography.titleSmall) }
|
||||||
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
|
item { ListItem(headlineContent = { Text("Status") }, supportingContent = { Text(statusText) }) }
|
||||||
if (serverName != null) {
|
if (serverName != null) {
|
||||||
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
|
item { ListItem(headlineContent = { Text("Server") }, supportingContent = { Text(serverName!!) }) }
|
||||||
@@ -291,31 +293,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
item { HorizontalDivider() }
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
if (!isConnected || visibleBridges.isNotEmpty()) {
|
if (!isConnected || visibleGateways.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (isConnected) "Other Bridges" else "Discovered Bridges",
|
if (isConnected) "Other Gateways" else "Discovered Gateways",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!isConnected && visibleBridges.isEmpty()) {
|
if (!isConnected && visibleGateways.isEmpty()) {
|
||||||
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
item { Text("No gateways found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
} else {
|
} else {
|
||||||
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
items(items = visibleGateways, key = { it.stableId }) { gateway ->
|
||||||
val detailLines =
|
val detailLines =
|
||||||
buildList {
|
buildList {
|
||||||
add("IP: ${bridge.host}:${bridge.port}")
|
add("IP: ${gateway.host}:${gateway.port}")
|
||||||
bridge.lanHost?.let { add("LAN: $it") }
|
gateway.lanHost?.let { add("LAN: $it") }
|
||||||
bridge.tailnetDns?.let { add("Tailnet: $it") }
|
gateway.tailnetDns?.let { add("Tailnet: $it") }
|
||||||
if (bridge.gatewayPort != null || bridge.bridgePort != null || bridge.canvasPort != null) {
|
if (gateway.gatewayPort != null || gateway.bridgePort != null || gateway.canvasPort != null) {
|
||||||
val gw = bridge.gatewayPort?.toString() ?: "—"
|
val gw = gateway.gatewayPort?.toString() ?: "—"
|
||||||
val br = (bridge.bridgePort ?: bridge.port).toString()
|
val br = (gateway.bridgePort ?: gateway.port).toString()
|
||||||
val canvas = bridge.canvasPort?.toString() ?: "—"
|
val canvas = gateway.canvasPort?.toString() ?: "—"
|
||||||
add("Ports: gw $gw · bridge $br · canvas $canvas")
|
add("Ports: gw $gw · bridge $br · canvas $canvas")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(bridge.name) },
|
headlineContent = { Text(gateway.name) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
detailLines.forEach { line ->
|
detailLines.forEach { line ->
|
||||||
@@ -327,7 +329,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
NodeForegroundService.start(context)
|
NodeForegroundService.start(context)
|
||||||
viewModel.connect(bridge)
|
viewModel.connect(gateway)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text("Connect")
|
Text("Connect")
|
||||||
@@ -338,7 +340,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
bridgeDiscoveryFooterText,
|
gatewayDiscoveryFooterText,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
@@ -352,7 +354,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Advanced") },
|
headlineContent = { Text("Advanced") },
|
||||||
supportingContent = { Text("Manual bridge connection") },
|
supportingContent = { Text("Manual gateway connection") },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
imageVector = if (advancedExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||||
@@ -369,7 +371,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
AnimatedVisibility(visible = advancedExpanded) {
|
AnimatedVisibility(visible = advancedExpanded) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Use Manual Bridge") },
|
headlineContent = { Text("Use Manual Gateway") },
|
||||||
supportingContent = { Text("Use this when discovery is blocked.") },
|
supportingContent = { Text("Use this when discovery is blocked.") },
|
||||||
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
|
trailingContent = { Switch(checked = manualEnabled, onCheckedChange = viewModel::setManualEnabled) },
|
||||||
)
|
)
|
||||||
@@ -388,6 +390,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = manualEnabled,
|
enabled = manualEnabled,
|
||||||
)
|
)
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text("Require TLS") },
|
||||||
|
supportingContent = { Text("Pin the gateway certificate on first connect.") },
|
||||||
|
trailingContent = { Switch(checked = manualTls, onCheckedChange = viewModel::setManualTls, enabled = manualEnabled) },
|
||||||
|
modifier = Modifier.alpha(if (manualEnabled) 1f else 0.5f),
|
||||||
|
)
|
||||||
|
|
||||||
val hostOk = manualHost.trim().isNotEmpty()
|
val hostOk = manualHost.trim().isNotEmpty()
|
||||||
val portOk = manualPort in 1..65535
|
val portOk = manualPort in 1..65535
|
||||||
@@ -496,7 +504,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item {
|
item {
|
||||||
Text(
|
Text(
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
"Any node can edit wake words. Changes sync via the gateway bridge."
|
"Any node can edit wake words. Changes sync via the gateway."
|
||||||
} else {
|
} else {
|
||||||
"Connect to a gateway to sync wake words globally."
|
"Connect to a gateway to sync wake words globally."
|
||||||
},
|
},
|
||||||
@@ -511,7 +519,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Allow Camera") },
|
headlineContent = { Text("Allow Camera") },
|
||||||
supportingContent = { Text("Allows the bridge to request photos or short video clips (foreground only).") },
|
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).") },
|
||||||
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
|
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -538,7 +546,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(
|
Text(
|
||||||
if (smsPermissionAvailable) {
|
if (smsPermissionAvailable) {
|
||||||
"Allow the bridge to send SMS from this device."
|
"Allow the gateway to send SMS from this device."
|
||||||
} else {
|
} else {
|
||||||
"SMS requires a device with telephony hardware."
|
"SMS requires a device with telephony hardware."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusPill(
|
fun StatusPill(
|
||||||
bridge: BridgeState,
|
gateway: GatewayState,
|
||||||
voiceEnabled: Boolean,
|
voiceEnabled: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -49,11 +49,11 @@ fun StatusPill(
|
|||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.size(9.dp),
|
modifier = Modifier.size(9.dp),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = bridge.color,
|
color = gateway.color,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = bridge.title,
|
text = gateway.title,
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ data class StatusActivity(
|
|||||||
val tint: Color? = null,
|
val tint: Color? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class BridgeState(val title: String, val color: Color) {
|
enum class GatewayState(val title: String, val color: Color) {
|
||||||
Connected("Connected", Color(0xFF2ECC71)),
|
Connected("Connected", Color(0xFF2ECC71)),
|
||||||
Connecting("Connecting…", Color(0xFFF1C40F)),
|
Connecting("Connecting…", Color(0xFFF1C40F)),
|
||||||
Error("Error", Color(0xFFE74C3C)),
|
Error("Error", Color(0xFFE74C3C)),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import android.speech.tts.TextToSpeech
|
|||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.clawdbot.android.gateway.GatewaySession
|
||||||
import com.clawdbot.android.isCanonicalMainSessionKey
|
import com.clawdbot.android.isCanonicalMainSessionKey
|
||||||
import com.clawdbot.android.normalizeMainKey
|
import com.clawdbot.android.normalizeMainKey
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
@@ -46,6 +46,9 @@ import kotlin.math.max
|
|||||||
class TalkModeManager(
|
class TalkModeManager(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
|
private val session: GatewaySession,
|
||||||
|
private val supportsChatSubscribe: Boolean,
|
||||||
|
private val isConnected: () -> Boolean,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private const val tag = "TalkMode"
|
private const val tag = "TalkMode"
|
||||||
@@ -99,7 +102,6 @@ class TalkModeManager(
|
|||||||
private var modelOverrideActive = false
|
private var modelOverrideActive = false
|
||||||
private var mainSessionKey: String = "main"
|
private var mainSessionKey: String = "main"
|
||||||
|
|
||||||
private var session: BridgeSession? = null
|
|
||||||
private var pendingRunId: String? = null
|
private var pendingRunId: String? = null
|
||||||
private var pendingFinal: CompletableDeferred<Boolean>? = null
|
private var pendingFinal: CompletableDeferred<Boolean>? = null
|
||||||
private var chatSubscribedSessionKey: String? = null
|
private var chatSubscribedSessionKey: String? = null
|
||||||
@@ -112,11 +114,6 @@ class TalkModeManager(
|
|||||||
private var systemTtsPending: CompletableDeferred<Unit>? = null
|
private var systemTtsPending: CompletableDeferred<Unit>? = null
|
||||||
private var systemTtsPendingId: String? = null
|
private var systemTtsPendingId: String? = null
|
||||||
|
|
||||||
fun attachSession(session: BridgeSession) {
|
|
||||||
this.session = session
|
|
||||||
chatSubscribedSessionKey = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMainSessionKey(sessionKey: String?) {
|
fun setMainSessionKey(sessionKey: String?) {
|
||||||
val trimmed = sessionKey?.trim().orEmpty()
|
val trimmed = sessionKey?.trim().orEmpty()
|
||||||
if (trimmed.isEmpty()) return
|
if (trimmed.isEmpty()) return
|
||||||
@@ -136,7 +133,7 @@ class TalkModeManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleBridgeEvent(event: String, payloadJson: String?) {
|
fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||||
if (event != "chat") return
|
if (event != "chat") return
|
||||||
if (payloadJson.isNullOrBlank()) return
|
if (payloadJson.isNullOrBlank()) return
|
||||||
val pending = pendingRunId ?: return
|
val pending = pendingRunId ?: return
|
||||||
@@ -306,25 +303,24 @@ class TalkModeManager(
|
|||||||
|
|
||||||
reloadConfig()
|
reloadConfig()
|
||||||
val prompt = buildPrompt(transcript)
|
val prompt = buildPrompt(transcript)
|
||||||
val bridge = session
|
if (!isConnected()) {
|
||||||
if (bridge == null) {
|
_statusText.value = "Gateway not connected"
|
||||||
_statusText.value = "Bridge not connected"
|
Log.w(tag, "finalize: gateway not connected")
|
||||||
Log.w(tag, "finalize: bridge not connected")
|
|
||||||
start()
|
start()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
|
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
|
||||||
subscribeChatIfNeeded(bridge = bridge, sessionKey = mainSessionKey)
|
subscribeChatIfNeeded(session = session, sessionKey = mainSessionKey)
|
||||||
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
|
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
|
||||||
val runId = sendChat(prompt, bridge)
|
val runId = sendChat(prompt, session)
|
||||||
Log.d(tag, "chat.send ok runId=$runId")
|
Log.d(tag, "chat.send ok runId=$runId")
|
||||||
val ok = waitForChatFinal(runId)
|
val ok = waitForChatFinal(runId)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
|
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
|
||||||
}
|
}
|
||||||
val assistant = waitForAssistantText(bridge, startedAt, if (ok) 12_000 else 25_000)
|
val assistant = waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
|
||||||
if (assistant.isNullOrBlank()) {
|
if (assistant.isNullOrBlank()) {
|
||||||
_statusText.value = "No reply"
|
_statusText.value = "No reply"
|
||||||
Log.w(tag, "assistant text timeout runId=$runId")
|
Log.w(tag, "assistant text timeout runId=$runId")
|
||||||
@@ -343,12 +339,13 @@ class TalkModeManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun subscribeChatIfNeeded(bridge: BridgeSession, sessionKey: String) {
|
private suspend fun subscribeChatIfNeeded(session: GatewaySession, sessionKey: String) {
|
||||||
|
if (!supportsChatSubscribe) return
|
||||||
val key = sessionKey.trim()
|
val key = sessionKey.trim()
|
||||||
if (key.isEmpty()) return
|
if (key.isEmpty()) return
|
||||||
if (chatSubscribedSessionKey == key) return
|
if (chatSubscribedSessionKey == key) return
|
||||||
try {
|
try {
|
||||||
bridge.sendEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""")
|
||||||
chatSubscribedSessionKey = key
|
chatSubscribedSessionKey = key
|
||||||
Log.d(tag, "chat.subscribe ok sessionKey=$key")
|
Log.d(tag, "chat.subscribe ok sessionKey=$key")
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
@@ -370,7 +367,7 @@ class TalkModeManager(
|
|||||||
return lines.joinToString("\n")
|
return lines.joinToString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sendChat(message: String, bridge: BridgeSession): String {
|
private suspend fun sendChat(message: String, session: GatewaySession): String {
|
||||||
val runId = UUID.randomUUID().toString()
|
val runId = UUID.randomUUID().toString()
|
||||||
val params =
|
val params =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
@@ -380,7 +377,7 @@ class TalkModeManager(
|
|||||||
put("timeoutMs", JsonPrimitive(30_000))
|
put("timeoutMs", JsonPrimitive(30_000))
|
||||||
put("idempotencyKey", JsonPrimitive(runId))
|
put("idempotencyKey", JsonPrimitive(runId))
|
||||||
}
|
}
|
||||||
val res = bridge.request("chat.send", params.toString())
|
val res = session.request("chat.send", params.toString())
|
||||||
val parsed = parseRunId(res) ?: runId
|
val parsed = parseRunId(res) ?: runId
|
||||||
if (parsed != runId) {
|
if (parsed != runId) {
|
||||||
pendingRunId = parsed
|
pendingRunId = parsed
|
||||||
@@ -411,13 +408,13 @@ class TalkModeManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun waitForAssistantText(
|
private suspend fun waitForAssistantText(
|
||||||
bridge: BridgeSession,
|
session: GatewaySession,
|
||||||
sinceSeconds: Double,
|
sinceSeconds: Double,
|
||||||
timeoutMs: Long,
|
timeoutMs: Long,
|
||||||
): String? {
|
): String? {
|
||||||
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||||
while (SystemClock.elapsedRealtime() < deadline) {
|
while (SystemClock.elapsedRealtime() < deadline) {
|
||||||
val text = fetchLatestAssistantText(bridge, sinceSeconds)
|
val text = fetchLatestAssistantText(session, sinceSeconds)
|
||||||
if (!text.isNullOrBlank()) return text
|
if (!text.isNullOrBlank()) return text
|
||||||
delay(300)
|
delay(300)
|
||||||
}
|
}
|
||||||
@@ -425,11 +422,11 @@ class TalkModeManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchLatestAssistantText(
|
private suspend fun fetchLatestAssistantText(
|
||||||
bridge: BridgeSession,
|
session: GatewaySession,
|
||||||
sinceSeconds: Double? = null,
|
sinceSeconds: Double? = null,
|
||||||
): String? {
|
): String? {
|
||||||
val key = mainSessionKey.ifBlank { "main" }
|
val key = mainSessionKey.ifBlank { "main" }
|
||||||
val res = bridge.request("chat.history", "{\"sessionKey\":\"$key\"}")
|
val res = session.request("chat.history", "{\"sessionKey\":\"$key\"}")
|
||||||
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
|
val root = json.parseToJsonElement(res).asObjectOrNull() ?: return null
|
||||||
val messages = root["messages"] as? JsonArray ?: return null
|
val messages = root["messages"] as? JsonArray ?: return null
|
||||||
for (item in messages.reversed()) {
|
for (item in messages.reversed()) {
|
||||||
@@ -813,12 +810,11 @@ class TalkModeManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reloadConfig() {
|
private suspend fun reloadConfig() {
|
||||||
val bridge = session ?: return
|
|
||||||
val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim()
|
val envVoice = System.getenv("ELEVENLABS_VOICE_ID")?.trim()
|
||||||
val sagVoice = System.getenv("SAG_VOICE_ID")?.trim()
|
val sagVoice = System.getenv("SAG_VOICE_ID")?.trim()
|
||||||
val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim()
|
val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim()
|
||||||
try {
|
try {
|
||||||
val res = bridge.request("config.get", "{}")
|
val res = session.request("config.get", "{}")
|
||||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||||
val config = root?.get("config").asObjectOrNull()
|
val config = root?.get("config").asObjectOrNull()
|
||||||
val talk = config?.get("talk").asObjectOrNull()
|
val talk = config?.get("talk").asObjectOrNull()
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ For tailnet-only setups (recommended for Vienna ⇄ London), bind the gateway to
|
|||||||
From the gateway machine:
|
From the gateway machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dns-sd -B _clawdbot._tcp local.
|
dns-sd -B _clawdbot-gateway._tcp local.
|
||||||
```
|
```
|
||||||
|
|
||||||
More debugging notes: [Bonjour](/gateway/bonjour).
|
More debugging notes: [Bonjour](/gateway/bonjour).
|
||||||
@@ -61,7 +61,7 @@ More debugging notes: [Bonjour](/gateway/bonjour).
|
|||||||
|
|
||||||
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
|
Android NSD/mDNS discovery won’t cross networks. If your Android node and the gateway are on different networks but connected via Tailscale, use Wide-Area Bonjour / unicast DNS-SD instead:
|
||||||
|
|
||||||
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot._tcp` records.
|
1) Set up a DNS-SD zone (example `clawdbot.internal.`) on the gateway host and publish `_clawdbot-gateway._tcp` records.
|
||||||
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
|
2) Configure Tailscale split DNS for `clawdbot.internal` pointing at that DNS server.
|
||||||
|
|
||||||
Details and example CoreDNS config: [Bonjour](/gateway/bonjour).
|
Details and example CoreDNS config: [Bonjour](/gateway/bonjour).
|
||||||
|
|||||||
Reference in New Issue
Block a user