Skip to content

feat(audio): engine availability control and external call system session mode#1127

Open
hiroshihorie wants to merge 5 commits into
mainfrom
hiroshi/audio-engine-availability
Open

feat(audio): engine availability control and external call system session mode#1127
hiroshihorie wants to merge 5 commits into
mainfrom
hiroshi/audio-engine-availability

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Jul 7, 2026

Copy link
Copy Markdown
Member

Description

Adds the two switches needed for CallKit coordination, mirroring the Swift SDK pattern (AudioManager.setEngineAvailability and AudioSessionEngineObserver.isAutomaticConfigurationEnabled).

Engine availability

// at startup, before connecting
await AudioManager.instance.setEngineAvailability(AudioEngineAvailability.none);

// in CallKit provider(didActivate:), for apps whose CallKit events arrive in Dart
// (e.g. flutter_callkit_incoming). Apps with a native CXProvider delegate should
// call the native static LiveKitPlugin.setEngineAvailability there instead.
await AudioManager.instance.setEngineAvailability(AudioEngineAvailability.defaultAvailability);

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.connect and setMicrophoneEnabled(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

await AudioManager.instance.setAudioSessionManagementMode(
  AudioSessionManagementMode.externalCallSystem,
);

LiveKit keeps configuring the session category/mode from engine lifecycle like automatic mode, but never calls setActive in either direction. CallKit owns activation timing. This improves on the Swift example flow, where the app must set the category itself inside didActivate.

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

  • Implemented entirely on LiveKit's own plugin and method channel. The ADM API (RTCAudioDeviceModule.setEngineAvailability) is public in the WebRTC-SDK pod, so no flutter_webrtc change or release is needed.
  • The availability setter dispatches off the platform thread, since an availability flip can rebuild the audio engine.
  • One new observer flag (isSessionActivationEnabled) threads through the three activation sites: managed configure, cached configure, and the engine-disable deactivation path.
  • deactivateAudioSession() is a guarded no-op under externalCallSystem.

Testing

  • dart analyze lib test clean, all 344 tests pass (4 new tests for channel args and the external-mode deactivation guard).
  • iOS example builds.
  • A CallKit example app exercising the full flow (outgoing, incoming via PushKit, mute, end) is the next step in livekit-examples.

Follow-ups

  • CallKit example app update in livekit-examples/flutter-callkit.
  • Android Telecom (androidx.core.telecom) integration behind the same externalCallSystem mode.
  • docs/audio.md CallKit section.

…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.
@hiroshihorie hiroshihorie marked this pull request as ready for review July 7, 2026 09:04
devin-ai-integration[bot]

This comment was marked as resolved.

- 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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread shared_swift/LiveKitPlugin.swift
Comment on lines +264 to +267
if (_managementMode == AudioSessionManagementMode.externalCallSystem) {
logger.warning('deactivateAudioSession skipped: the external call system owns session activation');
return;
}

@devin-ai-integration devin-ai-integration Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Open in Devin Review

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment on lines +763 to 764
if isActive, configuration.category == AVAudioSession.Category.playAndRecord.rawValue {
try rtcSession.overrideOutputAudioPort(forceSpeakerOutput ? .speaker : .none)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 _syncAppleAudioSessionManagementModesetAutomaticManagementEnabled(_, 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.
Open in Devin Review

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant