feat(ios audio): report real output route so the toggle icon is correct
BT audio already routes on iOS, but the web UI can't see it (iOS hides audio outputs
from enumerateDevices), so the icon was stuck on speaker. Plugin v1.1.0 now exposes the
active output: getRoute() + a 'routeChange' event ('speaker'|'bluetooth'|'wired'|
'receiver'|'airplay'). Web subscribes and drives the icon/label from the real route
(bluetooth/headphones/speaker), and the iOS toggle becomes a 2-state Speaker <-> Device
cycle (JS can't enumerate outputs there). Also strips the earpiece-investigation debug
logging from the plugin. Native needs one Codemagic build; web is live (batch148).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,145 +2,84 @@ 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).
|
||||
// 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).
|
||||
//
|
||||
// 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).
|
||||
// The built-in EARPIECE is unreachable inside a WKWebView — WebKit owns the WebRTC audio unit and forces the
|
||||
// loudspeaker (proven on device: overrideOutputAudioPort(.none) is a no-op there). So this plugin exposes only
|
||||
// what iOS actually allows for call audio:
|
||||
// * setSpeaker(true) -> force the loudspeaker (overrideOutputAudioPort(.speaker))
|
||||
// * setSpeaker(false) -> use the default output port: a connected Bluetooth/wired headset, else the default
|
||||
// It also REPORTS the active output to the web UI (getRoute() + a 'routeChange' event), because iOS hides audio
|
||||
// outputs from JavaScript (enumerateDevices returns none), so the web layer can't otherwise show the correct
|
||||
// speaker/bluetooth/headset icon.
|
||||
@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: "setSpeaker", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "getRoute", returnType: CAPPluginReturnPromise)
|
||||
]
|
||||
|
||||
// Bump on every native change so telemetry identifies the running binary unambiguously.
|
||||
private static let nativeTag = "1.0.5-settle"
|
||||
|
||||
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/mode trail, returned to JS for diagnosis
|
||||
|
||||
private let accessoryPorts: [AVAudioSession.Port] = [
|
||||
.bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .headphones, .headsetMic, .usbAudio, .carAudio, .airPlay
|
||||
]
|
||||
private var wantSpeaker = false
|
||||
private var pending: DispatchWorkItem?
|
||||
|
||||
override public func load() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||
try? session.setActive(true)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)),
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
try? s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||
try? s.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 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)
|
||||
}
|
||||
|
||||
private func log(_ s: String) {
|
||||
routeLog.append(s)
|
||||
if routeLog.count > 18 { routeLog.removeFirst(routeLog.count - 18) }
|
||||
}
|
||||
|
||||
@objc private func routeChanged(_ note: Notification) {
|
||||
let raw = (note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt) ?? 0
|
||||
log("\(reasonName(raw))>\(snap())")
|
||||
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 }
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
let outs = s.currentRoute.outputs
|
||||
let onSpeaker = outs.contains { $0.portType == .builtInSpeaker }
|
||||
let onAccessory = outs.contains { accessoryPorts.contains($0.portType) }
|
||||
if onAccessory && !wantSpeaker { log("skipAcc"); return }
|
||||
do {
|
||||
if wantSpeaker {
|
||||
if !onSpeaker { asserts += 1; try s.overrideOutputAudioPort(.speaker); log("assert>spk") }
|
||||
} else if onSpeaker {
|
||||
asserts += 1
|
||||
// .none reverts to the mode default; make that the RECEIVER by pinning .voiceChat first.
|
||||
if s.category != .playAndRecord {
|
||||
try s.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||
log("repinCat")
|
||||
} else if s.mode != .voiceChat {
|
||||
try s.setMode(.voiceChat); log("repinMode")
|
||||
}
|
||||
try s.overrideOutputAudioPort(.none)
|
||||
log("assert>rcv:\(snap())")
|
||||
// The ACTIVE output, mapped to a simple label the web UI turns into an icon.
|
||||
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
|
||||
}
|
||||
} catch { log("assertErr") }
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// 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() ?? "")") }
|
||||
}
|
||||
|
||||
private func diag(_ applied: String) -> [String: Any] {
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
return [
|
||||
"route": applied,
|
||||
"native": AudioRoutePlugin.nativeTag,
|
||||
"outputs": snap(),
|
||||
"category": s.category.rawValue,
|
||||
"opts": Int(s.categoryOptions.rawValue),
|
||||
"log": routeLog.joined(separator: ",") // trail (incl. settled probes) since the previous toggle
|
||||
]
|
||||
@objc private func routeChanged() {
|
||||
// Coalesce the burst iOS/WebKit fire on device (dis)connect, then re-hold the loudspeaker if the user
|
||||
// chose it, and tell the web UI where the audio actually is so the icon stays correct.
|
||||
pending?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
if self.wantSpeaker && !s.currentRoute.outputs.contains(where: { $0.portType == .builtInSpeaker }) {
|
||||
try? s.overrideOutputAudioPort(.speaker)
|
||||
}
|
||||
self.notifyListeners("routeChange", data: ["output": self.currentOutput()])
|
||||
}
|
||||
pending = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
|
||||
}
|
||||
|
||||
@objc func setSpeaker(_ call: CAPPluginCall) {
|
||||
wantSpeaker = call.getBool("on") ?? true
|
||||
asserts = 0
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat {
|
||||
try s.setMode(.voiceChat)
|
||||
}
|
||||
// "not speaker" means use the default port (headset if present); .voiceChat keeps AEC/AGC on.
|
||||
if !wantSpeaker && s.category == .playAndRecord && s.mode != .voiceChat { try s.setMode(.voiceChat) }
|
||||
try s.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
|
||||
scheduleSettleProbe() // settled reads land in the NEXT toggle's trail
|
||||
let out = diag(wantSpeaker ? "speaker" : "earpiece")
|
||||
routeLog.removeAll()
|
||||
call.resolve(out)
|
||||
call.resolve(["output": currentOutput()]) // immediate (may be stale); the routeChange event corrects it
|
||||
} catch {
|
||||
call.reject(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getRoute(_ call: CAPPluginCall) {
|
||||
call.resolve(["output": currentOutput()])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audio-route",
|
||||
"version": "1.0.5",
|
||||
"version": "1.1.0",
|
||||
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
||||
"main": "dist/plugin.cjs.js",
|
||||
"module": "dist/esm/index.js",
|
||||
|
||||
Reference in New Issue
Block a user