From 37cdea1fc8d230b14076c555f823f5d180ac0d79 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:10:23 +0800 Subject: [PATCH 1/3] Resolve the audio device module through the registered WebRTC plugin instance LiveKitPlugin read the WebRTC plugin's process-wide singleton on every call. That singleton is assigned in each plugin init, so a second Flutter engine in the process replaced the UI engine's instance with one that never runs the initialize channel call and never creates a peer connection factory. From then on startLocalRecording, setEngineAvailability, mute mode and the track lookups all failed with "audio device module is unavailable" for the rest of the process, which is how setMicrophoneEnabled(true) ended in AudioProcessingException(platformUnavailable) after backgrounding. Capture the WebRTC plugin instance at the first registration, as the Android plugin already does, and resolve every lookup through it while it owns the factory. Fall back to the process singleton otherwise. Also correct two comments that claimed accessing peerConnectionFactory creates it; only the initialize channel call does. Fixes #1206, fixes #1176 --- shared_swift/LiveKitPlugin.swift | 65 +++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index d268a6abc..d82f8b520 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -61,6 +61,31 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { // second Flutter engine does not wipe the pushed policy / management mode. private static var sharedAudioEngineObserver: LKAudioEngineObserver? + // WebRTC plugin instance captured at the first registration. Each Flutter engine registers its own + // instance and the process-wide singleton may move to a later one that never creates a peer connection + // factory, so it is not read per call. + private static let webrtcPluginLock = NSLock() + private static var registeredWebRTCPlugin: FlutterWebRTCPlugin? + + /// The WebRTC plugin instance that owns the peer connection factory. + static var webrtcPlugin: FlutterWebRTCPlugin? { + webrtcPluginLock.lock() + let registered = registeredWebRTCPlugin + webrtcPluginLock.unlock() + // Fall back to the process singleton while the captured instance has no factory. + if let registered, registered.peerConnectionFactory != nil { + return registered + } + return FlutterWebRTCPlugin.sharedSingleton() ?? registered + } + + private static func captureWebRTCPluginIfNeeded() { + webrtcPluginLock.lock() + defer { webrtcPluginLock.unlock() } + guard registeredWebRTCPlugin == nil else { return } + registeredWebRTCPlugin = FlutterWebRTCPlugin.sharedSingleton() + } + #if os(iOS) var cancellable = Set() #endif @@ -100,11 +125,11 @@ 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. + captureWebRTCPluginIfNeeded() + + // 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) @@ -169,7 +194,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { return existing } - let webrtc = FlutterWebRTCPlugin.sharedSingleton() + let webrtc = LiveKitPlugin.webrtcPlugin var audioTrack: AudioTrack? if let track = webrtc?.localTracks![trackId] as? LocalAudioTrack { @@ -440,7 +465,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { return } - let webrtc = FlutterWebRTCPlugin.sharedSingleton() + let webrtc = LiveKitPlugin.webrtcPlugin guard let localTrack = webrtc?.localTracks?[trackId] as? LocalAudioTrack, let audioTrack = localTrack.track() as? RTCAudioTrack else { @@ -481,7 +506,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { pendingEngineAvailability = availability engineAvailabilityLock.unlock() - guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { return false } return adm.setEngineAvailability(availability) == 0 @@ -493,10 +518,9 @@ 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. - guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + // The factory is created by the WebRTC initialize call, not on access. Until then the value stays + // pending and ensureMicrophoneAccess honors it. + guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { print("[LiveKit] engine availability pending but audio device module is unavailable") return } @@ -509,9 +533,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 = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "setEngineAvailability", message: "audio device module is unavailable", details: nil)) return } @@ -627,7 +650,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { return } - guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "setMicrophoneMuteMode", message: "audio device module is unavailable", details: nil)) return } @@ -650,7 +673,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } public func handleGetMicrophoneMuteMode(result: @escaping FlutterResult) { - guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "getMicrophoneMuteMode", message: "audio device module is unavailable", details: nil)) return } @@ -658,7 +681,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } public func handleStartLocalRecording(args: [String: Any?], result: @escaping FlutterResult) { - guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "rejectedPlatformUnavailable", message: "audio device module is unavailable", details: nil)) return } @@ -680,7 +703,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } public func handleStopLocalRecording(result: @escaping FlutterResult) { - guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "stopLocalRecording", message: "audio device module is unavailable", details: nil)) return } @@ -740,7 +763,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } public func handleGetAudioProcessingState(result: @escaping FlutterResult) { - guard let factory = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory else { + guard let factory = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory else { result(nil) return } @@ -1227,7 +1250,7 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { func audioDeviceModule(_: RTCAudioDeviceModule, engine _: AVAudioEngine, configureOutputFromSource _: AVAudioNode, toDestination _: AVAudioNode?, format _: AVAudioFormat, context _: [AnyHashable: Any]) -> Int { 0 } func audioDeviceModule(_: RTCAudioDeviceModule, didReceiveSpeechActivityEvent _: RTCSpeechActivityEvent) {} func audioDeviceModuleDidUpdateDevices(_ audioDeviceModule: RTCAudioDeviceModule) { - FlutterWebRTCPlugin.sharedSingleton()?.audioDeviceModuleDidUpdateDevices(audioDeviceModule) + LiveKitPlugin.webrtcPlugin?.audioDeviceModuleDidUpdateDevices(audioDeviceModule) } private func notifyEngineState(isPlayoutEnabled: Bool, isRecordingEnabled: Bool) { From 98f4b5cdb06e57024c21754d2f41f8e7db8aa1ec Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:40:50 +0800 Subject: [PATCH 2/3] Add changes entry --- .changes/darwin-webrtc-plugin-instance | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/darwin-webrtc-plugin-instance diff --git a/.changes/darwin-webrtc-plugin-instance b/.changes/darwin-webrtc-plugin-instance new file mode 100644 index 000000000..feef12004 --- /dev/null +++ b/.changes/darwin-webrtc-plugin-instance @@ -0,0 +1 @@ +patch type="fixed" "iOS/macOS: audio device module calls no longer fail with platformUnavailable after a second Flutter engine registers in the process" \ No newline at end of file From c63ec7580d55ee90405b733f421f661fbf8e076f Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:48:18 +0800 Subject: [PATCH 3/3] Resolve the WebRTC plugin registered with the calling engine flutter_webrtc 1.6.3 keeps its process-wide instance on a factory owner and hands it on when an engine detaches, so the first-registration capture is no longer needed. Holding it strongly also kept a destroyed engine's instance, factory and audio device module alive and kept routing calls to them. The singleton is still the first factory owner, so with two engines that both initialize WebRTC the second engine's mute mode, recording, audio processing and local track lookups landed on the first engine. Resolve the instance registered with this plugin's messenger instead, and fall back to the singleton while that engine has no factory. The static paths keep the singleton, since they run without an engine. --- .changes/darwin-webrtc-plugin-instance | 2 +- shared_swift/LiveKitPlugin.swift | 63 ++++++++++---------------- 2 files changed, 26 insertions(+), 39 deletions(-) diff --git a/.changes/darwin-webrtc-plugin-instance b/.changes/darwin-webrtc-plugin-instance index feef12004..2fed0de7c 100644 --- a/.changes/darwin-webrtc-plugin-instance +++ b/.changes/darwin-webrtc-plugin-instance @@ -1 +1 @@ -patch type="fixed" "iOS/macOS: audio device module calls no longer fail with platformUnavailable after a second Flutter engine registers in the process" \ No newline at end of file +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" \ No newline at end of file diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index d82f8b520..5b2d60e30 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -61,29 +61,18 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { // second Flutter engine does not wipe the pushed policy / management mode. private static var sharedAudioEngineObserver: LKAudioEngineObserver? - // WebRTC plugin instance captured at the first registration. Each Flutter engine registers its own - // instance and the process-wide singleton may move to a later one that never creates a peer connection - // factory, so it is not read per call. - private static let webrtcPluginLock = NSLock() - private static var registeredWebRTCPlugin: FlutterWebRTCPlugin? - - /// The WebRTC plugin instance that owns the peer connection factory. - static var webrtcPlugin: FlutterWebRTCPlugin? { - webrtcPluginLock.lock() - let registered = registeredWebRTCPlugin - webrtcPluginLock.unlock() - // Fall back to the process singleton while the captured instance has no factory. - if let registered, registered.peerConnectionFactory != nil { - return registered - } - return FlutterWebRTCPlugin.sharedSingleton() ?? registered - } - - private static func captureWebRTCPluginIfNeeded() { - webrtcPluginLock.lock() - defer { webrtcPluginLock.unlock() } - guard registeredWebRTCPlugin == nil else { return } - registeredWebRTCPlugin = FlutterWebRTCPlugin.sharedSingleton() + /// 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) @@ -125,8 +114,6 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { instance.audioEngineObserver = audioEngineObserver FlutterWebRTCPlugin.setAudioDeviceModuleObserver(audioEngineObserver) - captureWebRTCPluginIfNeeded() - // 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. @@ -194,7 +181,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { return existing } - let webrtc = LiveKitPlugin.webrtcPlugin + let webrtc = webrtcPlugin var audioTrack: AudioTrack? if let track = webrtc?.localTracks![trackId] as? LocalAudioTrack { @@ -465,7 +452,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { return } - let webrtc = LiveKitPlugin.webrtcPlugin + let webrtc = webrtcPlugin guard let localTrack = webrtc?.localTracks?[trackId] as? LocalAudioTrack, let audioTrack = localTrack.track() as? RTCAudioTrack else { @@ -506,7 +493,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { pendingEngineAvailability = availability engineAvailabilityLock.unlock() - guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = FlutterWebRTCPlugin.sharedSingleton()?.peerConnectionFactory?.audioDeviceModule else { return false } return adm.setEngineAvailability(availability) == 0 @@ -518,9 +505,9 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { engineAvailabilityLock.unlock() guard let pending else { return } - // The factory is created by the WebRTC initialize call, not on access. Until then the value stays - // pending and ensureMicrophoneAccess honors it. - guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { + // 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 } @@ -534,7 +521,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { let isOutputAvailable = (args["isOutputAvailable"] as? Bool) ?? true // The audio device module exists once LiveKitClient.initialize has run. - guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "setEngineAvailability", message: "audio device module is unavailable", details: nil)) return } @@ -650,7 +637,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { return } - guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "setMicrophoneMuteMode", message: "audio device module is unavailable", details: nil)) return } @@ -673,7 +660,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } public func handleGetMicrophoneMuteMode(result: @escaping FlutterResult) { - guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "getMicrophoneMuteMode", message: "audio device module is unavailable", details: nil)) return } @@ -681,7 +668,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } public func handleStartLocalRecording(args: [String: Any?], result: @escaping FlutterResult) { - guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "rejectedPlatformUnavailable", message: "audio device module is unavailable", details: nil)) return } @@ -703,7 +690,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } public func handleStopLocalRecording(result: @escaping FlutterResult) { - guard let adm = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { + guard let adm = webrtcPlugin?.peerConnectionFactory?.audioDeviceModule else { result(FlutterError(code: "stopLocalRecording", message: "audio device module is unavailable", details: nil)) return } @@ -763,7 +750,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { } public func handleGetAudioProcessingState(result: @escaping FlutterResult) { - guard let factory = LiveKitPlugin.webrtcPlugin?.peerConnectionFactory else { + guard let factory = webrtcPlugin?.peerConnectionFactory else { result(nil) return } @@ -1250,7 +1237,7 @@ class LKAudioEngineObserver: NSObject, RTCAudioDeviceModuleDelegate { func audioDeviceModule(_: RTCAudioDeviceModule, engine _: AVAudioEngine, configureOutputFromSource _: AVAudioNode, toDestination _: AVAudioNode?, format _: AVAudioFormat, context _: [AnyHashable: Any]) -> Int { 0 } func audioDeviceModule(_: RTCAudioDeviceModule, didReceiveSpeechActivityEvent _: RTCSpeechActivityEvent) {} func audioDeviceModuleDidUpdateDevices(_ audioDeviceModule: RTCAudioDeviceModule) { - LiveKitPlugin.webrtcPlugin?.audioDeviceModuleDidUpdateDevices(audioDeviceModule) + FlutterWebRTCPlugin.sharedSingleton()?.audioDeviceModuleDidUpdateDevices(audioDeviceModule) } private func notifyEngineState(isPlayoutEnabled: Bool, isRecordingEnabled: Bool) {