Request microphone permission before starting audio capture - #1085
Merged
Conversation
webrtc-sdk/webrtc#265 removed the blocking mic permission request from the AudioEngineDevice pre-enable path, so the ADM now returns kAudioEngineErrorInsufficientDevicePermission (-9000) instead of prompting. Requesting permission is the SDK's responsibility from 144.7559.12 onward. Trigger it in LocalAudioTrack.startCapture(), which every microphone path reaches: publishing goes through Track.start(), and pre-connect recording goes through LocalAudioTrackRecorder.start(). Audio mute and unmute do not re-run capture, so they stay untouched. The request is deliberately not awaited. AVCaptureDevice.requestAccess does not call back until the user answers, and when no dialog can be presented, for example because the app was woken in the background by CallKit, that answer never arrives. Waiting for it would reintroduce the hang the upstream change removed. So an undetermined status triggers the prompt, fails this attempt with .deviceAccessDenied, and the next attempt succeeds once permission is granted. Apps wanting the prompt resolved before the first publish should keep calling LiveKitSDK.ensureDeviceAccess(for:) while foregrounded. Gating on UIApplication.applicationState was considered and rejected. The SDK ships LKSampleHandler for broadcast upload extensions, and CI builds the target with APPLICATION_EXTENSION_API_ONLY=YES, where UIApplication.shared does not compile. Not waiting on the request removes the need to know the app state at all, so the protection is structural rather than conditional. The low-level AudioManager entry points (setEngineAvailability, startLocalRecording, setRecordingAlwaysPreparedMode) intentionally do not prompt. They already surface -9000 as .deviceAccessDenied through checkAdmResult, and callers driving the ADM directly should request access themselves. Supersedes #1047, which placed the request in LocalParticipant and so missed pre-connect audio and macOS, and which awaited the request behind an UIApplication.shared foreground check. Note for #1084: its `guard externalSource == nil else { return }` belongs above this permission check, so externally-fed tracks never request mic access. Refs #815, CLT-3243
The previous commit fired the permission request without awaiting it, so the first mic enable on a fresh install always failed and needed a retry. That was justified by the claim that awaiting would reintroduce the #815 hang, which is wrong: #815 blocked WebRTC's worker thread on a semaphore and wedged the audio subsystem, whereas awaiting here only suspends the calling task. startCapture() is async, so nothing is blocked by waiting. A caller that cannot present the dialog, for example an app woken in the background by CallKit, stays suspended until the prompt is answered and can cancel its own task to give up. No thread is held either way. This restores single-tap behavior on first use and matches how the Flutter and React Native stacks already gate mic access at getUserMedia. Note that no foreground check is involved, so nothing here reads app state. That also keeps the target compiling under APPLICATION_EXTENSION_API_ONLY=YES, where UIApplication.shared is unavailable and the SDK still ships LKSampleHandler for broadcast upload extensions.
Ports the design from the local hiroshi/mic-permission-foreground branch (2026-07-09), which already solved this and solved it better. The previous two commits here reinvented a worse subset. Awaiting requestAccess unconditionally is not enough. The system defers rather than fails a prompt it cannot present, so a caller woken in the background by CallKit stays suspended for as long as the app is inactive. Gating on UIApplication.applicationState is not an option either: it is unavailable to app extensions, and referencing it, even dynamically through a selector string, risks App Store extension-validation rejections for consumers that link LiveKit into an extension target. The SDK ships LKSampleHandler for broadcast upload extensions and CI builds with APPLICATION_EXTENSION_API_ONLY=YES. So AppStateListener tracks the active state through didBecomeActive and willResignActive, which are extension-safe notification names, and defaults to active optimistically because it is created lazily and cannot read the current state extension-safely. The optimistic default can be wrong for an app cold launched into the background, so ensureDeviceAccessIfForegrounded also bounds requestAccess with a 30 second timeout via AsyncCompleter. The bound is what makes the optimistic default safe. ensureMicrophoneAccessForRecording distinguishes "could not present the prompt" from "the user denied it", which the previous commits collapsed into one message. setRecordingAlwaysPreparedMode is gated too, since it is async and can prompt. startLocalRecording stays ungated because it is synchronous and cannot, and its doc comment now says so. Conflicts resolved against main's audioProcessingOptions parameters from #1048, keeping main's signatures. Removes MicrophoneAccessPolicyTests, whose subject no longer exists. Testing the ported policy needs a seam for the timeout and the active state.
Three changes to how the request decides whether to prompt: Never request from an app extension. The broadcast upload extension the SDK supports is headless (LKSampleHandler is an RPBroadcastSampleHandler subclass with no UI), so no prompt can appear there. Unlike the application state this is known exactly, and it runs first so an extension never reaches the state read. Read UIApplication.applicationState directly, as #1047 did, replacing the state inferred from AppStateListener lifecycle notifications. The inference assumed active until told otherwise, which is wrong for an app launched directly into the background, for example by a CallKit push. Drop the request timeout, along with AsyncCompleter and the detached task it needed. Waiting is safe because startCapture is async and holds no thread, and with an exact application state the request is only made when the prompt can actually appear. KNOWN FAILURE: reading UIApplication.shared does not compile for app extensions, so CI's extension-api-only leg fails: LiveKit+DeviceHelpers.swift:67:35: error: 'shared' is unavailable in application extensions for iOS Verified locally: macOS builds, iOS builds with APPLICATION_EXTENSION_API_ONLY=NO, iOS fails with =YES (exit 65). Same failure #1047 hit on 2026-06-22 in job 82683434613. It is a compile-time restriction only, so behavior is correct wherever it does build. Consumers who compile the SDK into a broadcast upload extension hit the same error in their own build, which is what #811 added the leg to prevent. AppStateListener.isApplicationActive and its didBecomeActive/willResignActive observers are now unused. They were added in the previous commit, so they need a separate revert if this direction is kept.
Referencing UIApplication.shared directly does not compile with APPLICATION_EXTENSION_API_ONLY=YES, which CI enforces because consumers build this module into broadcast upload extensions (#811). Resolve sharedApplication with NSSelectorFromString and perform instead: the compiler never type-checks the extension-unavailable declaration, while app processes still read the exact state. An extension process must not call sharedApplication at all, so the kIsAppExtension guard runs first and the accessor documents that requirement. This supersedes both earlier approaches on this branch. The direct read from the previous commit failed the extension-api-only leg as documented there. The AppStateListener inference before it assumed active until told otherwise, which required bounding the permission request with a timeout. With the exact state the request is only made when the prompt can actually appear, so the timeout machinery stays removed and AppStateListener returns to its original shape. The pattern has broad production precedent. GoogleUtilities, shipped inside Firebase, resolves sharedApplication via NSSelectorFromString behind an .appex bundle check in GULAppDelegateSwizzler. sentry-cocoa does the same in Swift via UIApplication.perform in SentryUIViewControllerSwizzling.findApp(). Verified locally: iOS Simulator build with APPLICATION_EXTENSION_API_ONLY=YES and macOS build both pass, swiftlint and swiftformat clean.
ensureDeviceAccessIfForegrounded is only consumed by ensureMicrophoneAccessForRecording, so it does not need to be public API. Exposing it later is an additive change, while removing it would be breaking, so start with the smaller surface.
hiroshihorie
marked this pull request as ready for review
August 13, 2026 10:05
Gating on applicationState == .active skipped the prompt during launch, system banners, and multitasking transitions, where UIApplication.State documents the app as foreground but .inactive and the system dialog can still be presented. Launch-time paths such as withPreConnectAudio commonly run before the scene activates, so a fresh install could throw deviceAccessDenied instead of prompting. Gate on != .background instead, which keeps the fail-fast for background wakes (for example by CallKit), and rename the accessor to isApplicationForegrounded to match. Reported by Devin review on #1085.
This was referenced Aug 13, 2026
Contributor
|
Three blockers:
Minor: |
setRecordingAlwaysPreparedMode is a latency optimization rather than a user-initiated capture, so requesting permission from it prompts at surprising moments. LocalMedia.observeDevices calls it from init, which meant constructing the view model raised a permission dialog, and when it failed the prewarm was silently lost since the caller only logs the error. The ADM still reports -9000 when permission is missing, so the typed .deviceAccessDenied error is unchanged for callers that check it. Apps that want the prompt should call LiveKitSDK.ensureDeviceAccess(for:) up front. Reported in review by @pblazej.
Docs/audio.md documents setManualRenderingMode(true) followed by setMicrophone(enabled: true) as the way to publish app audio, noting that it never touches the physical microphone. The unconditional request made that flow demand permission it does not need. This restores a condition that existed before the check moved into the lib: the earlier SDK-side implementation was gated on !engine.isInManualRenderingMode. Reported in review by @pblazej.
A foregrounded but inactive app (locked screen, call banner, app switcher, or launch before the scene activates) has the permission alert deferred rather than presented, and requestAccess has no cancellation-aware continuation, so awaiting it suspends the caller for as long as the app stays inactive. That suspension is not contained: startCapture runs inside Participant._publishSerialRunner, which also serializes publish(audioTrack:), publish(videoTrack:) and set(source:enabled:), so a stuck audio publish blocks camera and screen share publishing. Teardown is unaffected, since unpublish(publication:) does not go through that runner. Narrowing the gate to .active fails these cases immediately instead, and the next attempt prompts normally once the app is active. This reverts the widening to != .background: prompting during launch is given up in exchange for never queueing behind a deferred alert. Chosen over bounding the request with a timeout, which would still hold the serial runner for the length of the timeout before failing. Reported in review by @pblazej.
pblazej
approved these changes
Aug 28, 2026
…gs on the RTC executor With engine availability disabled (the CallKit flow from setEngineAvailability), the ADM defers opening input entirely and runs no permission check, so gating startCapture there turned a working connect into a deviceAccessDenied failure. Reading isManualRenderingMode and engineAvailability waits on WebRTC's worker thread, so both reads now happen inside a single RTC.run hop instead of on the cooperative pool.
os(iOS) is true under Catalyst, so the gate compiled in and a not-frontmost Catalyst app failed with deviceAccessDenied without ever prompting, while the same app built for AppKit shows the dialog regardless of frontmost state. Catalyst now skips the gate and behaves like native macOS, matching the targetEnvironment exclusion CameraCapturer already uses.
The permission gate only prompts while the app is active, and during launch the scene is typically still inactive, so the previous guidance failed on first run with no prompt. Point early callers at ensureDeviceAccess(for:) instead.
… reporting it The function has no SDK-side permission check, and the ADM skips its passive check in some modes (for example the restart mute mode with input muted), so the unconditional wording over-promised.
…ized async overload The bare auto-bridge hits a mixed Swift 5/6 thunk-coalescing crash before Swift 6.3 (swiftlang/swift#81846), which the repo rules require avoiding. The line predates this PR, but the SDK's own capture path now routes every first mic capture through it.
…re restoring input A recording requested while input was unavailable is honored on re-enable, but the ADM only passively checks permission at that point, so the app must request it up front. The other ungated AudioManager entry points already carry this note.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
webrtc-sdk/webrtc#265 removed the blocking mic permission request from the
AudioEngineDevicepre-enable path. The ADM now does a passive authorization check and returnskAudioEngineErrorInsufficientDevicePermission(-9000) instead of prompting, so requesting permission is the SDK's job.That change shipped in
144.7559.12, and main is now on150.7871.01(#1103, merged into this branch). So this is not precautionary: without it, microphone publishing fails on every fresh install, because.notDeterminedis 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 viaAudioManager.startLocalRecording. Publishing arrives throughTrack.start(), pre-connect recording throughLocalAudioTrackRecorder.start().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
@RTCexecutor (#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
LocalParticipant.setMicrophone(enabled: true)_publishandTrack.start().deviceAccessDeniedLocalParticipant.set(source: .microphone, enabled: true).deviceAccessDeniedLocalParticipant.publish(audioTrack:)with an app-created trackTrack.start().deviceAccessDeniedTrack.start()called directly on aLocalAudioTrack.deviceAccessDeniedRoom.withPreConnectAudio { }PreConnectAudioBufferandLocalAudioTrackRecorder.start().deviceAccessDeniedPreConnectAudioBuffer.startRecording().deviceAccessDeniedLocalAudioTrackRecorder.start()standalone.deviceAccessDeniedsetManualRenderingMode(true)thensetMicrophone(enabled: true))AudioManager.setRecordingAlwaysPreparedMode(true).deviceAccessDeniedAudioManager.startLocalRecording(_:)setEngineAvailability(.none)(CallKit, #815)AudioManager.setEngineAvailability(_:)restoring inputLocalAudioTrack.mute()andunmute()_unmuteonly restarts video tracks, so mic capture is never reopenedInitRecordingon sender attach_publishcallsTrack.start()beforeaddTransceiverAudioMixRecorderThe ungated
AudioManagercalls already surface -9000 as.deviceAccessDeniedthroughcheckAdmResult, and their doc comments now point atLiveKitSDK.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.sharedcannot be referenced here: it does not compile underAPPLICATION_EXTENSION_API_ONLY=YES, which CI exercises, and consumers build this module into broadcast upload extensions viaLKSampleHandler. Annotating the reader with@available(iOSApplicationExtension, unavailable)compiles, but availability propagates to callers andstartCapturehas 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, thenrespondsToSelector:before a dynamically resolvedsharedApplication. 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, andrequestAccesshas no cancellation-aware continuation, so waiting there would suspend the caller for as long as the app stays inactive. SincestartCaptureruns insideParticipant._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
targetEnvironmentexclusionCameraCaptureruses.Review fixes
From @pblazej's review:
Docs/audio.mddocuments it as publishing app audio without touching the microphone, so it must not require permission. This restores a condition the pre-Audio recording perms check at lib #793 implementation had.!= .backgroundto== .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.setRecordingAlwaysPreparedModeis no longer gated.LocalMedia.observeDevices()calls it frominit, so gating it raised a permission dialog on view-model construction and silently lost the prewarm when it failed.republishAllTracks()concern is resolved by merging main: Fix video republishing issues #1008 rewrote it with a per-trackdo/catchthat 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:
setEngineAvailability(.none)flow where the ADM skips its permission check and defers input (verified againstm150_release). Both flag reads moved onto the@RTCexecutor per Run blocking WebRTC calls on a dedicated RTC executor #1101.withPreConnectAudiono longer recommends calling at app launch (the scene is typically still inactive there), andsetRecordingAlwaysPreparedMode'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 buildandswift build -Xswiftc -application-extensionpassxcodebuild -destination 'generic/platform=iOS Simulator' APPLICATION_EXTENSION_API_ONLY=YESexits 0, mirroring the CI matrix legAudioManagerAdmResultTestspasses,swiftlintclean on the touched filesTwo caveats for reviewers:
Follow-up
Docs/audio.mdshould mention that microphone permission is now requested by the SDK at capture start, and that apps wanting the prompt earlier should callLiveKitSDK.ensureDeviceAccess(for:).Refs #815, CLT-3243