2026-07-20 17:06:16 +05:30
|
|
|
import Foundation
|
|
|
|
|
import Capacitor
|
|
|
|
|
import AVFoundation
|
|
|
|
|
|
2026-07-21 09:10:09 +05:30
|
|
|
// 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
|
2026-07-21 19:22:01 +05:30
|
|
|
// 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".
|
2026-07-20 17:06:16 +05:30
|
|
|
@objc(AudioRoutePlugin)
|
|
|
|
|
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
|
|
public let identifier = "AudioRoutePlugin"
|
|
|
|
|
public let jsName = "AudioRoute"
|
|
|
|
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
|
|
|
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise)
|
|
|
|
|
]
|
|
|
|
|
|
2026-07-21 19:22:01 +05:30
|
|
|
// Bump on every native change so the telemetry unambiguously identifies which binary is running.
|
|
|
|
|
private static let nativeTag = "1.0.2-diag"
|
|
|
|
|
|
2026-07-21 09:10:09 +05:30
|
|
|
// 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
|
|
|
|
|
|
2026-07-20 17:06:16 +05:30
|
|
|
override public func load() {
|
|
|
|
|
let session = AVAudioSession.sharedInstance()
|
2026-07-21 09:10:09 +05:30
|
|
|
// NO .defaultToSpeaker — the output port is driven explicitly (see note above).
|
|
|
|
|
try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
2026-07-20 17:06:16 +05:30
|
|
|
try? session.setActive(true)
|
2026-07-21 09:10:09 +05:30
|
|
|
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 { }
|
2026-07-20 17:06:16 +05:30
|
|
|
}
|
|
|
|
|
|
2026-07-21 19:22:01 +05:30
|
|
|
// 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)
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 17:06:16 +05:30
|
|
|
@objc func setSpeaker(_ call: CAPPluginCall) {
|
2026-07-21 09:10:09 +05:30
|
|
|
wantSpeaker = call.getBool("on") ?? true
|
2026-07-20 17:06:16 +05:30
|
|
|
let session = AVAudioSession.sharedInstance()
|
|
|
|
|
do {
|
2026-07-21 09:10:09 +05:30
|
|
|
// Flip ONLY the output port. No setCategory/setActive here — that would disrupt WebKit's audio.
|
|
|
|
|
try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
2026-07-21 19:22:01 +05:30
|
|
|
call.resolve(diag(wantSpeaker ? "speaker" : "earpiece"))
|
2026-07-20 17:06:16 +05:30
|
|
|
} catch {
|
|
|
|
|
call.reject(error.localizedDescription)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|