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/lib/src/core/room_preconnect.dart b/lib/src/core/room_preconnect.dart index 41671e891..a1165c93b 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 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 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..2e6434fe4 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 { diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index b73750cc5..e42276add 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -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 ensureMicrophoneAccess() async { + try { + await channel.invokeMethod('ensureMicrophoneAccess', {}); + } on PlatformException catch (error) { + if (error.code == 'Unimplemented') return; + rethrow; + } on MissingPluginException { + return; + } + } + @internal static Future setEngineAvailability({ required bool isInputAvailable, 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 05eb65ea5..4c8f0f9db 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -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 { @@ -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": @@ -795,6 +840,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 @@ -802,7 +851,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 79be9a5df..1631aa0aa 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -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().having((error) => error.code, 'code', audioEngineErrorCodeDeviceAccessDenied)), + ); + }); + }); + group('audioEngineExceptionFrom', () { test('maps missing microphone permission to TrackCreateException', () { final error = audioEngineExceptionFrom(