import Foundation import Capacitor 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 (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: "getRoute", returnType: CAPPluginReturnPromise) ] private var wantSpeaker = false private var pending: DispatchWorkItem? override public func load() { 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) } // 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 } } return "unknown" } @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 let s = AVAudioSession.sharedInstance() do { // "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) 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()]) } }