diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 305a66d65dd..725948394ec 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -8475,7 +8475,7 @@ }, { "kind": "ui-call", - "line": 162, + "line": 163, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "The session attaches once the gateway is ready.", "surface": "apple", @@ -8483,7 +8483,7 @@ }, { "kind": "ui-modifier", - "line": 261, + "line": 268, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Opens Settings / Gateway", "surface": "apple", @@ -8491,7 +8491,7 @@ }, { "kind": "ui-call", - "line": 286, + "line": 293, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "New Chat", "surface": "apple", @@ -8499,7 +8499,7 @@ }, { "kind": "ui-call", - "line": 299, + "line": 306, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "New Chat in Worktree", "surface": "apple", @@ -8507,7 +8507,7 @@ }, { "kind": "ui-call", - "line": 314, + "line": 321, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Export Transcript", "surface": "apple", @@ -8515,7 +8515,7 @@ }, { "kind": "ui-modifier", - "line": 324, + "line": 331, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Chat actions", "surface": "apple", @@ -8523,7 +8523,7 @@ }, { "kind": "conditional-branch", - "line": 377, + "line": 384, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Connected", "surface": "apple", @@ -8531,7 +8531,7 @@ }, { "kind": "conditional-branch", - "line": 377, + "line": 384, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Unavailable", "surface": "apple", @@ -8539,7 +8539,7 @@ }, { "kind": "ui-localized-call", - "line": 389, + "line": 396, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Message \\(self.agentDisplayName)...", "surface": "apple", @@ -8547,7 +8547,7 @@ }, { "kind": "ui-localized-call", - "line": 392, + "line": 399, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Message \\(self.agentDisplayName); sends when connected", "surface": "apple", @@ -8555,7 +8555,7 @@ }, { "kind": "ui-localized-call", - "line": 394, + "line": 401, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Connect to a gateway", "surface": "apple", @@ -8563,7 +8563,7 @@ }, { "kind": "ui-localized-call", - "line": 462, + "line": 469, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Check OpenClaw status", "surface": "apple", @@ -8571,7 +8571,7 @@ }, { "kind": "ui-localized-call", - "line": 463, + "line": 470, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Summarize the current OpenClaw status and tell me what needs attention.", "surface": "apple", @@ -8579,7 +8579,7 @@ }, { "kind": "ui-localized-call", - "line": 466, + "line": 473, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "What can I control here?", "surface": "apple", @@ -8587,7 +8587,7 @@ }, { "kind": "ui-localized-call", - "line": 467, + "line": 474, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Show me which phone controls and device capabilities are available right now.", "surface": "apple", @@ -8595,7 +8595,7 @@ }, { "kind": "ui-localized-call", - "line": 470, + "line": 477, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Help me start voice chat", "surface": "apple", @@ -8603,7 +8603,7 @@ }, { "kind": "ui-localized-call", - "line": 471, + "line": 478, "path": "apps/ios/Sources/Design/ChatProTab.swift", "source": "Help me start a realtime voice session from this phone.", "surface": "apple", @@ -12731,7 +12731,7 @@ }, { "kind": "conditional-branch", - "line": 2855, + "line": 2878, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Action required", "surface": "apple", @@ -12739,7 +12739,7 @@ }, { "kind": "conditional-branch", - "line": 2855, + "line": 2878, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Approval needed", "surface": "apple", @@ -12747,7 +12747,7 @@ }, { "kind": "conditional-branch", - "line": 3417, + "line": 3440, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting…", "surface": "apple", @@ -12755,7 +12755,7 @@ }, { "kind": "conditional-branch", - "line": 3417, + "line": 3440, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting…", "surface": "apple", @@ -12763,7 +12763,7 @@ }, { "kind": "conditional-branch", - "line": 3421, + "line": 3444, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connecting...", "surface": "apple", @@ -12771,7 +12771,7 @@ }, { "kind": "conditional-branch", - "line": 3421, + "line": 3444, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Reconnecting...", "surface": "apple", @@ -12779,7 +12779,7 @@ }, { "kind": "conditional-branch", - "line": 3713, + "line": 3736, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Connected", "surface": "apple", @@ -12787,7 +12787,7 @@ }, { "kind": "conditional-branch", - "line": 3713, + "line": 3736, "path": "apps/ios/Sources/Model/NodeAppModel.swift", "source": "Offline", "surface": "apple", @@ -14307,7 +14307,7 @@ }, { "kind": "conditional-branch", - "line": 260, + "line": 280, "path": "apps/ios/Sources/Voice/VoiceWakeManager.swift", "source": "Off", "surface": "apple", @@ -14315,7 +14315,7 @@ }, { "kind": "conditional-branch", - "line": 260, + "line": 280, "path": "apps/ios/Sources/Voice/VoiceWakeManager.swift", "source": "Paused", "surface": "apple", @@ -21811,7 +21811,7 @@ }, { "kind": "ui-modifier", - "line": 214, + "line": 240, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Context \\(percentage)% used", "surface": "apple", @@ -21819,7 +21819,7 @@ }, { "kind": "ui-call", - "line": 218, + "line": 244, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Thinking", "surface": "apple", @@ -21827,7 +21827,7 @@ }, { "kind": "ui-call", - "line": 239, + "line": 265, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Model", "surface": "apple", @@ -21835,7 +21835,7 @@ }, { "kind": "ui-call", - "line": 256, + "line": 282, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Pinned", "surface": "apple", @@ -21843,7 +21843,7 @@ }, { "kind": "ui-call", - "line": 264, + "line": 290, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Recent", "surface": "apple", @@ -21851,7 +21851,7 @@ }, { "kind": "ui-call", - "line": 272, + "line": 298, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Models", "surface": "apple", @@ -21859,7 +21859,7 @@ }, { "kind": "conditional-branch", - "line": 301, + "line": 327, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Pin model", "surface": "apple", @@ -21867,7 +21867,7 @@ }, { "kind": "conditional-branch", - "line": 301, + "line": 327, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Unpin model", "surface": "apple", @@ -21875,7 +21875,7 @@ }, { "kind": "ui-call", - "line": 305, + "line": 331, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Session", "surface": "apple", @@ -21883,7 +21883,7 @@ }, { "kind": "ui-modifier", - "line": 369, + "line": 395, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Add Image", "surface": "apple", @@ -21891,15 +21891,23 @@ }, { "kind": "ui-modifier", - "line": 370, + "line": 396, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Attachments", "surface": "apple", "id": "native.apple.1963dfa3285cb715" }, + { + "kind": "ui-call", + "line": 431, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", + "source": "Voice note", + "surface": "apple", + "id": "native.apple.f8dda832ed76441d" + }, { "kind": "conditional-branch", - "line": 540, + "line": 589, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Talk", "surface": "apple", @@ -21907,7 +21915,7 @@ }, { "kind": "conditional-branch", - "line": 586, + "line": 635, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Start realtime chat", "surface": "apple", @@ -21915,7 +21923,7 @@ }, { "kind": "conditional-branch", - "line": 586, + "line": 635, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Stop realtime chat", "surface": "apple", @@ -21923,7 +21931,7 @@ }, { "kind": "conditional-branch", - "line": 619, + "line": 668, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Start", "surface": "apple", @@ -21931,7 +21939,7 @@ }, { "kind": "conditional-branch", - "line": 619, + "line": 668, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Stop", "surface": "apple", @@ -21939,7 +21947,7 @@ }, { "kind": "ui-call", - "line": 729, + "line": 778, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Loading commands", "surface": "apple", @@ -21947,7 +21955,7 @@ }, { "kind": "ui-call", - "line": 738, + "line": 787, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Commands unavailable", "surface": "apple", @@ -21955,7 +21963,7 @@ }, { "kind": "ui-call", - "line": 747, + "line": 796, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Retry", "surface": "apple", @@ -21963,7 +21971,7 @@ }, { "kind": "ui-call", - "line": 756, + "line": 805, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "No matching commands", "surface": "apple", @@ -21971,7 +21979,7 @@ }, { "kind": "ui-modifier", - "line": 888, + "line": 937, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Stop response", "surface": "apple", @@ -21979,7 +21987,7 @@ }, { "kind": "ui-modifier", - "line": 913, + "line": 962, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Send message", "surface": "apple", @@ -21987,7 +21995,7 @@ }, { "kind": "ui-modifier", - "line": 928, + "line": 977, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Refresh", "surface": "apple", @@ -21995,7 +22003,7 @@ }, { "kind": "conditional-branch", - "line": 1038, + "line": 1108, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Connecting...", "surface": "apple", @@ -22003,7 +22011,7 @@ }, { "kind": "conditional-branch", - "line": 1038, + "line": 1108, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift", "source": "Gateway connected", "surface": "apple", @@ -22019,7 +22027,7 @@ }, { "kind": "conditional-branch", - "line": 557, + "line": 569, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Show full output", "surface": "apple", @@ -22027,7 +22035,7 @@ }, { "kind": "conditional-branch", - "line": 557, + "line": 569, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Show less", "surface": "apple", @@ -22035,7 +22043,7 @@ }, { "kind": "ui-call", - "line": 616, + "line": 628, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Writing", "surface": "apple", @@ -22043,7 +22051,7 @@ }, { "kind": "ui-call", - "line": 642, + "line": 654, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Preparing audio…", "surface": "apple", @@ -22051,7 +22059,7 @@ }, { "kind": "ui-call", - "line": 645, + "line": 657, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Speaking…", "surface": "apple", @@ -22059,7 +22067,7 @@ }, { "kind": "ui-call", - "line": 653, + "line": 665, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Preparing audio, tap to cancel", "surface": "apple", @@ -22067,7 +22075,7 @@ }, { "kind": "ui-call", - "line": 654, + "line": 666, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Speaking, tap to stop", "surface": "apple", @@ -22075,7 +22083,7 @@ }, { "kind": "conditional-branch", - "line": 677, + "line": 689, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Queued", "surface": "apple", @@ -22083,7 +22091,7 @@ }, { "kind": "conditional-branch", - "line": 679, + "line": 691, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Sending…", "surface": "apple", @@ -22091,7 +22099,7 @@ }, { "kind": "conditional-branch", - "line": 681, + "line": 693, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Confirming…", "surface": "apple", @@ -22099,7 +22107,7 @@ }, { "kind": "conditional-branch", - "line": 683, + "line": 695, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Delivery unknown", "surface": "apple", @@ -22107,7 +22115,7 @@ }, { "kind": "conditional-branch", - "line": 685, + "line": 697, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Not sent", "surface": "apple", @@ -22115,7 +22123,7 @@ }, { "kind": "conditional-branch", - "line": 707, + "line": 719, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Queued, sends when reconnected", "surface": "apple", @@ -22123,7 +22131,7 @@ }, { "kind": "conditional-branch", - "line": 709, + "line": 721, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Sending", "surface": "apple", @@ -22131,7 +22139,7 @@ }, { "kind": "conditional-branch", - "line": 711, + "line": 723, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Sent, waiting for chat history confirmation", "surface": "apple", @@ -22139,7 +22147,7 @@ }, { "kind": "conditional-branch", - "line": 713, + "line": 725, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Delivery unconfirmed, touch and hold to retry or delete", "surface": "apple", @@ -22147,7 +22155,7 @@ }, { "kind": "conditional-branch", - "line": 715, + "line": 727, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Not sent, touch and hold to retry or delete", "surface": "apple", @@ -22155,7 +22163,7 @@ }, { "kind": "ui-call", - "line": 810, + "line": 822, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Running tools…", "surface": "apple", @@ -22163,7 +22171,7 @@ }, { "kind": "ui-call", - "line": 818, + "line": 830, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "\\(display.emoji) \\(display.label)", "surface": "apple", @@ -22179,7 +22187,7 @@ }, { "kind": "ui-call", - "line": 430, + "line": 434, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Retry Send", "surface": "apple", @@ -22187,7 +22195,7 @@ }, { "kind": "ui-call", - "line": 444, + "line": 448, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Delete", "surface": "apple", @@ -22195,7 +22203,7 @@ }, { "kind": "ui-call", - "line": 475, + "line": 479, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Stop Listening", "surface": "apple", @@ -22203,7 +22211,7 @@ }, { "kind": "ui-call", - "line": 478, + "line": 482, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Listen", "surface": "apple", @@ -22211,7 +22219,7 @@ }, { "kind": "ui-call", - "line": 551, + "line": 555, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Jump to latest", "surface": "apple", @@ -22219,7 +22227,7 @@ }, { "kind": "ui-modifier", - "line": 562, + "line": 566, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Jump to latest reply", "surface": "apple", @@ -22227,7 +22235,7 @@ }, { "kind": "ui-named-argument", - "line": 582, + "line": 586, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Refresh", "surface": "apple", @@ -22235,7 +22243,7 @@ }, { "kind": "ui-call", - "line": 1007, + "line": 1011, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Loading chat", "surface": "apple", @@ -22243,15 +22251,31 @@ }, { "kind": "ui-modifier", - "line": 1087, + "line": 1091, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Dismiss", "surface": "apple", "id": "native.apple.5c481fea4eb3dc17" }, + { + "kind": "ui-localized-call", + "line": 22, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift", + "source": "Could not attach voice note: \\(error.localizedDescription)", + "surface": "apple", + "id": "native.apple.82befa424fe7bace" + }, + { + "kind": "ui-localized-call", + "line": 27, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift", + "source": "Voice note exceeds the 5 MB attachment limit", + "surface": "apple", + "id": "native.apple.ec5daba7e38184dc" + }, { "kind": "conditional-branch", - "line": 62, + "line": 91, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift", "source": "\\(baseName).jpg", "surface": "apple", @@ -22259,7 +22283,7 @@ }, { "kind": "conditional-branch", - "line": 1360, + "line": 1361, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift", "source": "delivery unconfirmed", "surface": "apple", @@ -22267,12 +22291,68 @@ }, { "kind": "conditional-branch", - "line": 1360, + "line": 1361, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift", "source": "queued after route change", "surface": "apple", "id": "native.apple.722f1f90b97e8e45" }, + { + "kind": "ui-modifier", + "line": 39, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift", + "source": "Record Voice Note", + "surface": "apple", + "id": "native.apple.71e0219d1501de06" + }, + { + "kind": "ui-modifier", + "line": 40, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift", + "source": "Record voice note", + "surface": "apple", + "id": "native.apple.e6770d7cc616021f" + }, + { + "kind": "ui-call", + "line": 59, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift", + "source": "Recording", + "surface": "apple", + "id": "native.apple.c971dc7dc243d0d1" + }, + { + "kind": "ui-modifier", + "line": 75, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift", + "source": "Cancel voice note", + "surface": "apple", + "id": "native.apple.0fb64b1b7ea0aa5e" + }, + { + "kind": "ui-modifier", + "line": 84, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift", + "source": "Finish voice note", + "surface": "apple", + "id": "native.apple.b7a9de9b3c8e4bad" + }, + { + "kind": "ui-localized-call", + "line": 108, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift", + "source": "Microphone access is required. Enable it in Settings.", + "surface": "apple", + "id": "native.apple.1fc7530ccdfc937e" + }, + { + "kind": "ui-localized-call", + "line": 121, + "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift", + "source": "Could not start recording: \\(error.localizedDescription)", + "surface": "apple", + "id": "native.apple.c963f6dfaa0610b5" + }, { "kind": "conditional-branch", "line": 235, diff --git a/apps/ios/Sources/Design/ChatProTab.swift b/apps/ios/Sources/Design/ChatProTab.swift index b08f7a64230..354a074fc65 100644 --- a/apps/ios/Sources/Design/ChatProTab.swift +++ b/apps/ios/Sources/Design/ChatProTab.swift @@ -150,6 +150,7 @@ struct ChatProTab: View { emptyAssistantIntro: String(localized: "What would you like to work on?"), emptyAssistantPrompts: Self.emptyAssistantPrompts, talkControl: self.talkControl, + voiceNoteControl: self.voiceNoteControl, speech: self.speech) // iMessage-style grey bubbles for agent replies in the clean chrome. .environment(\.openClawAssistantBubblesInCleanChrome, true) @@ -245,6 +246,12 @@ struct ChatProTab: View { }) } + private var voiceNoteControl: OpenClawChatVoiceNoteControl { + OpenClawChatVoiceNoteControl( + recorder: self.appModel.voiceNoteRecorder, + isTalkActive: self.appModel.talkMode.isEnabled || self.appModel.talkMode.isPushToTalkActive) + } + private var activeAgentID: String { self.normalized(self.appModel.chatAgentId) ?? "main" diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index b571ad40721..e281396b348 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -255,6 +255,7 @@ final class NodeAppModel { private var gatewayHealthMonitorDisabled = false private let notificationCenter: NotificationCentering let voiceWake = VoiceWakeManager() + let voiceNoteRecorder: OpenClawVoiceNoteRecorder let talkMode: TalkModeManager private let locationService: any LocationServicing private let deviceStatusService: any DeviceStatusServicing @@ -500,7 +501,8 @@ final class NodeAppModel { remindersService: any RemindersServicing = RemindersService(), motionService: any MotionServicing = MotionService(), watchMessagingService: any WatchMessagingServicing = WatchMessagingService(), - talkMode: TalkModeManager = TalkModeManager()) + talkMode: TalkModeManager = TalkModeManager(), + voiceNoteRecorder: OpenClawVoiceNoteRecorder = OpenClawVoiceNoteRecorder()) { self.screen = screen self.camera = camera @@ -515,6 +517,7 @@ final class NodeAppModel { self.motionService = motionService self.watchMessagingService = watchMessagingService self.talkMode = talkMode + self.voiceNoteRecorder = voiceNoteRecorder self.apnsDeviceTokenHex = UserDefaults.standard.string(forKey: Self.apnsDeviceTokenUserDefaultsKey) restorePersistedWatchExecApprovalBridgeState() GatewayDiagnostics.bootstrap() @@ -575,6 +578,9 @@ final class NodeAppModel { // Best-effort only. } } + self.voiceNoteRecorder.onRecordingActiveChanged = { [weak self] isActive in + self?.voiceWake.setSuppressedByVoiceNote(isActive) + } let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled") self.voiceWake.setEnabled(enabled) @@ -881,6 +887,9 @@ final class NodeAppModel { } UserDefaults.standard.set(enabled, forKey: "talk.enabled") if enabled { + if self.voiceNoteRecorder.isRecording || self.voiceNoteRecorder.isRequestingPermission { + self.voiceNoteRecorder.cancel() + } // Voice wake holds the microphone continuously; talk mode needs exclusive access for STT. // When talk is enabled from the UI, prioritize talk and pause voice wake. self.voiceWake.setSuppressedByTalk(true) @@ -1973,6 +1982,20 @@ final class NodeAppModel { } private func handleTalkInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + if req.command == OpenClawTalkCommand.pttOnce.rawValue || + req.command == OpenClawTalkCommand.pttStart.rawValue + { + // Composer recording and PTT use separate AVAudio stacks. Refuse the + // second owner before it can reconfigure the shared audio session. + guard !self.voiceNoteRecorder.isRecording, + !self.voiceNoteRecorder.isRequestingPermission + else { + throw NSError(domain: "TalkMode", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "Voice note recording is active", + ]) + } + } + if req.command == OpenClawTalkCommand.pttOnce.rawValue { self.acquirePttVoiceWakeLease() defer { self.releasePttVoiceWakeLease() } diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index c2adb9fe859..8a069021f47 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -49,6 +49,11 @@ private enum VoiceWakeAudioError: LocalizedError { } } +private enum VoiceWakeSuppressionReason: Hashable { + case talk + case voiceNote +} + extension AVAudioPCMBuffer { fileprivate func deepCopy() -> AVAudioPCMBuffer? { let format = self.format @@ -111,7 +116,7 @@ final class VoiceWakeManager: NSObject { private var lastDispatched: String? private var onCommand: (@Sendable (String) async -> Void)? private var userDefaultsObserver: NSObjectProtocol? - private var suppressedByTalk: Bool = false + private var suppressionReasons: Set = [] private let externalAudioResumeDelayNs: UInt64 private let recognitionErrorRestartDelayNs: UInt64 @@ -167,8 +172,23 @@ final class VoiceWakeManager: NSObject { } func setSuppressedByTalk(_ suppressed: Bool) { - self.suppressedByTalk = suppressed + self.setSuppressed(suppressed, reason: .talk) + } + + func setSuppressedByVoiceNote(_ suppressed: Bool) { + self.setSuppressed(suppressed, reason: .voiceNote) + } + + private func setSuppressed(_ suppressed: Bool, reason: VoiceWakeSuppressionReason) { if suppressed { + self.suppressionReasons.insert(reason) + } else { + self.suppressionReasons.remove(reason) + } + + // Each microphone owner clears only its own reason, so Talk ending + // cannot restart Voice Wake over an active voice-note recording. + if !self.suppressionReasons.isEmpty { self.cancelScheduledStart() if self.isListening { self.isListening = false @@ -214,7 +234,7 @@ final class VoiceWakeManager: NSObject { self.isStarting = true defer { self.isStarting = false } - guard !self.suppressedByTalk else { + guard self.suppressionReasons.isEmpty else { self.isListening = false self.statusText = "Paused" return @@ -255,7 +275,7 @@ final class VoiceWakeManager: NSObject { return } - guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else { + guard self.isEnabled, self.suppressionReasons.isEmpty, !self.isSuspendedForExternalAudio else { self.isListening = false self.statusText = self.isEnabled ? "Paused" : "Off" return @@ -303,7 +323,7 @@ final class VoiceWakeManager: NSObject { } private func startRecognition() throws { - guard self.isEnabled, !self.suppressedByTalk, !self.isSuspendedForExternalAudio else { return } + guard self.isEnabled, self.suppressionReasons.isEmpty, !self.isSuspendedForExternalAudio else { return } self.recognitionTask?.cancel() self.recognitionTask = nil diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index cde3afefc90..524217cca4f 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -79,6 +79,21 @@ private func makeProjectedWatchChatRawMessage( return try JSONDecoder().decode(AnyCodable.self, from: data) } +@MainActor +private final class VoiceNoteCaptureStub: VoiceNoteAudioCapture { + func requestPermission() async -> Bool { + true + } + + func start(url _: URL) throws {} + + func stop() -> TimeInterval { + 1 + } + + func cancel() {} +} + @MainActor private func waitForMainActorWork(_ condition: () -> Bool) async { for _ in 0..<100 { @@ -724,6 +739,23 @@ private actor WatchSnapshotSendGate { appModel.voiceWake.stop() } + @Test @MainActor func `PTT start is refused while a voice note owns the microphone`() async { + let voiceNoteRecorder = OpenClawVoiceNoteRecorder(capture: VoiceNoteCaptureStub()) + let talkMode = TalkModeManager(allowSimulatorCapture: true) + let appModel = NodeAppModel(talkMode: talkMode, voiceNoteRecorder: voiceNoteRecorder) + #expect(await voiceNoteRecorder.start()) + defer { voiceNoteRecorder.cancel() } + + let request = BridgeInvokeRequest( + id: "ptt-start-during-voice-note", + command: OpenClawTalkCommand.pttStart.rawValue) + let response = await appModel._test_handleInvoke(request) + + #expect(response.ok == false) + #expect(response.error?.message.contains("Voice note recording is active") == true) + #expect(talkMode.isPushToTalkActive == false) + } + @Test @MainActor func `overlapping PTT owners keep voice wake suspended until final release`() { let appModel = NodeAppModel(talkMode: TalkModeManager(allowSimulatorCapture: true)) appModel.voiceWake.isEnabled = true diff --git a/apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift b/apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift index 4aece2c82d8..d568c861607 100644 --- a/apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift +++ b/apps/ios/Tests/VoiceWakeManagerSuppressionTests.swift @@ -69,4 +69,23 @@ struct VoiceWakeManagerSuppressionTests { #expect(manager.statusText == "Voice Wake isn’t supported on Simulator") #expect(manager.isListening == false) } + + @Test + @MainActor func `clearing Talk suppression does not clobber voice note suppression`() async { + let manager = VoiceWakeManager._test_withoutRestartDelays() + manager.isEnabled = true + manager.statusText = "Listening" + + manager.setSuppressedByVoiceNote(true) + manager.setSuppressedByTalk(true) + manager.setSuppressedByTalk(false) + + await manager._test_waitForScheduledStart() + #expect(manager.statusText == "Paused") + #expect(manager.isListening == false) + + manager.setSuppressedByVoiceNote(false) + await manager._test_waitForScheduledStart() + #expect(manager.statusText == "Voice Wake isn’t supported on Simulator") + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift index c5fb8928648..e8313e97585 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift @@ -87,6 +87,8 @@ struct OpenClawChatComposer: View { let isAttachmentInputEnabled: Bool let messagePlaceholder: String? let talkControl: OpenClawChatTalkControl? + let voiceNoteControl: OpenClawChatVoiceNoteControl? + @State private var stagingVoiceNoteURL: URL? #if !os(macOS) @State private var pickerItems: [PhotosPickerItem] = [] @@ -101,7 +103,7 @@ struct OpenClawChatComposer: View { var body: some View { VStack(alignment: .leading, spacing: 4) { - if self.showsToolbar { + if self.showsToolbar, self.voiceNoteControl?.recorder.isRecording != true { self.composerToolbar } @@ -109,7 +111,12 @@ struct OpenClawChatComposer: View { self.attachmentsStrip } - self.editor + if let voiceNoteControl, voiceNoteControl.recorder.isRecording { + OpenClawVoiceNoteRecordingRow(recorder: voiceNoteControl.recorder) + .padding(self.editorPadding) + } else { + self.editor + } } .padding(self.composerPadding) .background { @@ -163,6 +170,18 @@ struct OpenClawChatComposer: View { self.viewModel.loadSlashCommandsIfNeeded() } #endif + .onChange(of: self.voiceNoteControl?.recorder.completedRecording) { _, recording in + guard recording != nil else { return } + self.stageCompletedVoiceNoteIfNeeded() + } + .onChange(of: self.voiceNoteControl?.recorder.errorMessage) { _, message in + if let message { + self.viewModel.errorText = message + } + } + .onAppear { + self.stageCompletedVoiceNoteIfNeeded() + } } private var composerToolbar: some View { @@ -193,6 +212,13 @@ struct OpenClawChatComposer: View { if self.style == .standard { self.refreshButton self.attachmentPicker + if let voiceNoteControl, !voiceNoteControl.isTalkActive { + OpenClawVoiceNoteButton( + control: voiceNoteControl, + compact: false, + isComposerEnabled: self.isComposerEnabled, + isAttachmentInputEnabled: self.isAttachmentInputEnabled) + } } } .padding(.horizontal, 10) @@ -400,13 +426,27 @@ struct OpenClawChatComposer: View { .scaledToFill() .frame(width: 22, height: 22) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } else if att.mimeType.hasPrefix("audio/") { + Image(systemName: "waveform") + Text("Voice note") + .font(OpenClawChatTypography.caption) + if let durationSeconds = att.durationSeconds { + Text(openClawVoiceNoteDurationLabel(durationSeconds)) + .font(OpenClawChatTypography.caption) + .foregroundStyle(.secondary) + } } else { Image(systemName: "photo") + Text(att.fileName) + .font(OpenClawChatTypography.caption) + .lineLimit(1) } - Text(att.fileName) - .font(OpenClawChatTypography.caption) - .lineLimit(1) + if att.preview != nil { + Text(att.fileName) + .font(OpenClawChatTypography.caption) + .lineLimit(1) + } Button { self.viewModel.removeAttachment(att.id) @@ -497,6 +537,15 @@ struct OpenClawChatComposer: View { self.attachmentPicker .frame(width: self.cleanIconControlSize, height: self.cleanEditorMinHeight) + if let voiceNoteControl, !voiceNoteControl.isTalkActive { + OpenClawVoiceNoteButton( + control: voiceNoteControl, + compact: true, + isComposerEnabled: self.isComposerEnabled, + isAttachmentInputEnabled: self.isAttachmentInputEnabled) + .frame(width: self.cleanIconControlSize, height: self.cleanEditorMinHeight) + } + self.editorOverlay .padding(.vertical, self.cleanEditorTextPadding) .padding(.horizontal, 14) @@ -1030,10 +1079,31 @@ struct OpenClawChatComposer: View { private var canSendMessage: Bool { self.isComposerEnabled + && self.voiceNoteControl?.recorder.completedRecording == nil && self.viewModel.canSend && (self.isAttachmentInputEnabled || self.viewModel.attachments.isEmpty) } + private func stageCompletedVoiceNoteIfNeeded() { + guard let recorder = self.voiceNoteControl?.recorder, + let recording = recorder.completedRecording, + self.stagingVoiceNoteURL != recording.fileURL + else { return } + + self.stagingVoiceNoteURL = recording.fileURL + Task { + await self.viewModel.addVoiceNoteAttachment( + fileURL: recording.fileURL, + durationSeconds: recording.durationSeconds) + if recorder.completedRecording == recording { + recorder.clearCompletedRecording() + } + if self.stagingVoiceNoteURL == recording.fileURL { + self.stagingVoiceNoteURL = nil + } + } + } + private var connectionStatusText: String { self.connectionOK ? "Gateway connected" : "Connecting..." } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index 9215c41bd66..2355c0c1379 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -478,17 +478,29 @@ private struct AttachmentRow: View { var body: some View { HStack(spacing: 8) { - Image(systemName: "paperclip") - Text(self.att.fileName ?? "Attachment") + Image(systemName: self.isAudio ? "waveform" : "paperclip") + Text(self.isAudio ? "Voice note" : (self.att.fileName ?? "Attachment")) .font(OpenClawChatTypography.footnote) .lineLimit(1) .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText) + if self.isAudio, let durationSeconds = self.att.durationSeconds { + Text(openClawVoiceNoteDurationLabel(durationSeconds)) + .font(OpenClawChatTypography.footnote) + .foregroundStyle( + self.isUser + ? OpenClawChatTheme.userText.opacity(0.72) + : OpenClawChatTheme.assistantText.opacity(0.72)) + } Spacer() } .padding(10) .background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } + + private var isAudio: Bool { + self.att.mimeType?.hasPrefix("audio/") == true + } } private struct ToolCallCard: View { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index 8b29a7b8ee7..09103abe8e4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -116,6 +116,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable { public let thinkingSignature: String? public let mimeType: String? public let fileName: String? + public let durationSeconds: Double? public let content: AnyCodable? // Tool-call fields (when `type == "toolCall"` or similar) @@ -130,6 +131,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable { thinkingSignature: String? = nil, mimeType: String?, fileName: String?, + durationSeconds: Double? = nil, content: AnyCodable?, id: String? = nil, name: String? = nil, @@ -141,6 +143,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable { self.thinkingSignature = thinkingSignature self.mimeType = mimeType self.fileName = fileName + self.durationSeconds = durationSeconds self.content = content self.id = id self.name = name @@ -154,6 +157,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable { case thinkingSignature case mimeType case fileName + case durationSeconds case content case id case name @@ -168,6 +172,7 @@ public struct OpenClawChatMessageContent: Codable, Hashable, Sendable { self.thinkingSignature = try container.decodeIfPresent(String.self, forKey: .thinkingSignature) self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType) self.fileName = try container.decodeIfPresent(String.self, forKey: .fileName) + self.durationSeconds = try container.decodeIfPresent(Double.self, forKey: .durationSeconds) self.id = try container.decodeIfPresent(String.self, forKey: .id) self.name = try container.decodeIfPresent(String.self, forKey: .name) self.arguments = try container.decodeIfPresent(AnyCodable.self, forKey: .arguments) @@ -496,6 +501,7 @@ public struct OpenClawPendingAttachment: Identifiable { public let mimeType: String public let type: String public let preview: OpenClawPlatformImage? + public let durationSeconds: Double? public init( url: URL?, @@ -503,7 +509,8 @@ public struct OpenClawPendingAttachment: Identifiable { fileName: String, mimeType: String, type: String = "file", - preview: OpenClawPlatformImage?) + preview: OpenClawPlatformImage?, + durationSeconds: Double? = nil) { self.url = url self.data = data @@ -511,6 +518,7 @@ public struct OpenClawPendingAttachment: Identifiable { self.mimeType = mimeType self.type = type self.preview = preview + self.durationSeconds = durationSeconds } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift index 81a7b3bf662..faae4b2e2fe 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTranscriptCache.swift @@ -1085,6 +1085,7 @@ public actor OpenClawChatSQLiteTranscriptCache: OpenClawChatTranscriptCache, thinkingSignature: nil, mimeType: item.mimeType, fileName: item.fileName, + durationSeconds: item.durationSeconds, content: nil, id: item.id, name: item.name, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index 2bfa87127b9..4e42fc5cdc4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -98,6 +98,7 @@ public struct OpenClawChatView: View { private let emptyAssistantIntro: String? private let emptyAssistantPrompts: [StarterPrompt] private let talkControl: OpenClawChatTalkControl? + private let voiceNoteControl: OpenClawChatVoiceNoteControl? private let speech: OpenClawChatSpeechController? private enum ScrollFollowTarget: Equatable { @@ -150,6 +151,7 @@ public struct OpenClawChatView: View { emptyAssistantIntro: String? = nil, emptyAssistantPrompts: [StarterPrompt] = [], talkControl: OpenClawChatTalkControl? = nil, + voiceNoteControl: OpenClawChatVoiceNoteControl? = nil, speech: OpenClawChatSpeechController? = nil) { _viewModel = State(initialValue: viewModel) @@ -170,6 +172,7 @@ public struct OpenClawChatView: View { self.emptyAssistantIntro = emptyAssistantIntro self.emptyAssistantPrompts = emptyAssistantPrompts self.talkControl = talkControl + self.voiceNoteControl = voiceNoteControl self.speech = speech } @@ -231,7 +234,8 @@ public struct OpenClawChatView: View { isComposerEnabled: self.isComposerEnabled, isAttachmentInputEnabled: self.isAttachmentInputEnabled, messagePlaceholder: self.messagePlaceholder, - talkControl: self.talkControl) + talkControl: self.talkControl, + voiceNoteControl: self.voiceNoteControl) } private var messageList: some View { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift index d0a6793a263..1a95ce8ad67 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift @@ -9,6 +9,35 @@ import UIKit #endif extension OpenClawChatViewModel { + /// Stages a recorded m4a voice note and removes its temporary file. + func addVoiceNoteAttachment(fileURL: URL, durationSeconds: Double) async { + defer { try? FileManager.default.removeItem(at: fileURL) } + + let data: Data + do { + data = try await Task.detached(priority: .userInitiated) { + try Data(contentsOf: fileURL) + }.value + } catch { + self.errorText = String(localized: "Could not attach voice note: \(error.localizedDescription)") + return + } + + guard data.count <= Self.maxAttachmentBytes else { + self.errorText = String(localized: "Voice note exceeds the 5 MB attachment limit") + return + } + + self.attachments.append( + OpenClawPendingAttachment( + url: nil, + data: data, + fileName: fileURL.lastPathComponent, + mimeType: "audio/mp4", + preview: nil, + durationSeconds: max(0, durationSeconds))) + } + func loadAttachments(urls: [URL]) async { for url in urls { do { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift index 14a88e56aed..ce28a6c3a98 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+HistoryReconciliation.swift @@ -25,6 +25,7 @@ extension OpenClawChatViewModel { thinkingSignature: content.thinkingSignature, mimeType: content.mimeType, fileName: content.fileName, + durationSeconds: content.durationSeconds, content: content.content, id: content.id, name: content.name, @@ -133,7 +134,9 @@ extension OpenClawChatViewModel { OpenClawChatMessage( id: existing.id, role: incoming.role, - content: incoming.content, + content: self.preservingLocalAudioDurations( + in: incoming.content, + from: existing.content), timestamp: incoming.timestamp ?? existing.timestamp, idempotencyKey: incoming.idempotencyKey, toolCallId: incoming.toolCallId, @@ -143,6 +146,38 @@ extension OpenClawChatViewModel { errorMessage: incoming.errorMessage) } + private static func preservingLocalAudioDurations( + in incoming: [OpenClawChatMessageContent], + from existing: [OpenClawChatMessageContent]) -> [OpenClawChatMessageContent] + { + let localDurations = existing + .filter { $0.mimeType?.hasPrefix("audio/") == true } + .map(\.durationSeconds) + var audioIndex = 0 + return incoming.map { content in + guard content.mimeType?.hasPrefix("audio/") == true else { return content } + defer { audioIndex += 1 } + guard content.durationSeconds == nil, + localDurations.indices.contains(audioIndex), + let localDuration = localDurations[audioIndex] + else { + return content + } + return OpenClawChatMessageContent( + type: content.type, + text: content.text, + thinking: content.thinking, + thinkingSignature: content.thinkingSignature, + mimeType: content.mimeType, + fileName: content.fileName, + durationSeconds: localDuration, + content: content.content, + id: content.id, + name: content.name, + arguments: content.arguments) + } + } + func currentRunMessageScope() -> RunMessageScope { RunMessageScope( session: self.currentSessionSnapshot(), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 0f03f229ec7..c419952d1f9 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -1232,7 +1232,7 @@ public final class OpenClawChatViewModel { fileName: att.fileName, content: att.data.base64EncodedString()) } - for att in encodedAttachments { + for (attachment, att) in zip(draftAttachments, encodedAttachments) { userContent.append( OpenClawChatMessageContent( type: att.type, @@ -1241,6 +1241,7 @@ public final class OpenClawChatViewModel { thinkingSignature: nil, mimeType: att.mimeType, fileName: att.fileName, + durationSeconds: attachment.durationSeconds, content: AnyCodable(att.content), id: nil, name: nil, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift new file mode 100644 index 00000000000..b4a55b579aa --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteComposerViews.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// Host-provided voice-note recording state for the shared chat composer. +public struct OpenClawChatVoiceNoteControl { + public var recorder: OpenClawVoiceNoteRecorder + public var isTalkActive: Bool + + public init(recorder: OpenClawVoiceNoteRecorder, isTalkActive: Bool) { + self.recorder = recorder + self.isTalkActive = isTalkActive + } +} + +struct OpenClawVoiceNoteButton: View { + let control: OpenClawChatVoiceNoteControl + let compact: Bool + let isComposerEnabled: Bool + let isAttachmentInputEnabled: Bool + + var isRecordingEnabled: Bool { + self.isComposerEnabled + && self.isAttachmentInputEnabled + && !self.control.isTalkActive + && !self.control.recorder.isRequestingPermission + } + + var body: some View { + Button { + Task { await self.control.recorder.start() } + } label: { + if self.control.recorder.isRequestingPermission { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: "mic") + .font(OpenClawChatTypography.display(size: 15, weight: .semibold, relativeTo: .subheadline)) + } + } + .help("Record Voice Note") + .accessibilityLabel("Record voice note") + .accessibilityIdentifier("chat-voice-note-record") + .modifier(VoiceNoteButtonChrome(compact: self.compact)) + .controlSize(.small) + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + .disabled(!self.isRecordingEnabled) + } +} + +struct OpenClawVoiceNoteRecordingRow: View { + let recorder: OpenClawVoiceNoteRecorder + + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(OpenClawChatTheme.danger) + .frame(width: 9, height: 9) + + Text("Recording") + .font(OpenClawChatTypography.footnoteSemiBold) + + Text(openClawVoiceNoteDurationLabel(self.recorder.elapsedSeconds)) + .font(OpenClawChatTypography.mono(size: 13, relativeTo: .footnote)) + .foregroundStyle(.secondary) + + Spacer(minLength: 8) + + Button { + self.recorder.cancel() + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityLabel("Cancel voice note") + + Button { + self.recorder.finish() + } label: { + Image(systemName: "checkmark") + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityLabel("Finish voice note") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(OpenClawChatTheme.composerField) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(OpenClawChatTheme.composerBorder))) + } +} + +private struct VoiceNoteButtonChrome: ViewModifier { + let compact: Bool + + func body(content: Content) -> some View { + if self.compact { + content.buttonStyle(.plain) + } else { + content.buttonStyle(.bordered) + } + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift new file mode 100644 index 00000000000..0b488148830 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/VoiceNoteRecorder.swift @@ -0,0 +1,305 @@ +import AVFAudio +import Foundation +import Observation + +private let voiceNoteMaximumDurationSeconds: TimeInterval = 180 + +/// Audio capture operations driven by the voice-note recorder state machine. +@MainActor +public protocol VoiceNoteAudioCapture: AnyObject { + /// Requests microphone access when recording is first used. + func requestPermission() async -> Bool + + /// Starts writing an audio recording to the supplied URL. + func start(url: URL) throws + + /// Stops capture and returns the recorded duration in seconds. + func stop() -> TimeInterval + + /// Stops and discards the active capture. + func cancel() +} + +/// A completed voice-note recording ready to stage as a chat attachment. +public struct OpenClawVoiceNoteRecording: Equatable, Sendable { + public let fileURL: URL + public let durationSeconds: TimeInterval + + public init(fileURL: URL, durationSeconds: TimeInterval) { + self.fileURL = fileURL + self.durationSeconds = durationSeconds + } +} + +/// Main-actor voice-note recorder with explicit permission and capture states. +@MainActor +@Observable +public final class OpenClawVoiceNoteRecorder { + public enum State: Equatable { + case idle + case requestingPermission + case recording(startedAt: Date, fileURL: URL) + case finished(recording: OpenClawVoiceNoteRecording) + case failed(message: String) + } + + public static let maximumDurationSeconds = voiceNoteMaximumDurationSeconds + + public private(set) var state: State = .idle + public private(set) var elapsedSeconds: TimeInterval = 0 + + @ObservationIgnored public var onRecordingActiveChanged: (@MainActor (Bool) -> Void)? + + @ObservationIgnored private let capture: any VoiceNoteAudioCapture + @ObservationIgnored private let durationLimit: TimeInterval + @ObservationIgnored private let timerIntervalNanoseconds: UInt64 + @ObservationIgnored private let now: () -> Date + @ObservationIgnored private var timerTask: Task? + + /// Creates a recorder backed by the system audio recorder. + public convenience init() { + self.init(capture: OpenClawVoiceNoteAudioCapture()) + } + + /// Creates a recorder with an injectable audio capture seam. + public init( + capture: any VoiceNoteAudioCapture, + durationLimit: TimeInterval = OpenClawVoiceNoteRecorder.maximumDurationSeconds, + timerIntervalNanoseconds: UInt64 = 100_000_000, + now: @escaping () -> Date = Date.init) + { + self.capture = capture + self.durationLimit = durationLimit + self.timerIntervalNanoseconds = timerIntervalNanoseconds + self.now = now + } + + deinit { + self.timerTask?.cancel() + } + + public var isRecording: Bool { + if case .recording = self.state { return true } + return false + } + + public var isRequestingPermission: Bool { + self.state == .requestingPermission + } + + public var errorMessage: String? { + guard case let .failed(message) = self.state else { return nil } + return message + } + + public var completedRecording: OpenClawVoiceNoteRecording? { + guard case let .finished(recording) = self.state else { return nil } + return recording + } + + /// Requests permission if needed and starts a new recording. + @discardableResult + public func start() async -> Bool { + guard self.state == .idle || self.errorMessage != nil else { return false } + + self.elapsedSeconds = 0 + self.state = .requestingPermission + guard await self.capture.requestPermission() else { + self.fail(message: String(localized: "Microphone access is required. Enable it in Settings.")) + return false + } + guard self.state == .requestingPermission else { return false } + + let fileURL = self.makeTemporaryFileURL() + self.onRecordingActiveChanged?(true) + do { + try self.capture.start(url: fileURL) + } catch { + try? FileManager.default.removeItem(at: fileURL) + self.capture.cancel() + self.onRecordingActiveChanged?(false) + self.fail(message: String(localized: "Could not start recording: \(error.localizedDescription)")) + return false + } + + self.state = .recording(startedAt: self.now(), fileURL: fileURL) + self.startTimer() + return true + } + + /// Finishes the active recording and publishes its attachment handoff. + @discardableResult + public func finish() -> OpenClawVoiceNoteRecording? { + guard case let .recording(_, fileURL) = self.state else { return nil } + + self.timerTask?.cancel() + self.timerTask = nil + let duration = max(0, self.capture.stop()) + let recording = OpenClawVoiceNoteRecording(fileURL: fileURL, durationSeconds: duration) + self.elapsedSeconds = duration + self.state = .finished(recording: recording) + self.onRecordingActiveChanged?(false) + return recording + } + + /// Cancels permission or capture and removes any temporary audio file. + public func cancel() { + let fileURL: URL? = switch self.state { + case let .recording(_, fileURL): + fileURL + case let .finished(recording): + recording.fileURL + default: + nil + } + + self.timerTask?.cancel() + self.timerTask = nil + self.capture.cancel() + if let fileURL { + try? FileManager.default.removeItem(at: fileURL) + } + let wasRecording = self.isRecording + self.elapsedSeconds = 0 + self.state = .idle + if wasRecording { + self.onRecordingActiveChanged?(false) + } + } + + /// Clears a completed handoff after the composer has staged it. + public func clearCompletedRecording() { + guard case .finished = self.state else { return } + self.state = .idle + self.elapsedSeconds = 0 + } + + private func startTimer() { + self.timerTask?.cancel() + self.timerTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: self.timerIntervalNanoseconds) + guard !Task.isCancelled else { return } + guard case let .recording(startedAt, _) = self.state else { return } + self.elapsedSeconds = max(0, self.now().timeIntervalSince(startedAt)) + if self.elapsedSeconds >= self.durationLimit { + self.finish() + return + } + } + } + } + + private func fail(message: String) { + self.timerTask?.cancel() + self.timerTask = nil + let wasRecording = self.isRecording + self.elapsedSeconds = 0 + self.state = .failed(message: message) + if wasRecording { + self.onRecordingActiveChanged?(false) + } + } + + private func makeTemporaryFileURL() -> URL { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd-HHmmss" + let timestamp = formatter.string(from: self.now()) + return FileManager.default.temporaryDirectory + .appendingPathComponent("voice-note-\(timestamp).m4a") + } +} + +/// AVAudioRecorder-backed AAC voice-note capture. +@MainActor +public final class OpenClawVoiceNoteAudioCapture: VoiceNoteAudioCapture { + private var recorder: AVAudioRecorder? + private var ownsAudioSession = false + + public init() {} + + public func requestPermission() async -> Bool { + #if os(iOS) + switch AVAudioApplication.shared.recordPermission { + case .granted: + return true + case .denied: + return false + case .undetermined: + return await withCheckedContinuation { continuation in + AVAudioApplication.requestRecordPermission { granted in + continuation.resume(returning: granted) + } + } + @unknown default: + return false + } + #else + return false + #endif + } + + public func start(url: URL) throws { + do { + #if os(iOS) + let session = AVAudioSession.sharedInstance() + try session.setCategory(.record, mode: .measurement, options: [.allowBluetoothHFP]) + try session.setActive(true) + self.ownsAudioSession = true + #endif + + let settings: [String: Any] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVSampleRateKey: 24000, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 32000, + AVEncoderAudioQualityKey: AVAudioQuality.medium.rawValue, + ] + let recorder = try AVAudioRecorder(url: url, settings: settings) + guard recorder.record() else { + throw NSError( + domain: "OpenClawVoiceNoteAudioCapture", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Audio recorder refused to start"]) + } + self.recorder = recorder + } catch { + self.deactivateAudioSession() + throw error + } + } + + public func stop() -> TimeInterval { + guard let recorder = self.recorder else { return 0 } + let duration = recorder.currentTime + recorder.stop() + self.recorder = nil + self.deactivateAudioSession() + return duration + } + + public func cancel() { + self.recorder?.stop() + self.recorder = nil + self.deactivateAudioSession() + } + + private func deactivateAudioSession() { + #if os(iOS) + guard self.ownsAudioSession else { return } + self.ownsAudioSession = false + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + #endif + } +} + +func openClawVoiceNoteDurationLabel(_ durationSeconds: TimeInterval) -> String { + guard durationSeconds.isFinite else { return "0:00" } + let boundedDuration = min( + max(0, durationSeconds), + voiceNoteMaximumDurationSeconds) + let totalSeconds = Int(boundedDuration) + return String(format: "%d:%02d", totalSeconds / 60, totalSeconds % 60) +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift index 74f1c902282..776bb16a6f1 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift @@ -6,7 +6,55 @@ import UniformTypeIdentifiers import XCTest @testable import OpenClawChatUI +private actor AttachmentSendCapture { + private(set) var attachments: [OpenClawChatAttachmentPayload] = [] + + func store(_ attachments: [OpenClawChatAttachmentPayload]) { + self.attachments = attachments + } + + func count() -> Int { + self.attachments.count + } + + func first() -> OpenClawChatAttachmentPayload? { + self.attachments.first + } +} + +private actor AttachmentHealthGate { + private var entered = false + private var released = false + private var continuation: CheckedContinuation? + + func wait() async { + self.entered = true + guard !self.released else { return } + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func hasEntered() -> Bool { + self.entered + } + + func release() { + self.released = true + self.continuation?.resume() + self.continuation = nil + } +} + private struct AttachmentProcessingTransport: OpenClawChatTransport { + let capture: AttachmentSendCapture? + let healthGate: AttachmentHealthGate? + + init(capture: AttachmentSendCapture? = nil, healthGate: AttachmentHealthGate? = nil) { + self.capture = capture + self.healthGate = healthGate + } + func requestHistory(sessionKey _: String) async throws -> OpenClawChatHistoryPayload { throw NSError(domain: "ChatViewModelAttachmentTests", code: 1) } @@ -15,13 +63,15 @@ private struct AttachmentProcessingTransport: OpenClawChatTransport { sessionKey _: String, message _: String, thinking _: String, - idempotencyKey _: String, - attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse + idempotencyKey: String, + attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse { - throw NSError(domain: "ChatViewModelAttachmentTests", code: 2) + await self.capture?.store(attachments) + return OpenClawChatSendResponse(runId: idempotencyKey, status: "started") } func requestHealth(timeoutMs _: Int) async throws -> Bool { + await self.healthGate?.wait() true } @@ -106,4 +156,160 @@ final class ChatViewModelAttachmentTests: XCTestCase { let errorText = await MainActor.run { viewModel.errorText } XCTAssertNil(errorText) } + + func testVoiceNoteAttachmentStagesAudioAndDeletesTemporaryFile() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("voice-note-20260706-120000.m4a") + let data = Data("voice-note-data".utf8) + try data.write(to: fileURL) + let viewModel = await MainActor.run { + OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport()) + } + + await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: 8.4) + + let attachment = try await MainActor.run { () throws -> (Data, String, String, String, Double?, Bool) in + let attachment = try XCTUnwrap(viewModel.attachments.first) + return ( + attachment.data, + attachment.fileName, + attachment.mimeType, + attachment.type, + attachment.durationSeconds, + attachment.preview == nil) + } + XCTAssertEqual(attachment.0, data) + XCTAssertEqual(attachment.1, "voice-note-20260706-120000.m4a") + XCTAssertEqual(attachment.2, "audio/mp4") + XCTAssertEqual(attachment.3, "file") + XCTAssertEqual(attachment.4, 8.4) + XCTAssertTrue(attachment.5) + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + } + + func testOversizeVoiceNoteIsRejectedAndDeleted() async throws { + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("voice-note-oversize.m4a") + try Data(repeating: 0x41, count: 5_000_001).write(to: fileURL) + let viewModel = await MainActor.run { + OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport()) + } + + await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: 180) + + let result = await MainActor.run { (viewModel.attachments.count, viewModel.errorText) } + XCTAssertEqual(result.0, 0) + XCTAssertEqual(result.1, "Voice note exceeds the 5 MB attachment limit") + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + } + + func testVoiceNoteSendUsesExistingAttachmentPayloadAndOptimisticDuration() async throws { + let capture = AttachmentSendCapture() + let transport = AttachmentProcessingTransport(capture: capture) + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("voice-note-20260706-120001.m4a") + let data = Data("encoded-voice-note".utf8) + try data.write(to: fileURL) + let viewModel = await MainActor.run { + OpenClawChatViewModel(sessionKey: "main", transport: transport) + } + + await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: 21.2) + await MainActor.run { viewModel.send() } + try await waitUntil("voice note sent") { + await capture.count() == 1 + } + + let capturedPayload = await capture.first() + let payload = try XCTUnwrap(capturedPayload) + XCTAssertEqual(payload.type, "file") + XCTAssertEqual(payload.mimeType, "audio/mp4") + XCTAssertEqual(payload.fileName, "voice-note-20260706-120001.m4a") + XCTAssertEqual(payload.content, data.base64EncodedString()) + + let optimisticAudio = await MainActor.run { + viewModel.messages.last?.content.first { $0.mimeType == "audio/mp4" } + } + XCTAssertEqual(optimisticAudio?.type, "file") + XCTAssertEqual(optimisticAudio?.mimeType, "audio/mp4") + XCTAssertEqual(optimisticAudio?.durationSeconds, 21.2) + } + + func testVoiceNoteSendKeepsCapturedDurationWhenDraftChangesDuringHealthCheck() async throws { + let capture = AttachmentSendCapture() + let healthGate = AttachmentHealthGate() + let transport = AttachmentProcessingTransport(capture: capture, healthGate: healthGate) + let draftAttachment = OpenClawPendingAttachment( + url: nil, + data: Data("draft-audio".utf8), + fileName: "draft.m4a", + mimeType: "audio/mp4", + preview: nil, + durationSeconds: 21.2) + let replacementAttachment = OpenClawPendingAttachment( + url: nil, + data: Data("replacement-audio".utf8), + fileName: "replacement.m4a", + mimeType: "audio/mp4", + preview: nil, + durationSeconds: 99) + let viewModel = await MainActor.run { + let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) + viewModel.attachments = [draftAttachment] + return viewModel + } + + await MainActor.run { viewModel.send() } + try await waitUntil("health check started") { + await healthGate.hasEntered() + } + await MainActor.run { + viewModel.removeAttachment(draftAttachment.id) + viewModel.attachments.append(replacementAttachment) + } + await healthGate.release() + try await waitUntil("voice note sent") { + await capture.count() == 1 + } + + let optimisticAudio = await MainActor.run { + viewModel.messages.last?.content.first { $0.mimeType == "audio/mp4" } + } + XCTAssertEqual(optimisticAudio?.fileName, "draft.m4a") + XCTAssertEqual(optimisticAudio?.durationSeconds, 21.2) + } + + @MainActor + func testCanonicalVoiceNotePreservesOptimisticDuration() throws { + let localAudio = OpenClawChatMessageContent( + type: "file", + text: nil, + mimeType: "audio/mp4", + fileName: "voice-note-local.m4a", + durationSeconds: 14.6, + content: AnyCodable("local")) + let canonicalAudio = OpenClawChatMessageContent( + type: "file", + text: nil, + mimeType: "audio/mp4", + fileName: "media-1.m4a", + content: AnyCodable("canonical")) + let existing = OpenClawChatMessage( + role: "user", + content: [localAudio], + timestamp: nil, + idempotencyKey: "run:user") + let incoming = OpenClawChatMessage( + role: "user", + content: [canonicalAudio], + timestamp: nil, + idempotencyKey: "run:user") + + let adopted = OpenClawChatViewModel.adoptingCanonicalMessage(incoming, over: existing) + + let audio = try XCTUnwrap(adopted.content.first) + XCTAssertEqual(audio.fileName, "media-1.m4a") + XCTAssertEqual(audio.content, AnyCodable("canonical")) + XCTAssertEqual(audio.durationSeconds, 14.6) + } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift new file mode 100644 index 00000000000..5178cd014e2 --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift @@ -0,0 +1,162 @@ +import Foundation +import XCTest +@testable import OpenClawChatUI + +@MainActor +private final class FakeVoiceNoteAudioCapture: VoiceNoteAudioCapture { + var permissionGranted = true + var duration: TimeInterval = 12.5 + var startError: Error? + var startCount = 0 + var cancelCount = 0 + var activeURL: URL? + var onStart: (() -> Void)? + + func requestPermission() async -> Bool { + self.permissionGranted + } + + func start(url: URL) throws { + self.onStart?() + if let startError { throw startError } + self.startCount += 1 + self.activeURL = url + try Data("voice-note".utf8).write(to: url) + } + + func stop() -> TimeInterval { + self.duration + } + + func cancel() { + self.cancelCount += 1 + } +} + +final class VoiceNoteRecorderTests: XCTestCase { + @MainActor + func testStartAndFinishProduceRecordingWithDuration() async throws { + let capture = FakeVoiceNoteAudioCapture() + let recorder = OpenClawVoiceNoteRecorder(capture: capture) + var activeChanges: [Bool] = [] + recorder.onRecordingActiveChanged = { activeChanges.append($0) } + capture.onStart = { XCTAssertEqual(activeChanges, [true]) } + + let started = await recorder.start() + XCTAssertTrue(started) + guard case .recording = recorder.state else { + return XCTFail("Expected recording state") + } + + let result = try XCTUnwrap(recorder.finish()) + XCTAssertEqual(result.durationSeconds, 12.5) + XCTAssertEqual(recorder.state, .finished(recording: result)) + XCTAssertEqual(recorder.completedRecording, result) + XCTAssertEqual(activeChanges, [true, false]) + XCTAssertTrue(FileManager.default.fileExists(atPath: result.fileURL.path)) + try FileManager.default.removeItem(at: result.fileURL) + } + + @MainActor + func testCancelReturnsToIdleAndDeletesTemporaryFile() async throws { + let capture = FakeVoiceNoteAudioCapture() + let recorder = OpenClawVoiceNoteRecorder(capture: capture) + + let started = await recorder.start() + XCTAssertTrue(started) + let fileURL = try XCTUnwrap(capture.activeURL) + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + + recorder.cancel() + + XCTAssertEqual(recorder.state, .idle) + XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) + XCTAssertEqual(capture.cancelCount, 1) + } + + @MainActor + func testDurationCapAutoFinishes() async throws { + let capture = FakeVoiceNoteAudioCapture() + capture.duration = 0.25 + let recorder = OpenClawVoiceNoteRecorder( + capture: capture, + durationLimit: 0, + timerIntervalNanoseconds: 1_000_000) + + let started = await recorder.start() + XCTAssertTrue(started) + try await waitUntil("voice note auto-finished") { + await MainActor.run { recorder.completedRecording != nil } + } + + let result = try XCTUnwrap(recorder.completedRecording) + XCTAssertEqual(result.durationSeconds, 0.25) + XCTAssertEqual(recorder.state, .finished(recording: result)) + try FileManager.default.removeItem(at: result.fileURL) + } + + @MainActor + func testPermissionDeniedBecomesUserVisibleFailure() async { + let capture = FakeVoiceNoteAudioCapture() + capture.permissionGranted = false + let recorder = OpenClawVoiceNoteRecorder(capture: capture) + + let started = await recorder.start() + XCTAssertFalse(started) + guard case let .failed(message) = recorder.state else { + return XCTFail("Expected failure state") + } + XCTAssertTrue(message.contains("Microphone access")) + XCTAssertEqual(capture.startCount, 0) + } + + @MainActor + func testCaptureStartFailureReleasesRecordingActivity() async { + let capture = FakeVoiceNoteAudioCapture() + capture.startError = NSError(domain: "VoiceNoteRecorderTests", code: 1) + let recorder = OpenClawVoiceNoteRecorder(capture: capture) + var activeChanges: [Bool] = [] + recorder.onRecordingActiveChanged = { activeChanges.append($0) } + + let started = await recorder.start() + + XCTAssertFalse(started) + XCTAssertEqual(activeChanges, [true, false]) + guard case .failed = recorder.state else { + return XCTFail("Expected failure state") + } + } + + @MainActor + func testStartIsRefusedWhileAlreadyRecording() async { + let capture = FakeVoiceNoteAudioCapture() + let recorder = OpenClawVoiceNoteRecorder(capture: capture) + + let firstStart = await recorder.start() + let secondStart = await recorder.start() + XCTAssertTrue(firstStart) + XCTAssertFalse(secondStart) + XCTAssertEqual(capture.startCount, 1) + recorder.cancel() + } + + func testDurationLabelBoundsMalformedHistoryValues() { + XCTAssertEqual(openClawVoiceNoteDurationLabel(.infinity), "0:00") + XCTAssertEqual(openClawVoiceNoteDurationLabel(.nan), "0:00") + XCTAssertEqual(openClawVoiceNoteDurationLabel(1e100), "3:00") + XCTAssertEqual(openClawVoiceNoteDurationLabel(-1), "0:00") + } + + @MainActor + func testRecordButtonRequiresAttachmentInput() { + let recorder = OpenClawVoiceNoteRecorder(capture: FakeVoiceNoteAudioCapture()) + let control = OpenClawChatVoiceNoteControl(recorder: recorder, isTalkActive: false) + let button = OpenClawVoiceNoteButton( + control: control, + compact: false, + isComposerEnabled: true, + isAttachmentInputEnabled: false) + + XCTAssertFalse(button.isRecordingEnabled) + } +}