diff --git a/CHANGELOG.md b/CHANGELOG.md index c41f6a9dbe..bf7317c5f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal. - iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan. +- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan. - Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus. ### Fixes diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index c6f5dac536..75950f55a4 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -114,6 +114,7 @@ final class NodeAppModel { private var talkVoiceWakeSuspended = false private var backgroundVoiceWakeSuspended = false private var backgroundTalkSuspended = false + private var backgroundTalkKeptActive = false private var backgroundedAt: Date? private var reconnectAfterBackgroundArmed = false @@ -269,15 +270,18 @@ final class NodeAppModel { func setScenePhase(_ phase: ScenePhase) { + let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled") switch phase { case .background: self.isBackgrounded = true self.stopGatewayHealthMonitor() self.backgroundedAt = Date() self.reconnectAfterBackgroundArmed = true - // Be conservative: release the mic when the app backgrounds. + // Release voice wake mic in background. self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture() - self.backgroundTalkSuspended = self.talkMode.suspendForBackground() + let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled + self.backgroundTalkKeptActive = shouldKeepTalkActive + self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive) case .active, .inactive: self.isBackgrounded = false if self.operatorConnected { @@ -289,8 +293,12 @@ final class NodeAppModel { Task { [weak self] in guard let self else { return } let suspended = await MainActor.run { self.backgroundTalkSuspended } - await MainActor.run { self.backgroundTalkSuspended = false } - await self.talkMode.resumeAfterBackground(wasSuspended: suspended) + let keptActive = await MainActor.run { self.backgroundTalkKeptActive } + await MainActor.run { + self.backgroundTalkSuspended = false + self.backgroundTalkKeptActive = false + } + await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive) } } if phase == .active, self.reconnectAfterBackgroundArmed { diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 31653f1966..915c332554 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -15,6 +15,7 @@ struct SettingsTab: View { @AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false @AppStorage("talk.enabled") private var talkEnabled: Bool = false @AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true + @AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false @AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true @AppStorage("camera.enabled") private var cameraEnabled: Bool = true @AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue @@ -256,6 +257,10 @@ struct SettingsTab: View { Text("Use this local override when gateway config redacts talk.apiKey for mobile clients.") .font(.footnote) .foregroundStyle(.secondary) + Toggle("Background Listening", isOn: self.$talkBackgroundEnabled) + Text("Keep listening when the app is in the background. Uses more battery.") + .font(.footnote) + .foregroundStyle(.secondary) Toggle("Voice Directive Hint", isOn: self.$talkVoiceDirectiveHintEnabled) Text("Include ElevenLabs voice switching instructions in the Talk Mode prompt. Disable to save tokens.") .font(.footnote) diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 10aaad8acb..5691da8f66 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -219,8 +219,12 @@ final class TalkModeManager: NSObject { /// Suspends microphone usage without disabling Talk Mode. /// Used when the app backgrounds (or when we need to temporarily release the mic). - func suspendForBackground() -> Bool { + func suspendForBackground(keepActive: Bool = false) -> Bool { guard self.isEnabled else { return false } + if keepActive { + self.statusText = self.isListening ? "Listening" : self.statusText + return false + } let wasActive = self.isListening || self.isSpeaking || self.isPushToTalkActive self.isListening = false @@ -247,7 +251,8 @@ final class TalkModeManager: NSObject { return wasActive } - func resumeAfterBackground(wasSuspended: Bool) async { + func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async { + if wasKeptActive { return } guard wasSuspended else { return } guard self.isEnabled else { return } await self.start()