From 36d51ba40d5b563df34617a94b11baec71db7007 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 21 Jul 2026 19:22:01 +0530 Subject: [PATCH] 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 --- .../AudioRoutePlugin/AudioRoutePlugin.swift | 36 ++++++++++++++----- mobile/plugins/audio-route/package.json | 2 +- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift index bd0f3d6..df0fa9c 100644 --- a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift +++ b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift @@ -7,14 +7,15 @@ import AVFoundation // // WHY THIS SHAPE (earlier version left the earpiece silent): // * 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 -// never receives audio. -// * setSpeaker flips ONLY overrideOutputAudioPort. It must NOT re-setCategory/setActive mid-call: doing so -// tears down the audio unit WebKit's WebRTC engine is actively using and leaves the earpiece route silent -// (forcing .speaker is a strong enough override to survive it, which is why only speaker "worked"). -// * 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 -// also removes the "call starts on speaker then drops to silence" flicker. +// 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". @objc(AudioRoutePlugin) public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AudioRoutePlugin" @@ -23,6 +24,9 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { 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 // side calls setSpeaker(true) at call start when it wants the loudspeaker (meetRoute defaults to 'speaker'). private var wantSpeaker = false @@ -66,13 +70,27 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { } 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) { wantSpeaker = call.getBool("on") ?? true let session = AVAudioSession.sharedInstance() do { // Flip ONLY the output port. No setCategory/setActive here — that would disrupt WebKit's audio. try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none) - call.resolve(["route": wantSpeaker ? "speaker" : "earpiece"]) + call.resolve(diag(wantSpeaker ? "speaker" : "earpiece")) } catch { call.reject(error.localizedDescription) } diff --git a/mobile/plugins/audio-route/package.json b/mobile/plugins/audio-route/package.json index f9e50ff..37b361e 100644 --- a/mobile/plugins/audio-route/package.json +++ b/mobile/plugins/audio-route/package.json @@ -1,6 +1,6 @@ { "name": "audio-route", - "version": "1.0.1", + "version": "1.0.2", "description": "iOS earpiece/speaker audio route toggle for Biz Connect", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js",