fix: auto-save voice wake words across apps

This commit is contained in:
Peter Steinberger
2026-01-23 23:57:53 +00:00
parent efec5fc751
commit 69f645c662
10 changed files with 113 additions and 28 deletions

View File

@@ -10,7 +10,7 @@ Docs: https://docs.clawd.bot
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0. - Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
### Fixes ### Fixes
- Gateway/WebChat: route inbound messages through the unified dispatch pipeline so /new works consistently across WebChat/TUI and channels. - Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. - Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. - Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway. - TUI: forward unknown slash commands (for example, `/context`) to the Gateway.

View File

@@ -8,10 +8,14 @@ object WakeWords {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
} }
fun parseIfChanged(input: String, current: List<String>): List<String>? {
val parsed = parseCommaSeparated(input)
return if (parsed == current) null else parsed
}
fun sanitize(words: List<String>, defaults: List<String>): List<String> { fun sanitize(words: List<String>, defaults: List<String>): List<String> {
val cleaned = val cleaned =
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) } words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults } return cleaned.ifEmpty { defaults }
} }
} }

View File

@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
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.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
@@ -49,7 +51,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode
import com.clawdbot.android.MainViewModel import com.clawdbot.android.MainViewModel
import com.clawdbot.android.NodeForegroundService import com.clawdbot.android.NodeForegroundService
import com.clawdbot.android.VoiceWakeMode import com.clawdbot.android.VoiceWakeMode
import com.clawdbot.android.WakeWords
@Composable @Composable
fun SettingsSheet(viewModel: MainViewModel) { fun SettingsSheet(viewModel: MainViewModel) {
@@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current
var wakeWordsHadFocus by remember { mutableStateOf(false) }
val deviceModel = val deviceModel =
remember { remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL) listOfNotNull(Build.MANUFACTURER, Build.MODEL)
@@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
} }
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
val commitWakeWords = {
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
if (parsed != null) {
viewModel.setWakeWords(parsed)
}
}
val permissionLauncher = val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
@@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
value = wakeWordsText, value = wakeWordsText,
onValueChange = setWakeWordsText, onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") }, label = { Text("Wake Words (comma-separated)") },
modifier = Modifier.fillMaxWidth(), modifier =
Modifier.fillMaxWidth().onFocusChanged { focusState ->
if (focusState.isFocused) {
wakeWordsHadFocus = true
} else if (wakeWordsHadFocus) {
wakeWordsHadFocus = false
commitWakeWords()
}
},
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(
onDone = {
commitWakeWords()
focusManager.clearFocus()
},
),
) )
} }
item { item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
viewModel.setWakeWords(parsed)
},
enabled = isConnected,
) {
Text("Save + Sync")
}
Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
}
}
item { item {
Text( Text(
if (isConnected) { if (isConnected) {

View File

@@ -1,6 +1,7 @@
package com.clawdbot.android package com.clawdbot.android
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test import org.junit.Test
class WakeWordsTest { class WakeWordsTest {
@@ -32,5 +33,18 @@ class WakeWordsTest {
assertEquals("w1", sanitized.first()) assertEquals("w1", sanitized.first())
assertEquals("w${WakeWords.maxWords}", sanitized.last()) assertEquals("w${WakeWords.maxWords}", sanitized.last())
} }
}
@Test
fun parseIfChangedSkipsWhenUnchanged() {
val current = listOf("clawd", "claude")
val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
assertNull(parsed)
}
@Test
fun parseIfChangedReturnsUpdatedList() {
val current = listOf("clawd")
val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
assertEquals(listOf("clawd", "jarvis"), parsed)
}
}

View File

@@ -1,8 +1,10 @@
import SwiftUI import SwiftUI
import Combine
struct VoiceWakeWordsSettingsView: View { struct VoiceWakeWordsSettingsView: View {
@Environment(NodeAppModel.self) private var appModel @Environment(NodeAppModel.self) private var appModel
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() @State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
@FocusState private var focusedTriggerIndex: Int?
@State private var syncTask: Task<Void, Never>? @State private var syncTask: Task<Void, Never>?
var body: some View { var body: some View {
@@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View {
TextField("Wake word", text: self.binding(for: index)) TextField("Wake word", text: self.binding(for: index))
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.focused(self.$focusedTriggerIndex, equals: index)
.onSubmit {
self.commitTriggerWords()
}
} }
.onDelete(perform: self.removeWords) .onDelete(perform: self.removeWords)
@@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View {
.onAppear { .onAppear {
if self.triggerWords.isEmpty { if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords self.triggerWords = VoiceWakePreferences.defaultTriggerWords
self.commitTriggerWords()
} }
} }
.onChange(of: self.triggerWords) { _, newValue in .onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
// Keep local voice wake responsive even if the gateway isn't connected yet. guard oldValue != nil, oldValue != newValue else { return }
VoiceWakePreferences.saveTriggerWords(newValue) self.commitTriggerWords()
}
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue) .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
self.syncTask?.cancel() guard self.focusedTriggerIndex == nil else { return }
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in let updated = VoiceWakePreferences.loadTriggerWords()
try? await Task.sleep(nanoseconds: 650_000_000) if updated != self.triggerWords {
await appModel?.setGlobalWakeWords(snapshot) self.triggerWords = updated
} }
} }
} }
@@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View {
if self.triggerWords.isEmpty { if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords self.triggerWords = VoiceWakePreferences.defaultTriggerWords
} }
self.commitTriggerWords()
} }
private func binding(for index: Int) -> Binding<String> { private func binding(for index: Int) -> Binding<String> {
@@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View {
self.triggerWords[index] = newValue self.triggerWords[index] = newValue
}) })
} }
private func commitTriggerWords() {
VoiceWakePreferences.saveTriggerWords(self.triggerWords)
let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
self.syncTask?.cancel()
self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
try? await Task.sleep(nanoseconds: 650_000_000)
await appModel?.setGlobalWakeWords(snapshot)
}
}
} }

