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). // // ROOT CAUSE (WebKit source: Source/WebCore/platform/audio/cocoa/MediaSessionManagerCocoa.mm, confirmed by // device telemetry): in a WKWebView the app does NOT own the AVAudioSession — WebKit's media process does. // While WebRTC capture is active it re-pins the session to .playAndRecord with mode .videoChat, and // .videoChat auto-implies .defaultToSpeaker. overrideOutputAudioPort(.none) means "revert to the mode // default", which under .videoChat is the LOUDSPEAKER — so .none alone can never reach the earpiece once // WebKit has flipped the mode. Our first .none won only because our .voiceChat mode was still in effect // (voiceChat's default route IS the receiver); after WebKit's reconfigure it latched to speaker. // // THE FIX: for earpiece, set mode .voiceChat FIRST, then overrideOutputAudioPort(.none). overrideOutputAudioPort // is a *transient* override (Apple QA1754) that every route/category change resets, and WebKit reconfigures on // capture start / setLocalDescription / ICE-connected / renegotiation — so we also observe routeChangeNotification // and re-assert (debounced, mismatch-only, capped) after WebKit's change settles. This reactive re-assert is the // only lever a WKWebView host has (WebKit's internal RTCAudioSession / useManualAudio is unreachable; WebKit bug // 167788 has left the app unable to disable WebKit's session management for 8+ years). @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.4-mode" 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 // Real external outputs — when one of these is active and the user wants "earpiece", we must NOT force the // built-in receiver (that would yank audio off the user's headset). Only correct the speaker<->receiver axis. private let accessoryPorts: [AVAudioSession.Port] = [ .bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .headphones, .headsetMic, .usbAudio, .carAudio, .airPlay ] override public func load() { let session = AVAudioSession.sharedInstance() // NO .defaultToSpeaker — .voiceChat's default route is the receiver, so the port override decides output. 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) } // Nudge the route toward the desired output, acting ONLY on a real built-in speaker<->receiver mismatch so // our own override (which itself fires a route change) self-terminates instead of looping. private func applyRoute() { guard asserts < 6 else { log("cap"); return } // anti-thrash: give up rather than ping-pong forever 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 } // leave a real headset/BT alone do { if wantSpeaker { if !onSpeaker { asserts += 1; try s.overrideOutputAudioPort(.speaker); log("assert>spk") } } else if onSpeaker { asserts += 1 // .none alone can't beat WebKit's .videoChat default (= speaker). Re-pin voiceChat first so the // "revert to default" the override does actually resolves to the RECEIVER. 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") } } 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 s = AVAudioSession.sharedInstance() do { // Earpiece: pin .voiceChat first so the very first press is correct even if WebKit already flipped // the mode to .videoChat. Speaker: override(.speaker) works under any mode, no mode change needed. if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { try s.setMode(.voiceChat) } try s.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) } } }