diff --git a/.changes/check-recording-perms b/.changes/check-recording-perms new file mode 100644 index 000000000..22c55fb6a --- /dev/null +++ b/.changes/check-recording-perms @@ -0,0 +1 @@ +patch type="added" "Recording permission check at SDK level" diff --git a/Sources/LiveKit/Audio/AudioDeviceModuleDelegateAdapter.swift b/Sources/LiveKit/Audio/AudioDeviceModuleDelegateAdapter.swift index e5598026f..a0e59222d 100644 --- a/Sources/LiveKit/Audio/AudioDeviceModuleDelegateAdapter.swift +++ b/Sources/LiveKit/Audio/AudioDeviceModuleDelegateAdapter.swift @@ -19,7 +19,7 @@ import Foundation internal import LiveKitWebRTC // Invoked on WebRTC's worker thread, do not block. -class AudioDeviceModuleDelegateAdapter: NSObject, LKRTCAudioDeviceModuleDelegate { +class AudioDeviceModuleDelegateAdapter: NSObject, LKRTCAudioDeviceModuleDelegate, Loggable { weak var audioManager: AudioManager? func audioDeviceModule(_: LKRTCAudioDeviceModule, didReceiveSpeechActivityEvent speechActivityEvent: LKRTCSpeechActivityEvent) { @@ -43,7 +43,31 @@ class AudioDeviceModuleDelegateAdapter: NSObject, LKRTCAudioDeviceModuleDelegate func audioDeviceModule(_: LKRTCAudioDeviceModule, willEnableEngine engine: AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) -> Int { guard let audioManager else { return 0 } let entryPoint = audioManager.buildEngineObserverChain() - return entryPoint?.engineWillEnable(engine, isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) ?? 0 + let result = entryPoint?.engineWillEnable(engine, isPlayoutEnabled: isPlayoutEnabled, isRecordingEnabled: isRecordingEnabled) ?? 0 + + // At this point mic perms / session should be configured for recording. + if result == 0, isRecordingEnabled { + // This will block WebRTC's worker thread, but when instantiating AVAudioInput node it will block by showing a dialog anyways. + // Attempt to acquire mic perms at this point to return an error at SDK level. + let isAuthorized = LiveKitSDK.ensureDeviceAccessSync(for: [.audio]) + log("AudioEngine pre-enable check, device permission: \(isAuthorized)") + if !isAuthorized { + return kAudioEngineErrorInsufficientDevicePermission + } + + #if os(iOS) || os(visionOS) || os(tvOS) + // Additional check for audio session category. + let session = LKRTCAudioSession.sharedInstance() + log("AudioEngine pre-enable check, audio session: \(session.category)") + if ![AVAudioSession.Category.playAndRecord.rawValue, + AVAudioSession.Category.record.rawValue].contains(session.category) + { + return kAudioEngineErrorAudioSessionCategoryRecordingRequired + } + #endif + } + + return result } func audioDeviceModule(_: LKRTCAudioDeviceModule, willStartEngine engine: AVAudioEngine, isPlayoutEnabled: Bool, isRecordingEnabled: Bool) -> Int { diff --git a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift index 6ec8d1290..a01ecab59 100644 --- a/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift +++ b/Sources/LiveKit/Audio/AudioSessionEngineObserver.swift @@ -14,8 +14,6 @@ * limitations under the License. */ -let kFailedToConfigureAudioSessionErrorCode = -4100 - #if os(iOS) || os(visionOS) || os(tvOS) import AVFoundation diff --git a/Sources/LiveKit/Audio/Manager/AudioManager.swift b/Sources/LiveKit/Audio/Manager/AudioManager.swift index 3b4b9f6aa..edf67b245 100644 --- a/Sources/LiveKit/Audio/Manager/AudioManager.swift +++ b/Sources/LiveKit/Audio/Manager/AudioManager.swift @@ -288,7 +288,7 @@ public class AudioManager: Loggable { /// Keep recording initialized (mic input) and pre-warm voice processing etc. /// Mic permission is required and dialog will appear if not already granted. /// This will per persisted accross Rooms and connections. - public func setRecordingAlwaysPreparedMode(_ enabled: Bool) throws { + public func setRecordingAlwaysPreparedMode(_ enabled: Bool) async throws { let result = RTC.audioDeviceModule.setRecordingAlwaysPreparedMode(enabled) try checkAdmResult(code: result) } @@ -405,10 +405,18 @@ extension AudioManager { } } +// SDK side AudioEngine error codes +let kAudioEngineErrorFailedToConfigureAudioSession = -4100 + +let kAudioEngineErrorInsufficientDevicePermission = -4101 +let kAudioEngineErrorAudioSessionCategoryRecordingRequired = -4102 + extension AudioManager { func checkAdmResult(code: Int) throws { - if code == kFailedToConfigureAudioSessionErrorCode { + if code == kAudioEngineErrorFailedToConfigureAudioSession { throw LiveKitError(.audioSession, message: "Failed to configure audio session") + } else if code == kAudioEngineErrorInsufficientDevicePermission { + throw LiveKitError(.deviceAccessDenied, message: "Device permissions are not granted") } else if code != 0 { throw LiveKitError(.audioEngine, message: "Audio engine returned error code: \(code)") } diff --git a/Sources/LiveKit/LiveKit+DeviceHelpers.swift b/Sources/LiveKit/LiveKit+DeviceHelpers.swift index 587e3e42e..359220a1b 100644 --- a/Sources/LiveKit/LiveKit+DeviceHelpers.swift +++ b/Sources/LiveKit/LiveKit+DeviceHelpers.swift @@ -40,4 +40,40 @@ public extension LiveKitSDK { return true } + + /// Blocking version of ensureDeviceAccess that uses DispatchGroup to wait for permissions. + static func ensureDeviceAccessSync(for types: Set) -> Bool { + let group = DispatchGroup() + var result = true + + for type in types { + if ![.video, .audio].contains(type) { + logger.log("types must be .video or .audio", .error, type: LiveKitSDK.self) + } + + let status = AVCaptureDevice.authorizationStatus(for: type) + switch status { + case .notDetermined: + group.enter() + AVCaptureDevice.requestAccess(for: type) { granted in + if !granted { + result = false + } + group.leave() + } + case .restricted, .denied: + return false + case .authorized: + continue // No action needed for authorized status + @unknown default: + logger.error("Unknown AVAuthorizationStatus") + return false + } + } + + // Wait for all permission requests to complete + group.wait() + + return result + } } diff --git a/Sources/LiveKit/Track/Local/LocalAudioTrack.swift b/Sources/LiveKit/Track/Local/LocalAudioTrack.swift index a0986193a..86d228751 100644 --- a/Sources/LiveKit/Track/Local/LocalAudioTrack.swift +++ b/Sources/LiveKit/Track/Local/LocalAudioTrack.swift @@ -93,14 +93,7 @@ public class LocalAudioTrack: Track, LocalTrack, AudioTrack, @unchecked Sendable override func startCapture() async throws { // AudioDeviceModule's InitRecording() and StartRecording() automatically get called by WebRTC, but // explicitly init & start it early to detect audio engine failures (mic not accessible for some reason, etc.). - do { - try AudioManager.shared.startLocalRecording() - } catch { - // Make sure internal state is updated to stopped state. (TODO: Remove if ADM reverts state automatically) - try? AudioManager.shared.stopLocalRecording() - // Rethrow - throw error - } + try AudioManager.shared.startLocalRecording() } override func stopCapture() async throws { diff --git a/Tests/LiveKitTests/AudioEngineTests.swift b/Tests/LiveKitTests/AudioEngineTests.swift index 827aba258..a27bde147 100644 --- a/Tests/LiveKitTests/AudioEngineTests.swift +++ b/Tests/LiveKitTests/AudioEngineTests.swift @@ -68,7 +68,7 @@ class AudioEngineTests: LKTestCase, @unchecked Sendable { XCTAssert(!adm.isRecordingInitialized) // Ensure recording is initialized after set to true. - try adm.setRecordingAlwaysPreparedMode(true) + try await adm.setRecordingAlwaysPreparedMode(true) #if os(iOS) let session = AVAudioSession.sharedInstance()