Skip to content
1 change: 1 addition & 0 deletions .changes/check-recording-perms
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="added" "Recording permission check at SDK level"
28 changes: 26 additions & 2 deletions Sources/LiveKit/Audio/AudioDeviceModuleDelegateAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
2 changes: 0 additions & 2 deletions Sources/LiveKit/Audio/AudioSessionEngineObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
* limitations under the License.
*/

let kFailedToConfigureAudioSessionErrorCode = -4100

#if os(iOS) || os(visionOS) || os(tvOS)

import AVFoundation
Expand Down
12 changes: 10 additions & 2 deletions Sources/LiveKit/Audio/Manager/AudioManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,10 @@
// Keep this var within State so it's protected by UnfairLock
public var localTracksCount: Int = 0
public var remoteTracksCount: Int = 0
public var customConfigureFunc: ConfigureAudioSessionFunc?

Check warning on line 104 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-14, 15.4, macOS,variant=Mac Catalyst)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, macOS,variant=Mac Catalyst)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-14, 15.4, tvOS Simulator,name=Apple TV,OS=17.5)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, tvOS Simulator,name=Apple TV,OS=18.5)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-14, 15.4, iOS Simulator,name=iPhone 15 Pro,OS=17.5)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, iOS Simulator,name=iPhone 16 Pro,OS=18.5, true)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, iOS Simulator,name=iPhone 16 Pro,OS=18.5, true)

'ConfigureAudioSessionFunc' is deprecated

Check warning on line 104 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, visionOS Simulator,name=Apple Vision Pro,OS=2.5)

'ConfigureAudioSessionFunc' is deprecated
public var sessionConfiguration: AudioSessionConfiguration?

public var trackState: TrackState {

Check warning on line 107 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-14, 15.4, macOS,variant=Mac Catalyst)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, macOS,variant=Mac Catalyst)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-14, 15.4, tvOS Simulator,name=Apple TV,OS=17.5)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, tvOS Simulator,name=Apple TV,OS=18.5)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-14, 15.4, iOS Simulator,name=iPhone 15 Pro,OS=17.5)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, iOS Simulator,name=iPhone 16 Pro,OS=18.5, true)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, iOS Simulator,name=iPhone 16 Pro,OS=18.5, true)

'TrackState' is deprecated

Check warning on line 107 in Sources/LiveKit/Audio/Manager/AudioManager.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macos-15, 16.4, visionOS Simulator,name=Apple Vision Pro,OS=2.5)

'TrackState' is deprecated
switch (localTracksCount > 0, remoteTracksCount > 0) {
case (true, false): .localOnly
case (false, true): .remoteOnly
Expand Down Expand Up @@ -288,7 +288,7 @@
/// 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)
}
Expand Down Expand Up @@ -405,10 +405,18 @@
}
}

// 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)")
}
Expand Down
36 changes: 36 additions & 0 deletions Sources/LiveKit/LiveKit+DeviceHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AVMediaType>) -> 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
}
}
9 changes: 1 addition & 8 deletions Sources/LiveKit/Track/Local/LocalAudioTrack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Tests/LiveKitTests/AudioEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading