diff --git a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift index df0fa9c..ceedbe1 100644 --- a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift +++ b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift @@ -5,17 +5,17 @@ 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. +// 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. // -// 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". +// 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" @@ -24,73 +24,88 @@ 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" + // Bump on every native change so telemetry identifies the running binary unambiguously. + private static let nativeTag = "1.0.3-reassert" - // 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 + 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 output port is driven explicitly (see note above). + // 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) - } + 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 + 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" } - 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 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 { - try session.overrideOutputAudioPort(.speaker) + asserts += 1; try session.overrideOutputAudioPort(.speaker); log("assert>spk") } else if !wantSpeaker && onSpeaker { - try session.overrideOutputAudioPort(.none) + asserts += 1; try session.overrideOutputAudioPort(.none); log("assert>none") } - } catch { } + } catch { log("assertErr") } } - // 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"... + "outputs": ports(), "category": s.category.rawValue, "mode": s.mode.rawValue, - "opts": Int(s.categoryOptions.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 { - // 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")) + 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) } diff --git a/mobile/plugins/audio-route/package.json b/mobile/plugins/audio-route/package.json index 37b361e..5d5572d 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.2", + "version": "1.0.3", "description": "iOS earpiece/speaker audio route toggle for Biz Connect", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js",