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 5 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.
| if (_managementMode == AudioSessionManagementMode.externalCallSystem) { | ||
| logger.warning('deactivateAudioSession skipped: the external call system owns session activation'); | ||
| return; | ||
| } |
There was a problem hiding this comment.
🔍 deactivateAudioSession blocks on Android under externalCallSystem despite 'behaves like automatic' doc
The deactivateAudioSession method (audio_manager.dart:263-274) returns early for externalCallSystem on ALL platforms, including Android. However, the externalCallSystem enum doc (audio_session.dart:40-45) states 'On Android this currently behaves like [automatic]'. This means an app using externalCallSystem mode cannot explicitly deactivate the Android audio session, even though there's no external system managing it on Android yet. This is arguably correct (the mode semantically means an external system owns the lifecycle), but it could surprise cross-platform developers who expect Android to behave like automatic in all respects under this mode.
Was this helpful? React with 👍 or 👎 to provide feedback.
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.
(Refers to line 240)
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.
| if isActive, configuration.category == AVAudioSession.Category.playAndRecord.rawValue { | ||
| try rtcSession.overrideOutputAudioPort(forceSpeakerOutput ? .speaker : .none) |
There was a problem hiding this comment.
🟡 Forced speaker output never takes effect when using the external call system mode
The speaker hard-override is skipped (overrideOutputAudioPort at shared_swift/LiveKitPlugin.swift:763) because the guard uses the session-activation-intent flag (isActive, which is false under externalCallSystem) instead of the actual session state, so calling setSpeakerOutputPreferred(true, force: true) has no effect even though CallKit has already activated the session.
Impact: Apps using CallKit coordination cannot force audio to the speaker while a headset or Bluetooth device is connected.
Mechanism: isActive conflates activation intent with session liveness
Under externalCallSystem, isSessionActivationEnabled is false (lib/src/audio/audio_manager.dart:106), and this value flows through _syncAppleAudioSessionManagementMode → setAutomaticManagementEnabled(_, sessionActivationEnabled: false) (shared_swift/LiveKitPlugin.swift:842) → stored as isSessionActivationEnabled = false (shared_swift/LiveKitPlugin.swift:817).
When applyCachedConfiguration or applyManagedConfiguration runs, it reads isSessionActivationEnabled into isActive (shared_swift/LiveKitPlugin.swift:868, :881) and passes it to applyAudioSessionConfiguration(_, forceSpeakerOutput:, isActive:) (shared_swift/LiveKitPlugin.swift:750-752).
Inside that method, the overrideOutputAudioPort call is guarded by if isActive (shared_swift/LiveKitPlugin.swift:763). Since isActive is false under externalCallSystem, the override is never applied — even though the session IS active (CallKit activated it). The isActive parameter conflates "should LiveKit activate the session" with "is the session currently active"; under externalCallSystem these differ.
The soft speaker preference (defaultToSpeaker category option) still works because it's part of the category options applied by setConfiguration. Only the hard override (overrideOutputAudioPort(.speaker)) — which forces the speaker even over a connected headset — is affected.
Prompt for agents
The `overrideOutputAudioPort` call in `applyAudioSessionConfiguration` at shared_swift/LiveKitPlugin.swift:763 is guarded by `isActive`, which represents whether LiveKit should activate the session (false under externalCallSystem). But the guard's intent is to check whether the session is actually active (it could be active via CallKit). Under externalCallSystem mode, CallKit has activated the session, so the override should still be applied.
Consider separating the two concerns: pass `isActive` to `setConfiguration(_, active:)` as before (to control whether LiveKit activates), but use the actual session state (e.g., `RTCAudioSession.sharedInstance().isActive`) or a separate parameter to guard the `overrideOutputAudioPort` call. Alternatively, always apply the override when the category is playAndRecord and the session is known to be active, regardless of who activated it.
Was this helpful? React with 👍 or 👎 to provide feedback.
| defer { rtcSession.unlockForConfiguration() } | ||
| do { | ||
| try rtcSession.setConfiguration(configuration, active: active) | ||
| try rtcSession.setConfiguration(configuration, active: isActive) |
There was a problem hiding this comment.
🔍 setConfiguration(_, active: false) behavior depends on WebRTC SDK version
Under externalCallSystem, applyAudioSessionConfiguration passes isActive: false to rtcSession.setConfiguration(configuration, active: isActive) at shared_swift/LiveKitPlugin.swift:757. The correctness of this depends on whether RTCAudioSession.setConfiguration(_:active:) in WebRTC-SDK 144.7559.09 (ios/livekit_client.podspec:19) only activates when active is true (safe — just configures category/mode) or also deactivates when active is false (dangerous — would undo CallKit's activation). Standard WebRTC implementations typically only call setActive when active is true, but this should be verified against the specific SDK version used here. If the SDK does deactivate on false, this would break CallKit coordination entirely.
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.