feat(android): sync wake words via gateway
parent
0cef22ef83
commit
d54cc49d66
|
|
@ -23,6 +23,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
val instanceId: StateFlow<String> = runtime.instanceId
|
val instanceId: StateFlow<String> = runtime.instanceId
|
||||||
val displayName: StateFlow<String> = runtime.displayName
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||||
|
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||||
val manualHost: StateFlow<String> = runtime.manualHost
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
|
|
@ -55,6 +56,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
runtime.setManualPort(value)
|
runtime.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setWakeWords(words: List<String>) {
|
||||||
|
runtime.setWakeWords(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetWakeWordsDefaults() {
|
||||||
|
runtime.resetWakeWordsDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
runtime.connect(endpoint)
|
runtime.connect(endpoint)
|
||||||
}
|
}
|
||||||
|
|
@ -75,4 +84,3 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
runtime.sendChat(sessionKey, message)
|
runtime.sendChat(sessionKey, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
import com.steipete.clawdis.node.node.CanvasController
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
@ -57,6 +59,7 @@ class NodeRuntime(context: Context) {
|
||||||
_serverName.value = name
|
_serverName.value = name
|
||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
},
|
},
|
||||||
onDisconnected = { message ->
|
onDisconnected = { message ->
|
||||||
_statusText.value = message
|
_statusText.value = message
|
||||||
|
|
@ -75,12 +78,15 @@ class NodeRuntime(context: Context) {
|
||||||
val instanceId: StateFlow<String> = prefs.instanceId
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
val displayName: StateFlow<String> = prefs.displayName
|
val displayName: StateFlow<String> = prefs.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||||
|
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||||
val manualHost: StateFlow<String> = prefs.manualHost
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
|
|
||||||
private var didAutoConnect = false
|
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?)
|
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)
|
prefs.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setWakeWords(words: List<String>) {
|
||||||
|
prefs.setWakeWords(words)
|
||||||
|
scheduleWakeWordsSyncIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetWakeWordsDefaults() {
|
||||||
|
setWakeWords(SecurePrefs.defaultWakeWords)
|
||||||
|
}
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_statusText.value = "Connecting…"
|
_statusText.value = "Connecting…"
|
||||||
|
|
@ -257,7 +272,22 @@ class NodeRuntime(context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleBridgeEvent(event: String, payloadJson: String?) {
|
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 {
|
try {
|
||||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||||
val state = payload["state"].asStringOrNull()
|
val state = payload["state"].asStringOrNull()
|
||||||
|
|
@ -292,6 +322,44 @@ class NodeRuntime(context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun applyWakeWordsFromGateway(words: List<String>) {
|
||||||
|
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<ChatMessage> {
|
private fun parseHistory(historyJson: String): List<ChatMessage> {
|
||||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
|
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return emptyList()
|
||||||
val raw = root["messages"] ?: return emptyList()
|
val raw = root["messages"] ?: return emptyList()
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,19 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
import androidx.security.crypto.MasterKey
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
class SecurePrefs(context: Context) {
|
class SecurePrefs(context: Context) {
|
||||||
|
companion object {
|
||||||
|
val defaultWakeWords: List<String> = listOf("clawd", "claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
private val masterKey =
|
private val masterKey =
|
||||||
MasterKey.Builder(context)
|
MasterKey.Builder(context)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
|
@ -44,6 +54,9 @@ class SecurePrefs(context: Context) {
|
||||||
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
MutableStateFlow(prefs.getString("bridge.lastDiscoveredStableId", "")!!)
|
||||||
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = _lastDiscoveredStableId
|
||||||
|
|
||||||
|
private val _wakeWords = MutableStateFlow(loadWakeWords())
|
||||||
|
val wakeWords: StateFlow<List<String>> = _wakeWords
|
||||||
|
|
||||||
fun setLastDiscoveredStableId(value: String) {
|
fun setLastDiscoveredStableId(value: String) {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
|
prefs.edit().putString("bridge.lastDiscoveredStableId", trimmed).apply()
|
||||||
|
|
@ -94,4 +107,32 @@ class SecurePrefs(context: Context) {
|
||||||
prefs.edit().putString("node.instanceId", fresh).apply()
|
prefs.edit().putString("node.instanceId", fresh).apply()
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setWakeWords(words: List<String>) {
|
||||||
|
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<String> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String> {
|
||||||
|
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitize(words: List<String>, defaults: List<String>): List<String> {
|
||||||
|
val cleaned =
|
||||||
|
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
|
||||||
|
return cleaned.ifEmpty { defaults }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -22,8 +22,11 @@ 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
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -37,6 +40,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
val instanceId by viewModel.instanceId.collectAsState()
|
val instanceId by viewModel.instanceId.collectAsState()
|
||||||
val displayName by viewModel.displayName.collectAsState()
|
val displayName by viewModel.displayName.collectAsState()
|
||||||
val cameraEnabled by viewModel.cameraEnabled.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 manualEnabled by viewModel.manualEnabled.collectAsState()
|
||||||
val manualHost by viewModel.manualHost.collectAsState()
|
val manualHost by viewModel.manualHost.collectAsState()
|
||||||
val manualPort by viewModel.manualPort.collectAsState()
|
val manualPort by viewModel.manualPort.collectAsState()
|
||||||
|
|
@ -46,6 +51,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
val bridges by viewModel.bridges.collectAsState()
|
val bridges by viewModel.bridges.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
|
|
||||||
val permissionLauncher =
|
val permissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
||||||
val cameraOk = perms[Manifest.permission.CAMERA] == true
|
val cameraOk = perms[Manifest.permission.CAMERA] == true
|
||||||
|
|
@ -71,6 +79,43 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
|
|
||||||
item { HorizontalDivider() }
|
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 { Text("Camera") }
|
||||||
item {
|
item {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue