From b792175ec57c99bd32db6b8f72f579f55059f087 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 02:01:42 +0000 Subject: [PATCH] feat(android): keep node connected via foreground service --- apps/android/app/src/main/AndroidManifest.xml | 8 + .../com/steipete/clawdis/node/MainActivity.kt | 14 + .../steipete/clawdis/node/MainViewModel.kt | 383 ++-------------- .../java/com/steipete/clawdis/node/NodeApp.kt | 8 + .../clawdis/node/NodeForegroundService.kt | 129 ++++++ .../com/steipete/clawdis/node/NodeRuntime.kt | 428 ++++++++++++++++++ .../steipete/clawdis/node/ui/SettingsSheet.kt | 41 +- docs/android/connect.md | 2 +- 8 files changed, 649 insertions(+), 364 deletions(-) create mode 100644 apps/android/app/src/main/java/com/steipete/clawdis/node/NodeApp.kt create mode 100644 apps/android/app/src/main/java/com/steipete/clawdis/node/NodeForegroundService.kt create mode 100644 apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 21bb8c8a4..7703e3a69 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + @@ -11,10 +14,15 @@ + diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainActivity.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainActivity.kt index ee940b042..e3a6c1840 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainActivity.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainActivity.kt @@ -18,6 +18,8 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) requestDiscoveryPermissionsIfNeeded() + requestNotificationPermissionIfNeeded() + NodeForegroundService.start(this) viewModel.camera.attachLifecycleOwner(this) setContent { 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) + } + } } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt index 058ca528b..303ec78da 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/MainViewModel.kt @@ -2,402 +2,77 @@ package com.steipete.clawdis.node import android.app.Application 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.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.Dispatchers -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 MainViewModel(app: Application) : AndroidViewModel(app) { - private val prefs = SecurePrefs(app) + private val runtime: NodeRuntime = (app as NodeApp).runtime - val canvas = CanvasController() - val camera = CameraCaptureManager(app) - private val json = Json { ignoreUnknownKeys = true } + val canvas: CanvasController = runtime.canvas + val camera: CameraCaptureManager = runtime.camera - private val discovery = BridgeDiscovery(app) - val bridges: StateFlow> = discovery.bridges + val bridges: StateFlow> = runtime.bridges - private val _isConnected = MutableStateFlow(false) - val isConnected: StateFlow = _isConnected.asStateFlow() + val isConnected: StateFlow = runtime.isConnected + val statusText: StateFlow = runtime.statusText + val serverName: StateFlow = runtime.serverName + val remoteAddress: StateFlow = runtime.remoteAddress - private val _statusText = MutableStateFlow("Not connected") - val statusText: StateFlow = _statusText.asStateFlow() + val instanceId: StateFlow = runtime.instanceId + val displayName: StateFlow = runtime.displayName + val cameraEnabled: StateFlow = runtime.cameraEnabled + val manualEnabled: StateFlow = runtime.manualEnabled + val manualHost: StateFlow = runtime.manualHost + val manualPort: StateFlow = runtime.manualPort - private val _serverName = MutableStateFlow(null) - val serverName: StateFlow = _serverName.asStateFlow() - - private val _remoteAddress = MutableStateFlow(null) - val remoteAddress: StateFlow = _remoteAddress.asStateFlow() - - private val _isForeground = MutableStateFlow(true) - val isForeground: StateFlow = _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 = prefs.instanceId - val displayName: StateFlow = prefs.displayName - val cameraEnabled: StateFlow = prefs.cameraEnabled - val manualEnabled: StateFlow = prefs.manualEnabled - val manualHost: StateFlow = prefs.manualHost - val manualPort: StateFlow = prefs.manualPort - val lastDiscoveredStableId: StateFlow = 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) - } - } - } + val chatMessages: StateFlow> = runtime.chatMessages + val chatError: StateFlow = runtime.chatError + val pendingRunCount: StateFlow = runtime.pendingRunCount fun setForeground(value: Boolean) { - _isForeground.value = value + runtime.setForeground(value) } fun setDisplayName(value: String) { - prefs.setDisplayName(value) + runtime.setDisplayName(value) } fun setCameraEnabled(value: Boolean) { - prefs.setCameraEnabled(value) + runtime.setCameraEnabled(value) } fun setManualEnabled(value: Boolean) { - prefs.setManualEnabled(value) + runtime.setManualEnabled(value) } fun setManualHost(value: String) { - prefs.setManualHost(value) + runtime.setManualHost(value) } fun setManualPort(value: Int) { - prefs.setManualPort(value) + runtime.setManualPort(value) } fun connect(endpoint: BridgeEndpoint) { - viewModelScope.launch(Dispatchers.IO) { - _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", - ), - ) - } + runtime.connect(endpoint) } 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)) + runtime.connectManual() } 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>(emptyList()) - val chatMessages: StateFlow> = _chatMessages.asStateFlow() - - private val _chatError = MutableStateFlow(null) - val chatError: StateFlow = _chatError.asStateFlow() - - private val pendingRuns = mutableSetOf() - private val _pendingRunCount = MutableStateFlow(0) - val pendingRunCount: StateFlow = _pendingRunCount.asStateFlow() - fun loadChat(sessionKey: String = "main") { - viewModelScope.launch(Dispatchers.IO) { - _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 - } - } + runtime.loadChat(sessionKey) } - fun sendChat(sessionKey: String = "main", message: String, thinking: String = "off") { - val trimmed = message.trim() - 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 { - 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", - ) - } + fun sendChat(sessionKey: String = "main", message: String) { + runtime.sendChat(sessionKey, message) } } -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\"" -} diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeApp.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeApp.kt new file mode 100644 index 000000000..36b8616bc --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeApp.kt @@ -0,0 +1,8 @@ +package com.steipete.clawdis.node + +import android.app.Application + +class NodeApp : Application() { + val runtime: NodeRuntime by lazy { NodeRuntime(this) } +} + diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeForegroundService.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeForegroundService.kt new file mode 100644 index 000000000..b68f004cb --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeForegroundService.kt @@ -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) + } + } +} diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt new file mode 100644 index 000000000..489b9c488 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/NodeRuntime.kt @@ -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> = discovery.bridges + + private val _isConnected = MutableStateFlow(false) + val isConnected: StateFlow = _isConnected.asStateFlow() + + private val _statusText = MutableStateFlow("Not connected") + val statusText: StateFlow = _statusText.asStateFlow() + + private val _serverName = MutableStateFlow(null) + val serverName: StateFlow = _serverName.asStateFlow() + + private val _remoteAddress = MutableStateFlow(null) + val remoteAddress: StateFlow = _remoteAddress.asStateFlow() + + private val _isForeground = MutableStateFlow(true) + val isForeground: StateFlow = _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 = prefs.instanceId + val displayName: StateFlow = prefs.displayName + val cameraEnabled: StateFlow = prefs.cameraEnabled + val manualEnabled: StateFlow = prefs.manualEnabled + val manualHost: StateFlow = prefs.manualHost + val manualPort: StateFlow = prefs.manualPort + val lastDiscoveredStableId: StateFlow = 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>(emptyList()) + val chatMessages: StateFlow> = _chatMessages.asStateFlow() + + private val _chatError = MutableStateFlow(null) + val chatError: StateFlow = _chatError.asStateFlow() + + private val pendingRuns = mutableSetOf() + private val _pendingRunCount = MutableStateFlow(0) + val pendingRunCount: StateFlow = _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 { + 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 + } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt index 9f61e2ff5..89a8daedf 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/ui/SettingsSheet.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -26,6 +26,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import com.steipete.clawdis.node.MainViewModel +import com.steipete.clawdis.node.NodeForegroundService @Composable fun SettingsSheet(viewModel: MainViewModel) { @@ -57,7 +58,7 @@ fun SettingsSheet(viewModel: MainViewModel) { ) Text("Instance ID: $instanceId") - Divider() + HorizontalDivider() Text("Camera") 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.") - Divider() + HorizontalDivider() Text("Bridge") Text("Status: $statusText") @@ -91,10 +92,17 @@ fun SettingsSheet(viewModel: MainViewModel) { if (remoteAddress != null) Text("Address: $remoteAddress") 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") Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { @@ -115,9 +123,17 @@ fun SettingsSheet(viewModel: MainViewModel) { modifier = Modifier.fillMaxWidth(), 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") if (bridges.isEmpty()) { @@ -134,9 +150,16 @@ fun SettingsSheet(viewModel: MainViewModel) { Text("${bridge.host}:${bridge.port}") } 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() } } } diff --git a/docs/android/connect.md b/docs/android/connect.md index f9e24a9ba..6f3cc9ad1 100644 --- a/docs/android/connect.md +++ b/docs/android/connect.md @@ -43,6 +43,7 @@ More debugging notes: `docs/bonjour.md`. In the Android app: +- The app keeps its bridge connection alive via a **foreground service** (persistent notification). - Open **Settings**. - Under **Discovered Bridges**, select your gateway and hit **Connect**. - 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) See `docs/camera.md` for parameters and CLI helpers. -