diff --git a/.changes/audio-engine-error-mapping b/.changes/audio-engine-error-mapping new file mode 100644 index 000000000..56f911842 --- /dev/null +++ b/.changes/audio-engine-error-mapping @@ -0,0 +1 @@ +patch type="changed" "Microphone permission and audio session failures now throw TrackCreateException / AudioSessionException instead of AudioProcessingException" diff --git a/.changes/audio-session-exception b/.changes/audio-session-exception new file mode 100644 index 000000000..a6d8645fe --- /dev/null +++ b/.changes/audio-session-exception @@ -0,0 +1 @@ +patch type="added" "Add AudioSessionException for iOS audio session failures" diff --git a/.changes/native-audio-session-preset b/.changes/native-audio-session-preset new file mode 100644 index 000000000..5b6e51de8 --- /dev/null +++ b/.changes/native-audio-session-preset @@ -0,0 +1 @@ +patch type="fixed" "iOS: the audio session is configured from engine state even before a policy is pushed (pre-connect audio, pre-join mic, playout-only), fixing audio engine error -9001" diff --git a/doc/audio.md b/doc/audio.md index 47718bfb4..dcb05913c 100644 --- a/doc/audio.md +++ b/doc/audio.md @@ -139,7 +139,7 @@ The session intent decides how the platform treats audio. Capture options decide - Use `AudioProcessingOptions.communication()` when you want all four voice filters on for an existing local audio track. - Use `AudioProcessingOptions.noProcessing()` for local capture where you want minimal processing, such as high quality recording or app-managed audio effects. -Create-time processing is configured through `AudioCaptureOptions`. `LocalAudioTrack.create(...)` stores these options, and LiveKit prepares them when local recording starts, such as during publish or preconnect. If the exposed native platform API reports that capture-time setup failed, the start path throws `AudioProcessingException` before publish creates a server-side publication. +Create-time processing is configured through `AudioCaptureOptions`. `LocalAudioTrack.create(...)` stores these options, and LiveKit prepares them when local recording starts, such as during publish or preconnect. If the exposed native platform API reports that capture-time setup failed, the start path throws before publish creates a server-side publication: `TrackCreateException` when microphone permission is missing, `AudioSessionException` when the audio session cannot be configured for or does not permit recording, and `AudioProcessingException` for other capture-time setup failures. ```dart final track = await LocalAudioTrack.create( diff --git a/lib/src/audio/audio_engine_error.dart b/lib/src/audio/audio_engine_error.dart new file mode 100644 index 000000000..aeaad2e80 --- /dev/null +++ b/lib/src/audio/audio_engine_error.dart @@ -0,0 +1,48 @@ +// 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/services.dart' show PlatformException; + +import 'package:meta/meta.dart'; + +import '../exceptions.dart'; + +/// Error codes the native plugin uses for audio device module failures with a +/// known cause. Anything else keeps the caller-specific fallback code. +@internal +const String audioEngineErrorCodeDeviceAccessDenied = 'deviceAccessDenied'; +@internal +const String audioEngineErrorCodeAudioSessionInvalidCategory = 'audioSessionInvalidCategory'; +@internal +const String audioEngineErrorCodeAudioSessionConfigureFailed = 'audioSessionConfigureFailed'; + +/// Maps a [PlatformException] from an audio device module call to the +/// [LiveKitException] describing its cause, or `null` when the code is not one +/// of the known audio engine failures and the caller should apply its own +/// mapping. +@internal +LiveKitException? audioEngineExceptionFrom(PlatformException error) { + final native = error.message?.trim() ?? ''; + String message(String fallback) => native.isEmpty ? fallback : native; + switch (error.code) { + case audioEngineErrorCodeDeviceAccessDenied: + return TrackCreateException(message('Microphone permission is not granted')); + case audioEngineErrorCodeAudioSessionInvalidCategory: + return AudioSessionException(message('Audio session category does not support recording')); + case audioEngineErrorCodeAudioSessionConfigureFailed: + return AudioSessionException(message('Failed to configure the audio session')); + default: + return null; + } +} diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index be25e83e2..67e50dff1 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -14,6 +14,8 @@ import 'dart:async'; +import 'package:flutter/services.dart' show PlatformException; + import 'package:meta/meta.dart'; import '../logger.dart'; @@ -21,6 +23,7 @@ import '../support/native.dart'; import '../support/platform.dart'; import 'android_audio_session_adapter.dart'; import 'audio_engine_availability.dart'; +import 'audio_engine_error.dart'; import 'audio_processing_state.dart'; import 'audio_session.dart'; import 'audio_session_policy.dart'; @@ -214,16 +217,23 @@ class AudioManager { /// cross-platform code. /// /// Throws if the native side rejects the change, so callers never assume - /// the engine is gated when it is not. + /// the engine is gated when it is not. Enabling input requires microphone + /// permission, which is not requested here: a [TrackCreateException] is + /// thrown when it is missing, and an [AudioSessionException] when the audio + /// session does not permit recording (iOS only; macOS has no audio session). /// /// Experimental: this API may change in a future release. @experimental Future setEngineAvailability(AudioEngineAvailability availability) async { if (!lkPlatformIsApple()) return; - await Native.setEngineAvailability( - isInputAvailable: availability.isInputAvailable, - isOutputAvailable: availability.isOutputAvailable, - ); + try { + await Native.setEngineAvailability( + isInputAvailable: availability.isInputAvailable, + isOutputAvailable: availability.isOutputAvailable, + ); + } on PlatformException catch (error) { + throw audioEngineExceptionFrom(error) ?? error; + } } /// Selects whether LiveKit manages the platform audio session automatically. @@ -337,6 +347,24 @@ class AudioManager { } } + /// Pushes the current resolved policy to native before a local recording + /// starts (pre-connect audio, pre-join microphone), so the session comes + /// from the real Dart policy instead of the native built-in preset. The + /// preset remains as a fallback for engine starts that happen before the + /// Flutter side exists (e.g. a CallKit killed-state wake). + /// + /// In automatic mode with the engine idle this only caches the policy + /// natively; it is applied on engine start. iOS only: manual mode (app owns + /// the session) and other platforms are untouched. + @internal + Future ensureAppleAudioSessionPolicy() async { + if (!lkPlatformIs(PlatformType.iOS)) return; + await _syncAppleAudioSessionManagementMode(); + if (_isAutomaticConfigurationEnabled) { + await _configureAppleAudioSession(_options); + } + } + Future _syncAppleAudioSessionManagementMode() async { if (lkPlatformIs(PlatformType.iOS)) { await Native.setAppleAudioSessionAutomaticManagementEnabled( @@ -414,13 +442,21 @@ class AudioManager { /// Bluetooth headsets). /// /// Throws if the native side rejects the change, so callers never assume - /// a muting behavior that is not actually in effect. + /// a muting behavior that is not actually in effect. A mode change can + /// rebuild the audio engine, so like [setEngineAvailability] it throws a + /// [TrackCreateException] when microphone permission is missing and an + /// [AudioSessionException] when the audio session does not permit recording + /// (iOS only; macOS has no audio session). /// /// This is engine-wide state. Prefer setting it once before connecting. Future setMicrophoneMuteMode(MicrophoneMuteMode mode) async { if (mode == MicrophoneMuteMode.unknown) return; if (!lkPlatformIsApple()) return; - await Native.setMicrophoneMuteMode(mode.name); + try { + await Native.setMicrophoneMuteMode(mode.name); + } on PlatformException catch (error) { + throw audioEngineExceptionFrom(error) ?? error; + } } /// Diagnostic snapshot of the resolved audio processing state. diff --git a/lib/src/exceptions.dart b/lib/src/exceptions.dart index bae9a4819..db437810d 100644 --- a/lib/src/exceptions.dart +++ b/lib/src/exceptions.dart @@ -82,6 +82,17 @@ class TrackCreateException extends LiveKitException { TrackCreateException([String msg = 'Failed to create track']) : super._(msg); } +/// The platform audio session could not be configured for, or does not permit, +/// the requested audio operation (iOS; macOS has no audio session, so engine +/// failures there keep their generic error codes). +/// Common reasons: +/// - Recording was started while the app-managed audio session +/// (`AudioSessionManagementMode.manual`) has a category without input. +/// - The system rejected the audio session configuration. +class AudioSessionException extends LiveKitException { + AudioSessionException([String msg = 'Audio session error']) : super._(msg); +} + /// Failed to publish a local track. /// Common reasons: /// - Token does not have track publish permission. diff --git a/lib/src/track/local/audio.dart b/lib/src/track/local/audio.dart index 47b21e910..c92af74ae 100644 --- a/lib/src/track/local/audio.dart +++ b/lib/src/track/local/audio.dart @@ -20,6 +20,8 @@ import 'package:collection/collection.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; import 'package:meta/meta.dart'; +import '../../audio/audio_engine_error.dart'; +import '../../audio/audio_manager.dart'; import '../../events.dart'; import '../../internal/events.dart'; import '../../logger.dart'; @@ -86,15 +88,24 @@ class LocalAudioTrack extends LocalTrack with AudioTrack, LocalAudioManagementMi Future startCapture() async { await super.startCapture(); if (lkPlatformSupportsExplicitAudioRecordingStart()) { + // Recording can start before any policy push (pre-connect audio, + // pre-join mic). Hand native the resolved Dart policy first — cache-only + // while the engine is idle — so the session is not left to the native + // built-in preset. + await AudioManager.instance.ensureAppleAudioSessionPolicy(); try { // Match Swift: start the ADM before publishing so capture-time audio // processing options are applied before WebRTC opens the microphone. await Native.startLocalRecording(currentOptions.processing.toMap()); } on PlatformException catch (error) { - throw track_options.AudioProcessingException( - _audioProcessingFailureReason(error.code), - error.message ?? '', - ); + // Missing microphone permission or an audio session that does not + // permit recording are not audio processing failures, so they surface + // as their own exception types. + throw audioEngineExceptionFrom(error) ?? + track_options.AudioProcessingException( + _audioProcessingFailureReason(error.code), + error.message ?? '', + ); } } } diff --git a/pubspec.lock b/pubspec.lock index 86f4871ee..b43f6672f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -452,10 +452,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -468,10 +468,10 @@ packages: dependency: "direct main" description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" tint: dependency: transitive description: @@ -785,10 +785,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index a4f1b6b48..cc09b4d78 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -57,6 +57,9 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { // the audio device module both hold it weakly, so LiveKit must keep it alive. var channel: FlutterMethodChannel? var audioEngineObserver: LKAudioEngineObserver? + // Process-wide engine observer, kept across plugin registrations so a + // second Flutter engine does not wipe the pushed policy / management mode. + private static var sharedAudioEngineObserver: LKAudioEngineObserver? #if os(iOS) var cancellable = Set() @@ -84,8 +87,16 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { // engine emits these events on both iOS and macOS. macOS has no // AVAudioSession to configure, so there it only surfaces engine state. // Set before the peer connection factory is created. + // + // The observer is shared across plugin registrations: its state (the + // pushed policy and management mode) describes the process-wide audio + // session, so a later registration (e.g. a second Flutter engine in + // the same process) must not reset it. Only the notification channel + // is rebound to the latest registration. instance.channel = channel - let audioEngineObserver = LKAudioEngineObserver(channel: channel) + let audioEngineObserver = sharedAudioEngineObserver ?? LKAudioEngineObserver(channel: channel) + audioEngineObserver.updateChannel(channel) + sharedAudioEngineObserver = audioEngineObserver instance.audioEngineObserver = audioEngineObserver FlutterWebRTCPlugin.setAudioDeviceModuleObserver(audioEngineObserver) @@ -524,11 +535,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { if admResult == 0 { result(nil) } else { - result(FlutterError( - code: "setEngineAvailability", - message: "Audio engine returned error code: \(admResult)", - details: nil - )) + result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult, + fallbackCode: "setEngineAvailability")) } } } @@ -575,11 +583,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { if admResult == 0 { result(nil) } else { - result(FlutterError( - code: "setMicrophoneMuteMode", - message: "Audio engine returned error code: \(admResult)", - details: nil - )) + // A mute-mode change can rebuild the engine and hit the same + // permission / audio session checks as a recording start. + result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult, + fallbackCode: "setMicrophoneMuteMode")) } } } @@ -606,11 +613,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { if admResult == 0 { result(nil) } else { - result(FlutterError( - code: "applyFailed", - message: "Audio engine returned error code: \(admResult)", - details: nil - )) + // Permission and audio session failures get their own codes so + // Dart does not report them as audio processing failures. + result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult, + fallbackCode: "applyFailed")) } } } @@ -628,11 +634,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { if admResult == 0 { result(nil) } else { - result(FlutterError( - code: "stopLocalRecording", - message: "Audio engine returned error code: \(admResult)", - details: nil - )) + result(LiveKitPlugin.flutterError(forAudioEngineResult: admResult, + fallbackCode: "stopLocalRecording")) } } } @@ -786,14 +789,46 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } } -#if !os(macOS) -@available(iOS 13.0, *) extension LiveKitPlugin { /// SDK-side audio engine error code (mirrors client-sdk-swift): returned /// from a delegate callback to make WebRTC abort / roll back the engine /// operation when the audio session cannot be configured. static let kAudioEngineErrorFailedToConfigureAudioSession = -4100 + /// Error codes originating from the WebRTC AudioEngineDevice. Keep in sync + /// with `audio_engine_device.h` in the webrtc-sdk fork. + static let kAudioEngineErrorInsufficientDevicePermission = -9000 + static let kAudioEngineErrorAudioSessionInvalidCategory = -9001 + + /// 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", + message: "Microphone permission is not granted (audio engine error \(result))", + details: result) + case kAudioEngineErrorAudioSessionInvalidCategory: + return FlutterError(code: "audioSessionInvalidCategory", + message: "Audio session category does not support recording (audio engine error \(result))", + details: result) + case kAudioEngineErrorFailedToConfigureAudioSession: + return FlutterError(code: "audioSessionConfigureFailed", + message: "Failed to configure the audio session (audio engine error \(result))", + details: result) + default: + return FlutterError(code: fallbackCode, + message: "Audio engine returned error code: \(result)", + details: result) + } + } +} + +#if !os(macOS) +@available(iOS 13.0, *) +extension LiveKitPlugin { /// Applies an `RTCAudioSessionConfiguration` to the shared `RTCAudioSession`. /// Returns `nil` on success or the thrown error. Safe to call on any thread. static func applyAudioSessionConfiguration(_ configuration: RTCAudioSessionConfiguration, @@ -869,6 +904,13 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { private weak var channel: FlutterMethodChannel? #if !os(macOS) + // Policy pushed from Dart, if any. It is an override: when nothing has been + // pushed yet (recording before the room connects, or an engine start driven + // from native before the Flutter side exists) the observer resolves a + // built-in preset from engine state instead, so the engine never enables + // against the app-default soloAmbient category. Mirrors the Swift SDK's + // AudioSessionEngineObserver, which derives the session from engine state + // alone. private var cachedConfiguration: RTCAudioSessionConfiguration? // When true, the category is chosen from the live engine state at apply time // (playAndRecord while recording, playback for playout-only) rather than @@ -898,6 +940,14 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { super.init() } + /// Rebinds Dart notifications to the given channel. Called on every plugin + /// registration, since the observer itself outlives registrations. + func updateChannel(_ channel: FlutterMethodChannel) { + lock.lock() + self.channel = channel + lock.unlock() + } + #if !os(macOS) var isSessionActive: Bool { lock.lock(); defer { lock.unlock() } @@ -931,12 +981,12 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { /// manual mode and for re-applying while the engine is already running. func applyCachedConfiguration() -> Error? { lock.lock() - let configuration = effectiveConfigurationLocked(isRecordingEnabled: lastIsRecordingEnabled) + let resolved = effectiveConfigurationLocked(isRecordingEnabled: lastIsRecordingEnabled) let forceSpeakerOutput = self.forceSpeakerOutput let isActive = isSessionActivationEnabled lock.unlock() - guard let configuration else { return nil } - return LiveKitPlugin.applyAudioSessionConfiguration(configuration, + guard let resolved else { return nil } + return LiveKitPlugin.applyAudioSessionConfiguration(resolved.configuration, forceSpeakerOutput: forceSpeakerOutput, isActive: isActive) } @@ -944,15 +994,26 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { private func applyManagedConfiguration(isRecordingEnabled: Bool) -> Error? { lock.lock() let shouldManageSession = isAutomaticManagementEnabled - let configuration = effectiveConfigurationLocked(isRecordingEnabled: isRecordingEnabled) + let resolved = effectiveConfigurationLocked(isRecordingEnabled: isRecordingEnabled) let forceSpeakerOutput = self.forceSpeakerOutput let isActive = isSessionActivationEnabled lock.unlock() - guard shouldManageSession, let configuration else { return nil } - return LiveKitPlugin.applyAudioSessionConfiguration(configuration, - forceSpeakerOutput: forceSpeakerOutput, - isActive: isActive) + guard shouldManageSession, let resolved else { return nil } + guard let error = LiveKitPlugin.applyAudioSessionConfiguration(resolved.configuration, + forceSpeakerOutput: forceSpeakerOutput, + isActive: isActive) + else { return nil } + // The built-in preset is best-effort: before it existed, engine starts + // with nothing pushed proceeded against whatever session the app had, + // and the ADM's own pre-enable checks still gate recording on an + // unsuitable category. Only a policy the app actually pushed aborts + // the engine operation when it cannot be applied. + if resolved.isDefaultPreset { + print("[LiveKit] AudioEngine: default audio session preset not applied, continuing: \(error)") + return nil + } + return error } private func recordEngineState(isPlayoutEnabled: Bool, isRecordingEnabled: Bool) { @@ -972,15 +1033,51 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { /// category would leave playAndRecord-only mode/options (e.g. videoChat, /// allowBluetooth) that are invalid for the playback category. Mirrors the /// Swift SDK's `.playback` preset (playback + spokenAudio + mixWithOthers). - private func effectiveConfigurationLocked(isRecordingEnabled: Bool) -> RTCAudioSessionConfiguration? { - guard let configuration = cachedConfiguration else { return nil } - guard selectCategoryByEngineState, !isRecordingEnabled else { return configuration } + /// + /// With no pushed config and automatic management on, the built-in + /// recording preset stands in for the Dart policy, so the result is the + /// same as if the default policy had been pushed. Returns `nil` only in + /// manual mode with nothing pushed, where the app owns the session. + private func effectiveConfigurationLocked(isRecordingEnabled: Bool) + -> (configuration: RTCAudioSessionConfiguration, isDefaultPreset: Bool)? + { + let usesDefaultPreset = cachedConfiguration == nil + guard let configuration = cachedConfiguration + ?? (isAutomaticManagementEnabled ? defaultRecordingConfigurationLocked() : nil) + else { return nil } + // The default preset is always resolved by engine state, like the Dart + // automatic-mode push it stands in for. + guard usesDefaultPreset || selectCategoryByEngineState, !isRecordingEnabled else { + return (configuration, usesDefaultPreset) + } let playback = copyConfiguration(configuration) playback.category = AVAudioSession.Category.playback.rawValue playback.categoryOptions = [.mixWithOthers] playback.mode = AVAudioSession.Mode.spokenAudio.rawValue - return playback + return (playback, usesDefaultPreset) + } + + /// Built-in playAndRecord preset used until Dart pushes a policy. Must be + /// called with `lock` held. + /// + /// Keep in sync with the automatic-mode branch of + /// `ResolvedAudioSessionPolicy.appleConfiguration` in + /// `lib/src/audio/audio_session_policy.dart`, so a later push of the default + /// policy (on connect) does not change the live session. + private func defaultRecordingConfigurationLocked() -> RTCAudioSessionConfiguration { + // `webRTC()` returns the process-wide shared configuration object; + // mutating it would rewrite WebRTC's defaults for every other consumer + // (and the result escapes `lock`), so start from a copy. + let configuration = copyConfiguration(RTCAudioSessionConfiguration.webRTC()) + configuration.category = AVAudioSession.Category.playAndRecord.rawValue + configuration.categoryOptions = [.allowBluetooth, .allowBluetoothA2DP, .allowAirPlay] + // videoChat routes to the speaker, matching the Dart AudioManager's + // default speaker preference. A non-default preference always arrives + // as a pushed policy (its mode carries the preference), which bypasses + // this preset entirely. + configuration.mode = AVAudioSession.Mode.videoChat.rawValue + return configuration } private func copyConfiguration(_ configuration: RTCAudioSessionConfiguration) -> RTCAudioSessionConfiguration { @@ -1071,7 +1168,10 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { } private func notifyEngineState(isPlayoutEnabled: Bool, isRecordingEnabled: Bool) { - guard let channel = channel else { return } + lock.lock() + let channel = channel + lock.unlock() + guard let channel else { return } DispatchQueue.main.async { channel.invokeMethod("onAudioEngineState", arguments: [ "isPlayoutEnabled": isPlayoutEnabled, diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index d02b54d58..286a69ce3 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -17,9 +17,11 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/src/audio/android_audio_session_adapter.dart'; +import 'package:livekit_client/src/audio/audio_engine_error.dart'; import 'package:livekit_client/src/audio/audio_manager.dart'; import 'package:livekit_client/src/audio/audio_session.dart'; import 'package:livekit_client/src/audio/audio_session_policy.dart'; +import 'package:livekit_client/src/exceptions.dart'; import 'package:livekit_client/src/support/native.dart'; import 'package:livekit_client/src/support/native_audio.dart' as native_audio; import 'package:livekit_client/src/support/webrtc_initialize_options.dart'; @@ -873,4 +875,34 @@ void main() { ); }); }); + + group('audioEngineExceptionFrom', () { + test('maps missing microphone permission to TrackCreateException', () { + final error = audioEngineExceptionFrom( + PlatformException(code: audioEngineErrorCodeDeviceAccessDenied, message: 'no mic'), + ); + + expect(error, isA()); + expect(error!.message, 'no mic'); + }); + + test('maps audio session failures to AudioSessionException', () { + final invalidCategory = audioEngineExceptionFrom( + PlatformException(code: audioEngineErrorCodeAudioSessionInvalidCategory), + ); + final configureFailed = audioEngineExceptionFrom( + PlatformException(code: audioEngineErrorCodeAudioSessionConfigureFailed, message: ' detail '), + ); + + expect(invalidCategory, isA()); + expect(invalidCategory!.message, 'Audio session category does not support recording'); + expect(configureFailed, isA()); + expect(configureFailed!.message, 'detail'); + }); + + test('leaves other codes to the caller', () { + expect(audioEngineExceptionFrom(PlatformException(code: 'applyFailed')), isNull); + expect(audioEngineExceptionFrom(PlatformException(code: 'setEngineAvailability')), isNull); + }); + }); }