diff --git a/.changes/mic-permission-before-capture b/.changes/mic-permission-before-capture new file mode 100644 index 000000000..5c53625c7 --- /dev/null +++ b/.changes/mic-permission-before-capture @@ -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" diff --git a/.changes/preconnect-buffer-cleanup b/.changes/preconnect-buffer-cleanup new file mode 100644 index 000000000..53a6596af --- /dev/null +++ b/.changes/preconnect-buffer-cleanup @@ -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" diff --git a/lib/src/core/room_preconnect.dart b/lib/src/core/room_preconnect.dart index 41671e891..f891d6eff 100644 --- a/lib/src/core/room_preconnect.dart +++ b/lib/src/core/room_preconnect.dart @@ -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 withPreConnectAudio( Future Function() operation, { diff --git a/lib/src/preconnect/pre_connect_audio_buffer.dart b/lib/src/preconnect/pre_connect_audio_buffer.dart index 4427c90d1..b2994b170 100644 --- a/lib/src/preconnect/pre_connect_audio_buffer.dart +++ b/lib/src/preconnect/pre_connect_audio_buffer.dart @@ -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 startRecording({ Duration timeout = const Duration(seconds: 20), }) async { @@ -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(); @@ -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; @@ -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; @@ -183,7 +191,7 @@ class PreConnectAudioBuffer { _agentReadyManager.complete(); } catch (error) { _agentReadyManager.completeError(error); - _onError?.call(error); + _notifyError(error); } }, ); @@ -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'); + } + } } diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index ff5f75802..d604e078c 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -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 ensureMicrophoneAccess() async { + try { + await channel.invokeMethod('ensureMicrophoneAccess', {}); + } 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 setEngineAvailability({ required bool isInputAvailable, diff --git a/lib/src/support/reusable_completer.dart b/lib/src/support/reusable_completer.dart index c48fa0b88..3f01dccc7 100644 --- a/lib/src/support/reusable_completer.dart +++ b/lib/src/support/reusable_completer.dart @@ -72,6 +72,14 @@ class ReusableCompleter { 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; } diff --git a/lib/src/track/local/local.dart b/lib/src/track/local/local.dart index 33dd4d4de..7252cb045 100644 --- a/lib/src/track/local/local.dart +++ b/lib/src/track/local/local.dart @@ -16,10 +16,12 @@ 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'; @@ -27,6 +29,7 @@ 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'; @@ -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) { diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index cc09b4d78..d268a6abc 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -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 { @@ -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": @@ -800,6 +859,10 @@ 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 @@ -807,7 +870,7 @@ extension LiveKitPlugin { 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: diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index 286a69ce3..bdda926e1 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -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().having((error) => error.code, 'code', audioEngineErrorCodeDeviceAccessDenied)), + ); + }); + }); + group('audioEngineExceptionFrom', () { test('maps missing microphone permission to TrackCreateException', () { final error = audioEngineExceptionFrom( diff --git a/test/preconnect/pre_connect_audio_buffer_test.dart b/test/preconnect/pre_connect_audio_buffer_test.dart new file mode 100644 index 000000000..63e1fc2e9 --- /dev/null +++ b/test/preconnect/pre_connect_audio_buffer_test.dart @@ -0,0 +1,86 @@ +// 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 = []; + 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.delayed(const Duration(milliseconds: 100)); + }); + + test('cleans up even when the error handler throws', () async { + final buffer = container.room.preConnectAudioBuffer; + var callbackCalls = 0; + buffer.setErrorHandler((error) { + callbackCalls++; + throw StateError('app callback bug'); + }); + + // The original track creation failure must reach the caller, not the + // callback's own error, and cleanup must still run. + await expectLater( + buffer.startRecording(timeout: const Duration(milliseconds: 50)), + throwsA(isNot(isA())), + ); + + expect(callbackCalls, 1); + expect(buffer.isRecording, isFalse); + await Future.delayed(const Duration(milliseconds: 100)); + }); + }); +} diff --git a/test/support/reusable_completer_test.dart b/test/support/reusable_completer_test.dart index 9b68d5bdc..60c8838ec 100644 --- a/test/support/reusable_completer_test.dart +++ b/test/support/reusable_completer_test.dart @@ -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.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');