From fef60ba7017bad33d31ad3abc9b87e77bc37761f Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:59:13 +0800 Subject: [PATCH] Request microphone permission before audio capture starts webrtc-sdk/webrtc#265 (m144.7559.12 and later) removed the blocking mic permission request from the AudioEngine device. It now only checks the status and fails with kAudioEngineErrorInsufficientDevicePermission, so requesting permission is the SDK's job. The native plugin gains ensureMicrophoneAccess: authorized passes, denied or restricted fails, and notDetermined requests access, on iOS only while the app is active. An inactive or backgrounded app has the alert deferred by the system, and waiting on it would suspend getUserMedia and the publish queue behind it, so it fails fast instead and the next foreground attempt prompts normally. LocalTrack.createStream calls it for audio on Apple platforms before getUserMedia, which covers publishing, restartTrack on unmute, and pre-connect audio. Failures surface as TrackCreateException through the existing audio engine error mapping. --- .changes/mic-permission-before-capture | 1 + lib/src/core/room_preconnect.dart | 8 ++- .../preconnect/pre_connect_audio_buffer.dart | 5 +- lib/src/support/native.dart | 19 +++++++ lib/src/track/local/local.dart | 17 +++++++ shared_swift/LiveKitPlugin.swift | 51 ++++++++++++++++++- test/audio/audio_session_test.dart | 31 +++++++++++ 7 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 .changes/mic-permission-before-capture 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(