diff --git a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift index 6ebceb8..9f1d1fc 100644 --- a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift +++ b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift @@ -2,145 +2,84 @@ import Foundation import Capacitor import AVFoundation -// Earpiece <-> speaker toggle for calls. 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). +// 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). // -// STATE OF THE INVESTIGATION (from device telemetry): -// * In a WKWebView the app does NOT own the AVAudioSession; WebKit's media process re-pins it while WebRTC -// capture is active. overrideOutputAudioPort is a *transient* override (Apple QA1754) that any route/category -// change resets, so we must re-assert on AVAudioSession.routeChangeNotification (debounced, mismatch-only, capped). -// * For earpiece, override(.none) reverts to the mode default — the RECEIVER only if the mode is .voiceChat. -// .videoChat implies .defaultToSpeaker => the default is the loudspeaker. So we pin .voiceChat before .none. -// * OPEN QUESTION this build answers: telemetry showed mode already == .voiceChat when earpiece still landed on -// Speaker, AND setSpeaker read the route SYNCHRONOUSLY right after the override (which can be stale). So this -// build adds a reliable SETTLED probe: it re-reads the actual output port + mode at +0.4s and +1.2s after each -// toggle, and stamps the live mode into every route-change log line. That tells us definitively whether WebKit -// flips to .videoChat and where the route truly settles — i.e. whether the mode re-pin works or we've hit the -// documented WKWebView ceiling (earpiece not reliably forceable on video calls). +// The built-in EARPIECE is unreachable inside a WKWebView — WebKit owns the WebRTC audio unit and forces the +// loudspeaker (proven on device: overrideOutputAudioPort(.none) is a no-op there). So this plugin exposes only +// what iOS actually allows for call audio: +// * setSpeaker(true) -> force the loudspeaker (overrideOutputAudioPort(.speaker)) +// * setSpeaker(false) -> use the default output port: a connected Bluetooth/wired headset, else the default +// It also REPORTS the active output to the web UI (getRoute() + a 'routeChange' event), because iOS hides audio +// outputs from JavaScript (enumerateDevices returns none), so the web layer can't otherwise show the correct +// speaker/bluetooth/headset icon. @objc(AudioRoutePlugin) public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AudioRoutePlugin" public let jsName = "AudioRoute" public let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise) ] - // Bump on every native change so telemetry identifies the running binary unambiguously. - private static let nativeTag = "1.0.5-settle" - - private var wantSpeaker = false // desired output; source of truth, re-applied on route changes - private var pending: DispatchWorkItem? // debounced re-assert - private var asserts = 0 // re-assert count this toggle (capped, anti-thrash) - private var routeLog: [String] = [] // reason->port/mode trail, returned to JS for diagnosis - - private let accessoryPorts: [AVAudioSession.Port] = [ - .bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .headphones, .headsetMic, .usbAudio, .carAudio, .airPlay - ] + private var wantSpeaker = false + private var pending: DispatchWorkItem? override public func load() { - let session = AVAudioSession.sharedInstance() - try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) - try? session.setActive(true) - NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)), + let s = AVAudioSession.sharedInstance() + try? s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) + try? s.setActive(true) + NotificationCenter.default.addObserver(self, selector: #selector(routeChanged), name: AVAudioSession.routeChangeNotification, object: nil) } deinit { NotificationCenter.default.removeObserver(self) } - private func reasonName(_ r: UInt) -> String { - switch r { - case 1: return "newDev"; case 2: return "oldDev"; case 3: return "catChg" - case 4: return "override"; case 6: return "wake"; case 7: return "noRoute" - case 8: return "cfgChg"; default: return "unknown" - } - } - - private func modeShort(_ m: AVAudioSession.Mode) -> String { - if m == .voiceChat { return "vc" } - if m == .videoChat { return "vid" } - if m == .default { return "def" } - return "oth" - } - - // Actual output port(s) + live mode — the ground-truth snapshot. - private func snap() -> String { - let s = AVAudioSession.sharedInstance() - let outs = s.currentRoute.outputs.map { $0.portType.rawValue } - return (outs.isEmpty ? "(none)" : outs.joined(separator: "+")) + "/" + modeShort(s.mode) - } - - private func log(_ s: String) { - routeLog.append(s) - if routeLog.count > 18 { routeLog.removeFirst(routeLog.count - 18) } - } - - @objc private func routeChanged(_ note: Notification) { - let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) ?? 0 - log("\(reasonName(raw))>\(snap())") - pending?.cancel() - let work = DispatchWorkItem { [weak self] in self?.applyRoute() } - pending = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) - } - - private func applyRoute() { - guard asserts < 6 else { log("cap"); return } - let s = AVAudioSession.sharedInstance() - let outs = s.currentRoute.outputs - let onSpeaker = outs.contains { $0.portType == .builtInSpeaker } - let onAccessory = outs.contains { accessoryPorts.contains($0.portType) } - if onAccessory && !wantSpeaker { log("skipAcc"); return } - do { - if wantSpeaker { - if !onSpeaker { asserts += 1; try s.overrideOutputAudioPort(.speaker); log("assert>spk") } - } else if onSpeaker { - asserts += 1 - // .none reverts to the mode default; make that the RECEIVER by pinning .voiceChat first. - if s.category != .playAndRecord { - try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) - log("repinCat") - } else if s.mode != .voiceChat { - try s.setMode(.voiceChat); log("repinMode") - } - try s.overrideOutputAudioPort(.none) - log("assert>rcv:\(snap())") + // 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 { + case .builtInSpeaker: return "speaker" + case .builtInReceiver: return "receiver" + case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE, .carAudio: return "bluetooth" + case .headphones, .headsetMic, .usbAudio: return "wired" + case .airPlay: return "airplay" + default: continue } - } catch { log("assertErr") } + } + return "unknown" } - // Re-read the SETTLED route a beat after the override, since the immediate read can be stale. - private func scheduleSettleProbe() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.log("s4>\(self?.snap() ?? "")") } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.log("s12>\(self?.snap() ?? "")") } - } - - private func diag(_ applied: String) -> [String: Any] { - let s = AVAudioSession.sharedInstance() - return [ - "route": applied, - "native": AudioRoutePlugin.nativeTag, - "outputs": snap(), - "category": s.category.rawValue, - "opts": Int(s.categoryOptions.rawValue), - "log": routeLog.joined(separator: ",") // trail (incl. settled probes) since the previous toggle - ] + @objc private func routeChanged() { + // Coalesce the burst iOS/WebKit fire on device (dis)connect, then re-hold the loudspeaker if the user + // chose it, and tell the web UI where the audio actually is so the icon stays correct. + pending?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self = self else { return } + let s = AVAudioSession.sharedInstance() + if self.wantSpeaker && !s.currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }) { + try? s.overrideOutputAudioPort(.speaker) + } + self.notifyListeners("routeChange", data: ["output": self.currentOutput()]) + } + pending = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work) } @objc func setSpeaker(_ call: CAPPluginCall) { wantSpeaker = call.getBool("on") ?? true - asserts = 0 let s = AVAudioSession.sharedInstance() do { - if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { - try s.setMode(.voiceChat) - } + // "not speaker" means use the default port (headset if present); .voiceChat keeps AEC/AGC on. + if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { try s.setMode(.voiceChat) } try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none) - scheduleSettleProbe() // settled reads land in the NEXT toggle's trail - let out = diag(wantSpeaker ? "speaker" : "earpiece") - routeLog.removeAll() - call.resolve(out) + call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it } catch { call.reject(error.localizedDescription) } } + + @objc func getRoute(_ call: CAPPluginCall) { + call.resolve(["output": currentOutput()]) + } } diff --git a/mobile/plugins/audio-route/package.json b/mobile/plugins/audio-route/package.json index 6c7f8df..ba45f13 100644 --- a/mobile/plugins/audio-route/package.json +++ b/mobile/plugins/audio-route/package.json @@ -1,6 +1,6 @@ { "name": "audio-route", - "version": "1.0.5", + "version": "1.1.0", "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 f8f66fe..e5a7e76 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -1158,7 +1158,7 @@
-