Files
BizGaze_Remote/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift
T

114 lines
5.4 KiB
Swift
Raw Normal View History

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).
//
// THE PROBLEM (from device telemetry): overrideOutputAudioPort(.none) routes to the receiver ONCE, then the
// route latches to the built-in Speaker on every later toggle — even though the category has NO
// .defaultToSpeaker. That fingerprints WebKit's WebRTC engine re-forcing the loudspeaker AFTER our override.
// The earlier build ignored .override-reason route changes (to avoid a feedback loop) and so never corrected
// WebKit's re-force → stuck on speaker → earpiece silent.
//
// THE FIX: re-assert the chosen port AFTER each route change settles (debounced 0.25s), reacting to ALL
// changes but acting ONLY on a real mismatch — so our own override (which also fires a change) finds the
// route already correct and does nothing, terminating the loop. Capped per toggle so a persistent adversary
// can't thrash the audio. A ring-buffer route log (reason -> port) rides back in setSpeaker's result so the
// telemetry shows the exact battle.
@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 telemetry identifies the running binary unambiguously.
private static let nativeTag = "1.0.3-reassert"
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)
private var routeLog: [String] = [] // reason->port trail, returned to JS for diagnosis
override public func load() {
let session = AVAudioSession.sharedInstance()
// NO .defaultToSpeaker — the port is driven explicitly so .none means the earpiece/receiver.
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) }
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"
}
}
private func ports() -> String {
let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { $0.portType.rawValue }
return outs.isEmpty ? "(none)" : outs.joined(separator: "+")
}
private func log(_ s: String) {
routeLog.append(s)
if routeLog.count > 16 { routeLog.removeFirst(routeLog.count - 16) }
}
@objc private func routeChanged(_ note: Notification) {
let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) ?? 0
log("\(reasonName(raw))>\(ports())")
// Re-assert AFTER WebKit's change settles; coalesce bursts into one late assert so we act last.
pending?.cancel()
let work = DispatchWorkItem { [weak self] in self?.applyRoute() }
pending = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
}
private func applyRoute() {
guard asserts < 6 else { log("cap"); return } // anti-thrash: give up rather than ping-pong forever
let session = AVAudioSession.sharedInstance()
let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
do {
if wantSpeaker && !onSpeaker {
asserts += 1; try session.overrideOutputAudioPort(.speaker); log("assert>spk")
} else if !wantSpeaker && onSpeaker {
asserts += 1; try session.overrideOutputAudioPort(.none); log("assert>none")
}
} catch { log("assertErr") }
}
private func diag(_ applied: String) -> [String: Any] {
let s = AVAudioSession.sharedInstance()
return [
"route": applied,
"native": AudioRoutePlugin.nativeTag,
"outputs": ports(),
"category": s.category.rawValue,
"mode": s.mode.rawValue,
"opts": Int(s.categoryOptions.rawValue),
"log": routeLog.joined(separator: ",") // route-change trail since the previous toggle
]
}
@objc func setSpeaker(_ call: CAPPluginCall) {
wantSpeaker = call.getBool("on") ?? true
asserts = 0
let session = AVAudioSession.sharedInstance()
do {
try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
let out = diag(wantSpeaker ? "speaker" : "earpiece")
routeLog.removeAll() // reset the trail; next toggle reports what happened in between
call.resolve(out)
} catch {
call.reject(error.localizedDescription)
}
}
}