Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
14 changes: 11 additions & 3 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');
_onError?.call(error);
Comment thread
hiroshihorie marked this conversation as resolved.
Outdated
await stopRecording(withError: error);
rethrow;
}
logger.fine('[Preconnect audio] created local track ${_localTrack!.mediaStreamTrack.id}');

final rendererId = Uuid().v4();
Expand Down
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
66 changes: 66 additions & 0 deletions test/preconnect/pre_connect_audio_buffer_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'package:flutter_test/flutter_test.dart';

import '../mock/e2e_container.dart';

void main() {
TestWidgetsFlutterBinding.ensureInitialized();

group('PreConnectAudioBuffer.startRecording', () {
late E2EContainer container;

setUp(() {
container = E2EContainer();
});

tearDown(() async {
await container.dispose();
});

// In the test environment LocalAudioTrack.create() always fails (no
// platform channels), which stands in for a real create failure such as
// a denied microphone permission.
test('stays reusable after track creation fails', () async {
final buffer = container.room.preConnectAudioBuffer;
final errors = <Object>[];
buffer.setErrorHandler(errors.add);

await expectLater(
buffer.startRecording(timeout: const Duration(milliseconds: 50)),
throwsA(anything),
);

// The buffer must return to an idle state, not stay latched on
// _isRecording so that retries are silently ignored.
expect(buffer.isRecording, isFalse);
expect(errors, hasLength(1));

// A retry reaches track creation again and reports its own failure
// instead of returning early as "already recording".
await expectLater(
buffer.startRecording(timeout: const Duration(milliseconds: 50)),
throwsA(anything),
);
expect(buffer.isRecording, isFalse);
expect(errors, hasLength(2));

// The agent-ready timeout was cancelled by the cleanup. If it were
// still armed, it would complete the unobserved agentReadyFuture with
// a TimeoutException and fail this test as an unhandled error.
await Future<void>.delayed(const Duration(milliseconds: 100));
});
});
}
19 changes: 19 additions & 0 deletions test/support/reusable_completer_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ void main() {
}
});

test('should complete an unobserved error silently', () async {
// No call to `future` before the error: nothing can ever observe it
// (accessing `future` afterwards returns a fresh completer), so it
// must not surface as an unhandled async error.
final result = completer.completeError(Exception('unobserved'));

expect(result, isTrue);
expect(completer.isCompleted, isTrue);

// Let any (incorrect) unhandled error surface and fail the test.
await Future<void>.delayed(Duration.zero);

// The completer stays reusable.
final future = completer.future;
expect(completer.isActive, isTrue);
completer.complete('next');
await expectLater(future, completion('next'));
});

test('should return false when completing already completed completer', () {
completer.complete('first');
final result1 = completer.complete('second');
Expand Down
Loading