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).
|
|
|
|
|
//
|
2026-07-21 22:44:38 +05:30
|
|
|
// STATE OF THE INVESTIGATION (from device telemetry):
|
|
|
|
|
// * In a WKWebView the app does NOT own the AVAudioSession; WebKit's media process re-pins it while WebRTC
|
|
|
|
|
// capture is active. overrideOutputAudioPort is a *transient* override (Apple QA1754) that any route/category
|
|
|
|
|
// change resets, so we must re-assert on AVAudioSession.routeChangeNotification (debounced, mismatch-only, capped).
|
|
|
|
|
// * For earpiece, override(.none) reverts to the mode default — the RECEIVER only if the mode is .voiceChat.
|
|
|
|
|
// .videoChat implies .defaultToSpeaker => the default is the loudspeaker. So we pin .voiceChat before .none.
|
|
|
|
|
// * OPEN QUESTION this build answers: telemetry showed mode already == .voiceChat when earpiece still landed on
|
|
|
|
|
// Speaker, AND setSpeaker read the route SYNCHRONOUSLY right after the override (which can be stale). So this
|
|
|
|
|
// build adds a reliable SETTLED probe: it re-reads the actual output port + mode at +0.4s and +1.2s after each
|
|
|
|
|
// toggle, and stamps the live mode into every route-change log line. That tells us definitively whether WebKit
|
|
|
|
|
// flips to .videoChat and where the route truly settles — i.e. whether the mode re-pin works or we've hit the
|
|
|
|
|
// documented WKWebView ceiling (earpiece not reliably forceable on video calls).
|
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 22:20:29 +05:30
|
|
|
// Bump on every native change so telemetry identifies the running binary unambiguously.
|
2026-07-21 22:44:38 +05:30
|
|
|
private static let nativeTag = "1.0.5-settle"
|
2026-07-21 19:22:01 +05:30
|
|
|
|
2026-07-21 22:20:29 +05:30
|
|
|
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)
|
2026-07-21 22:44:38 +05:30
|
|
|
private var routeLog: [String] = [] // reason->port/mode trail, returned to JS for diagnosis
|
2026-07-21 09:10:09 +05:30
|
|
|
|
2026-07-21 22:31:14 +05:30
|
|
|
private let accessoryPorts: [AVAudioSession.Port] = [
|
|
|
|
|
.bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .headphones, .headsetMic, .usbAudio, .carAudio, .airPlay
|
|
|
|
|
]
|
|
|
|
|
|
2026-07-20 17:06:16 +05:30
|
|
|
override public func load() {
|
|
|
|
|
let session = AVAudioSession.sharedInstance()
|
2026-07-21 09:10:09 +05:30
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 22:20:29 +05:30
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 22:44:38 +05:30
|
|
|
private func modeShort(_ m: AVAudioSession.Mode) -> String {
|
|
|
|
|
if m == .voiceChat { return "vc" }
|
|
|
|
|
if m == .videoChat { return "vid" }
|
|
|
|
|
if m == .default { return "def" }
|
|
|
|
|
return "oth"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Actual output port(s) + live mode — the ground-truth snapshot.
|
|
|
|
|
private func snap() -> String {
|
|
|
|
|
let s = AVAudioSession.sharedInstance()
|
|
|
|
|
let outs = s.currentRoute.outputs.map { $0.portType.rawValue }
|
|
|
|
|
return (outs.isEmpty ? "(none)" : outs.joined(separator: "+")) + "/" + modeShort(s.mode)
|
2026-07-21 22:20:29 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func log(_ s: String) {
|
|
|
|
|
routeLog.append(s)
|
2026-07-21 22:44:38 +05:30
|
|
|
if routeLog.count > 18 { routeLog.removeFirst(routeLog.count - 18) }
|
2026-07-21 09:10:09 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc private func routeChanged(_ note: Notification) {
|
2026-07-21 22:20:29 +05:30
|
|
|
let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) ?? 0
|
2026-07-21 22:44:38 +05:30
|
|
|
log("\(reasonName(raw))>\(snap())")
|
2026-07-21 22:20:29 +05:30
|
|
|
pending?.cancel()
|
|
|
|
|
let work = DispatchWorkItem { [weak self] in self?.applyRoute() }
|
|
|
|
|
pending = work
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
|
2026-07-21 09:10:09 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func applyRoute() {
|
2026-07-21 22:44:38 +05:30
|
|
|
guard asserts < 6 else { log("cap"); return }
|
2026-07-21 22:31:14 +05:30
|
|
|
let s = AVAudioSession.sharedInstance()
|
|
|
|
|
let outs = s.currentRoute.outputs
|
|
|
|
|
let onSpeaker = outs.contains { $0.portType == .builtInSpeaker }
|
|
|
|
|
let onAccessory = outs.contains { accessoryPorts.contains($0.portType) }
|
2026-07-21 22:44:38 +05:30
|
|
|
if onAccessory && !wantSpeaker { log("skipAcc"); return }
|
2026-07-21 09:10:09 +05:30
|
|
|
do {
|
2026-07-21 22:31:14 +05:30
|
|
|
if wantSpeaker {
|
|
|
|
|
if !onSpeaker { asserts += 1; try s.overrideOutputAudioPort(.speaker); log("assert>spk") }
|
|
|
|
|
} else if onSpeaker {
|
|
|
|
|
asserts += 1
|
2026-07-21 22:44:38 +05:30
|
|
|
// .none reverts to the mode default; make that the RECEIVER by pinning .voiceChat first.
|
2026-07-21 22:31:14 +05:30
|
|
|
if s.category != .playAndRecord {
|
|
|
|
|
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
|
|
|
|
log("repinCat")
|
|
|
|
|
} else if s.mode != .voiceChat {
|
2026-07-21 22:44:38 +05:30
|
|
|
try s.setMode(.voiceChat); log("repinMode")
|
2026-07-21 22:31:14 +05:30
|
|
|
}
|
|
|
|
|
try s.overrideOutputAudioPort(.none)
|
2026-07-21 22:44:38 +05:30
|
|
|
log("assert>rcv:\(snap())")
|
2026-07-21 09:10:09 +05:30
|
|
|
}
|
2026-07-21 22:20:29 +05:30
|
|
|
} catch { log("assertErr") }
|
2026-07-20 17:06:16 +05:30
|
|
|
}
|
|
|
|
|
|
2026-07-21 22:44:38 +05:30
|
|
|
// Re-read the SETTLED route a beat after the override, since the immediate read can be stale.
|
|
|
|
|
private func scheduleSettleProbe() {
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in self?.log("s4>\(self?.snap() ?? "")") }
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.log("s12>\(self?.snap() ?? "")") }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 19:22:01 +05:30
|
|
|
private func diag(_ applied: String) -> [String: Any] {
|
|
|
|
|
let s = AVAudioSession.sharedInstance()
|
|
|
|
|
return [
|
|
|
|
|
"route": applied,
|
|
|
|
|
"native": AudioRoutePlugin.nativeTag,
|
2026-07-21 22:44:38 +05:30
|
|
|
"outputs": snap(),
|
2026-07-21 19:22:01 +05:30
|
|
|
"category": s.category.rawValue,
|
2026-07-21 22:20:29 +05:30
|
|
|
"opts": Int(s.categoryOptions.rawValue),
|
2026-07-21 22:44:38 +05:30
|
|
|
"log": routeLog.joined(separator: ",") // trail (incl. settled probes) since the previous toggle
|
2026-07-21 19:22:01 +05:30
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
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-21 22:20:29 +05:30
|
|
|
asserts = 0
|
2026-07-21 22:31:14 +05:30
|
|
|
let s = AVAudioSession.sharedInstance()
|
2026-07-20 17:06:16 +05:30
|
|
|
do {
|
2026-07-21 22:31:14 +05:30
|
|
|
if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat {
|
|
|
|
|
try s.setMode(.voiceChat)
|
|
|
|
|
}
|
|
|
|
|
try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
2026-07-21 22:44:38 +05:30
|
|
|
scheduleSettleProbe() // settled reads land in the NEXT toggle's trail
|
2026-07-21 22:20:29 +05:30
|
|
|
let out = diag(wantSpeaker ? "speaker" : "earpiece")
|
2026-07-21 22:44:38 +05:30
|
|
|
routeLog.removeAll()
|
2026-07-21 22:20:29 +05:30
|
|
|
call.resolve(out)
|
2026-07-20 17:06:16 +05:30
|
|
|
} catch {
|
|
|
|
|
call.reject(error.localizedDescription)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|