View File

@@ -6,6 +6,8 @@ enum VoiceWakePreferences {
// Keep defaults aligned with the mac app. // Keep defaults aligned with the mac app.
static let defaultTriggerWords: [String] = ["clawd", "claude"] static let defaultTriggerWords: [String] = ["clawd", "claude"]
static let maxWords = 32
static let maxWordLength = 64
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? { static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
guard let data = payloadJSON.data(using: .utf8) else { return nil } guard let data = payloadJSON.data(using: .utf8) else { return nil }
@@ -30,6 +32,8 @@ enum VoiceWakePreferences {
let cleaned = words let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
.prefix(Self.maxWords)
.map { String($0.prefix(Self.maxWordLength)) }
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
} }

View File

@@ -11,6 +11,18 @@ import Testing
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords) #expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
} }
@Test func sanitizeTriggerWordsLimitsWordLength() {
let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
#expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
}
@Test func sanitizeTriggerWordsLimitsWordCount() {
let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
#expect(cleaned.count == VoiceWakePreferences.maxWords)
}
@Test func displayStringUsesSanitizedWords() { @Test func displayStringUsesSanitizedWords() {
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude") #expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
} }

View File

@@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime" let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
let showDockIconKey = "clawdbot.showDockIcon" let showDockIconKey = "clawdbot.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"] let defaultVoiceWakeTriggers = ["clawd", "claude"]
let voiceWakeMaxWords = 32
let voiceWakeMaxWordLength = 64
let voiceWakeMicKey = "clawdbot.voiceWakeMicID" let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName" let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID" let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"

View File

@@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
let cleaned = words let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty } .filter { !$0.isEmpty }
.prefix(voiceWakeMaxWords)
.map { String($0.prefix(voiceWakeMaxWordLength)) }
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
} }

View File

@@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests {
#expect(cleaned == defaultVoiceWakeTriggers) #expect(cleaned == defaultVoiceWakeTriggers)
} }
@Test func sanitizeTriggersLimitsWordLength() {
let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
#expect(cleaned[1].count == voiceWakeMaxWordLength)
}
@Test func sanitizeTriggersLimitsWordCount() {
let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
let cleaned = sanitizeVoiceWakeTriggers(words)
#expect(cleaned.count == voiceWakeMaxWords)
}
@Test func normalizeLocaleStripsCollation() { @Test func normalizeLocaleStripsCollation() {
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US") #expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
} }