diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 3025fcd6aa13..fc59927f242d 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -19542,7 +19542,7 @@ "sites": [ { "kind": "ui-localized-call", - "path": "apps/ios/Sources/Voice/TalkModeManager+Permissions.swift" + "path": "apps/ios/Sources/Voice/VoicePermissionSupport.swift" }, { "kind": "ui-localized-call", @@ -19557,7 +19557,7 @@ "sites": [ { "kind": "ui-localized-call", - "path": "apps/ios/Sources/Voice/TalkModeManager+Permissions.swift" + "path": "apps/ios/Sources/Voice/VoicePermissionSupport.swift" }, { "kind": "ui-localized-call", @@ -19572,11 +19572,7 @@ "sites": [ { "kind": "ui-localized-call", - "path": "apps/ios/Sources/Voice/TalkModeManager+Permissions.swift" - }, - { - "kind": "ui-localized-call", - "path": "apps/ios/Sources/Voice/VoiceWakeManager.swift" + "path": "apps/ios/Sources/Voice/VoicePermissionSupport.swift" } ] }, diff --git a/apps/ios/Sources/Voice/TalkModeManager.swift b/apps/ios/Sources/Voice/TalkModeManager.swift index 5c79a8cd7e05..535668883e11 100644 --- a/apps/ios/Sources/Voice/TalkModeManager.swift +++ b/apps/ios/Sources/Voice/TalkModeManager.swift @@ -695,7 +695,7 @@ final class TalkModeManager: NSObject { let micOk = if self.allowSimulatorCapture { true } else { - await Self.requestMicrophonePermission() + await VoicePermissionSupport.requestMicrophonePermission(timeoutErrorDomain: "TalkMode") } GatewayDiagnostics.log( "talk.timeline microphone permission ok=\(micOk) " @@ -734,13 +734,13 @@ final class TalkModeManager: NSObject { let speechOk = if self.allowSimulatorCapture { true } else { - await Self.requestSpeechPermission() + await VoicePermissionSupport.requestSpeechPermission(timeoutErrorDomain: "TalkMode") } guard speechOk else { self.logger.warning("start blocked: speech permission denied") self.stopNativeCaptureAndDiscardTranscript() deactivateAudioSession() - let status = Self.permissionMessage( + let status = VoicePermissionSupport.speechPermissionMessage( kind: String(localized: "Speech recognition"), status: SFSpeechRecognizer.authorizationStatus()) self.setStatus( @@ -1278,7 +1278,7 @@ final class TalkModeManager: NSObject { { guard !self.allowSimulatorCapture else { return } - let micOk = await Self.requestMicrophonePermission() + let micOk = await VoicePermissionSupport.requestMicrophonePermission(timeoutErrorDomain: "TalkMode") try self.ensurePushToTalkStartCurrent(captureId: captureId, canStartCapture: canStartCapture) guard micOk else { self.setStatus( @@ -1290,10 +1290,10 @@ final class TalkModeManager: NSObject { ]) } - let speechOk = await Self.requestSpeechPermission() + let speechOk = await VoicePermissionSupport.requestSpeechPermission(timeoutErrorDomain: "TalkMode") try self.ensurePushToTalkStartCurrent(captureId: captureId, canStartCapture: canStartCapture) guard speechOk else { - let status = Self.permissionMessage( + let status = VoicePermissionSupport.speechPermissionMessage( kind: String(localized: "Speech recognition"), status: SFSpeechRecognizer.authorizationStatus()) self.setStatus( diff --git a/apps/ios/Sources/Voice/TalkModeManager+Permissions.swift b/apps/ios/Sources/Voice/VoicePermissionSupport.swift similarity index 73% rename from apps/ios/Sources/Voice/TalkModeManager+Permissions.swift rename to apps/ios/Sources/Voice/VoicePermissionSupport.swift index f5b624be3ea5..cf327b190d46 100644 --- a/apps/ios/Sources/Voice/TalkModeManager+Permissions.swift +++ b/apps/ios/Sources/Voice/VoicePermissionSupport.swift @@ -3,25 +3,23 @@ import Foundation import OpenClawKit import Speech -extension TalkModeManager { - nonisolated static func requestMicrophonePermission() async -> Bool { +enum VoicePermissionSupport { + static func requestMicrophonePermission(timeoutErrorDomain: String) async -> Bool { switch AVAudioApplication.shared.recordPermission { case .granted: return true case .denied: return false case .undetermined: - return await self.requestPermissionWithTimeout { completion in - AVAudioApplication.requestRecordPermission(completionHandler: { ok in - completion(ok) - }) + return await self.requestPermissionWithTimeout(errorDomain: timeoutErrorDomain) { completion in + AVAudioApplication.requestRecordPermission(completionHandler: completion) } @unknown default: return false } } - nonisolated static func requestSpeechPermission() async -> Bool { + static func requestSpeechPermission(timeoutErrorDomain: String) async -> Bool { let status = SFSpeechRecognizer.authorizationStatus() switch status { case .authorized: @@ -34,29 +32,14 @@ extension TalkModeManager { return false } - return await self.requestPermissionWithTimeout { completion in + return await self.requestPermissionWithTimeout(errorDomain: timeoutErrorDomain) { completion in SFSpeechRecognizer.requestAuthorization { authStatus in completion(authStatus == .authorized) } } } - private nonisolated static func requestPermissionWithTimeout( - _ operation: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool - { - do { - return try await AsyncTimeout.withTimeout( - seconds: 8, - onTimeout: { NSError(domain: "TalkMode", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "permission request timed out", - ]) }, - operation: { await PermissionRequestBridge.awaitRequest(operation) }) - } catch { - return false - } - } - - static func permissionMessage( + static func speechPermissionMessage( kind: String, status: SFSpeechRecognizerAuthorizationStatus) -> String { @@ -83,4 +66,20 @@ extension TalkModeManager { kind) } } + + private static func requestPermissionWithTimeout( + errorDomain: String, + operation: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool + { + do { + return try await AsyncTimeout.withTimeout( + seconds: 8, + onTimeout: { NSError(domain: errorDomain, code: 6, userInfo: [ + NSLocalizedDescriptionKey: "permission request timed out", + ]) }, + operation: { await PermissionRequestBridge.awaitRequest(operation) }) + } catch { + return false + } + } } diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift index c841013ae8b0..03a9e59c642e 100644 --- a/apps/ios/Sources/Voice/VoiceWakeManager.swift +++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift @@ -1,7 +1,6 @@ import AVFAudio import Foundation import Observation -import OpenClawKit import Speech import SwabbleKit @@ -279,7 +278,7 @@ final class VoiceWakeManager: NSObject { self.statusText = String(localized: "Requesting permissions…") - let micOk = await Self.requestMicrophonePermission() + let micOk = await VoicePermissionSupport.requestMicrophonePermission(timeoutErrorDomain: "VoiceWake") guard micOk else { self.statusText = Self.microphonePermissionMessage( kind: String(localized: "Microphone")) @@ -287,9 +286,9 @@ final class VoiceWakeManager: NSObject { return } - let speechOk = await Self.requestSpeechPermission() + let speechOk = await VoicePermissionSupport.requestSpeechPermission(timeoutErrorDomain: "VoiceWake") guard speechOk else { - self.statusText = Self.permissionMessage( + self.statusText = VoicePermissionSupport.speechPermissionMessage( kind: String(localized: "Speech recognition"), status: SFSpeechRecognizer.authorizationStatus()) self.isListening = false @@ -549,23 +548,6 @@ final class VoiceWakeManager: NSObject { } } - private nonisolated static func requestMicrophonePermission() async -> Bool { - switch AVAudioApplication.shared.recordPermission { - case .granted: - return true - case .denied: - return false - case .undetermined: - break - @unknown default: - return false - } - - return await self.requestPermissionWithTimeout { completion in - AVAudioApplication.requestRecordPermission(completionHandler: completion) - } - } - private nonisolated static func microphonePermissionMessage(kind: String) -> String { let status = AVAudioApplication.shared.recordPermission return self.deniedByDefaultPermissionMessage( @@ -573,69 +555,6 @@ final class VoiceWakeManager: NSObject { isUndetermined: status == .undetermined) } - private nonisolated static func requestSpeechPermission() async -> Bool { - let status = SFSpeechRecognizer.authorizationStatus() - switch status { - case .authorized: - return true - case .denied, .restricted: - return false - case .notDetermined: - break - @unknown default: - return false - } - - return await self.requestPermissionWithTimeout { completion in - SFSpeechRecognizer.requestAuthorization { authStatus in - completion(authStatus == .authorized) - } - } - } - - private nonisolated static func requestPermissionWithTimeout( - _ operation: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool - { - do { - return try await AsyncTimeout.withTimeout( - seconds: 8, - onTimeout: { NSError(domain: "VoiceWake", code: 6, userInfo: [ - NSLocalizedDescriptionKey: "permission request timed out", - ]) }, - operation: { await PermissionRequestBridge.awaitRequest(operation) }) - } catch { - return false - } - } - - private static func permissionMessage( - kind: String, - status: SFSpeechRecognizerAuthorizationStatus) -> String - { - switch status { - case .denied: - return String( - format: String(localized: "%@ permission denied"), - kind) - case .restricted: - return String( - format: String(localized: "%@ permission restricted"), - kind) - case .notDetermined: - return String( - format: String(localized: "%@ permission not granted"), - kind) - case .authorized: - return String( - format: String(localized: "%@ permission denied"), - kind) - @unknown default: - return String( - format: String(localized: "%@ permission denied"), - kind) - } - } - private nonisolated static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String { if isUndetermined { return String( diff --git a/apps/ios/Tests/VoicePermissionSupportTests.swift b/apps/ios/Tests/VoicePermissionSupportTests.swift new file mode 100644 index 000000000000..45d8bbd67f19 --- /dev/null +++ b/apps/ios/Tests/VoicePermissionSupportTests.swift @@ -0,0 +1,19 @@ +import Speech +import Testing +@testable import OpenClaw + +struct VoicePermissionSupportTests { + @Test func `speech permission messages preserve authorization detail`() { + let kind = "Speech recognition" + let cases: [(SFSpeechRecognizerAuthorizationStatus, String)] = [ + (.denied, "Speech recognition permission denied"), + (.restricted, "Speech recognition permission restricted"), + (.notDetermined, "Speech recognition permission not granted"), + (.authorized, "Speech recognition permission denied"), + ] + + for (status, expected) in cases { + #expect(VoicePermissionSupport.speechPermissionMessage(kind: kind, status: status) == expected) + } + } +}