diff --git a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift index d931c6c..6ebceb8 100644 --- a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift +++ b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift @@ -5,20 +5,18 @@ 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). // -// ROOT CAUSE (WebKit source: Source/WebCore/platform/audio/cocoa/MediaSessionManagerCocoa.mm, confirmed by -// device telemetry): in a WKWebView the app does NOT own the AVAudioSession — WebKit's media process does. -// While WebRTC capture is active it re-pins the session to .playAndRecord with mode .videoChat, and -// .videoChat auto-implies .defaultToSpeaker. overrideOutputAudioPort(.none) means "revert to the mode -// default", which under .videoChat is the LOUDSPEAKER — so .none alone can never reach the earpiece once -// WebKit has flipped the mode. Our first .none won only because our .voiceChat mode was still in effect -// (voiceChat's default route IS the receiver); after WebKit's reconfigure it latched to speaker. -// -// THE FIX: for earpiece, set mode .voiceChat FIRST, then overrideOutputAudioPort(.none). overrideOutputAudioPort -// is a *transient* override (Apple QA1754) that every route/category change resets, and WebKit reconfigures on -// capture start / setLocalDescription / ICE-connected / renegotiation — so we also observe routeChangeNotification -// and re-assert (debounced, mismatch-only, capped) after WebKit's change settles. This reactive re-assert is the -// only lever a WKWebView host has (WebKit's internal RTCAudioSession / useManualAudio is unreachable; WebKit bug -// 167788 has left the app unable to disable WebKit's session management for 8+ years). +// 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). @objc(AudioRoutePlugin) public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AudioRoutePlugin" @@ -28,22 +26,19 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { ] // Bump on every native change so telemetry identifies the running binary unambiguously. - private static let nativeTag = "1.0.4-mode" + 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 trail, returned to JS for diagnosis + private var routeLog: [String] = [] // reason->port/mode trail, returned to JS for diagnosis - // Real external outputs — when one of these is active and the user wants "earpiece", we must NOT force the - // built-in receiver (that would yank audio off the user's headset). Only correct the speaker<->receiver axis. private let accessoryPorts: [AVAudioSession.Port] = [ .bluetoothHFP, .bluetoothA2DP, .bluetoothLE, .headphones, .headsetMic, .usbAudio, .carAudio, .airPlay ] override public func load() { let session = AVAudioSession.sharedInstance() - // NO .defaultToSpeaker — .voiceChat's default route is the receiver, so the port override decides output. try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) try? session.setActive(true) NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)), @@ -60,65 +55,74 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { } } - private func ports() -> String { - let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { $0.portType.rawValue } - return outs.isEmpty ? "(none)" : outs.joined(separator: "+") + 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 > 16 { routeLog.removeFirst(routeLog.count - 16) } + 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))>\(ports())") - // Re-assert AFTER WebKit's change settles; coalesce bursts into one late assert so we act last. + 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) } - // Nudge the route toward the desired output, acting ONLY on a real built-in speaker<->receiver mismatch so - // our own override (which itself fires a route change) self-terminates instead of looping. private func applyRoute() { - guard asserts < 6 else { log("cap"); return } // anti-thrash: give up rather than ping-pong forever + 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 } // leave a real headset/BT alone + 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 alone can't beat WebKit's .videoChat default (= speaker). Re-pin voiceChat first so the - // "revert to default" the override does actually resolves to the RECEIVER. + // .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.setMode(.voiceChat); log("repinMode") } try s.overrideOutputAudioPort(.none) - log("assert>rcv") + log("assert>rcv:\(snap())") } } catch { log("assertErr") } } + // 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": ports(), + "outputs": snap(), "category": s.category.rawValue, - "mode": s.mode.rawValue, "opts": Int(s.categoryOptions.rawValue), - "log": routeLog.joined(separator: ",") // route-change trail since the previous toggle + "log": routeLog.joined(separator: ",") // trail (incl. settled probes) since the previous toggle ] } @@ -127,14 +131,13 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { asserts = 0 let s = AVAudioSession.sharedInstance() do { - // Earpiece: pin .voiceChat first so the very first press is correct even if WebKit already flipped - // the mode to .videoChat. Speaker: override(.speaker) works under any mode, no mode change needed. 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() // reset the trail; next toggle reports what happened in between + routeLog.removeAll() 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 b7a076c..6c7f8df 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.4", + "version": "1.0.5", "description": "iOS earpiece/speaker audio route toggle for Biz Connect", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js",