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). // // KNOWN CEILING (WKWebView): the app does NOT own the AVAudioSession — WebKit's media process does, and it // re-asserts its own category (Bluetooth allowed) on every change. So the earpiece can't be forced, and the // built-in speaker can't be held over an actively-connected Bluetooth device (a re-force just oscillates with // WebKit — proven on device). This plugin therefore does a SINGLE override per user tap and does NOT fight // route changes; it only reports the active output so the web UI can show the correct icon. // * setSpeaker(true) -> try to force the loudspeaker (drops Bluetooth options + overrideOutputAudioPort(.speaker)) // * setSpeaker(false) -> "Device": allow BT/wired and use the default port (routes to a connected headset) // * getRoute() + 'routeChange' event -> the active output ('speaker'|'bluetooth'|'wired'|'receiver'|'airplay') @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 static let nativeTag = "1.1.2-stable" private var pending: DispatchWorkItem? override public func load() { try? configureDevice() // start in "device" mode (BT/wired allowed); the web forces speaker per call NotificationCenter.default.addObserver(self, selector: #selector(routeChanged), name: AVAudioSession.routeChangeNotification, object: nil) } deinit { NotificationCenter.default.removeObserver(self) } private func configureSpeaker() throws { let s = AVAudioSession.sharedInstance() try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) try s.setActive(true) try s.overrideOutputAudioPort(.speaker) } private func configureDevice() throws { let s = AVAudioSession.sharedInstance() try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) try s.setActive(true) try s.overrideOutputAudioPort(.none) } 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" } // Report the active output to the web UI. Deliberately does NOT re-force any route — fighting WebKit's // re-assertion just oscillates the audio (see ceiling note above). @objc private func routeChanged() { pending?.cancel() let work = DispatchWorkItem { [weak self] in guard let self = self else { return } self.notifyListeners("routeChange", data: ["output": self.currentOutput(), "native": AudioRoutePlugin.nativeTag]) } pending = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work) } @objc func setSpeaker(_ call: CAPPluginCall) { let on = call.getBool("on") ?? true do { if on { try configureSpeaker() } else { try configureDevice() } call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag]) } catch { call.reject(error.localizedDescription) } } @objc func getRoute(_ call: CAPPluginCall) { call.resolve(["output": currentOutput(), "native": AudioRoutePlugin.nativeTag]) } }