You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: Sources/LiveKit/Core/Room+PreConnect.swift
+1-1Lines changed: 1 addition & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -43,7 +43,7 @@ public extension Room {
43
43
/// ```
44
44
///
45
45
/// - 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``.
0 commit comments