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). // // WHY THIS SHAPE (earlier version left the earpiece silent): // * The category is configured WITHOUT .defaultToSpeaker, so overrideOutputAudioPort(.none) reliably means // the earpiece/receiver. WITH .defaultToSpeaker, .none falls back to the loudspeaker. // * setSpeaker flips ONLY overrideOutputAudioPort — no re-setCategory/setActive mid-call, which would tear // down the audio unit WebKit's WebRTC engine is using and silence the earpiece. // * WebKit reconfigures the shared AVAudioSession when call audio starts / on headset plug-unplug, which // would clobber our choice — so we observe routeChangeNotification and re-assert the desired port. // // DIAGNOSTICS: setSpeaker resolves with the ACTUAL current output port + live category/mode/options and a // native-build marker, so the JS `route` telemetry shows exactly where iOS put the audio (the web __BUILD // tag can't distinguish native binaries). This is what turns "override didn't throw" into "audio is on X". @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 the telemetry unambiguously identifies which binary is running. private static let nativeTag = "1.0.2-diag" // Desired output — the source of truth, re-applied on every route change. Defaults to earpiece; the JS // side calls setSpeaker(true) at call start when it wants the loudspeaker (meetRoute defaults to 'speaker'). private var wantSpeaker = false override public func load() { let session = AVAudioSession.sharedInstance() // NO .defaultToSpeaker — the output port is driven explicitly (see note above). 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) } // WebKit/WebRTC reset the route (call audio just started, or a headset was (un)plugged). Re-assert ours, // but ignore the change we caused ourselves (.override) so we don't fight in a feedback loop. @objc private func routeChanged(_ note: Notification) { if let info = note.userInfo, let raw = info[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: raw), reason == .override { return } applyRoute() } // Nudge the port toward the desired output, but only when it actually differs — this leaves an attached // Bluetooth/wired headset alone in the earpiece case (its route is neither builtInSpeaker nor forced). private func applyRoute() { let session = AVAudioSession.sharedInstance() let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker } do { if wantSpeaker && !onSpeaker { try session.overrideOutputAudioPort(.speaker) } else if !wantSpeaker && onSpeaker { try session.overrideOutputAudioPort(.none) } } catch { } } // Snapshot of what iOS actually selected — this is the data that ends the guessing. private func diag(_ applied: String) -> [String: Any] { let s = AVAudioSession.sharedInstance() let outs = s.currentRoute.outputs.map { $0.portType.rawValue }.joined(separator: "+") return [ "route": applied, "native": AudioRoutePlugin.nativeTag, "outputs": outs.isEmpty ? "(none)" : outs, // "Receiver"=earpiece, "Speaker", "BluetoothA2DP"... "category": s.category.rawValue, "mode": s.mode.rawValue, "opts": Int(s.categoryOptions.rawValue) ] } @objc func setSpeaker(_ call: CAPPluginCall) { wantSpeaker = call.getBool("on") ?? true let session = AVAudioSession.sharedInstance() do { // Flip ONLY the output port. No setCategory/setActive here — that would disrupt WebKit's audio. try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none) call.resolve(diag(wantSpeaker ? "speaker" : "earpiece")) } catch { call.reject(error.localizedDescription) } } }