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). // // THE PROBLEM (from device telemetry): overrideOutputAudioPort(.none) routes to the receiver ONCE, then the // route latches to the built-in Speaker on every later toggle — even though the category has NO // .defaultToSpeaker. That fingerprints WebKit's WebRTC engine re-forcing the loudspeaker AFTER our override. // The earlier build ignored .override-reason route changes (to avoid a feedback loop) and so never corrected // WebKit's re-force → stuck on speaker → earpiece silent. // // THE FIX: re-assert the chosen port AFTER each route change settles (debounced 0.25s), reacting to ALL // changes but acting ONLY on a real mismatch — so our own override (which also fires a change) finds the // route already correct and does nothing, terminating the loop. Capped per toggle so a persistent adversary // can't thrash the audio. A ring-buffer route log (reason -> port) rides back in setSpeaker's result so the // telemetry shows the exact battle. @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.3-reassert" 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 trail, returned to JS for diagnosis override public func load() { let session = AVAudioSession.sharedInstance() // NO .defaultToSpeaker — the port is driven explicitly so .none means the earpiece/receiver. 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 ports() -> String { let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { $0.portType.rawValue } return outs.isEmpty ? "(none)" : outs.joined(separator: "+") } private func log(_ s: String) { routeLog.append(s) if routeLog.count > 16 { routeLog.removeFirst(routeLog.count - 16) } } @objc private func routeChanged(_ note: Notification) { let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) ?? 0 log("\(reasonName(raw))>\(ports())") // Re-assert AFTER WebKit's change settles; coalesce bursts into one late assert so we act last. 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 } // anti-thrash: give up rather than ping-pong forever let session = AVAudioSession.sharedInstance() let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker } do { if wantSpeaker && !onSpeaker { asserts += 1; try session.overrideOutputAudioPort(.speaker); log("assert>spk") } else if !wantSpeaker && onSpeaker { asserts += 1; try session.overrideOutputAudioPort(.none); log("assert>none") } } catch { log("assertErr") } } private func diag(_ applied: String) -> [String: Any] { let s = AVAudioSession.sharedInstance() return [ "route": applied, "native": AudioRoutePlugin.nativeTag, "outputs": ports(), "category": s.category.rawValue, "mode": s.mode.rawValue, "opts": Int(s.categoryOptions.rawValue), "log": routeLog.joined(separator: ",") // route-change trail since the previous toggle ] } @objc func setSpeaker(_ call: CAPPluginCall) { wantSpeaker = call.getBool("on") ?? true asserts = 0 let session = AVAudioSession.sharedInstance() do { try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none) let out = diag(wantSpeaker ? "speaker" : "earpiece") routeLog.removeAll() // reset the trail; next toggle reports what happened in between call.resolve(out) } catch { call.reject(error.localizedDescription) } } }