Files
BizGaze_Remote/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift
T
Sravan 36d51ba40d 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>
2026-07-21 19:22:01 +05:30

99 lines
4.9 KiB
Swift

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).
//
// 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.
// * 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"
public let jsName = "AudioRoute"
public let pluginMethods: [CAPPluginMethod] = [
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
override public func load() {
let session = AVAudioSession.sharedInstance()
// NO .defaultToSpeaker — the output port is driven explicitly (see note above).
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)
}
// WebKit/WebRTC reset the route (call audio just started, or a headset was (un)plugged). Re-assert ours,
// but ignore the change we caused ourselves (.override) so we don't fight in a feedback loop.
@objc private func routeChanged(_ note: Notification) {
if let info = note.userInfo,
let raw = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: raw),
reason == .override {
return
}
applyRoute()
}
// Nudge the port toward the desired output, but only when it actually differs — this leaves an attached
// Bluetooth/wired headset alone in the earpiece case (its route is neither builtInSpeaker nor forced).
private func applyRoute() {
let session = AVAudioSession.sharedInstance()
let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
do {
if wantSpeaker && !onSpeaker {
try session.overrideOutputAudioPort(.speaker)
} else if !wantSpeaker && onSpeaker {
try session.overrideOutputAudioPort(.none)
}
} 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(diag(wantSpeaker ? "speaker" : "earpiece"))
} catch {
call.reject(error.localizedDescription)
}
}
}