diff --git a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift index dbdb984..804e78a 100644 --- a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift +++ b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift @@ -5,16 +5,14 @@ import AVFoundation // Call-audio routing for the iOS app. Registered by cap sync as a real Capacitor plugin package, so it shows // up as window.Capacitor.Plugins.AudioRoute (an app-embedded class gets stripped in release builds). // -// The built-in EARPIECE is unreachable inside a WKWebView — WebKit owns the WebRTC audio unit and forces the -// loudspeaker. So this plugin exposes only what iOS actually allows for call audio: -// * setSpeaker(true) -> force the loudspeaker. IMPORTANT: overrideOutputAudioPort(.speaker) ALONE cannot -// beat a connected Bluetooth headset (BT is higher priority), so we also DROP the -// Bluetooth options from the category (options [.defaultToSpeaker]) so BT isn't a -// candidate output — that's what actually forces the speaker over BT. -// * setSpeaker(false) -> "Device": allow BT/wired again and use the default port, which routes to a connected -// headset (else the system default). -// It also REPORTS the active output to the web UI (getRoute() + a 'routeChange' event), because iOS hides audio -// outputs from JavaScript, so the web layer can't otherwise show the correct speaker/bluetooth/headset icon. +// KNOWN CEILING (WKWebView): the app does NOT own the AVAudioSession — WebKit's media process does, and it +// re-asserts its own category (Bluetooth allowed) on every change. So the earpiece can't be forced, and the +// built-in speaker can't be held over an actively-connected Bluetooth device (a re-force just oscillates with +// WebKit — proven on device). This plugin therefore does a SINGLE override per user tap and does NOT fight +// route changes; it only reports the active output so the web UI can show the correct icon. +// * setSpeaker(true) -> try to force the loudspeaker (drops Bluetooth options + overrideOutputAudioPort(.speaker)) +// * setSpeaker(false) -> "Device": allow BT/wired and use the default port (routes to a connected headset) +// * getRoute() + 'routeChange' event -> the active output ('speaker'|'bluetooth'|'wired'|'receiver'|'airplay') @objc(AudioRoutePlugin) public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AudioRoutePlugin" @@ -24,7 +22,7 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise) ] - private var wantSpeaker = false + private static let nativeTag = "1.1.2-stable" private var pending: DispatchWorkItem? override public func load() { @@ -35,8 +33,6 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { deinit { NotificationCenter.default.removeObserver(self) } - // Force the loudspeaker even over a connected BT headset: with no .allowBluetooth option, BT is not an - // eligible output, so the port override lands on the built-in speaker. private func configureSpeaker() throws { let s = AVAudioSession.sharedInstance() try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) @@ -44,7 +40,6 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { try s.overrideOutputAudioPort(.speaker) } - // Allow BT/wired and use the default port (routes to a connected headset, else the system default). private func configureDevice() throws { let s = AVAudioSession.sharedInstance() try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) @@ -52,7 +47,6 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { try s.overrideOutputAudioPort(.none) } - // The ACTIVE output, mapped to a simple label the web UI turns into an icon. private func currentOutput() -> String { for o in AVAudioSession.sharedInstance().currentRoute.outputs { switch o.portType { @@ -67,31 +61,29 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { return "unknown" } + // Report the active output to the web UI. Deliberately does NOT re-force any route — fighting WebKit's + // re-assertion just oscillates the audio (see ceiling note above). @objc private func routeChanged() { - // Coalesce the burst iOS fires on device (dis)connect, then re-hold the loudspeaker if the user chose - // it (a BT connect can steal the route), and tell the web UI where the audio actually is. pending?.cancel() let work = DispatchWorkItem { [weak self] in guard let self = self else { return } - let onSpeaker = AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.portType == .builtInSpeaker } - if self.wantSpeaker && !onSpeaker { try? self.configureSpeaker() } - self.notifyListeners("routeChange", data: ["output": self.currentOutput()]) + self.notifyListeners("routeChange", data: ["output": self.currentOutput(), "native": AudioRoutePlugin.nativeTag]) } pending = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work) } @objc func setSpeaker(_ call: CAPPluginCall) { - wantSpeaker = call.getBool("on") ?? true + let on = call.getBool("on") ?? true do { - if wantSpeaker { try configureSpeaker() } else { try configureDevice() } - call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it + if on { try configureSpeaker() } else { try configureDevice() } + call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag]) } catch { call.reject(error.localizedDescription) } } @objc func getRoute(_ call: CAPPluginCall) { - call.resolve(["output": currentOutput()]) + call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag]) } } diff --git a/mobile/plugins/audio-route/package.json b/mobile/plugins/audio-route/package.json index 41a1f76..eef4102 100644 --- a/mobile/plugins/audio-route/package.json +++ b/mobile/plugins/audio-route/package.json @@ -1,6 +1,6 @@ { "name": "audio-route", - "version": "1.1.1", + "version": "1.1.2", "description": "iOS earpiece/speaker audio route toggle for Biz Connect", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js", diff --git a/server/public/home.html b/server/public/home.html index 8566675..1d7fd17 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -1158,7 +1158,7 @@
-