Skip to content

Request microphone permission before starting audio capture - #1085

Merged
hiroshihorie merged 20 commits into
mainfrom
hiroshi/mic-permission-at-capture
Aug 31, 2026
Merged

Request microphone permission before starting audio capture#1085
hiroshihorie merged 20 commits into
mainfrom
hiroshi/mic-permission-at-capture

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Aug 12, 2026

Copy link
Copy Markdown
Member

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().

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-Audio recording perms check at lib #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: Fix video republishing issues #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 Run blocking WebRTC calls on a dedicated RTC executor #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

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
hiroshihorie marked this pull request as ready for review August 13, 2026 10:05
devin-ai-integration[bot]

This comment was marked as resolved.

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.
@pblazej

pblazej commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Three blockers:

  1. Manual rendering mode breaks — the gate in startCapture() is unconditional, but Docs/audio.md documents setManualRenderingMode(true) + setMicrophone(enabled: true) as the way to publish app audio with no mic TCC at all; needs a guard !AudioManager.shared.isManualRenderingMode (same in setRecordingAlwaysPreparedMode).
  2. != .background can hang forever.inactive (locked, call banner, app switcher) defers the TCC alert and requestAccess's async overload isn't cancellable, so startCapture() never returns and _publishSerialRunner wedges camera/screen-share/unpublish along with it; narrow to .active or keep the 30s bound.
  3. The throw aborts republishAllTracks() — it's a bare for try await _publish(...) after unpublishAll(), so one audio failure means camera and screen share are never republished after a full reconnect.

Minor: LocalMedia.observeDevices() (SwiftUI/LocalMedia.swift:117) calls setRecordingAlwaysPreparedMode(true) from init and only logs the error, so the gate permanently kills its pre-warm — and prompts for mic on view-model construction.

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.
…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.
devin-ai-integration[bot]

This comment was marked as resolved.

…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.
@hiroshihorie
hiroshihorie merged commit e86e8e4 into main Aug 31, 2026
31 of 32 checks passed
@hiroshihorie
hiroshihorie deleted the hiroshi/mic-permission-at-capture branch August 31, 2026 20:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blocking engine availability method call when microphone permission is not determined

2 participants