Skip to content

Request microphone permission in foreground before enabling recording. - #1047

Open
silviudeac wants to merge 1 commit into
livekit:mainfrom
silviudeac:fix-foreground-mic-permission
Open

Request microphone permission in foreground before enabling recording.#1047
silviudeac wants to merge 1 commit into
livekit:mainfrom
silviudeac:fix-foreground-mic-permission

Conversation

@silviudeac

Copy link
Copy Markdown
Contributor

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: LocalParticipant proactively 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 — new LiveKitSDK.ensureDeviceAccessIfForegrounded(for:): requests access only when the app is .active, reusing the existing non-blocking ensureDeviceAccess(for:). A no-op on non-active / non-UIKit contexts.
  • LocalParticipant.set(source:enabled:) — in the .microphone create branch, before LocalAudioTrack.createTrack(...):
if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined {
    _ = await LiveKitSDK.ensureDeviceAccessIfForegrounded(for: [.audio])
}

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

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`.
@CLAassistant

CLAassistant commented Jun 22, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

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

2 participants