mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(ios): record and send voice notes from the chat composer (#100946)
* feat(ios): record and send voice notes from the chat composer Adds tap-to-record voice notes to the shared chat composer: m4a/AAC mono capture with a 3-minute cap, lazy mic permission, cancel/finish controls, audio attachment chips with duration, and a voice-note transcript row. Voice-wake suppression becomes reason-scoped so recording and Talk cannot clobber each other. Attachments stay online-only per the outbox contract. Related: #100709 * fix(ios): harden voice note attachment staging * chore(ios): keep voice note changelog release-owned * fix(ios): gate voice notes with attachment availability * chore(ios): refresh native localization inventory
This commit is contained in:
parent
bb44c2311d
commit
b978dcc63b
18 changed files with 1224 additions and 103 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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<VoiceWakeSuppressionReason> = []
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Void, Never>?
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
|
@ -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<Void, Never>?
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue