refactor(ios): share voice permission support (#120176)

This commit is contained in:
Vincent Koc
2026-08-26 08:30:06 +08:00
committed by GitHub
parent 37a32708c3
commit 2671eff4bf
5 changed files with 54 additions and 121 deletions
+3 -7
View File
@@ -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"
}
]
},
+6 -6
View File
@@ -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(
@@ -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
}
}
}
+3 -84
View File
@@ -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(
@@ -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)
}
}
}