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). // // 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). @objc(AudioRoutePlugin) public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AudioRoutePlugin" public let jsName = "AudioRoute" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "setSpeaker", 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 ] 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(_:)), 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())") } } catch { log("assertErr") } } // 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 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) } 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) } catch { call.reject(error.localizedDescription) } } }