Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/mic-permission-before-capture
Original file line number Diff line number Diff line change
@@ -0,0 +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"
8 changes: 6 additions & 2 deletions lib/src/core/room_preconnect.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ extension RoomPreConnect on Room {
/// );
/// ```
///
/// - Note: Ensure microphone permissions are granted early in your app
/// lifecycle so pre-connect can start without additional prompts.
/// - Note: Requires microphone permission. On iOS/macOS the SDK requests it
/// when recording starts, but only while the app is in the foreground, so
/// call this from a foreground context (for example a user tap) where the
/// system prompt can appear. Otherwise it throws a [TrackCreateException].
/// Requesting permission earlier in the app lifecycle avoids the prompt
/// delaying the first recording.
/// - SeeAlso: [PreConnectAudioBuffer]
Future<T> withPreConnectAudio<T>(
Future<T> Function() operation, {
Expand Down
5 changes: 3 additions & 2 deletions lib/src/preconnect/pre_connect_audio_buffer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ class PreConnectAudioBuffer {
/// [agentReadyFuture] completes with an error and callers should [reset] the
/// buffer.
///
/// Ensure microphone permissions are granted before calling this.
/// Audio capture may fail without permissions.
/// Requires microphone permission. On iOS/macOS it is requested here while
/// the app is in the foreground. Throws a [TrackCreateException] when it is
/// denied or cannot be requested (app not in the foreground).
Future<void> startRecording({
Duration timeout = const Duration(seconds: 20),
}) async {
Expand Down
19 changes: 19 additions & 0 deletions lib/src/support/native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,25 @@ class Native {
/// platform errors: a failed availability change means the engine may run
/// outside the window the caller intended (e.g. CallKit's
/// didActivate/didDeactivate), so the error must reach the caller.
/// Requests microphone permission before audio capture starts (iOS/macOS).
///
/// The WebRTC audio device only checks the permission and fails when it is
/// missing, so the SDK requests it here. On iOS the prompt is only shown while
/// the app is active. Throws a [PlatformException] with code
/// `deviceAccessDenied` when permission is denied, restricted, or could not be
/// requested. A no-op where the platform does not implement it.
@internal
static Future<void> ensureMicrophoneAccess() async {
try {
await channel.invokeMethod<void>('ensureMicrophoneAccess', <String, dynamic>{});
} on PlatformException catch (error) {
if (error.code == 'Unimplemented') return;
rethrow;
} on MissingPluginException {
return;
}
}

@internal
static Future<void> setEngineAvailability({
required bool isInputAvailable,
Expand Down
17 changes: 17 additions & 0 deletions lib/src/track/local/local.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ import 'dart:async';

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show PlatformException;

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

import '../../audio/audio_engine_error.dart';
import '../../audio/audio_frame_capture.dart';
import '../../events.dart';
import '../../exceptions.dart';
import '../../extensions.dart';
import '../../internal/events.dart';
import '../../logger.dart';
import '../../participant/remote.dart';
import '../../support/native.dart';
import '../../support/platform.dart';
import '../../types/other.dart';
import '../options.dart';
Expand Down Expand Up @@ -254,6 +257,20 @@ abstract class LocalTrack extends Track {
'video': options is VideoCaptureOptions ? options.toMediaConstraintsMap() : false,
};

if (options is AudioCaptureOptions && lkPlatformIsApple()) {
// The WebRTC audio device only checks microphone permission and fails
// when it is missing, so the SDK requests it before opening the mic. On
// iOS this fails fast while the app is not in the foreground instead of
// suspending getUserMedia (and the publish queue behind it) on a prompt
// the system cannot show yet.
try {
await Native.ensureMicrophoneAccess();
} on PlatformException catch (error) {
throw audioEngineExceptionFrom(error) ??
TrackCreateException(error.message ?? 'Microphone permission is not granted');
}
}

final rtc.MediaStream stream;
if (options is ScreenShareCaptureOptions) {
if (kIsWeb) {
Expand Down
51 changes: 50 additions & 1 deletion shared_swift/LiveKitPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,49 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
}
}

// MARK: - Microphone permission

/// Ensures microphone access is granted before audio capture starts.
///
/// The WebRTC audio device does not request microphone permission itself. It
/// only checks it and fails with kAudioEngineErrorInsufficientDevicePermission,
/// so requesting is the SDK's job. On iOS the request is only made while the
/// app is active: while inactive or in the background the system defers the
/// alert, and waiting on it would suspend the caller, and the publish queue
/// behind it, for as long as the app stays there. Failing fast lets the next
/// attempt prompt normally. macOS can present the prompt regardless.
///
/// Method channel handlers run on the main thread, which UIApplication needs.
public func handleEnsureMicrophoneAccess(result: @escaping FlutterResult) {
let denied = { (message: String) in
result(FlutterError(code: LiveKitPlugin.deviceAccessDeniedErrorCode, message: message, details: nil))
}
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
result(nil)
case .notDetermined:
#if !os(macOS)
guard UIApplication.shared.applicationState == .active else {
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.")
return
}
#endif
AVCaptureDevice.requestAccess(for: .audio) { granted in
DispatchQueue.main.async {
if granted {
result(nil)
} else {
denied("Microphone permission was denied.")
}
}
}
case .denied, .restricted:
denied("Microphone permission is not granted.")
@unknown default:
denied("Microphone permission is not granted.")
}
}

// MARK: - Microphone mute mode

static func muteModeString(_ mode: RTCAudioEngineMuteMode) -> String {
Expand Down Expand Up @@ -763,6 +806,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
handleStopLocalRecording(result: result)
case "setEngineAvailability":
handleSetEngineAvailability(args: args, result: result)
case "ensureMicrophoneAccess":
handleEnsureMicrophoneAccess(result: result)
case "setAudioProcessingOptions":
handleSetAudioProcessingOptions(args: args, result: result)
case "getAudioProcessingState":
Expand Down Expand Up @@ -795,14 +840,18 @@ extension LiveKitPlugin {
static let kAudioEngineErrorInsufficientDevicePermission = -9000
static let kAudioEngineErrorAudioSessionInvalidCategory = -9001

/// FlutterError code for missing microphone permission. Dart maps it to
/// TrackCreateException (see audio_engine_error.dart).
static let deviceAccessDeniedErrorCode = "deviceAccessDenied"

/// Maps a non-zero audio device module result to a `FlutterError` whose code
/// the Dart side can act on. Codes with a known cause get their own error
/// code, mirroring client-sdk-swift's `checkAdmResult`. Anything else falls
/// back to `fallbackCode` with the raw value in the message.
static func flutterError(forAudioEngineResult result: Int, fallbackCode: String) -> FlutterError {
switch result {
case kAudioEngineErrorInsufficientDevicePermission:
return FlutterError(code: "deviceAccessDenied",
return FlutterError(code: deviceAccessDeniedErrorCode,
message: "Microphone permission is not granted (audio engine error \(result))",
details: result)
case kAudioEngineErrorAudioSessionInvalidCategory:
Expand Down
31 changes: 31 additions & 0 deletions test/audio/audio_session_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,37 @@ void main() {
});
});

group('Native.ensureMicrophoneAccess', () {
test('is a no-op when the platform does not implement it', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
Native.channel,
(call) async => throw PlatformException(code: 'Unimplemented'),
);
await expectLater(Native.ensureMicrophoneAccess(), completes);

TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
Native.channel,
null,
);
await expectLater(Native.ensureMicrophoneAccess(), completes);
});

test('propagates a denied permission so callers can map it', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
Native.channel,
(call) async {
expect(call.method, 'ensureMicrophoneAccess');
throw PlatformException(code: audioEngineErrorCodeDeviceAccessDenied, message: 'denied');
},
);

await expectLater(
Native.ensureMicrophoneAccess(),
throwsA(isA<PlatformException>().having((error) => error.code, 'code', audioEngineErrorCodeDeviceAccessDenied)),
);
});
});

group('audioEngineExceptionFrom', () {
test('maps missing microphone permission to TrackCreateException', () {
final error = audioEngineExceptionFrom(
Expand Down