From 69f645c662c4888845f69f8b00477d7d42bde4f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 23:57:53 +0000 Subject: [PATCH] fix: auto-save voice wake words across apps --- CHANGELOG.md | 2 +- .../java/com/clawdbot/android/WakeWords.kt | 6 ++- .../com/clawdbot/android/ui/SettingsSheet.kt | 48 ++++++++++++------- .../com/clawdbot/android/WakeWordsTest.kt | 16 ++++++- .../Settings/VoiceWakeWordsSettingsView.swift | 37 ++++++++++---- .../Sources/Voice/VoiceWakePreferences.swift | 4 ++ .../ios/Tests/VoiceWakePreferencesTests.swift | 12 +++++ apps/macos/Sources/Clawdbot/Constants.swift | 2 + .../Sources/Clawdbot/VoiceWakeHelpers.swift | 2 + .../VoiceWakeHelpersTests.swift | 12 +++++ 10 files changed, 113 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a09fcd603..81f667782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ### 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. - Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. - TUI: forward unknown slash commands (for example, `/context`) to the Gateway. diff --git a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt b/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt index 855a0de7c..d54ed1e08 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt @@ -8,10 +8,14 @@ object WakeWords { return input.split(",").map { it.trim() }.filter { it.isNotEmpty() } } + fun parseIfChanged(input: String, current: List): List? { + val parsed = parseCommaSeparated(input) + return if (parsed == current) null else parsed + } + 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/clawdbot/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt index aee1059bd..e3a9b3ecb 100644 --- a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items 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.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -49,7 +51,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.onFocusChanged 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.unit.dp import androidx.core.content.ContextCompat @@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode import com.clawdbot.android.MainViewModel import com.clawdbot.android.NodeForegroundService import com.clawdbot.android.VoiceWakeMode +import com.clawdbot.android.WakeWords @Composable fun SettingsSheet(viewModel: MainViewModel) { @@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) { val listState = rememberLazyListState() val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") } val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + var wakeWordsHadFocus by remember { mutableStateOf(false) } val deviceModel = remember { listOfNotNull(Build.MANUFACTURER, Build.MODEL) @@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) { } LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) } + val commitWakeWords = { + val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords) + if (parsed != null) { + viewModel.setWakeWords(parsed) + } + } val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms -> @@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) { value = wakeWordsText, onValueChange = setWakeWordsText, 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, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + commitWakeWords() + focusManager.clearFocus() + }, + ), ) } - item { - 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 { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } } item { Text( if (isConnected) { diff --git a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt index 1d61383e8..9363e810c 100644 --- a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt +++ b/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt @@ -1,6 +1,7 @@ package com.clawdbot.android import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class WakeWordsTest { @@ -32,5 +33,18 @@ class WakeWordsTest { assertEquals("w1", sanitized.first()) 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) + } +} diff --git a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift index d13edafe2..5aef87b0c 100644 --- a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift +++ b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift @@ -1,8 +1,10 @@ import SwiftUI +import Combine struct VoiceWakeWordsSettingsView: View { @Environment(NodeAppModel.self) private var appModel @State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords() + @FocusState private var focusedTriggerIndex: Int? @State private var syncTask: Task? var body: some View { @@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View { TextField("Wake word", text: self.binding(for: index)) .textInputAutocapitalization(.never) .autocorrectionDisabled() + .focused(self.$focusedTriggerIndex, equals: index) + .onSubmit { + self.commitTriggerWords() + } } .onDelete(perform: self.removeWords) @@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View { .onAppear { if self.triggerWords.isEmpty { self.triggerWords = VoiceWakePreferences.defaultTriggerWords + self.commitTriggerWords() } } - .onChange(of: self.triggerWords) { _, newValue in - // Keep local voice wake responsive even if the gateway isn't connected yet. - VoiceWakePreferences.saveTriggerWords(newValue) - - let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue) - self.syncTask?.cancel() - self.syncTask = Task { [snapshot, weak appModel = self.appModel] in - try? await Task.sleep(nanoseconds: 650_000_000) - await appModel?.setGlobalWakeWords(snapshot) + .onChange(of: self.focusedTriggerIndex) { oldValue, newValue in + guard oldValue != nil, oldValue != newValue else { return } + self.commitTriggerWords() + } + .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + guard self.focusedTriggerIndex == nil else { return } + let updated = VoiceWakePreferences.loadTriggerWords() + if updated != self.triggerWords { + self.triggerWords = updated } } } @@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View { if self.triggerWords.isEmpty { self.triggerWords = VoiceWakePreferences.defaultTriggerWords } + self.commitTriggerWords() } private func binding(for index: Int) -> Binding { @@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View { 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) + } + } } diff --git a/apps/ios/Sources/Voice/VoiceWakePreferences.swift b/apps/ios/Sources/Voice/VoiceWakePreferences.swift index 96f46518e..4c75c22a6 100644 --- a/apps/ios/Sources/Voice/VoiceWakePreferences.swift +++ b/apps/ios/Sources/Voice/VoiceWakePreferences.swift @@ -6,6 +6,8 @@ enum VoiceWakePreferences { // Keep defaults aligned with the mac app. static let defaultTriggerWords: [String] = ["clawd", "claude"] + static let maxWords = 32 + static let maxWordLength = 64 static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? { guard let data = payloadJSON.data(using: .utf8) else { return nil } @@ -30,6 +32,8 @@ enum VoiceWakePreferences { let cleaned = words .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + .prefix(Self.maxWords) + .map { String($0.prefix(Self.maxWordLength)) } return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned } diff --git a/apps/ios/Tests/VoiceWakePreferencesTests.swift b/apps/ios/Tests/VoiceWakePreferencesTests.swift index acf501654..ec4a63afa 100644 --- a/apps/ios/Tests/VoiceWakePreferencesTests.swift +++ b/apps/ios/Tests/VoiceWakePreferencesTests.swift @@ -11,6 +11,18 @@ import Testing #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() { #expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude") } diff --git a/apps/macos/Sources/Clawdbot/Constants.swift b/apps/macos/Sources/Clawdbot/Constants.swift index 25f2589e3..b55bd6d20 100644 --- a/apps/macos/Sources/Clawdbot/Constants.swift +++ b/apps/macos/Sources/Clawdbot/Constants.swift @@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime" let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime" let showDockIconKey = "clawdbot.showDockIcon" let defaultVoiceWakeTriggers = ["clawd", "claude"] +let voiceWakeMaxWords = 32 +let voiceWakeMaxWordLength = 64 let voiceWakeMicKey = "clawdbot.voiceWakeMicID" let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName" let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID" diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift b/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift index a60aa7d7c..98cdc0cb5 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift @@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] { let cleaned = words .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + .prefix(voiceWakeMaxWords) + .map { String($0.prefix(voiceWakeMaxWordLength)) } return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned } diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift index e7f7e06fc..49ad5a124 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift @@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests { #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() { #expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US") }