From 71c4ad95c3bfefac0e1f104929b7376f22176040 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:04:41 +0900 Subject: [PATCH 1/5] feat(audio): engine availability control and external call system session mode Adds the two switches needed for CallKit coordination, mirroring the Swift SDK pattern: - AudioManager.setEngineAvailability/getEngineAvailability with a LiveKit-owned AudioEngineAvailability type. This is the highest priority gate over anything that may start the audio engine. Requests made while unavailable are honored once availability allows, so apps can connect and publish inside CallKit action handlers and let the engine start in provider(didActivate:). No-op on non-Apple platforms. - AudioSessionManagementMode.externalCallSystem: LiveKit keeps configuring the session from engine lifecycle but never activates or deactivates it, since the external call system (CallKit) owns activation timing. Named platform-neutrally. On Android it currently behaves like manual, reserved to also stand down audio-focus management when Telecom integration lands. - Native LiveKitPlugin.setEngineAvailability static API so an AppDelegate can gate audio before the Flutter engine exists (CallKit killed-state wake). The value is stored and applied at plugin registration. Implemented entirely on LiveKit's own plugin/channel (the ADM API is public in the WebRTC-SDK pod), so no flutter_webrtc release is needed. --- .changes/audio-engine-availability | 1 + lib/src/audio/audio_engine_availability.dart | 56 +++++++++ lib/src/audio/audio_manager.dart | 66 +++++++++- lib/src/audio/audio_session.dart | 15 +++ lib/src/support/native.dart | 41 +++++- lib/src/support/platform.dart | 2 + shared_swift/LiveKitPlugin.swift | 126 +++++++++++++++++-- test/audio/audio_session_test.dart | 30 ++++- 8 files changed, 323 insertions(+), 14 deletions(-) create mode 100644 .changes/audio-engine-availability create mode 100644 lib/src/audio/audio_engine_availability.dart 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..ef0d67bcf 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() { @@ -160,12 +167,54 @@ 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. + 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 +235,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 +252,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 +322,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..0f9f248e8 100644 --- a/lib/src/audio/audio_session.dart +++ b/lib/src/audio/audio_session.dart @@ -27,6 +27,21 @@ 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 [manual]. When Telecom + /// (`androidx.core.telecom`) integration lands, this mode will also stand + /// down LiveKit's audio-focus and routing management, which the Telecom + /// framework owns for registered calls. + externalCallSystem, } @immutable diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 6817a9705..7a93f81b5 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -219,18 +219,55 @@ 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). + @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..a77179d56 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,97 @@ 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 + } + + // Availability changes can stop/start the engine, so keep that work + // off the platform thread. + DispatchQueue.global(qos: .userInitiated).async { + let availability = RTCAudioEngineAvailability(isInputAvailable: ObjCBool(isInputAvailable), + isOutputAvailable: ObjCBool(isOutputAvailable)) + 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 +703,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 +740,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 +802,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 +827,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 +856,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 +869,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 +959,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..69a8aa5f7 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,33 @@ 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('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(); From 48fb1846fde5d42ab1c55fd4bfffd8176852f466 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:25:17 +0900 Subject: [PATCH 2/5] docs: correct Android behavior description for externalCallSystem On Android the mode currently behaves like automatic, not manual. The Android session configure and speakerphone paths still run, since the activation flag only exists on iOS. This is the intended interim behavior: a cross-platform app sets the mode once and Android keeps normal session management until Telecom integration lands. --- lib/src/audio/audio_session.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/audio/audio_session.dart b/lib/src/audio/audio_session.dart index 0f9f248e8..73b0b5602 100644 --- a/lib/src/audio/audio_session.dart +++ b/lib/src/audio/audio_session.dart @@ -37,9 +37,11 @@ enum AudioSessionManagementMode { /// with `AudioManager.setEngineAvailability` so the engine only runs inside /// CallKit's `didActivate`/`didDeactivate` window. /// - /// On Android this currently behaves like [manual]. When Telecom - /// (`androidx.core.telecom`) integration lands, this mode will also stand - /// down LiveKit's audio-focus and routing management, which the Telecom + /// On Android this currently behaves like [automatic], 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. externalCallSystem, } From 05ec2fa2efbc646270ff3a135076c7a8f5cd0675 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:13:06 +0900 Subject: [PATCH 3/5] Address review findings - setInitialAudioSessionOptions now also seeds under externalCallSystem, which drives configuration from engine lifecycle like automatic mode. Previously the initialize-time options were silently dropped. - Document that setEngineAvailability deliberately propagates platform errors, unlike the other Native methods. A failed availability change means the engine may run outside the intended CallKit window, so the error must reach the caller. Mirrors the Swift SDK's throwing API. --- lib/src/audio/audio_manager.dart | 13 +++++++++++-- lib/src/support/native.dart | 5 +++++ test/audio/audio_session_test.dart | 12 ++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index ef0d67bcf..d590c1c18 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -148,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; @@ -193,6 +198,10 @@ class AudioManager { /// /// 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 (mirrors the Swift SDK's + /// throwing API), so callers never assume the engine is gated when it is + /// not. Future setEngineAvailability(AudioEngineAvailability availability) async { if (!lkPlatformIsApple()) return; await Native.setEngineAvailability( diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 7a93f81b5..54834a621 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -242,6 +242,11 @@ class Native { } /// 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, diff --git a/test/audio/audio_session_test.dart b/test/audio/audio_session_test.dart index 69a8aa5f7..8ad86f417 100644 --- a/test/audio/audio_session_test.dart +++ b/test/audio/audio_session_test.dart @@ -605,6 +605,18 @@ void main() { 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); From 3cbf322e7b75197b2c8837ebd30079e4f05985b5 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:48:16 +0900 Subject: [PATCH 4/5] docs: drop cross-SDK reference from setEngineAvailability doc --- lib/src/audio/audio_manager.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/src/audio/audio_manager.dart b/lib/src/audio/audio_manager.dart index d590c1c18..a4306048c 100644 --- a/lib/src/audio/audio_manager.dart +++ b/lib/src/audio/audio_manager.dart @@ -199,9 +199,8 @@ class AudioManager { /// 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 (mirrors the Swift SDK's - /// throwing API), so callers never assume the engine is gated when it is - /// not. + /// 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( From 51717936152515a223bb3cd9109a0e01aa03c0fa Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:31:31 +0900 Subject: [PATCH 5/5] Address second round review findings - The Dart channel path now records into the pending engine availability, so both the native static and the channel keep the latest intent. A later plugin registration (a second Flutter engine in the same process) re-applies the pending value and must not lag behind a Dart-side update. - Document that deactivateAudioSession is disabled under externalCallSystem on all platforms, and scope the Android 'behaves like automatic' description to session configuration. --- lib/src/audio/audio_session.dart | 17 +++++++++++------ shared_swift/LiveKitPlugin.swift | 13 +++++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/lib/src/audio/audio_session.dart b/lib/src/audio/audio_session.dart index 73b0b5602..5ec21678c 100644 --- a/lib/src/audio/audio_session.dart +++ b/lib/src/audio/audio_session.dart @@ -37,12 +37,17 @@ enum AudioSessionManagementMode { /// with `AudioManager.setEngineAvailability` so the engine only runs inside /// CallKit's `didActivate`/`didDeactivate` window. /// - /// On Android this currently behaves like [automatic], 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. + /// 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, } diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index a77179d56..689f3d5fa 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -505,11 +505,20 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { 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 availability = RTCAudioEngineAvailability(isInputAvailable: ObjCBool(isInputAvailable), - isOutputAvailable: ObjCBool(isOutputAvailable)) let admResult = adm.setEngineAvailability(availability) DispatchQueue.main.async { if admResult == 0 {