Skip to content
Merged
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"
1 change: 1 addition & 0 deletions .changes/preconnect-buffer-cleanup
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Pre-connect audio buffer returns to a reusable state when recording fails to start, instead of ignoring retries and leaking the agent timeout"
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 active, so call this
/// once the app has become active and the system prompt can appear. At app
/// launch the app may not be active yet, so when calling this early,
/// request permission up front (for example with the permission_handler
/// package). Otherwise it throws a [TrackCreateException].
/// - SeeAlso: [PreConnectAudioBuffer]
Future<T> withPreConnectAudio<T>(
Future<T> Function() operation, {
Expand Down
31 changes: 25 additions & 6 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 All @@ -119,7 +120,14 @@ class PreConnectAudioBuffer {
// Set up timeout for agent readiness
_agentReadyManager.setTimer(timeout, timeoutReason: 'Agent did not become ready within timeout');

_localTrack = await LocalAudioTrack.create();
try {
_localTrack = await LocalAudioTrack.create();
} catch (error) {
logger.severe('[Preconnect audio] failed to create local track: $error');
_notifyError(error);
await stopRecording(withError: error);
rethrow;
}
logger.fine('[Preconnect audio] created local track ${_localTrack!.mediaStreamTrack.id}');

final rendererId = Uuid().v4();
Expand All @@ -137,7 +145,7 @@ class PreConnectAudioBuffer {
if (!result) {
final error = StateError('Failed to start audio renderer ($result)');
logger.severe('[Preconnect audio] $error');
_onError?.call(error);
_notifyError(error);
await stopRecording(withError: error);
await _localTrack?.stop();
_localTrack = null;
Expand All @@ -149,7 +157,7 @@ class PreConnectAudioBuffer {
_nativeRecordingStarted = lkPlatformSupportsExplicitAudioRecordingStart();
} catch (error) {
logger.severe('[Preconnect audio] failed to start local recording: $error');
_onError?.call(error);
_notifyError(error);
await stopRecording(withError: error);
await _localTrack?.stop();
_localTrack = null;
Expand Down Expand Up @@ -183,7 +191,7 @@ class PreConnectAudioBuffer {
_agentReadyManager.complete();
} catch (error) {
_agentReadyManager.completeError(error);
_onError?.call(error);
_notifyError(error);
}
},
);
Expand Down Expand Up @@ -345,4 +353,15 @@ class PreConnectAudioBuffer {
void setErrorHandler(PreConnectOnError? onError) {
_onError = onError;
}

/// Invokes the app-provided error callback without letting a throwing
/// callback derail the failure path it is called from: cleanup must still
/// run and the original error must stay the one callers see.
void _notifyError(Object error) {
try {
_onError?.call(error);
} catch (callbackError) {
logger.warning('[Preconnect audio] onError callback threw: $callbackError');
}
}
}
27 changes: 27 additions & 0 deletions lib/src/support/native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,39 @@ class Native {
}
}

/// 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, or while the
/// engine's input availability is disabled via [setEngineAvailability] (the
/// audio device defers opening input there, so no permission is needed yet).
@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;
}
}

/// Sets whether the WebRTC audio engine is allowed to run (iOS/macOS).
///
/// Unlike most methods in this class this deliberately does not swallow
/// 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.
///
/// Microphone permission is not requested here. A recording requested while
/// input was unavailable is honored on re-enable, but the audio device only
/// passively checks permission at that point, so it must be granted before
/// input availability is restored. Otherwise this throws a
/// [PlatformException] with code `deviceAccessDenied`.
@internal
static Future<void> setEngineAvailability({
required bool isInputAvailable,
Expand Down
8 changes: 8 additions & 0 deletions lib/src/support/reusable_completer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ class ReusableCompleter<T> {
return false;
}

if (!_hasPendingListener) {
// No one can observe this error: [future] creates a fresh completer once
// completed, so delivering it would only surface an unhandled async
// error. Mark completed silently, like reset() and dispose() do.
_markCompletedWithoutNotify();
return true;
}

_completeCurrent((completer) => completer.completeError(error, stackTrace));
return true;
}
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();
Comment thread
hiroshihorie marked this conversation as resolved.
} 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
65 changes: 64 additions & 1 deletion shared_swift/LiveKitPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,63 @@ 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) {
// With engine input availability disabled (the CallKit flow, see
// setEngineAvailability) the audio device module defers opening input
// entirely and runs no permission check, so gating here would turn a
// working background connect into a deviceAccessDenied failure. The
// last-set value is tracked by both the channel and native static
// paths, so it covers gating done before the Flutter engine exists.
LiveKitPlugin.engineAvailabilityLock.lock()
let pendingAvailability = LiveKitPlugin.pendingEngineAvailability
LiveKitPlugin.engineAvailabilityLock.unlock()
if let pendingAvailability, !pendingAvailability.isInputAvailable.boolValue {
result(nil)
return
}

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 @@ -768,6 +825,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 @@ -800,14 +859,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 @@ -876,6 +876,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
Loading
Loading