Skip to content

Commit bdcb57e

Browse files
committed
Request microphone permission before audio capture starts
webrtc-sdk/webrtc#265 (m144.7559.12 and later) removed the blocking mic permission request from the AudioEngine device. It now only checks the status and fails with kAudioEngineErrorInsufficientDevicePermission, so requesting permission is the SDK's job. The native plugin gains ensureMicrophoneAccess: authorized passes, denied or restricted fails, and notDetermined requests access, on iOS only while the app is active. An inactive or backgrounded app has the alert deferred by the system, and waiting on it would suspend getUserMedia and the publish queue behind it, so it fails fast instead and the next foreground attempt prompts normally. LocalTrack.createStream calls it for audio on Apple platforms before getUserMedia, which covers publishing, restartTrack on unmute, and pre-connect audio. Failures surface as TrackCreateException through the existing audio engine error mapping.
1 parent 8827c32 commit bdcb57e

7 files changed

Lines changed: 127 additions & 5 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="fixed" "iOS/macOS: request microphone permission before audio capture starts, failing fast with TrackCreateException while the app is not in the foreground"

lib/src/core/room_preconnect.dart

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,12 @@ extension RoomPreConnect on Room {
4141
/// );
4242
/// ```
4343
///
44-
/// - Note: Ensure microphone permissions are granted early in your app
45-
/// lifecycle so pre-connect can start without additional prompts.
44+
/// - Note: Requires microphone permission. On iOS/macOS the SDK requests it
45+
/// when recording starts, but only while the app is in the foreground, so
46+
/// call this from a foreground context (for example a user tap) where the
47+
/// system prompt can appear. Otherwise it throws a [TrackCreateException].
48+
/// Requesting permission earlier in the app lifecycle avoids the prompt
49+
/// delaying the first recording.
4650
/// - SeeAlso: [PreConnectAudioBuffer]
4751
Future<T> withPreConnectAudio<T>(
4852
Future<T> Function() operation, {

lib/src/preconnect/pre_connect_audio_buffer.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,9 @@ class PreConnectAudioBuffer {
105105
/// [agentReadyFuture] completes with an error and callers should [reset] the
106106
/// buffer.
107107
///
108-
/// Ensure microphone permissions are granted before calling this.
109-
/// Audio capture may fail without permissions.
108+
/// Requires microphone permission. On iOS/macOS it is requested here while
109+
/// the app is in the foreground. Throws a [TrackCreateException] when it is
110+
/// denied or cannot be requested (app not in the foreground).
110111
Future<void> startRecording({
111112
Duration timeout = const Duration(seconds: 20),
112113
}) async {

lib/src/support/native.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,25 @@ class Native {
272272
/// platform errors: a failed availability change means the engine may run
273273
/// outside the window the caller intended (e.g. CallKit's
274274
/// didActivate/didDeactivate), so the error must reach the caller.
275+
/// Requests microphone permission before audio capture starts (iOS/macOS).
276+
///
277+
/// The WebRTC audio device only checks the permission and fails when it is
278+
/// missing, so the SDK requests it here. On iOS the prompt is only shown while
279+
/// the app is active. Throws a [PlatformException] with code
280+
/// `deviceAccessDenied` when permission is denied, restricted, or could not be
281+
/// requested. A no-op where the platform does not implement it.
282+
@internal
283+
static Future<void> ensureMicrophoneAccess() async {
284+
try {
285+
await channel.invokeMethod<void>('ensureMicrophoneAccess', <String, dynamic>{});
286+
} on PlatformException catch (error) {
287+
if (error.code == 'Unimplemented') return;
288+
rethrow;
289+
} on MissingPluginException {
290+
return;
291+
}
292+
}
293+
275294
@internal
276295
static Future<void> setEngineAvailability({
277296
required bool isInputAvailable,

lib/src/track/local/local.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,20 @@ import 'dart:async';
1616

1717
import 'package:flutter/foundation.dart' show kIsWeb;
1818
import 'package:flutter/material.dart';
19+
import 'package:flutter/services.dart' show PlatformException;
1920

2021
import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc;
2122
import 'package:meta/meta.dart';
2223

24+
import '../../audio/audio_engine_error.dart';
2325
import '../../audio/audio_frame_capture.dart';
2426
import '../../events.dart';
2527
import '../../exceptions.dart';
2628
import '../../extensions.dart';
2729
import '../../internal/events.dart';
2830
import '../../logger.dart';
2931
import '../../participant/remote.dart';
32+
import '../../support/native.dart';
3033
import '../../support/platform.dart';
3134
import '../../types/other.dart';
3235
import '../options.dart';
@@ -254,6 +257,20 @@ abstract class LocalTrack extends Track {
254257
'video': options is VideoCaptureOptions ? options.toMediaConstraintsMap() : false,
255258
};
256259

260+
if (options is AudioCaptureOptions && lkPlatformIsApple()) {
261+
// The WebRTC audio device only checks microphone permission and fails
262+
// when it is missing, so the SDK requests it before opening the mic. On
263+
// iOS this fails fast while the app is not in the foreground instead of
264+
// suspending getUserMedia (and the publish queue behind it) on a prompt
265+
// the system cannot show yet.
266+
try {
267+
await Native.ensureMicrophoneAccess();
268+
} on PlatformException catch (error) {
269+
throw audioEngineExceptionFrom(error) ??
270+
TrackCreateException(error.message ?? 'Microphone permission is not granted');
271+
}
272+
}
273+
257274
final rtc.MediaStream stream;
258275
if (options is ScreenShareCaptureOptions) {
259276
if (kIsWeb) {

shared_swift/LiveKitPlugin.swift

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,49 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
542542
}
543543
}
544544

545+
// MARK: - Microphone permission
546+
547+
/// Ensures microphone access is granted before audio capture starts.
548+
///
549+
/// The WebRTC audio device does not request microphone permission itself. It
550+
/// only checks it and fails with kAudioEngineErrorInsufficientDevicePermission,
551+
/// so requesting is the SDK's job. On iOS the request is only made while the
552+
/// app is active: while inactive or in the background the system defers the
553+
/// alert, and waiting on it would suspend the caller, and the publish queue
554+
/// behind it, for as long as the app stays there. Failing fast lets the next
555+
/// attempt prompt normally. macOS can present the prompt regardless.
556+
///
557+
/// Method channel handlers run on the main thread, which UIApplication needs.
558+
public func handleEnsureMicrophoneAccess(result: @escaping FlutterResult) {
559+
let denied = { (message: String) in
560+
result(FlutterError(code: LiveKitPlugin.deviceAccessDeniedErrorCode, message: message, details: nil))
561+
}
562+
switch AVCaptureDevice.authorizationStatus(for: .audio) {
563+
case .authorized:
564+
result(nil)
565+
case .notDetermined:
566+
#if !os(macOS)
567+
guard UIApplication.shared.applicationState == .active else {
568+
denied("Microphone permission could not be requested because the app is not in the foreground. Request it while the app is active before enabling recording.")
569+
return
570+
}
571+
#endif
572+
AVCaptureDevice.requestAccess(for: .audio) { granted in
573+
DispatchQueue.main.async {
574+
if granted {
575+
result(nil)
576+
} else {
577+
denied("Microphone permission was denied.")
578+
}
579+
}
580+
}
581+
case .denied, .restricted:
582+
denied("Microphone permission is not granted.")
583+
@unknown default:
584+
denied("Microphone permission is not granted.")
585+
}
586+
}
587+
545588
// MARK: - Microphone mute mode
546589

547590
static func muteModeString(_ mode: RTCAudioEngineMuteMode) -> String {
@@ -768,6 +811,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
768811
handleStopLocalRecording(result: result)
769812
case "setEngineAvailability":
770813
handleSetEngineAvailability(args: args, result: result)
814+
case "ensureMicrophoneAccess":
815+
handleEnsureMicrophoneAccess(result: result)
771816
case "setAudioProcessingOptions":
772817
handleSetAudioProcessingOptions(args: args, result: result)
773818
case "getAudioProcessingState":
@@ -800,14 +845,18 @@ extension LiveKitPlugin {
800845
static let kAudioEngineErrorInsufficientDevicePermission = -9000
801846
static let kAudioEngineErrorAudioSessionInvalidCategory = -9001
802847

848+
/// FlutterError code for missing microphone permission. Dart maps it to
849+
/// TrackCreateException (see audio_engine_error.dart).
850+
static let deviceAccessDeniedErrorCode = "deviceAccessDenied"
851+
803852
/// Maps a non-zero audio device module result to a `FlutterError` whose code
804853
/// the Dart side can act on. Codes with a known cause get their own error
805854
/// code, mirroring client-sdk-swift's `checkAdmResult`. Anything else falls
806855
/// back to `fallbackCode` with the raw value in the message.
807856
static func flutterError(forAudioEngineResult result: Int, fallbackCode: String) -> FlutterError {
808857
switch result {
809858
case kAudioEngineErrorInsufficientDevicePermission:
810-
return FlutterError(code: "deviceAccessDenied",
859+
return FlutterError(code: deviceAccessDeniedErrorCode,
811860
message: "Microphone permission is not granted (audio engine error \(result))",
812861
details: result)
813862
case kAudioEngineErrorAudioSessionInvalidCategory:

test/audio/audio_session_test.dart

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,37 @@ void main() {
876876
});
877877
});
878878

879+
group('Native.ensureMicrophoneAccess', () {
880+
test('is a no-op when the platform does not implement it', () async {
881+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
882+
Native.channel,
883+
(call) async => throw PlatformException(code: 'Unimplemented'),
884+
);
885+
await expectLater(Native.ensureMicrophoneAccess(), completes);
886+
887+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
888+
Native.channel,
889+
null,
890+
);
891+
await expectLater(Native.ensureMicrophoneAccess(), completes);
892+
});
893+
894+
test('propagates a denied permission so callers can map it', () async {
895+
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
896+
Native.channel,
897+
(call) async {
898+
expect(call.method, 'ensureMicrophoneAccess');
899+
throw PlatformException(code: audioEngineErrorCodeDeviceAccessDenied, message: 'denied');
900+
},
901+
);
902+
903+
await expectLater(
904+
Native.ensureMicrophoneAccess(),
905+
throwsA(isA<PlatformException>().having((error) => error.code, 'code', audioEngineErrorCodeDeviceAccessDenied)),
906+
);
907+
});
908+
});
909+
879910
group('audioEngineExceptionFrom', () {
880911
test('maps missing microphone permission to TrackCreateException', () {
881912
final error = audioEngineExceptionFrom(

0 commit comments

Comments
 (0)