From d54cc49d66ec376cf878fb3c135ab6dd0a155486 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 14 Dec 2025 05:05:30 +0000 Subject: [PATCH] feat(android): sync wake words via gateway --- .../steipete/clawdis/node/MainViewModel.kt | 10 ++- .../com/steipete/clawdis/node/NodeRuntime.kt | 70 ++++++++++++++++++- .../com/steipete/clawdis/node/SecurePrefs.kt | 41 +++++++++++ .../com/steipete/clawdis/node/WakeWords.kt | 17 +++++ .../steipete/clawdis/node/ui/SettingsSheet.kt | 45 ++++++++++++ .../steipete/clawdis/node/WakeWordsTest.kt | 36 ++++++++++ 6 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 apps/android/app/src/main/java/com/steipete/clawdis/node/WakeWords.kt create mode 100644 apps/android/app/src/test/java/com/steipete/clawdis/node/WakeWordsTest.kt 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 303ec78da..527aa470a 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 @@ -23,6 +23,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { val instanceId: StateFlow = runtime.instanceId val displayName: StateFlow = runtime.displayName val cameraEnabled: StateFlow = runtime.cameraEnabled + val wakeWords: StateFlow> = runtime.wakeWords val manualEnabled: StateFlow = runtime.manualEnabled val manualHost: StateFlow = runtime.manualHost val manualPort: StateFlow = runtime.manualPort @@ -55,6 +56,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setManualPort(value) } + fun setWakeWords(words: List) { + runtime.setWakeWords(words) + } + + fun resetWakeWordsDefaults() { + runtime.resetWakeWordsDefaults() + } + fun connect(endpoint: BridgeEndpoint) { runtime.connect(endpoint) } @@ -75,4 +84,3 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.sendChat(sessionKey, message) } } - 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 index 489b9c488..ccaf791d3 100644 --- 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 @@ -9,7 +9,9 @@ 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.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -57,6 +59,7 @@ class NodeRuntime(context: Context) { _serverName.value = name _remoteAddress.value = remote _isConnected.value = true + scope.launch { refreshWakeWordsFromGateway() } }, onDisconnected = { message -> _statusText.value = message @@ -75,12 +78,15 @@ class NodeRuntime(context: Context) { val instanceId: StateFlow = prefs.instanceId val displayName: StateFlow = prefs.displayName val cameraEnabled: StateFlow = prefs.cameraEnabled + val wakeWords: StateFlow> = prefs.wakeWords val manualEnabled: StateFlow = prefs.manualEnabled val manualHost: StateFlow = prefs.manualHost val manualPort: StateFlow = prefs.manualPort val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId private var didAutoConnect = false + private var suppressWakeWordsSync = false + private var wakeWordsSyncJob: Job? = null data class ChatMessage(val id: String, val role: String, val text: String, val timestampMs: Long?) @@ -151,6 +157,15 @@ class NodeRuntime(context: Context) { prefs.setManualPort(value) } + fun setWakeWords(words: List) { + prefs.setWakeWords(words) + scheduleWakeWordsSyncIfNeeded() + } + + fun resetWakeWordsDefaults() { + setWakeWords(SecurePrefs.defaultWakeWords) + } + fun connect(endpoint: BridgeEndpoint) { scope.launch { _statusText.value = "Connecting…" @@ -257,7 +272,22 @@ class NodeRuntime(context: Context) { } private fun handleBridgeEvent(event: String, payloadJson: String?) { - if (event != "chat" || payloadJson.isNullOrBlank()) return + if (payloadJson.isNullOrBlank()) return + + if (event == "voicewake.changed") { + try { + val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return + val array = payload["triggers"] as? JsonArray ?: return + val triggers = array.mapNotNull { it.asStringOrNull() } + applyWakeWordsFromGateway(triggers) + } catch (_: Throwable) { + // ignore + } + return + } + + if (event != "chat") return + try { val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return val state = payload["state"].asStringOrNull() @@ -292,6 +322,44 @@ class NodeRuntime(context: Context) { } } + private fun applyWakeWordsFromGateway(words: List) { + suppressWakeWordsSync = true + prefs.setWakeWords(words) + suppressWakeWordsSync = false + } + + private fun scheduleWakeWordsSyncIfNeeded() { + if (suppressWakeWordsSync) return + if (!_isConnected.value) return + + val snapshot = prefs.wakeWords.value + wakeWordsSyncJob?.cancel() + wakeWordsSyncJob = + scope.launch { + delay(650) + val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() } + val params = """{"triggers":[$jsonList]}""" + try { + session.request("voicewake.set", params) + } catch (_: Throwable) { + // ignore + } + } + } + + private suspend fun refreshWakeWordsFromGateway() { + if (!_isConnected.value) return + try { + val res = session.request("voicewake.get", "{}") + val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return + val array = payload["triggers"] as? JsonArray ?: return + val triggers = array.mapNotNull { it.asStringOrNull() } + applyWakeWordsFromGateway(triggers) + } catch (_: Throwable) { + // ignore + } + } + private fun parseHistory(historyJson: String): List { val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList() val raw = root["messages"] ?: return emptyList() diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt index 67abd6aca..b6dd875e8 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/SecurePrefs.kt @@ -5,9 +5,19 @@ import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive import java.util.UUID class SecurePrefs(context: Context) { + companion object { + val defaultWakeWords: List = listOf("clawd", "claude") + } + + private val json = Json { ignoreUnknownKeys = true } + private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) @@ -44,6 +54,9 @@ class SecurePrefs(context: Context) { MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!) val lastDiscoveredStableId: StateFlow = _lastDiscoveredStableId + private val _wakeWords = MutableStateFlow(loadWakeWords()) + val wakeWords: StateFlow> = _wakeWords + fun setLastDiscoveredStableId(value: String) { val trimmed = value.trim() prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply() @@ -94,4 +107,32 @@ class SecurePrefs(context: Context) { prefs.edit().putString("node.instanceId", fresh).apply() return fresh } + + fun setWakeWords(words: List) { + val sanitized = WakeWords.sanitize(words, defaultWakeWords) + val encoded = + JsonArray(sanitized.map { JsonPrimitive(it) }).toString() + prefs.edit().putString("voiceWake.triggerWords", encoded).apply() + _wakeWords.value = sanitized + } + + private fun loadWakeWords(): List { + val raw = prefs.getString("voiceWake.triggerWords", null)?.trim() + if (raw.isNullOrEmpty()) return defaultWakeWords + return try { + val element = json.parseToJsonElement(raw) + val array = element as? JsonArray ?: return defaultWakeWords + val decoded = + array.mapNotNull { item -> + when (item) { + is JsonNull -> null + is JsonPrimitive -> item.content.trim().takeIf { it.isNotEmpty() } + else -> null + } + } + WakeWords.sanitize(decoded, defaultWakeWords) + } catch (_: Throwable) { + defaultWakeWords + } + } } diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/WakeWords.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/WakeWords.kt new file mode 100644 index 000000000..175cf07f3 --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/WakeWords.kt @@ -0,0 +1,17 @@ +package com.steipete.clawdis.node + +object WakeWords { + const val maxWords: Int = 32 + const val maxWordLength: Int = 64 + + fun parseCommaSeparated(input: String): List { + return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } + + fun sanitize(words: List, defaults: List): List { + val cleaned = + words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } + return cleaned.ifEmpty { defaults } + } +} + 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 6249baaba..7fdb91046 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 @@ -22,8 +22,11 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -37,6 +40,8 @@ fun SettingsSheet(viewModel: MainViewModel) { val instanceId by viewModel.instanceId.collectAsState() val displayName by viewModel.displayName.collectAsState() val cameraEnabled by viewModel.cameraEnabled.collectAsState() + val wakeWords by viewModel.wakeWords.collectAsState() + val isConnected by viewModel.isConnected.collectAsState() val manualEnabled by viewModel.manualEnabled.collectAsState() val manualHost by viewModel.manualHost.collectAsState() val manualPort by viewModel.manualPort.collectAsState() @@ -46,6 +51,9 @@ fun SettingsSheet(viewModel: MainViewModel) { val bridges by viewModel.bridges.collectAsState() val listState = rememberLazyListState() + val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } + LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> val cameraOk = perms[Manifest.permission.CAMERA] == true @@ -71,6 +79,43 @@ fun SettingsSheet(viewModel: MainViewModel) { item { HorizontalDivider() } + item { Text("Wake Words") } + item { + OutlinedTextField( + value = wakeWordsText, + onValueChange = setWakeWordsText, + label = { Text("Comma-separated (global)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + val parsed = com.steipete.clawdis.node.WakeWords.parseCommaSeparated(wakeWordsText) + viewModel.setWakeWords(parsed) + }, + enabled = isConnected, + ) { + Text("Save + Sync") + } + + Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } + } + } + item { + Text( + if (isConnected) { + "Any node can edit wake words. Changes sync via the gateway bridge." + } else { + "Connect to a gateway to sync wake words globally." + }, + ) + } + + item { HorizontalDivider() } + item { Text("Camera") } item { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/WakeWordsTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/WakeWordsTest.kt new file mode 100644 index 000000000..d934e95e8 --- /dev/null +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/WakeWordsTest.kt @@ -0,0 +1,36 @@ +package com.steipete.clawdis.node + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WakeWordsTest { + @Test + fun parseCommaSeparatedTrimsAndDropsEmpty() { + assertEquals(listOf("clawd", "claude"), WakeWords.parseCommaSeparated(" clawd , claude, , ")) + } + + @Test + fun sanitizeTrimsCapsAndFallsBack() { + val defaults = listOf("clawd", "claude") + val long = "x".repeat(WakeWords.maxWordLength + 10) + val words = listOf(" ", " hello ", long) + + val sanitized = WakeWords.sanitize(words, defaults) + assertEquals(2, sanitized.size) + assertEquals("hello", sanitized[0]) + assertEquals("x".repeat(WakeWords.maxWordLength), sanitized[1]) + + assertEquals(defaults, WakeWords.sanitize(listOf(" ", ""), defaults)) + } + + @Test + fun sanitizeLimitsWordCount() { + val defaults = listOf("clawd") + val words = (1..(WakeWords.maxWords + 5)).map { "w$it" } + val sanitized = WakeWords.sanitize(words, defaults) + assertEquals(WakeWords.maxWords, sanitized.size) + assertEquals("w1", sanitized.first()) + assertEquals("w${WakeWords.maxWords}", sanitized.last()) + } +} +