feat(audio): engine availability control and external call system session mode#1127
feat(audio): engine availability control and external call system session mode#1127hiroshihorie wants to merge 13 commits into
Conversation
…sion 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.
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.
- 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.
There was a problem hiding this comment.
🟡 Audio session configuration not re-applied when switching from manual to external-call-system mode
The mode transition check (mode == AudioSessionManagementMode.automatic at lib/src/audio/audio_manager.dart:240) only re-applies the audio configuration when switching to automatic, so switching from manual to externalCallSystem while the engine is running leaves stale native configuration in place.
Impact: An app that switches from manual to external-call-system mode mid-session will keep the old audio session configuration until the next engine lifecycle event, causing incorrect audio routing or category.
Mechanism: the re-apply condition doesn't account for externalCallSystem being an automatic-configuration mode
Before this PR there were only two modes (automatic and manual), so the condition previousMode != automatic && mode == automatic correctly covered the only transition that needed a re-apply. With the new externalCallSystem mode, _isAutomaticConfigurationEnabled returns true for both automatic and externalCallSystem (lib/src/audio/audio_manager.dart:104). But the re-apply guard at line 240 still only checks for mode == AudioSessionManagementMode.automatic, missing the manual → externalCallSystem transition.
The fix should check whether the new mode is any automatic-configuration mode, e.g.:
if (previousMode == AudioSessionManagementMode.manual && _isAutomaticConfigurationEnabled) {
or equivalently check both automatic and externalCallSystem.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Yea I think that's expected, mid-session switching is not really supported.
- 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.
Apps that set the gate know what they set. Dropping the getter halves the exposed experimental surface. The native adm.engineAvailability property remains readable from Swift for debugging.
…ystems setConfiguration(_:active:) always forwards its active flag to setActive, verified in WebRTC-SDK m144 (RTCAudioSession+Configuration.mm line 34 and 129). Passing active: false therefore deactivated the session CallKit had just activated on every engine lifecycle event, silencing the call. Use the configure-only setConfiguration(_:) variant when session activation belongs to the external call system.
The overrideOutputAudioPort call was guarded by isActive, which is the activation ownership intent, not the actual session state. Under externalCallSystem the session is active during a CallKit call, yet the guard made setSpeakerOutputPreferred(force: true) a silent no-op. Apply the override for the playAndRecord category regardless of ownership and tolerate failures while the session is not active yet, where the next engine lifecycle event re-applies it.
| 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; |
There was a problem hiding this comment.
🔴 Android audio focus stays held after leaving a call in the new external call system mode
In the new external call system mode, connecting acquires the Android platform audio session/focus (_isAutomaticConfigurationEnabled now returns true for it at lib/src/audio/audio_manager.dart:104), but leaving the room never releases it, so the audio focus and communication audio mode stay held after the call ends.
Impact: On Android, after a user disconnects while using this mode, the device can stay stuck in communication audio mode and keep other apps' audio ducked/paused indefinitely.
Acquire/release asymmetry between connect and disconnect paths
On connect, Room calls NativeAudioManagement.start() (lib/src/core/room.dart:325) which runs applyOptionsForConnect(). Because _isAutomaticConfigurationEnabled is now _managementMode != manual (lib/src/audio/audio_manager.dart:104), it is true under externalCallSystem, so _applyCurrentAudioSessionPolicy() runs _configureAndroidAudioSession() which calls setAndroidAudioSessionConfiguration() and acquires audio focus (config has manageAudioFocus: true).
On disconnect, NativeAudioManagement.stop() (lib/src/core/room.dart:1059) only releases the Android session when managementMode == AudioSessionManagementMode.automatic (lib/src/track/audio_management.dart:34-35), so under externalCallSystem it never calls Native.stopAndroidAudioSession(). deactivateAudioSession() is also a guarded no-op in this mode (lib/src/audio/audio_manager.dart:252-255), so there is no release path at all. This contradicts the documented intent that on Android externalCallSystem "keeps LiveKit's normal session management".
Prompt for agents
The new externalCallSystem mode makes _isAutomaticConfigurationEnabled true (audio_manager.dart:104), so on connect applyOptionsForConnect -> _configureAndroidAudioSession acquires Android audio focus/session. However NativeAudioManagement.stop() in lib/src/track/audio_management.dart only releases the Android session when managementMode == AudioSessionManagementMode.automatic, so under externalCallSystem the acquired focus/session is never released on disconnect, and deactivateAudioSession() is also a no-op in this mode. The documented intent is that Android under externalCallSystem behaves like automatic for session configuration. Fix the asymmetry so Android releases its audio session under externalCallSystem as well (e.g. change the condition in NativeAudioManagement.stop() to release when the current mode drives Android configuration, mirroring the acquire condition used on connect), while keeping the guard that manual mode does not auto-release.
Was this helpful? React with 👍 or 👎 to provide feedback.
Description
Adds the two switches needed for CallKit coordination, mirroring the Swift SDK pattern (
AudioManager.setEngineAvailabilityandAudioSessionEngineObserver.isAutomaticConfigurationEnabled).Engine availability
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 call
room.connectandsetMicrophoneEnabled(true)inside CallKit action handlers and let the engine start when CallKit activates the session. No-op on non-Apple platforms, always safe from cross-platform code.External call system session mode
LiveKit keeps configuring the session category/mode from engine lifecycle like automatic mode, but never calls
setActivein either direction. CallKit owns activation timing. This improves on the Swift example flow, where the app must set the category itself insidedidActivate.The mode is named platform-neutrally on purpose. On Android it currently behaves like manual mode, and the same mode is reserved to stand down LiveKit's audio-focus and routing management when Telecom (
androidx.core.telecom) integration lands, since the Telecom framework owns routing for registered calls.Native early gating
LiveKitPlugin.setEngineAvailability(isInputAvailable:isOutputAvailable:)is a public static so an AppDelegate can gate audio before the Flutter engine exists (CallKit killed-state wake). The value is stored and applied at plugin registration, after the engine observer is installed and before any audio operation can start the engine.Implementation notes
RTCAudioDeviceModule.setEngineAvailability) is public in the WebRTC-SDK pod, so no flutter_webrtc change or release is needed.isSessionActivationEnabled) threads through the three activation sites: managed configure, cached configure, and the engine-disable deactivation path.deactivateAudioSession()is a guarded no-op underexternalCallSystem.Testing
dart analyze lib testclean, all 344 tests pass (4 new tests for channel args and the external-mode deactivation guard).Follow-ups
androidx.core.telecom) integration behind the sameexternalCallSystemmode.