Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/darwin-webrtc-plugin-instance
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "iOS/macOS: audio device module and local track calls act on the calling Flutter engine's WebRTC instance when more than one engine initializes WebRTC"
46 changes: 28 additions & 18 deletions shared_swift/LiveKitPlugin.swift

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 CallKit gate misses the active engine

After another Flutter engine registers, setEngineAvailability targets its singleton instead of the active engine. CallKit gating leaves the original engine running outside its activation window.

(Refers to this code)

Learn more

The native static entry point is used by an AppDelegate before or during CallKit activation. WebRTC's singleton changes when another Flutter engine registers, but the first engine's factory can still own the running audio device module. The method stores the requested availability, then checks only the current singleton; it returns false if that engine has no factory, or updates the wrong module if it does. The pending value cannot correct the first engine without another targeted application.

Example: The UI engine is recording with its factory initialized. A background engine registers without initializing WebRTC, and CallKit calls setEngineAvailability(isInputAvailable: false, isOutputAvailable: false). The call returns false and the UI engine continues recording; the gate was expected to stop it.

Recommended fix: Keep a process-level reference or registry of initialized WebRTC factories for native CallKit calls, and apply the gate to the active module instead of the moving singleton. Preserve deferred application before any factory exists.

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.

flutter_webrtc 1.6.3 no longer moves the singleton when an engine registers. The first registered instance keeps it, and another instance takes it over only by creating a factory while the current one has none. In this example the UI engine keeps the singleton after the background engine registers, so the gate reaches its audio device module and returns true. The reverse order (background engine first, UI engine initializing later) also ends on the UI engine through the takeover. With two engines that both create a factory, the gate targets the first factory owner, which is unchanged from main.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Device updates reach the wrong engine

After a second engine registers, audioDeviceModuleDidUpdateDevices forwards the first engine's updates to the second engine's singleton. The first engine misses audio device changes.

(Refers to this code)

Learn more

The shared observer receives callbacks from an audio device module attached to a WebRTC factory. The process-wide singleton can change when another Flutter engine registers, independently of the module that emitted the callback. Forwarding the callback to that singleton updates a different engine's WebRTC device listeners, leaving the owning engine's listeners without the event.

Example: Engine A connects Bluetooth audio and has an active peer connection. Engine B registers WebRTC, becoming the singleton. When engine A's module reports a device update, the callback invokes engine B's handler, so engine A's device-change listener receives nothing.

Recommended fix: Associate the callback's audioDeviceModule with its owning registered WebRTC plugin and forward to that plugin. Consider how to retain the association when multiple factories use the shared observer.

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.

Same premise as above. On flutter_webrtc 1.6.3 a second engine registering does not take the singleton, so engine A keeps receiving its own device updates. The remaining case is two engines that both create a factory, where the second engine's updates go to the first factory owner. That is unchanged from main, since this PR leaves the observer on the singleton, and fixing it needs a flutter_webrtc API that maps an audio device module back to its owning instance.

Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
// second Flutter engine does not wipe the pushed policy / management mode.
private static var sharedAudioEngineObserver: LKAudioEngineObserver?

/// The WebRTC plugin instance registered with this plugin's Flutter engine. Each engine registers its
/// own instance and the process-wide singleton can belong to another engine. Falls back to the singleton
/// while this engine has not created a peer connection factory, for example a background engine that
/// only toggles engine availability.
var webrtcPlugin: FlutterWebRTCPlugin? {
if let messenger = binaryMessenger as? NSObject & FlutterBinaryMessenger,
let plugin = FlutterWebRTCPlugin.instance(for: messenger),
plugin.peerConnectionFactory != nil
{
return plugin
}
return FlutterWebRTCPlugin.sharedSingleton()
}

#if os(iOS)
var cancellable = Set<AnyCancellable>()
#endif
Expand Down Expand Up @@ -100,11 +114,9 @@ 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.
// An AppDelegate may have gated engine availability before the Flutter engine existed (CallKit
// killed-state wake). Apply it now if the audio device module already exists, before any audio
// operation can start the engine.
applyPendingEngineAvailabilityIfNeeded()

#if os(iOS)
Expand Down Expand Up @@ -169,7 +181,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
return existing
}

let webrtc = FlutterWebRTCPlugin.sharedSingleton()
let webrtc = webrtcPlugin

var audioTrack: AudioTrack?
if let track = webrtc?.localTracks![trackId] as? LocalAudioTrack {
Expand Down Expand Up @@ -440,7 +452,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
return
}

let webrtc = FlutterWebRTCPlugin.sharedSingleton()
let webrtc = webrtcPlugin
guard let localTrack = webrtc?.localTracks?[trackId] as? LocalAudioTrack,
let audioTrack = localTrack.track() as? RTCAudioTrack
else {
Expand Down Expand Up @@ -493,9 +505,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
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.
// The factory is created by the WebRTC initialize call, not on access, so this only applies the value
// when another engine has already initialized WebRTC.
guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else {
print("[LiveKit] engine availability pending but audio device module is unavailable")
return
Expand All @@ -509,9 +520,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
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 {
// The audio device module exists once LiveKitClient.initialize has run.
guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else {
result(FlutterError(code: "setEngineAvailability", message: "audio device module is unavailable", details: nil))
return
}
Expand Down Expand Up @@ -627,7 +637,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
return
}

guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else {
guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else {
result(FlutterError(code: "setMicrophoneMuteMode", message: "audio device module is unavailable", details: nil))
return
}
Expand All @@ -650,15 +660,15 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
}

public func handleGetMicrophoneMuteMode(result: @escaping FlutterResult) {
guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else {
guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else {
result(FlutterError(code: "getMicrophoneMuteMode", message: "audio device module is unavailable", details: nil))
return
}
result(LiveKitPlugin.muteModeString(adm.muteMode))
}

public func handleStartLocalRecording(args: [String: Any?], result: @escaping FlutterResult) {
guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else {
guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else {
result(FlutterError(code: "rejectedPlatformUnavailable", message: "audio device module is unavailable", details: nil))
return
}
Expand All @@ -680,7 +690,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
}

public func handleStopLocalRecording(result: @escaping FlutterResult) {
guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else {
guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else {
result(FlutterError(code: "stopLocalRecording", message: "audio device module is unavailable", details: nil))
return
}
Expand Down Expand Up @@ -740,7 +750,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
}

public func handleGetAudioProcessingState(result: @escaping FlutterResult) {
guard let factory = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory else {
guard let factory = webrtcPlugin?.peerConnectionFactory else {
result(nil)
return
}
Expand Down
Loading