diag(ios audio): setSpeaker returns actual output port + category/mode + native marker

Telemetry only showed 'override didn't throw', and the web __BUILD tag can't tell
native binaries apart, so we couldn't see WHERE iOS actually routed the audio or
which plugin build ran. setSpeaker now resolves with the real currentRoute output
port (Receiver/Speaker/Bluetooth), the live AVAudioSession category/mode/options,
and a native-build marker (1.0.2-diag) so the route log is unambiguous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 19:22:01 +05:30
parent 1920345ecd
commit 36d51ba40d
2 changed files with 28 additions and 10 deletions
@@ -7,14 +7,15 @@ import AVFoundation
// //
// WHY THIS SHAPE (earlier version left the earpiece silent): // WHY THIS SHAPE (earlier version left the earpiece silent):
// * The category is configured WITHOUT .defaultToSpeaker, so overrideOutputAudioPort(.none) reliably means // * The category is configured WITHOUT .defaultToSpeaker, so overrideOutputAudioPort(.none) reliably means
// the earpiece/receiver. WITH .defaultToSpeaker, .none falls back to the loudspeaker and the earpiece // the earpiece/receiver. WITH .defaultToSpeaker, .none falls back to the loudspeaker.
// never receives audio. // * setSpeaker flips ONLY overrideOutputAudioPort no re-setCategory/setActive mid-call, which would tear
// * setSpeaker flips ONLY overrideOutputAudioPort. It must NOT re-setCategory/setActive mid-call: doing so // down the audio unit WebKit's WebRTC engine is using and silence the earpiece.
// tears down the audio unit WebKit's WebRTC engine is actively using and leaves the earpiece route silent // * WebKit reconfigures the shared AVAudioSession when call audio starts / on headset plug-unplug, which
// (forcing .speaker is a strong enough override to survive it, which is why only speaker "worked"). // would clobber our choice so we observe routeChangeNotification and re-assert the desired port.
// * WebKit reconfigures the shared AVAudioSession when call audio starts (and on headset plug/unplug), which //
// would clobber our choice so we observe routeChangeNotification and re-assert the desired port. That // DIAGNOSTICS: setSpeaker resolves with the ACTUAL current output port + live category/mode/options and a
// also removes the "call starts on speaker then drops to silence" flicker. // 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) @objc(AudioRoutePlugin)
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AudioRoutePlugin" public let identifier = "AudioRoutePlugin"
@@ -23,6 +24,9 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise) 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 // 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'). // side calls setSpeaker(true) at call start when it wants the loudspeaker (meetRoute defaults to 'speaker').
private var wantSpeaker = false private var wantSpeaker = false
@@ -66,13 +70,27 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
} catch { } } 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) { @objc func setSpeaker(_ call: CAPPluginCall) {
wantSpeaker = call.getBool("on") ?? true wantSpeaker = call.getBool("on") ?? true
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
do { do {
// Flip ONLY the output port. No setCategory/setActive here that would disrupt WebKit's audio. // Flip ONLY the output port. No setCategory/setActive here that would disrupt WebKit's audio.
try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none) try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none)
call.resolve(["route": wantSpeaker ? "speaker" : "earpiece"]) call.resolve(diag(wantSpeaker ? "speaker" : "earpiece"))
} catch { } catch {
call.reject(error.localizedDescription) call.reject(error.localizedDescription)
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audio-route", "name": "audio-route",
"version": "1.0.1", "version": "1.0.2",
"description": "iOS earpiece/speaker audio route toggle for Biz Connect", "description": "iOS earpiece/speaker audio route toggle for Biz Connect",
"main": "dist/plugin.cjs.js", "main": "dist/plugin.cjs.js",
"module": "dist/esm/index.js", "module": "dist/esm/index.js",