feat(android): keep node connected via foreground service
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
@@ -11,10 +14,15 @@
|
|||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".NodeApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ClawdisNode">
|
android:theme="@style/Theme.ClawdisNode">
|
||||||
|
<service
|
||||||
|
android:name=".NodeForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestDiscoveryPermissionsIfNeeded()
|
requestDiscoveryPermissionsIfNeeded()
|
||||||
|
requestNotificationPermissionIfNeeded()
|
||||||
|
NodeForegroundService.start(this)
|
||||||
viewModel.camera.attachLifecycleOwner(this)
|
viewModel.camera.attachLifecycleOwner(this)
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
@@ -59,4 +61,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun requestNotificationPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT < 33) return
|
||||||
|
val ok =
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!ok) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 102)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,402 +2,77 @@ package com.steipete.clawdis.node
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.steipete.clawdis.node.bridge.BridgeDiscovery
|
|
||||||
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||||
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
|
||||||
import com.steipete.clawdis.node.bridge.BridgeSession
|
|
||||||
import com.steipete.clawdis.node.node.CameraCaptureManager
|
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
import com.steipete.clawdis.node.node.CanvasController
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
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
|
|
||||||
|
|
||||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
private val prefs = SecurePrefs(app)
|
private val runtime: NodeRuntime = (app as NodeApp).runtime
|
||||||
|
|
||||||
val canvas = CanvasController()
|
val canvas: CanvasController = runtime.canvas
|
||||||
val camera = CameraCaptureManager(app)
|
val camera: CameraCaptureManager = runtime.camera
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
private val discovery = BridgeDiscovery(app)
|
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
|
||||||
|
|
||||||
private val _isConnected = MutableStateFlow(false)
|
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
||||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
val statusText: StateFlow<String> = runtime.statusText
|
||||||
|
val serverName: StateFlow<String?> = runtime.serverName
|
||||||
|
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||||
|
|
||||||
private val _statusText = MutableStateFlow("Not connected")
|
val instanceId: StateFlow<String> = runtime.instanceId
|
||||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
|
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||||
|
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||||
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
|
|
||||||
private val _serverName = MutableStateFlow<String?>(null)
|
val chatMessages: StateFlow<List<NodeRuntime.ChatMessage>> = runtime.chatMessages
|
||||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
val chatError: StateFlow<String?> = runtime.chatError
|
||||||
|
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
||||||
private val _remoteAddress = MutableStateFlow<String?>(null)
|
|
||||||
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
|
||||||
|
|
||||||
private val _isForeground = MutableStateFlow(true)
|
|
||||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
|
||||||
|
|
||||||
private val session =
|
|
||||||
BridgeSession(
|
|
||||||
scope = viewModelScope,
|
|
||||||
onConnected = { name, remote ->
|
|
||||||
_statusText.value = "Connected"
|
|
||||||
_serverName.value = name
|
|
||||||
_remoteAddress.value = remote
|
|
||||||
_isConnected.value = true
|
|
||||||
},
|
|
||||||
onDisconnected = { message ->
|
|
||||||
_statusText.value = message
|
|
||||||
_serverName.value = null
|
|
||||||
_remoteAddress.value = null
|
|
||||||
_isConnected.value = false
|
|
||||||
},
|
|
||||||
onEvent = { event, payloadJson ->
|
|
||||||
handleBridgeEvent(event, payloadJson)
|
|
||||||
},
|
|
||||||
onInvoke = { req ->
|
|
||||||
handleInvoke(req.command, req.paramsJson)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
val instanceId: StateFlow<String> = prefs.instanceId
|
|
||||||
val displayName: StateFlow<String> = prefs.displayName
|
|
||||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
|
||||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
|
||||||
val manualHost: StateFlow<String> = prefs.manualHost
|
|
||||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
|
||||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
|
||||||
|
|
||||||
private var didAutoConnect = false
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
|
||||||
bridges.collect { list ->
|
|
||||||
if (list.isNotEmpty()) {
|
|
||||||
// Persist the last discovered bridge (best-effort UX parity with iOS).
|
|
||||||
prefs.setLastDiscoveredStableId(list.last().stableId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (didAutoConnect) return@collect
|
|
||||||
if (_isConnected.value) return@collect
|
|
||||||
|
|
||||||
val token = prefs.loadBridgeToken()
|
|
||||||
if (token.isNullOrBlank()) return@collect
|
|
||||||
|
|
||||||
if (manualEnabled.value) {
|
|
||||||
val host = manualHost.value.trim()
|
|
||||||
val port = manualPort.value
|
|
||||||
if (host.isNotEmpty() && port in 1..65535) {
|
|
||||||
didAutoConnect = true
|
|
||||||
connect(BridgeEndpoint.manual(host = host, port = port))
|
|
||||||
}
|
|
||||||
return@collect
|
|
||||||
}
|
|
||||||
|
|
||||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
|
||||||
if (targetStableId.isEmpty()) return@collect
|
|
||||||
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
|
||||||
didAutoConnect = true
|
|
||||||
connect(target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setForeground(value: Boolean) {
|
fun setForeground(value: Boolean) {
|
||||||
_isForeground.value = value
|
runtime.setForeground(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDisplayName(value: String) {
|
fun setDisplayName(value: String) {
|
||||||
prefs.setDisplayName(value)
|
runtime.setDisplayName(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCameraEnabled(value: Boolean) {
|
fun setCameraEnabled(value: Boolean) {
|
||||||
prefs.setCameraEnabled(value)
|
runtime.setCameraEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualEnabled(value: Boolean) {
|
fun setManualEnabled(value: Boolean) {
|
||||||
prefs.setManualEnabled(value)
|
runtime.setManualEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualHost(value: String) {
|
fun setManualHost(value: String) {
|
||||||
prefs.setManualHost(value)
|
runtime.setManualHost(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualPort(value: Int) {
|
fun setManualPort(value: Int) {
|
||||||
prefs.setManualPort(value)
|
runtime.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
runtime.connect(endpoint)
|
||||||
_statusText.value = "Connecting…"
|
|
||||||
val token = prefs.loadBridgeToken()
|
|
||||||
val resolved =
|
|
||||||
if (token.isNullOrBlank()) {
|
|
||||||
_statusText.value = "Pairing…"
|
|
||||||
BridgePairingClient().pairAndHello(
|
|
||||||
endpoint = endpoint,
|
|
||||||
hello = BridgePairingClient.Hello(
|
|
||||||
nodeId = instanceId.value,
|
|
||||||
displayName = displayName.value,
|
|
||||||
token = null,
|
|
||||||
platform = "Android",
|
|
||||||
version = "dev",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
BridgePairingClient.PairResult(ok = true, token = token.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
|
||||||
_statusText.value = "Failed: pairing required"
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
prefs.saveBridgeToken(resolved.token!!)
|
|
||||||
session.connect(
|
|
||||||
endpoint = endpoint,
|
|
||||||
hello = BridgeSession.Hello(
|
|
||||||
nodeId = instanceId.value,
|
|
||||||
displayName = displayName.value,
|
|
||||||
token = resolved.token,
|
|
||||||
platform = "Android",
|
|
||||||
version = "dev",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connectManual() {
|
fun connectManual() {
|
||||||
val host = manualHost.value.trim()
|
runtime.connectManual()
|
||||||
val port = manualPort.value
|
|
||||||
if (host.isEmpty() || port <= 0 || port > 65535) {
|
|
||||||
_statusText.value = "Failed: invalid manual host/port"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
connect(BridgeEndpoint.manual(host = host, port = port))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
session.disconnect()
|
runtime.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?)
|
|
||||||
|
|
||||||
private val _chatMessages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
|
||||||
val chatMessages: StateFlow<List<ChatMessage>> = _chatMessages.asStateFlow()
|
|
||||||
|
|
||||||
private val _chatError = MutableStateFlow<String?>(null)
|
|
||||||
val chatError: StateFlow<String?> = _chatError.asStateFlow()
|
|
||||||
|
|
||||||
private val pendingRuns = mutableSetOf<String>()
|
|
||||||
private val _pendingRunCount = MutableStateFlow(0)
|
|
||||||
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
|
||||||
|
|
||||||
fun loadChat(sessionKey: String = "main") {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
runtime.loadChat(sessionKey)
|
||||||
_chatError.value = null
|
|
||||||
try {
|
|
||||||
// Best-effort; push events are optional, but improve latency.
|
|
||||||
session.sendEvent("chat.subscribe", """{"sessionKey":"$sessionKey"}""")
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val res = session.request("chat.history", """{"sessionKey":"$sessionKey"}""")
|
|
||||||
_chatMessages.value = parseHistory(res)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_chatError.value = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendChat(sessionKey: String = "main", message: String, thinking: String = "off") {
|
fun sendChat(sessionKey: String = "main", message: String) {
|
||||||
val trimmed = message.trim()
|
runtime.sendChat(sessionKey, message)
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
_chatError.value = null
|
|
||||||
val idem = java.util.UUID.randomUUID().toString()
|
|
||||||
|
|
||||||
_chatMessages.value =
|
|
||||||
_chatMessages.value +
|
|
||||||
ChatMessage(
|
|
||||||
id = java.util.UUID.randomUUID().toString(),
|
|
||||||
role = "user",
|
|
||||||
text = trimmed,
|
|
||||||
timestampMs = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val params =
|
|
||||||
"""{"sessionKey":"$sessionKey","message":${trimmed.toJsonString()},"thinking":"$thinking","timeoutMs":30000,"idempotencyKey":"$idem"}"""
|
|
||||||
val res = session.request("chat.send", params)
|
|
||||||
val runId = parseRunId(res) ?: idem
|
|
||||||
pendingRuns.add(runId)
|
|
||||||
_pendingRunCount.value = pendingRuns.size
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_chatError.value = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
|
||||||
if (event != "chat" || payloadJson.isNullOrBlank()) return
|
|
||||||
try {
|
|
||||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
|
||||||
val state = payload["state"].asStringOrNull()
|
|
||||||
val runId = payload["runId"].asStringOrNull()
|
|
||||||
if (!runId.isNullOrBlank()) {
|
|
||||||
pendingRuns.remove(runId)
|
|
||||||
_pendingRunCount.value = pendingRuns.size
|
|
||||||
}
|
|
||||||
|
|
||||||
when (state) {
|
|
||||||
"final" -> {
|
|
||||||
val msgObj = payload["message"].asObjectOrNull()
|
|
||||||
val role = msgObj?.get("role").asStringOrNull() ?: "assistant"
|
|
||||||
val text = extractTextFromMessage(msgObj)
|
|
||||||
if (!text.isNullOrBlank()) {
|
|
||||||
_chatMessages.value =
|
|
||||||
_chatMessages.value +
|
|
||||||
ChatMessage(
|
|
||||||
id = java.util.UUID.randomUUID().toString(),
|
|
||||||
role = role,
|
|
||||||
text = text,
|
|
||||||
timestampMs = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"error" -> {
|
|
||||||
_chatError.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseHistory(historyJson: String): List<ChatMessage> {
|
|
||||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
|
|
||||||
val raw = root["messages"] ?: return emptyList()
|
|
||||||
val array = raw as? JsonArray ?: return emptyList()
|
|
||||||
return array.mapNotNull { item ->
|
|
||||||
val obj = item as? JsonObject ?: return@mapNotNull null
|
|
||||||
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
|
||||||
val text = extractTextFromMessage(obj) ?: return@mapNotNull null
|
|
||||||
ChatMessage(
|
|
||||||
id = java.util.UUID.randomUUID().toString(),
|
|
||||||
role = role,
|
|
||||||
text = text,
|
|
||||||
timestampMs = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractTextFromMessage(msgObj: JsonObject?): String? {
|
|
||||||
if (msgObj == null) return null
|
|
||||||
val content = msgObj["content"] ?: return null
|
|
||||||
return when (content) {
|
|
||||||
is JsonPrimitive -> content.asStringOrNull()
|
|
||||||
else -> {
|
|
||||||
val arr = (content as? JsonArray) ?: return null
|
|
||||||
arr.mapNotNull { part ->
|
|
||||||
val p = part as? JsonObject ?: return@mapNotNull null
|
|
||||||
p["text"].asStringOrNull()
|
|
||||||
}.joinToString("\n").trim().ifBlank { null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseRunId(resJson: String): String? {
|
|
||||||
return try {
|
|
||||||
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
|
||||||
if ((command.startsWith("screen.") || command.startsWith("camera.")) && !isForeground.value) {
|
|
||||||
return BridgeSession.InvokeResult.error(
|
|
||||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
|
||||||
message = "NODE_BACKGROUND_UNAVAILABLE: screen/camera commands require foreground",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (command.startsWith("camera.") && !cameraEnabled.value) {
|
|
||||||
return BridgeSession.InvokeResult.error(
|
|
||||||
code = "CAMERA_DISABLED",
|
|
||||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (command) {
|
|
||||||
"screen.show" -> BridgeSession.InvokeResult.ok(null)
|
|
||||||
"screen.hide" -> BridgeSession.InvokeResult.ok(null)
|
|
||||||
"screen.setMode" -> {
|
|
||||||
val mode = CanvasController.parseMode(paramsJson)
|
|
||||||
canvas.setMode(mode)
|
|
||||||
BridgeSession.InvokeResult.ok(null)
|
|
||||||
}
|
|
||||||
"screen.navigate" -> {
|
|
||||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
|
||||||
if (url != null) canvas.navigate(url)
|
|
||||||
BridgeSession.InvokeResult.ok(null)
|
|
||||||
}
|
|
||||||
"screen.eval" -> {
|
|
||||||
val js = CanvasController.parseEvalJs(paramsJson) ?: return BridgeSession.InvokeResult.error(
|
|
||||||
code = "INVALID_REQUEST",
|
|
||||||
message = "INVALID_REQUEST: javaScript required",
|
|
||||||
)
|
|
||||||
val result = canvas.eval(js)
|
|
||||||
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
|
||||||
}
|
|
||||||
"screen.snapshot" -> {
|
|
||||||
val maxWidth = CanvasController.parseSnapshotMaxWidth(paramsJson)
|
|
||||||
val base64 = canvas.snapshotPngBase64(maxWidth = maxWidth)
|
|
||||||
BridgeSession.InvokeResult.ok("""{"format":"png","base64":"$base64"}""")
|
|
||||||
}
|
|
||||||
"camera.snap" -> {
|
|
||||||
val res = camera.snap(paramsJson)
|
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
|
||||||
}
|
|
||||||
"camera.clip" -> {
|
|
||||||
val res = camera.clip(paramsJson)
|
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
|
||||||
}
|
|
||||||
else ->
|
|
||||||
BridgeSession.InvokeResult.error(
|
|
||||||
code = "INVALID_REQUEST",
|
|
||||||
message = "INVALID_REQUEST: unknown command",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 String.toJsonString(): String {
|
|
||||||
val escaped =
|
|
||||||
this.replace("\\", "\\\\")
|
|
||||||
.replace("\"", "\\\"")
|
|
||||||
.replace("\n", "\\n")
|
|
||||||
.replace("\r", "\\r")
|
|
||||||
return "\"$escaped\""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class NodeApp : Application() {
|
||||||
|
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class NodeForegroundService : Service() {
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
ensureChannel()
|
||||||
|
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
startForeground(NOTIFICATION_ID, initial, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
val runtime = (application as NodeApp).runtime
|
||||||
|
notificationJob =
|
||||||
|
scope.launch {
|
||||||
|
combine(runtime.statusText, runtime.serverName, runtime.isConnected) { status, server, connected ->
|
||||||
|
Triple(status, server, connected)
|
||||||
|
}.collect { (status, server, connected) ->
|
||||||
|
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
|
||||||
|
val text = server?.let { "$status · $it" } ?: status
|
||||||
|
updateNotification(buildNotification(title = title, text = text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
(application as NodeApp).runtime.disconnect()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
notificationJob?.cancel()
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?) = null
|
||||||
|
|
||||||
|
private fun ensureChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val mgr = getSystemService(NotificationManager::class.java)
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Connection",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = "Clawdis node connection status"
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
mgr.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(title: String, text: String): Notification {
|
||||||
|
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
|
val flags =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
} else {
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
}
|
||||||
|
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.addAction(0, "Disconnect", stopPending)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(notification: Notification) {
|
||||||
|
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
mgr.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "connection"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
|
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP"
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent = Intent(context, NodeForegroundService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeDiscovery
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||||
|
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||||
|
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
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
|
||||||
|
|
||||||
|
class NodeRuntime(context: Context) {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
|
val prefs = SecurePrefs(appContext)
|
||||||
|
val canvas = CanvasController()
|
||||||
|
val camera = CameraCaptureManager(appContext)
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private val discovery = BridgeDiscovery(appContext)
|
||||||
|
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||||
|
|
||||||
|
private val _isConnected = MutableStateFlow(false)
|
||||||
|
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||||
|
|
||||||
|
private val _statusText = MutableStateFlow("Not connected")
|
||||||
|
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||||
|
|
||||||
|
private val _serverName = MutableStateFlow<String?>(null)
|
||||||
|
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||||
|
|
||||||
|
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||||
|
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
||||||
|
|
||||||
|
private val _isForeground = MutableStateFlow(true)
|
||||||
|
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||||
|
|
||||||
|
private val session =
|
||||||
|
BridgeSession(
|
||||||
|
scope = scope,
|
||||||
|
onConnected = { name, remote ->
|
||||||
|
_statusText.value = "Connected"
|
||||||
|
_serverName.value = name
|
||||||
|
_remoteAddress.value = remote
|
||||||
|
_isConnected.value = true
|
||||||
|
},
|
||||||
|
onDisconnected = { message ->
|
||||||
|
_statusText.value = message
|
||||||
|
_serverName.value = null
|
||||||
|
_remoteAddress.value = null
|
||||||
|
_isConnected.value = false
|
||||||
|
},
|
||||||
|
onEvent = { event, payloadJson ->
|
||||||
|
handleBridgeEvent(event, payloadJson)
|
||||||
|
},
|
||||||
|
onInvoke = { req ->
|
||||||
|
handleInvoke(req.command, req.paramsJson)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
|
val displayName: StateFlow<String> = prefs.displayName
|
||||||
|
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||||
|
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||||
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
|
|
||||||
|
private var didAutoConnect = false
|
||||||
|
|
||||||
|
data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?)
|
||||||
|
|
||||||
|
private val _chatMessages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||||
|
val chatMessages: StateFlow<List<ChatMessage>> = _chatMessages.asStateFlow()
|
||||||
|
|
||||||
|
private val _chatError = MutableStateFlow<String?>(null)
|
||||||
|
val chatError: StateFlow<String?> = _chatError.asStateFlow()
|
||||||
|
|
||||||
|
private val pendingRuns = mutableSetOf<String>()
|
||||||
|
private val _pendingRunCount = MutableStateFlow(0)
|
||||||
|
val pendingRunCount: StateFlow<Int> = _pendingRunCount.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
bridges.collect { list ->
|
||||||
|
if (list.isNotEmpty()) {
|
||||||
|
// Persist the last discovered bridge (best-effort UX parity with iOS).
|
||||||
|
prefs.setLastDiscoveredStableId(list.last().stableId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didAutoConnect) return@collect
|
||||||
|
if (_isConnected.value) return@collect
|
||||||
|
|
||||||
|
val token = prefs.loadBridgeToken()
|
||||||
|
if (token.isNullOrBlank()) return@collect
|
||||||
|
|
||||||
|
if (manualEnabled.value) {
|
||||||
|
val host = manualHost.value.trim()
|
||||||
|
val port = manualPort.value
|
||||||
|
if (host.isNotEmpty() && port in 1..65535) {
|
||||||
|
didAutoConnect = true
|
||||||
|
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||||
|
}
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||||
|
if (targetStableId.isEmpty()) return@collect
|
||||||
|
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||||
|
didAutoConnect = true
|
||||||
|
connect(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForeground(value: Boolean) {
|
||||||
|
_isForeground.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDisplayName(value: String) {
|
||||||
|
prefs.setDisplayName(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCameraEnabled(value: Boolean) {
|
||||||
|
prefs.setCameraEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualEnabled(value: Boolean) {
|
||||||
|
prefs.setManualEnabled(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualHost(value: String) {
|
||||||
|
prefs.setManualHost(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setManualPort(value: Int) {
|
||||||
|
prefs.setManualPort(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
|
scope.launch {
|
||||||
|
_statusText.value = "Connecting…"
|
||||||
|
val storedToken = prefs.loadBridgeToken()
|
||||||
|
val resolved =
|
||||||
|
if (storedToken.isNullOrBlank()) {
|
||||||
|
_statusText.value = "Pairing…"
|
||||||
|
BridgePairingClient().pairAndHello(
|
||||||
|
endpoint = endpoint,
|
||||||
|
hello =
|
||||||
|
BridgePairingClient.Hello(
|
||||||
|
nodeId = instanceId.value,
|
||||||
|
displayName = displayName.value,
|
||||||
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "dev",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
||||||
|
_statusText.value = "Failed: pairing required"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val authToken = requireNotNull(resolved.token).trim()
|
||||||
|
prefs.saveBridgeToken(authToken)
|
||||||
|
session.connect(
|
||||||
|
endpoint = endpoint,
|
||||||
|
hello =
|
||||||
|
BridgeSession.Hello(
|
||||||
|
nodeId = instanceId.value,
|
||||||
|
displayName = displayName.value,
|
||||||
|
token = authToken,
|
||||||
|
platform = "Android",
|
||||||
|
version = "dev",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun connectManual() {
|
||||||
|
val host = manualHost.value.trim()
|
||||||
|
val port = manualPort.value
|
||||||
|
if (host.isEmpty() || port <= 0 || port > 65535) {
|
||||||
|
_statusText.value = "Failed: invalid manual host/port"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connect(BridgeEndpoint.manual(host = host, port = port))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
session.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadChat(sessionKey: String = "main") {
|
||||||
|
scope.launch {
|
||||||
|
_chatError.value = null
|
||||||
|
try {
|
||||||
|
// Best-effort; push events are optional, but improve latency.
|
||||||
|
session.sendEvent("chat.subscribe", """{"sessionKey":"$sessionKey"}""")
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val res = session.request("chat.history", """{"sessionKey":"$sessionKey"}""")
|
||||||
|
_chatMessages.value = parseHistory(res)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_chatError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendChat(sessionKey: String = "main", message: String, thinking: String = "off") {
|
||||||
|
val trimmed = message.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
scope.launch {
|
||||||
|
_chatError.value = null
|
||||||
|
val idem = java.util.UUID.randomUUID().toString()
|
||||||
|
|
||||||
|
_chatMessages.value =
|
||||||
|
_chatMessages.value +
|
||||||
|
ChatMessage(
|
||||||
|
id = java.util.UUID.randomUUID().toString(),
|
||||||
|
role = "user",
|
||||||
|
text = trimmed,
|
||||||
|
timestampMs = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val params =
|
||||||
|
"""{"sessionKey":"$sessionKey","message":${trimmed.toJsonString()},"thinking":"$thinking","timeoutMs":30000,"idempotencyKey":"$idem"}"""
|
||||||
|
val res = session.request("chat.send", params)
|
||||||
|
val runId = parseRunId(res) ?: idem
|
||||||
|
pendingRuns.add(runId)
|
||||||
|
_pendingRunCount.value = pendingRuns.size
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_chatError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
||||||
|
if (event != "chat" || payloadJson.isNullOrBlank()) return
|
||||||
|
try {
|
||||||
|
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||||
|
val state = payload["state"].asStringOrNull()
|
||||||
|
val runId = payload["runId"].asStringOrNull()
|
||||||
|
if (!runId.isNullOrBlank()) {
|
||||||
|
pendingRuns.remove(runId)
|
||||||
|
_pendingRunCount.value = pendingRuns.size
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state) {
|
||||||
|
"final" -> {
|
||||||
|
val msgObj = payload["message"].asObjectOrNull()
|
||||||
|
val role = msgObj?.get("role").asStringOrNull() ?: "assistant"
|
||||||
|
val text = extractTextFromMessage(msgObj)
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
_chatMessages.value =
|
||||||
|
_chatMessages.value +
|
||||||
|
ChatMessage(
|
||||||
|
id = java.util.UUID.randomUUID().toString(),
|
||||||
|
role = role,
|
||||||
|
text = text,
|
||||||
|
timestampMs = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
_chatError.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHistory(historyJson: String): List<ChatMessage> {
|
||||||
|
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
|
||||||
|
val raw = root["messages"] ?: return emptyList()
|
||||||
|
val array = raw as? JsonArray ?: return emptyList()
|
||||||
|
return array.mapNotNull { item ->
|
||||||
|
val obj = item as? JsonObject ?: return@mapNotNull null
|
||||||
|
val role = obj["role"].asStringOrNull() ?: return@mapNotNull null
|
||||||
|
val text = extractTextFromMessage(obj) ?: return@mapNotNull null
|
||||||
|
ChatMessage(
|
||||||
|
id = java.util.UUID.randomUUID().toString(),
|
||||||
|
role = role,
|
||||||
|
text = text,
|
||||||
|
timestampMs = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractTextFromMessage(msgObj: JsonObject?): String? {
|
||||||
|
if (msgObj == null) return null
|
||||||
|
val content = msgObj["content"] ?: return null
|
||||||
|
return when (content) {
|
||||||
|
is JsonPrimitive -> content.asStringOrNull()
|
||||||
|
else -> {
|
||||||
|
val arr = (content as? JsonArray) ?: return null
|
||||||
|
arr.mapNotNull { part ->
|
||||||
|
val p = part as? JsonObject ?: return@mapNotNull null
|
||||||
|
p["text"].asStringOrNull()
|
||||||
|
}.joinToString("\n").trim().ifBlank { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRunId(resJson: String): String? {
|
||||||
|
return try {
|
||||||
|
json.parseToJsonElement(resJson).asObjectOrNull()?.get("runId").asStringOrNull()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||||
|
if (command.startsWith("screen.") || command.startsWith("camera.")) {
|
||||||
|
if (!isForeground.value) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message = "NODE_BACKGROUND_UNAVAILABLE: screen/camera commands require foreground",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command.startsWith("camera.") && !cameraEnabled.value) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "CAMERA_DISABLED",
|
||||||
|
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (command) {
|
||||||
|
"screen.show" -> BridgeSession.InvokeResult.ok(null)
|
||||||
|
"screen.hide" -> BridgeSession.InvokeResult.ok(null)
|
||||||
|
"screen.setMode" -> {
|
||||||
|
val mode = CanvasController.parseMode(paramsJson)
|
||||||
|
canvas.setMode(mode)
|
||||||
|
BridgeSession.InvokeResult.ok(null)
|
||||||
|
}
|
||||||
|
"screen.navigate" -> {
|
||||||
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
|
if (url != null) canvas.navigate(url)
|
||||||
|
BridgeSession.InvokeResult.ok(null)
|
||||||
|
}
|
||||||
|
"screen.eval" -> {
|
||||||
|
val js =
|
||||||
|
CanvasController.parseEvalJs(paramsJson)
|
||||||
|
?: return BridgeSession.InvokeResult.error(
|
||||||
|
code = "INVALID_REQUEST",
|
||||||
|
message = "INVALID_REQUEST: javaScript required",
|
||||||
|
)
|
||||||
|
val result =
|
||||||
|
try {
|
||||||
|
canvas.eval(js)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||||
|
}
|
||||||
|
"screen.snapshot" -> {
|
||||||
|
val maxWidth = CanvasController.parseSnapshotMaxWidth(paramsJson)
|
||||||
|
val base64 =
|
||||||
|
try {
|
||||||
|
canvas.snapshotPngBase64(maxWidth = maxWidth)
|
||||||
|
} catch (err: Throwable) {
|
||||||
|
return BridgeSession.InvokeResult.error(
|
||||||
|
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||||
|
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeSession.InvokeResult.ok("""{"format":"png","base64":"$base64"}""")
|
||||||
|
}
|
||||||
|
"camera.snap" -> {
|
||||||
|
val res = camera.snap(paramsJson)
|
||||||
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
|
}
|
||||||
|
"camera.clip" -> {
|
||||||
|
val res = camera.clip(paramsJson)
|
||||||
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
BridgeSession.InvokeResult.error(
|
||||||
|
code = "INVALID_REQUEST",
|
||||||
|
message = "INVALID_REQUEST: unknown command",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toJsonString(): String {
|
||||||
|
val escaped =
|
||||||
|
this.replace("\\", "\\\\")
|
||||||
|
.replace("\"", "\\\"")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
return "\"$escaped\""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Divider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.steipete.clawdis.node.MainViewModel
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
import com.steipete.clawdis.node.NodeForegroundService
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsSheet(viewModel: MainViewModel) {
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
@@ -57,7 +58,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
Text("Instance ID: $instanceId")
|
Text("Instance ID: $instanceId")
|
||||||
|
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
|
|
||||||
Text("Camera")
|
Text("Camera")
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -83,7 +84,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
Text("Tip: grant Microphone permission for video clips with audio.")
|
Text("Tip: grant Microphone permission for video clips with audio.")
|
||||||
|
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
|
|
||||||
Text("Bridge")
|
Text("Bridge")
|
||||||
Text("Status: $statusText")
|
Text("Status: $statusText")
|
||||||
@@ -91,10 +92,17 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
if (remoteAddress != null) Text("Address: $remoteAddress")
|
if (remoteAddress != null) Text("Address: $remoteAddress")
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(onClick = viewModel::disconnect) { Text("Disconnect") }
|
Button(
|
||||||
|
onClick = {
|
||||||
|
viewModel.disconnect()
|
||||||
|
NodeForegroundService.stop(context)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Disconnect")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
|
|
||||||
Text("Advanced")
|
Text("Advanced")
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -115,9 +123,17 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = manualEnabled,
|
enabled = manualEnabled,
|
||||||
)
|
)
|
||||||
Button(onClick = viewModel::connectManual, enabled = manualEnabled) { Text("Connect (Manual)") }
|
Button(
|
||||||
|
onClick = {
|
||||||
|
NodeForegroundService.start(context)
|
||||||
|
viewModel.connectManual()
|
||||||
|
},
|
||||||
|
enabled = manualEnabled,
|
||||||
|
) {
|
||||||
|
Text("Connect (Manual)")
|
||||||
|
}
|
||||||
|
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
|
|
||||||
Text("Discovered Bridges")
|
Text("Discovered Bridges")
|
||||||
if (bridges.isEmpty()) {
|
if (bridges.isEmpty()) {
|
||||||
@@ -134,9 +150,16 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
Text("${bridge.host}:${bridge.port}")
|
Text("${bridge.host}:${bridge.port}")
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.padding(4.dp))
|
Spacer(modifier = Modifier.padding(4.dp))
|
||||||
Button(onClick = { viewModel.connect(bridge) }) { Text("Connect") }
|
Button(
|
||||||
|
onClick = {
|
||||||
|
NodeForegroundService.start(context)
|
||||||
|
viewModel.connect(bridge)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Connect")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Divider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ More debugging notes: `docs/bonjour.md`.
|
|||||||
|
|
||||||
In the Android app:
|
In the Android app:
|
||||||
|
|
||||||
|
- The app keeps its bridge connection alive via a **foreground service** (persistent notification).
|
||||||
- Open **Settings**.
|
- Open **Settings**.
|
||||||
- Under **Discovered Bridges**, select your gateway and hit **Connect**.
|
- Under **Discovered Bridges**, select your gateway and hit **Connect**.
|
||||||
- If mDNS is blocked, use **Advanced → Manual Bridge** (host + port) and **Connect (Manual)**.
|
- If mDNS is blocked, use **Advanced → Manual Bridge** (host + port) and **Connect (Manual)**.
|
||||||
@@ -91,4 +92,3 @@ Camera commands (foreground only; permission-gated):
|
|||||||
- `camera.clip` (mp4)
|
- `camera.clip` (mp4)
|
||||||
|
|
||||||
See `docs/camera.md` for parameters and CLI helpers.
|
See `docs/camera.md` for parameters and CLI helpers.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user