Request microphone permission in foreground before enabling recording. - #1047
Open
silviudeac wants to merge 1 commit into
Open
Request microphone permission in foreground before enabling recording.#1047silviudeac wants to merge 1 commit into
silviudeac wants to merge 1 commit into
Conversation
When mic permission is `.notDetermined`, request access at the async mic-enable path (`LocalParticipant.set(source:enabled:)`), but only while the app is foregrounded so the system dialog can appear. This restores the prompt the WebRTC ADM no longer performs implicitly, without blocking a worker thread (livekit#815). The foreground gate lives in a reusable `LiveKitSDK.ensureDeviceAccessIfForegrounded(for:)` helper alongside `ensureDeviceAccess`.
silviudeac
requested review from
hiroshihorie,
pblazej and
xianshijing-lk
as code owners
June 22, 2026 09:43
hiroshihorie
added a commit
that referenced
this pull request
Aug 12, 2026
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.
hiroshihorie
added a commit
that referenced
this pull request
Aug 31, 2026
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
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.
This is the SDK-side follow-up to webrtc-sdk/webrtc#204. That change makes the WebRTC Audio Device Module (ADM) stop implicitly requesting microphone permission — the blocking request that hangs a worker thread when the app is woken in the background (#815) — and replaces it with a passive authorized-check. With the ADM no longer prompting, the SDK now does it:
LocalParticipantproactively requests access at its async mic-enable path, but only while the app is foregrounded so the system dialog can actually appear.The two are meant to land together — #204 removes the hang, this PR restores the prompt.
What changed
LiveKit+DeviceHelpers.swift— newLiveKitSDK.ensureDeviceAccessIfForegrounded(for:): requests access only when the app is.active, reusing the existing non-blockingensureDeviceAccess(for:). A no-op on non-active / non-UIKit contexts.LocalParticipant.set(source:enabled:)— in the.microphonecreate branch, beforeLocalAudioTrack.createTrack(...):Only .notDetermined triggers a request; backgrounded/extension contexts never prompt; muting/unmuting an existing track is untouched. No change to existing API signatures.
Refs #815 (full fix together with webrtc-sdk/webrtc#204).