Skip to content

Commit e86e8e4

Browse files
authored
Request microphone permission before starting audio capture (#1085)
webrtc-sdk/webrtc#265 removed the blocking mic permission request from the `AudioEngineDevice` pre-enable path. The ADM now does a passive authorization check and returns `kAudioEngineErrorInsufficientDevicePermission` (-9000) instead of prompting, so requesting permission is the SDK's job. That change shipped in `144.7559.12`, and main is now on **`150.7871.01`** (#1103, merged into this branch). So this is not precautionary: without it, microphone publishing fails on every fresh install, because `.notDetermined` is the state of a newly installed app and nothing prompts. Supersedes #1047. Credit to @silviudeac for the original foreground-gate helper there. ## Where the check goes `LocalAudioTrack.startCapture()` is the one async choke point every microphone track path reaches, and it is what actually opens ADM input via `AudioManager.startLocalRecording`. Publishing arrives through `Track.start()`, pre-connect recording through `LocalAudioTrackRecorder.start()`. ```swift override func startCapture() async throws { let needsMicrophonePermission = await RTC.run { !AudioManager.shared.isManualRenderingMode && AudioManager.shared.engineAvailability.isInputAvailable } if needsMicrophonePermission { try await LiveKitSDK.ensureMicrophoneAccessForRecording() } ... } ``` Manual rendering mode never opens the microphone, and disabled input availability (the CallKit flow) defers opening it entirely, so neither needs permission. Both flags are read on the `@RTC` executor (#1101) since they wait on WebRTC's worker thread. It is the only gated entry point. Everything else either cannot prompt or should not. ## Call path coverage | Entry point | Gated | Behavior when not authorized | | --- | --- | --- | | `LocalParticipant.setMicrophone(enabled: true)` | Yes, via `_publish` and `Track.start()` | Prompts if active, else throws `.deviceAccessDenied` | | `LocalParticipant.set(source: .microphone, enabled: true)` | Yes, same path | Prompts if active, else throws `.deviceAccessDenied` | | `LocalParticipant.publish(audioTrack:)` with an app-created track | Yes, via `Track.start()` | Prompts if active, else throws `.deviceAccessDenied` | | `Track.start()` called directly on a `LocalAudioTrack` | Yes | Prompts if active, else throws `.deviceAccessDenied` | | `Room.withPreConnectAudio { }` | Yes, via `PreConnectAudioBuffer` and `LocalAudioTrackRecorder.start()` | Prompts if active, else throws `.deviceAccessDenied` | | `PreConnectAudioBuffer.startRecording()` | Yes, same path | Prompts if active, else throws `.deviceAccessDenied` | | `LocalAudioTrackRecorder.start()` standalone | Yes | Prompts if active, else throws `.deviceAccessDenied` | | Manual rendering mode (`setManualRenderingMode(true)` then `setMicrophone(enabled: true)`) | No, never touches the mic | Publishes app audio with no permission needed | | `AudioManager.setRecordingAlwaysPreparedMode(true)` | No, prewarming is not user-initiated | Fails fast, ADM returns -9000 mapped to `.deviceAccessDenied` | | `AudioManager.startLocalRecording(_:)` | No, synchronous so it cannot prompt | Fails fast, -9000 mapped | | Publish while `setEngineAvailability(.none)` (CallKit, #815) | Exempt, input never opens | Publishes with input deferred; permission applies when availability is restored | | `AudioManager.setEngineAvailability(_:)` restoring input | No, synchronous so it cannot prompt | Fails fast, -9000 mapped | | `LocalAudioTrack.mute()` and `unmute()` | Not applicable | `_unmute` only restarts video tracks, so mic capture is never reopened | | WebRTC's implicit `InitRecording` on sender attach | Already gated | `_publish` calls `Track.start()` before `addTransceiver` | | `AudioMixRecorder` | Not applicable | No ADM input path | The ungated `AudioManager` calls already surface -9000 as `.deviceAccessDenied` through `checkAdmResult`, and their doc comments now point at `LiveKitSDK.ensureDeviceAccess(for:)` for callers driving the ADM directly. ## Reading the application state The SDK only prompts while the app can actually present the alert. `UIApplication.shared` cannot be referenced here: it does not compile under `APPLICATION_EXTENSION_API_ONLY=YES`, which CI exercises, and consumers build this module into broadcast upload extensions via `LKSampleHandler`. Annotating the reader with `@available(iOSApplicationExtension, unavailable)` compiles, but availability propagates to callers and `startCapture` has to stay extension-available, so it cannot be reached from there. So the state is read through the Objective-C runtime, behind an app-extension check. This is the same shape Google's GoogleUtilities uses in `GULAppDelegateSwizzler.sharedApplication`, which underpins Firebase: extension guard first, then `respondsToSelector:` before a dynamically resolved `sharedApplication`. That precedent ships in a large share of App Store apps, so the dynamic lookup is not an App Review concern. The gate is `== .active`. An inactive app (locked screen, call banner, app switcher, or launch before the scene activates) has the alert deferred rather than presented, and `requestAccess` has no cancellation-aware continuation, so waiting there would suspend the caller for as long as the app stays inactive. Since `startCapture` runs inside `Participant._publishSerialRunner`, that suspension would also block camera and screen share publishing. Failing fast avoids it, and the next attempt prompts normally. The gate applies only to iOS-family devices: Mac Catalyst is excluded (like native macOS, the system presents the mic dialog regardless of frontmost state), matching the `targetEnvironment` exclusion `CameraCapturer` uses. ## Review fixes From @pblazej's review: - Manual rendering mode is no longer gated. `Docs/audio.md` documents it as publishing app audio without touching the microphone, so it must not require permission. This restores a condition the pre-#793 implementation had. - The gate narrowed from `!= .background` to `== .active`, per above. A timeout was considered and rejected, since it would still hold the publish serial runner for the length of the timeout before failing. - `setRecordingAlwaysPreparedMode` is no longer gated. `LocalMedia.observeDevices()` calls it from `init`, so gating it raised a permission dialog on view-model construction and silently lost the prewarm when it failed. - The `republishAllTracks()` concern is resolved by merging main: #1008 rewrote it with a per-track `do/catch` that records the first error and rethrows only after the loop, so camera and screen share are republished even when audio fails. Round 2, from a deeper review pass: - The gate also exempts disabled input availability, restoring the CallKit `setEngineAvailability(.none)` flow where the ADM skips its permission check and defers input (verified against `m150_release`). Both flag reads moved onto the `@RTC` executor per #1101. - Mac Catalyst is excluded from the foreground gate, per above. - Doc corrections: `withPreConnectAudio` no longer recommends calling at app launch (the scene is typically still inactive there), and `setRecordingAlwaysPreparedMode`'s throw is conditioned on the ADM reporting the missing permission (its passive check is skipped in some mute modes). A follow-up branch makes the permission wait cancellation-aware so an unanswered prompt cannot wedge `stop`/`unpublish`/`disconnect`; it will be a separate PR on top of this one. ## Verification - `swift build` and `swift build -Xswiftc -application-extension` pass - `xcodebuild -destination 'generic/platform=iOS Simulator' APPLICATION_EXTENSION_API_ONLY=YES` exits 0, mirroring the CI matrix leg - `AudioManagerAdmResultTests` passes, `swiftlint` clean on the touched files Two caveats for reviewers: - **No new test coverage.** The gate depends on process and application state that is not reachable from a unit test without a seam. Happy to add one if wanted. - **Residual suspension.** If the app is active, the alert is presented, and the user then backgrounds without answering, the request stays suspended until they return and answer. That self-heals, unlike the inactive case this PR removes. ## Follow-up `Docs/audio.md` should mention that microphone permission is now requested by the SDK at capture start, and that apps wanting the prompt earlier should call `LiveKitSDK.ensureDeviceAccess(for:)`. Refs #815, CLT-3243
1 parent ae75761 commit e86e8e4

5 files changed

Lines changed: 113 additions & 4 deletions

File tree

.changes/mic-permission-foreground

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="fixed" "Request microphone permission while foregrounded before enabling recording"

Sources/LiveKit/Audio/Manager/AudioManager.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,9 +430,12 @@ public class AudioManager: Loggable {
430430
/// - Parameter enabled: Pass `true` to enable always-prepared recording, or `false` to disable it.
431431
/// - Parameter audioProcessingOptions: Optional voice-processing options used when prewarming mic input.
432432
/// - Note: If `audioSession.isAutomaticConfigurationEnabled` is `true`, the session category is configured to `.playAndRecord`.
433-
/// - Note: Microphone permission is required. iOS may prompt if not already granted.
433+
/// - Note: Microphone permission is required to enable, but is not requested here: prewarming is not a
434+
/// user-initiated capture, so prompting from it would surprise the user. Request it up front with
435+
/// ``LiveKitSDK/ensureDeviceAccess(for:)``, otherwise this throws ``LiveKitError`` of type
436+
/// ``LiveKitErrorType/deviceAccessDenied`` when the audio device module reports the missing permission.
434437
/// - Note: This persists across ``Room`` lifecycles and connections until disabled.
435-
/// - Throws: An error if the underlying audio device module fails to apply the setting.
438+
/// - Throws: An error if microphone permission is not granted, or if the underlying audio device module fails to apply the setting.
436439
public func setRecordingAlwaysPreparedMode(
437440
_ enabled: Bool,
438441
audioProcessingOptions: AudioProcessingOptions? = nil,
@@ -449,6 +452,9 @@ public class AudioManager: Loggable {
449452

450453
/// Starts mic input to the SDK even without any ``Room`` or a connection.
451454
/// Audio buffers will flow into ``LocalAudioTrack/add(audioRenderer:)`` and ``capturePostProcessingDelegate``.
455+
///
456+
/// - Note: Being synchronous, this cannot request microphone permission. Ensure permission is granted first (for example via
457+
/// ``LiveKitSDK/ensureDeviceAccess(for:)``), otherwise it throws ``LiveKitError`` of type ``LiveKitErrorType/deviceAccessDenied``.
452458
public func startLocalRecording(audioProcessingOptions: AudioProcessingOptions? = nil) throws {
453459
updateExpectedPlatformVoiceProcessing(for: audioProcessingOptions)
454460
// Always unmute APM if muted by last session.
@@ -481,6 +487,11 @@ public class AudioManager: Loggable {
481487
/// This is useful when you need to set up connections without touching the audio
482488
/// device yet (e.g., CallKit flows), or to guarantee the engine remains off
483489
/// regardless of subscription/publication requests.
490+
///
491+
/// - Note: Microphone permission is not requested here. If recording was requested while input
492+
/// was unavailable, ensure permission is granted (for example via
493+
/// ``LiveKitSDK/ensureDeviceAccess(for:)``) before restoring input availability, otherwise
494+
/// this throws ``LiveKitError`` of type ``LiveKitErrorType/deviceAccessDenied``.
484495
public func setEngineAvailability(_ availability: AudioEngineAvailability) throws {
485496
let result = RTC.audioDeviceModule.setEngineAvailability(availability.toRTCType())
486497
try checkAdmResult(code: result)

Sources/LiveKit/Core/Room+PreConnect.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public extension Room {
4343
/// ```
4444
///
4545
/// - See: ``PreConnectAudioBuffer``
46-
/// - Important: Call ``AudioManager/setRecordingAlwaysPreparedMode(_:)`` during app launch sequence to request microphone permissions early.
46+
/// - Important: Requires microphone permission. It is requested automatically while the app is active, so call this once the app has become active and the system prompt can appear. At app launch the scene may not be active yet, so when calling this early, request permission first with ``LiveKitSDK/ensureDeviceAccess(for:)``. Otherwise it throws ``LiveKitError`` of type ``LiveKitErrorType/deviceAccessDenied``.
4747
///
4848
func withPreConnectAudio<T>(timeout: TimeInterval = PreConnectAudioBuffer.Constants.timeout,
4949
_ operation: @Sendable @escaping () async throws -> T,

Sources/LiveKit/LiveKit+DeviceHelpers.swift

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,35 @@
1616

1717
import AVFoundation
1818

19+
#if canImport(UIKit) && (os(iOS) || os(visionOS) || os(tvOS)) && !targetEnvironment(macCatalyst)
20+
import UIKit
21+
#endif
22+
23+
// Whether this process is an app extension, which cannot present a permission dialog. The broadcast
24+
// upload extension the SDK supports (see `LKSampleHandler`) is headless, so no prompt can appear.
25+
// The `.appex` bundle suffix is Apple's documented way to detect an extension process.
26+
private let kIsAppExtension = Bundle.main.bundleURL.pathExtension == "appex"
27+
28+
#if canImport(UIKit) && (os(iOS) || os(visionOS) || os(tvOS)) && !targetEnvironment(macCatalyst)
29+
// Resolves `UIApplication.shared` through the Objective-C runtime because referencing it directly
30+
// does not compile with APPLICATION_EXTENSION_API_ONLY=YES, and consumers build this module into
31+
// broadcast upload extensions. An extension process is prohibited from calling `sharedApplication`,
32+
// so callers must be guarded by `kIsAppExtension`. The selector itself would resolve there too.
33+
@MainActor
34+
private func isApplicationForegrounded() -> Bool {
35+
let selector = NSSelectorFromString("sharedApplication")
36+
guard UIApplication.responds(to: selector),
37+
let shared = UIApplication.perform(selector),
38+
let application = shared.takeUnretainedValue() as? UIApplication
39+
else { return false }
40+
// Only .active can actually present the alert. While .inactive (locked screen, call banner, app
41+
// switcher, or launch before the scene activates) the system defers it, and requestAccess has no
42+
// cancellation-aware continuation, so waiting there would suspend the caller for as long as the
43+
// app stays inactive. Failing fast instead lets the next attempt prompt normally.
44+
return application.applicationState == .active
45+
}
46+
#endif
47+
1948
public extension LiveKitSDK {
2049
/// Helper method to ensure authorization for video(camera) / audio(microphone) permissions in a single call.
2150
static func ensureDeviceAccess(for types: Set<AVMediaType>) async -> Bool {
@@ -27,7 +56,12 @@ public extension LiveKitSDK {
2756
let status = AVCaptureDevice.authorizationStatus(for: type)
2857
switch status {
2958
case .notDetermined:
30-
if await !(AVCaptureDevice.requestAccess(for: type)) {
59+
// Explicit continuation instead of the synthesized async overload, which hits a
60+
// mixed Swift 5/6 thunk-coalescing crash before Swift 6.3 (swiftlang/swift#81846).
61+
let granted = await withCheckedContinuation { continuation in
62+
AVCaptureDevice.requestAccess(for: type) { continuation.resume(returning: $0) }
63+
}
64+
if !granted {
3165
return false
3266
}
3367
case .restricted, .denied: return false
@@ -82,3 +116,55 @@ public extension LiveKitSDK {
82116
return granted
83117
}
84118
}
119+
120+
extension LiveKitSDK {
121+
/// Requests authorization for the given media types, but only while the app can present the
122+
/// system permission dialog.
123+
///
124+
/// An app extension always returns `false` without prompting, since it cannot present the dialog.
125+
///
126+
/// On iOS-family platforms this also returns `false` without prompting unless the app is active, so
127+
/// a caller woken in the background (for example by CallKit) does not wait on an alert that cannot
128+
/// appear. An inactive app is treated the same way, since the system defers the alert there and
129+
/// waiting would suspend the caller. On macOS and Mac Catalyst the prompt can be presented
130+
/// regardless, so this otherwise behaves like ``ensureDeviceAccess(for:)``.
131+
static func ensureDeviceAccessIfForegrounded(for types: Set<AVMediaType>) async -> Bool {
132+
if kIsAppExtension { return false }
133+
#if canImport(UIKit) && (os(iOS) || os(visionOS) || os(tvOS)) && !targetEnvironment(macCatalyst)
134+
guard await isApplicationForegrounded() else { return false }
135+
#endif
136+
return await ensureDeviceAccess(for: types)
137+
}
138+
139+
/// Ensures microphone access is granted before enabling recording.
140+
///
141+
/// The WebRTC audio device no longer requests microphone permission implicitly, so the SDK
142+
/// requests it here while the app is foregrounded. When permission is undetermined and the app
143+
/// cannot present the prompt (backgrounded or app extension), this fails fast instead of blocking.
144+
///
145+
/// - Throws: ``LiveKitError`` of type ``LiveKitErrorType/deviceAccessDenied`` when microphone
146+
/// access is denied or restricted, or when permission is undetermined and cannot be requested.
147+
static func ensureMicrophoneAccessForRecording() async throws {
148+
switch AVCaptureDevice.authorizationStatus(for: .audio) {
149+
case .authorized:
150+
return
151+
case .notDetermined:
152+
guard await ensureDeviceAccessIfForegrounded(for: [.audio]) else {
153+
// Distinguish "could not present the prompt" from "the user denied it".
154+
if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined {
155+
if kIsAppExtension {
156+
throw LiveKitError(.deviceAccessDenied,
157+
message: "Microphone permission cannot be requested from an app extension. Request it in the host app before enabling recording.")
158+
}
159+
throw LiveKitError(.deviceAccessDenied,
160+
message: "Microphone permission could not be requested. Request it while the app is in the foreground before enabling recording.")
161+
}
162+
throw LiveKitError(.deviceAccessDenied, message: "Microphone permission was denied.")
163+
}
164+
case .denied, .restricted:
165+
throw LiveKitError(.deviceAccessDenied, message: "Microphone permission is not granted.")
166+
@unknown default:
167+
throw LiveKitError(.deviceAccessDenied, message: "Microphone permission is not granted.")
168+
}
169+
}
170+
}

Sources/LiveKit/Track/Local/LocalAudioTrack.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,17 @@ public class LocalAudioTrack: Track, LocalTrackProtocol, AudioTrackProtocol, @un
142142
// MARK: - Internal
143143

144144
override func startCapture() async throws {
145+
// The WebRTC audio device no longer prompts for mic permission (see webrtc-sdk#265),
146+
// so request it here while foregrounded before starting recording. Manual rendering mode
147+
// publishes app audio without ever opening the microphone, and disabled input availability
148+
// (CallKit flows, see setEngineAvailability) defers opening it entirely, so neither needs
149+
// permission. Reading these flags waits on WebRTC's worker thread, hence the RTC hop.
150+
let needsMicrophonePermission = await RTC.run {
151+
!AudioManager.shared.isManualRenderingMode && AudioManager.shared.engineAvailability.isInputAvailable
152+
}
153+
if needsMicrophonePermission {
154+
try await LiveKitSDK.ensureMicrophoneAccessForRecording()
155+
}
145156
// AudioDeviceModule's InitRecording() and StartRecording() automatically get called by WebRTC, but
146157
// explicitly init & start it early to detect audio engine failures (mic not accessible for some reason, etc.).
147158
let audioProcessingOptions = captureOptions.audioProcessing

0 commit comments

Comments
 (0)