diff --git a/.changes/audio-engine-availability b/.changes/audio-engine-availability new file mode 100644 index 000000000..56d61ab36 --- /dev/null +++ b/.changes/audio-engine-availability @@ -0,0 +1 @@ +minor type="added" "AudioManager engine availability control and externalCallSystem session mode for CallKit coordination" diff --git a/lib/src/audio/audio_engine_availability.dart b/lib/src/audio/audio_engine_availability.dart new file mode 100644 index 000000000..4a2ba6825 --- /dev/null +++ b/lib/src/audio/audio_engine_availability.dart @@ -0,0 +1,56 @@ +// 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. + +/// Whether the WebRTC audio engine is allowed to run, per direction. +/// +/// This is the highest-priority gate over anything that may start the engine +/// (enabling the microphone, starting playback of remote audio). Requests +/// made while a direction is unavailable are not lost: the engine starts as +/// soon as availability allows. +/// +/// Used to coordinate with platform call systems that own audio activation +/// timing, such as CallKit on iOS: keep the engine unavailable until +/// `provider(didActivate:)` and make it available inside the +/// activate/deactivate window. See `AudioManager.setEngineAvailability`. +class AudioEngineAvailability { + /// Whether the engine may run its input (microphone / recording) side. + final bool isInputAvailable; + + /// Whether the engine may run its output (playout / remote audio) side. + final bool isOutputAvailable; + + const AudioEngineAvailability({ + required this.isInputAvailable, + required this.isOutputAvailable, + }); + + /// Both input and output are available (the default). + static const defaultAvailability = AudioEngineAvailability(isInputAvailable: true, isOutputAvailable: true); + + /// Neither input nor output is available. The engine will not run. + static const none = AudioEngineAvailability(isInputAvailable: false, isOutputAvailable: false); + + @override + bool operator ==(Object other) => + other is AudioEngineAvailability && + other.isInputAvailable == isInputAvailable && + other.isOutputAvailable == isOutputAvailable; + + @override + int get hashCode => Object.hash(isInputAvailable, isOutputAvailable); + + @override + String toString() => + 'AudioEngineAvailability(isInputAvailable: $isInputAvailable, isOutputAvailable: $isOutputAvailable)'; +} diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index efb4a8d32..a4306048c 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -20,10 +20,13 @@ import '../logger.dart'; import '../support/native.dart'; import '../support/platform.dart'; import 'android_audio_session_adapter.dart'; +import 'audio_engine_availability.dart'; import 'audio_processing_state.dart'; import 'audio_session.dart'; import 'audio_session_policy.dart'; +export 'audio_engine_availability.dart'; + /// Snapshot of the WebRTC audio engine's playout/recording state. /// /// Surfaced by [AudioManager] from real audio-engine lifecycle events on the @@ -96,7 +99,11 @@ class AudioManager { /// A broadcast stream of audio engine state changes (native engine lifecycle). Stream get audioEngineStateStream => _audioEngineStateController.stream; - bool get _isAutomaticConfigurationEnabled => _managementMode == AudioSessionManagementMode.automatic; + // externalCallSystem keeps engine-driven configuration (like automatic) but + // the native side never activates/deactivates the session. + bool get _isAutomaticConfigurationEnabled => _managementMode != AudioSessionManagementMode.manual; + + bool get _isSessionActivationEnabled => _managementMode != AudioSessionManagementMode.externalCallSystem; @visibleForTesting void resetForTest() { @@ -141,11 +148,16 @@ class AudioManager { /// `LiveKitClient.initialize(initialAudioSessionOptions: ...)` uses this so the /// WebRTC initialization-time Android audio attributes and LiveKit's automatic /// runtime session policy start from the same intent. Unlike - /// [setAudioSessionOptions], this keeps automatic management enabled and does + /// [setAudioSessionOptions], this keeps the current management mode and does /// not apply native session changes immediately. + /// + /// Applies under [AudioSessionManagementMode.automatic] and + /// [AudioSessionManagementMode.externalCallSystem], both of which drive + /// configuration from engine lifecycle. Skipped under + /// [AudioSessionManagementMode.manual], where the app owns the options. @internal void setInitialAudioSessionOptions(AudioSessionOptions options) { - if (_managementMode != AudioSessionManagementMode.automatic) { + if (!_isAutomaticConfigurationEnabled) { return; } _options = options; @@ -160,12 +172,57 @@ class AudioManager { /// /// The speaker preference and force flag are owned by setSpeakerOutputPreferred /// and are preserved across this call. + /// + /// Under [AudioSessionManagementMode.externalCallSystem] the mode is + /// preserved: the configuration is applied without activating the session. Future setAudioSessionOptions(AudioSessionOptions options) async { await _enterManualMode(); _options = options; await _applyCurrentAudioSessionPolicy(); } + /// Sets whether the WebRTC audio engine is allowed to run. + /// + /// This is the highest-priority gate over anything that may start the + /// engine (enabling the microphone, remote audio playback). Requests made + /// while unavailable are honored as soon as availability allows, so it is + /// safe to connect a room and publish the microphone while the engine is + /// still gated off. + /// + /// This is the core of CallKit coordination on iOS: set + /// [AudioEngineAvailability.none] before connecting, then flip to + /// [AudioEngineAvailability.defaultAvailability] in CallKit's + /// `provider(didActivate:)` and back to `none` in `didDeactivate`. Combine + /// with [AudioSessionManagementMode.externalCallSystem] so LiveKit never + /// activates the session itself. + /// + /// iOS/macOS only. A no-op elsewhere, so it is always safe to call from + /// cross-platform code. + /// + /// Throws if the native side rejects the change, so callers never assume + /// the engine is gated when it is not. + Future setEngineAvailability(AudioEngineAvailability availability) async { + if (!lkPlatformIsApple()) return; + await Native.setEngineAvailability( + isInputAvailable: availability.isInputAvailable, + isOutputAvailable: availability.isOutputAvailable, + ); + } + + /// The WebRTC audio engine's current availability. + /// + /// Returns [AudioEngineAvailability.defaultAvailability] on platforms + /// without engine gating (everything except iOS/macOS). + Future getEngineAvailability() async { + if (!lkPlatformIsApple()) return AudioEngineAvailability.defaultAvailability; + final response = await Native.getEngineAvailability(); + if (response == null) return AudioEngineAvailability.defaultAvailability; + return AudioEngineAvailability( + isInputAvailable: response['isInputAvailable'] as bool? ?? true, + isOutputAvailable: response['isOutputAvailable'] as bool? ?? true, + ); + } + /// Selects whether LiveKit manages the platform audio session automatically. /// /// In [AudioSessionManagementMode.manual], LiveKit does not update the audio @@ -186,8 +243,12 @@ class AudioManager { } /// Switches to manual mode if not already, syncing the native side once. + /// + /// [AudioSessionManagementMode.externalCallSystem] is preserved: an explicit + /// configuration under an external call system applies without LiveKit + /// taking over the session lifecycle. Future _enterManualMode() async { - if (_managementMode == AudioSessionManagementMode.manual) return; + if (_managementMode != AudioSessionManagementMode.automatic) return; _managementMode = AudioSessionManagementMode.manual; await _syncAppleAudioSessionManagementMode(); } @@ -199,6 +260,10 @@ class AudioManager { /// session on its own. Re-apply a configuration with [setAudioSessionOptions], /// or hand control back with [setAudioSessionManagementMode]. Future deactivateAudioSession() async { + if (_managementMode == AudioSessionManagementMode.externalCallSystem) { + logger.warning('deactivateAudioSession skipped: the external call system owns session activation'); + return; + } await _enterManualMode(); if (lkPlatformIs(PlatformType.iOS)) { await Native.deactivateAppleAudioSession(); @@ -265,7 +330,10 @@ class AudioManager { Future _syncAppleAudioSessionManagementMode() async { if (lkPlatformIs(PlatformType.iOS)) { - await Native.setAppleAudioSessionAutomaticManagementEnabled(_isAutomaticConfigurationEnabled); + await Native.setAppleAudioSessionAutomaticManagementEnabled( + _isAutomaticConfigurationEnabled, + sessionActivationEnabled: _isSessionActivationEnabled, + ); } } diff --git a/lib/src/audio/audio_session.dart b/lib/src/audio/audio_session.dart index 942f130d0..5ec21678c 100644 --- a/lib/src/audio/audio_session.dart +++ b/lib/src/audio/audio_session.dart @@ -27,6 +27,28 @@ enum AudioSessionManagementMode { /// The app must call AudioManager APIs when it wants to apply a session /// configuration. manual, + + /// An external telephony system (iOS CallKit / Android Telecom) owns the + /// platform audio lifecycle. + /// + /// On iOS, LiveKit keeps configuring the session's category/mode/options + /// from the audio engine lifecycle like [automatic], but never activates or + /// deactivates the session, since CallKit owns activation timing. Combine + /// with `AudioManager.setEngineAvailability` so the engine only runs inside + /// CallKit's `didActivate`/`didDeactivate` window. + /// + /// On Android this currently behaves like [automatic] for session + /// configuration, so a cross-platform app can set this mode once at + /// startup: iOS gets the CallKit contract and Android keeps LiveKit's + /// normal session management. When Telecom (`androidx.core.telecom`) + /// integration lands, this mode will stand down LiveKit's audio-focus and + /// routing management, which the Telecom framework owns for registered + /// calls. + /// + /// `AudioManager.deactivateAudioSession` is disabled in this mode on all + /// platforms, since releasing platform audio belongs to the external call + /// system. + externalCallSystem, } @immutable diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 6817a9705..54834a621 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -219,18 +219,60 @@ class Native { /// Enable or disable LiveKit's automatic iOS audio-session management from /// native WebRTC audio-engine lifecycle callbacks. + /// + /// [sessionActivationEnabled] controls whether LiveKit may activate or + /// deactivate the session at all. When false, LiveKit only configures the + /// category/mode and an external system (CallKit) owns activation timing. @internal - static Future setAppleAudioSessionAutomaticManagementEnabled(bool enabled) async { + static Future setAppleAudioSessionAutomaticManagementEnabled( + bool enabled, { + bool sessionActivationEnabled = true, + }) async { try { await channel.invokeMethod( 'setAppleAudioSessionAutomaticManagementEnabled', - {'enabled': enabled}, + { + 'enabled': enabled, + 'sessionActivationEnabled': sessionActivationEnabled, + }, ); } catch (error) { logger.warning('setAppleAudioSessionAutomaticManagementEnabled did throw $error'); } } + /// 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. + @internal + static Future setEngineAvailability({ + required bool isInputAvailable, + required bool isOutputAvailable, + }) async { + await channel.invokeMethod( + 'setEngineAvailability', + { + 'isInputAvailable': isInputAvailable, + 'isOutputAvailable': isOutputAvailable, + }, + ); + } + + /// Reads the WebRTC audio engine's availability (iOS/macOS). Returns null + /// when the native side cannot provide it. + @internal + static Future?> getEngineAvailability() async { + try { + return await channel.invokeMethod>('getEngineAvailability', {}); + } catch (error) { + logger.warning('getEngineAvailability did throw $error'); + return null; + } + } + @internal static Future startVisualizer( String trackId, { diff --git a/lib/src/support/platform.dart b/lib/src/support/platform.dart index 242c0ff01..d6f0308c8 100644 --- a/lib/src/support/platform.dart +++ b/lib/src/support/platform.dart @@ -23,6 +23,8 @@ bool lkPlatformIsMobile() => [PlatformType.iOS, PlatformType.android].contains(l bool lkPlatformIsWebMobile() => lkPlatformIsWebMobileImplementation(); +bool lkPlatformIsApple() => [PlatformType.iOS, PlatformType.macOS].contains(lkPlatform()); + bool lkPlatformIsDesktop() => [ PlatformType.macOS, PlatformType.windows, diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index 2c6ae0ecf..689f3d5fa 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -89,6 +89,13 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { instance.audioEngineObserver = audioEngineObserver FlutterWebRTCPlugin.setAudioDeviceModuleObserver(audioEngineObserver) + // An AppDelegate may have gated engine availability before the Flutter + // engine existed (CallKit killed-state wake). Apply it now, before any + // audio operation can start the engine. This must run after the engine + // observer above, since applying forces creation of the peer + // connection factory, which attaches the observer. + applyPendingEngineAvailabilityIfNeeded() + #if os(iOS) BroadcastManager.shared.isBroadcastingPublisher .sink { isBroadcasting in @@ -381,7 +388,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { result(FlutterMethodNotImplemented) #else let enabled = (args["enabled"] as? Bool) ?? true - audioEngineObserver?.setAutomaticManagementEnabled(enabled) + let sessionActivationEnabled = (args["sessionActivationEnabled"] as? Bool) ?? true + audioEngineObserver?.setAutomaticManagementEnabled(enabled, sessionActivationEnabled: sessionActivationEnabled) result(true) #endif } @@ -438,6 +446,106 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { ]) } + // MARK: - Engine availability + + private static let engineAvailabilityLock = NSLock() + private static var pendingEngineAvailability: RTCAudioEngineAvailability? + + /// Sets whether the WebRTC audio engine is allowed to run. This is the + /// highest-priority gate over anything that may start the engine. Requests + /// made while unavailable are honored once availability allows. + /// + /// Public native API so an AppDelegate can gate audio before the Flutter + /// engine exists (e.g. a CallKit killed-state wake): when the audio device + /// module is not created yet, the value is stored and applied at plugin + /// registration, before any audio operation can start the engine. + /// + /// Returns `false` when the value was stored for deferred application, + /// `true` when it was applied to the live audio device module. + @objc @discardableResult + public static func setEngineAvailability(isInputAvailable: Bool, isOutputAvailable: Bool) -> Bool { + let availability = RTCAudioEngineAvailability(isInputAvailable: ObjCBool(isInputAvailable), + isOutputAvailable: ObjCBool(isOutputAvailable)) + engineAvailabilityLock.lock() + pendingEngineAvailability = availability + engineAvailabilityLock.unlock() + + guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + return false + } + return adm.setEngineAvailability(availability) == 0 + } + + private static func applyPendingEngineAvailabilityIfNeeded() { + engineAvailabilityLock.lock() + let pending = pendingEngineAvailability + engineAvailabilityLock.unlock() + guard let pending else { return } + + // Accessing peerConnectionFactory creates it (and the audio device + // module) if needed. That is intentional here, so the gate is in + // place before anything else can start the engine. + guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + print("[LiveKit] engine availability pending but audio device module is unavailable") + return + } + if adm.setEngineAvailability(pending) != 0 { + print("[LiveKit] failed to apply pending engine availability") + } + } + + public func handleSetEngineAvailability(args: [String: Any?], result: @escaping FlutterResult) { + let isInputAvailable = (args["isInputAvailable"] as? Bool) ?? true + let isOutputAvailable = (args["isOutputAvailable"] as? Bool) ?? true + + // Accessing peerConnectionFactory creates the audio device module if + // needed, so the gate is effective even before any track exists. + guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + result(FlutterError(code: "setEngineAvailability", message: "audio device module is unavailable", details: nil)) + return + } + + let availability = RTCAudioEngineAvailability(isInputAvailable: ObjCBool(isInputAvailable), + isOutputAvailable: ObjCBool(isOutputAvailable)) + + // Keep the pending value in sync so both the native static and this + // channel path record the latest intent. A later plugin registration + // (e.g. a second Flutter engine in the same process) re-applies the + // pending value, so it must never lag behind a Dart-side update. + LiveKitPlugin.engineAvailabilityLock.lock() + LiveKitPlugin.pendingEngineAvailability = availability + LiveKitPlugin.engineAvailabilityLock.unlock() + + // Availability changes can stop/start the engine, so keep that work + // off the platform thread. + DispatchQueue.global(qos: .userInitiated).async { + let admResult = adm.setEngineAvailability(availability) + DispatchQueue.main.async { + if admResult == 0 { + result(nil) + } else { + result(FlutterError( + code: "setEngineAvailability", + message: "Audio engine returned error code: \(admResult)", + details: nil + )) + } + } + } + } + + public func handleGetEngineAvailability(result: @escaping FlutterResult) { + guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + result(FlutterError(code: "getEngineAvailability", message: "audio device module is unavailable", details: nil)) + return + } + let availability = adm.engineAvailability + result([ + "isInputAvailable": availability.isInputAvailable.boolValue, + "isOutputAvailable": availability.isOutputAvailable.boolValue, + ]) + } + public func handleStartLocalRecording(args: [String: Any?], result: @escaping FlutterResult) { guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "rejectedPlatformUnavailable", message: "audio device module is unavailable", details: nil)) @@ -604,6 +712,10 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { handleStartLocalRecording(args: args, result: result) case "stopLocalRecording": handleStopLocalRecording(result: result) + case "setEngineAvailability": + handleSetEngineAvailability(args: args, result: result) + case "getEngineAvailability": + handleGetEngineAvailability(result: result) case "setAudioProcessingOptions": handleSetAudioProcessingOptions(args: args, result: result) case "getAudioProcessingState": @@ -637,18 +749,18 @@ extension LiveKitPlugin { /// Returns `nil` on success or the thrown error. Safe to call on any thread. static func applyAudioSessionConfiguration(_ configuration: RTCAudioSessionConfiguration, forceSpeakerOutput: Bool, - active: Bool) -> Error? { + isActive: Bool) -> Error? { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() defer { rtcSession.unlockForConfiguration() } do { - try rtcSession.setConfiguration(configuration, active: active) + try rtcSession.setConfiguration(configuration, active: isActive) // overrideOutputAudioPort hard-routes to the speaker even over a // connected headset. Plain speaker preference is expressed by the // selected audio mode/category options, so clear any stale hard // override unless the app explicitly forced speaker output. // Only valid for the playAndRecord category. - if active, configuration.category == AVAudioSession.Category.playAndRecord.rawValue { + if isActive, configuration.category == AVAudioSession.Category.playAndRecord.rawValue { try rtcSession.overrideOutputAudioPort(forceSpeakerOutput ? .speaker : .none) } return nil @@ -699,6 +811,10 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { private var selectCategoryByEngineState = false private var forceSpeakerOutput = false private var isAutomaticManagementEnabled = true + // False when an external call system (CallKit) owns session activation: + // configurations are applied without activating, and the session is never + // deactivated from engine lifecycle. CallKit activates/deactivates. + private var isSessionActivationEnabled = true // True when cached policy changes should apply immediately. This includes // an engine already running under manual mode, because switching back to // automatic should configure the live session without waiting for another @@ -720,9 +836,10 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { return sessionActive } - func setAutomaticManagementEnabled(_ enabled: Bool) { + func setAutomaticManagementEnabled(_ enabled: Bool, sessionActivationEnabled: Bool = true) { lock.lock() isAutomaticManagementEnabled = enabled + isSessionActivationEnabled = sessionActivationEnabled lock.unlock() } @@ -748,11 +865,12 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { lock.lock() let configuration = effectiveConfigurationLocked(isRecordingEnabled: lastIsRecordingEnabled) let forceSpeakerOutput = self.forceSpeakerOutput + let isActive = isSessionActivationEnabled lock.unlock() guard let configuration else { return nil } return LiveKitPlugin.applyAudioSessionConfiguration(configuration, forceSpeakerOutput: forceSpeakerOutput, - active: true) + isActive: isActive) } private func applyManagedConfiguration(isRecordingEnabled: Bool) -> Error? { @@ -760,12 +878,13 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { let shouldManageSession = isAutomaticManagementEnabled let configuration = 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, - active: true) + isActive: isActive) } private func recordEngineState(isPlayoutEnabled: Bool, isRecordingEnabled: Bool) { @@ -849,7 +968,7 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { } } else { lock.lock() - let shouldManageSession = isAutomaticManagementEnabled + let shouldManageSession = isAutomaticManagementEnabled && isSessionActivationEnabled lock.unlock() if shouldManageSession, let error = LiveKitPlugin.deactivateAudioSession() { diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index 583f27995..8ad86f417 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -58,12 +58,13 @@ void main() { ).androidConfiguration; group('AudioSessionManagementMode', () { - test('supports automatic and manual management', () { + test('supports automatic, manual, and external call system management', () { expect( AudioSessionManagementMode.values, [ AudioSessionManagementMode.automatic, AudioSessionManagementMode.manual, + AudioSessionManagementMode.externalCallSystem, ], ); }); @@ -590,6 +591,45 @@ void main() { expect(calls.single.arguments, {'enable': true, 'force': true}); }); + test('passes session activation ownership to Apple management method', () async { + await Native.setAppleAudioSessionAutomaticManagementEnabled(true, sessionActivationEnabled: false); + + expect(calls.single.method, 'setAppleAudioSessionAutomaticManagementEnabled'); + expect(calls.single.arguments, {'enabled': true, 'sessionActivationEnabled': false}); + }); + + test('passes engine availability to platform method', () async { + await Native.setEngineAvailability(isInputAvailable: false, isOutputAvailable: true); + + expect(calls.single.method, 'setEngineAvailability'); + expect(calls.single.arguments, {'isInputAvailable': false, 'isOutputAvailable': true}); + }); + + test('initial session options seed under an external call system', () async { + AudioManager.instance.resetForTest(); + await AudioManager.instance.setAudioSessionManagementMode(AudioSessionManagementMode.externalCallSystem); + + const options = AudioSessionOptions.mediaPlayback(); + AudioManager.instance.setInitialAudioSessionOptions(options); + + expect(AudioManager.instance.options, options); + + AudioManager.instance.resetForTest(); + }); + + test('deactivateAudioSession is a no-op under an external call system', () async { + AudioManager.instance.resetForTest(); + await AudioManager.instance.setAudioSessionManagementMode(AudioSessionManagementMode.externalCallSystem); + calls.clear(); + + await AudioManager.instance.deactivateAudioSession(); + + expect(calls, isEmpty); + expect(AudioManager.instance.managementMode, AudioSessionManagementMode.externalCallSystem); + + AudioManager.instance.resetForTest(); + }); + test('passes audio session deactivation to platform methods', () async { await Native.stopAndroidAudioSession(); await Native.deactivateAppleAudioSession();