From b54ba10c691598fcd41f608175031c4a278e6ed5 Mon Sep 17 00:00:00 2001 From: sravan Date: Tue, 21 Jul 2026 22:31:14 +0530 Subject: [PATCH] fix(ios audio): pin .voiceChat before override(.none) so earpiece is reachable Root cause (WebKit source MediaSessionManagerCocoa.mm + Apple DTS, confirmed by telemetry): WKWebView's WebRTC re-pins the session to mode .videoChat while capture is active, and .videoChat auto-implies .defaultToSpeaker. So override(.none) reverts to the mode default = LOUDSPEAKER, and .none alone can never reach the earpiece once WebKit flips the mode. Our first override won only because .voiceChat was still active. Fix: for earpiece, setMode(.voiceChat) (its default route IS the receiver) before override(.none) in both setSpeaker and the debounced route-change re-assert. Add an accessory guard so a connected BT/wired headset isn't yanked to the built-in receiver. Reconcile the launch patch: drop .defaultToSpeaker from inject-audio.js so it stops contradicting the plugin. native marker 1.0.4-mode. Co-Authored-By: Claude Opus 4.8 --- .../AudioRoutePlugin/AudioRoutePlugin.swift | 70 +++++++++++++------ mobile/plugins/audio-route/package.json | 2 +- mobile/scripts/inject-audio.js | 12 ++-- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift index ceedbe1..d931c6c 100644 --- a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift +++ b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift @@ -5,17 +5,20 @@ 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). // -// 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. +// 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: 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. +// 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). @objc(AudioRoutePlugin) public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "AudioRoutePlugin" @@ -25,16 +28,22 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { ] // Bump on every native change so telemetry identifies the running binary unambiguously. - private static let nativeTag = "1.0.3-reassert" + private static let nativeTag = "1.0.4-mode" 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 + // 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 — the port is driven explicitly so .none means the earpiece/receiver. + // 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(_:)), @@ -71,15 +80,31 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { 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 - let session = AVAudioSession.sharedInstance() - let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker } + 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 do { - if wantSpeaker && !onSpeaker { - asserts += 1; try session.overrideOutputAudioPort(.speaker); log("assert>spk") - } else if !wantSpeaker && onSpeaker { - asserts += 1; try session.overrideOutputAudioPort(.none); log("assert>none") + 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. + 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.overrideOutputAudioPort(.none) + log("assert>rcv") } } catch { log("assertErr") } } @@ -100,9 +125,14 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { @objc func setSpeaker(_ call: CAPPluginCall) { wantSpeaker = call.getBool("on") ?? true asserts = 0 - let session = AVAudioSession.sharedInstance() + let s = AVAudioSession.sharedInstance() do { - try session.overrideOutputAudioPort(wantSpeaker ? .speaker : .none) + // 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) let out = diag(wantSpeaker ? "speaker" : "earpiece") routeLog.removeAll() // reset the trail; next toggle reports what happened in between call.resolve(out) diff --git a/mobile/plugins/audio-route/package.json b/mobile/plugins/audio-route/package.json index 5d5572d..b7a076c 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.3", + "version": "1.0.4", "description": "iOS earpiece/speaker audio route toggle for Biz Connect", "main": "dist/plugin.cjs.js", "module": "dist/esm/index.js", diff --git a/mobile/scripts/inject-audio.js b/mobile/scripts/inject-audio.js index 29b166c..806b4f6 100644 --- a/mobile/scripts/inject-audio.js +++ b/mobile/scripts/inject-audio.js @@ -9,24 +9,28 @@ const p = process.argv[2]; if (!p || !fs.existsSync(p)) { console.log(' (AppDelegate.swift not found — audio patch skipped)'); process.exit(0); } try { let s = fs.readFileSync(p, 'utf8'); - if (s.includes('.defaultToSpeaker')) { console.log(' audio launch-default already patched'); process.exit(0); } + if (s.includes('bzcAudioLaunch')) { console.log(' audio launch baseline already patched'); process.exit(0); } const orig = s; if (!s.includes('import AVFoundation')) { if (s.includes('import Capacitor')) s = s.replace('import Capacitor', 'import Capacitor\nimport AVFoundation'); else if (s.includes('import UIKit')) s = s.replace('import UIKit', 'import UIKit\nimport AVFoundation'); } + // NOTE: NO .defaultToSpeaker here. The earpiece<->speaker toggle plugin (mobile/plugins/audio-route) drives + // the output port explicitly and needs .voiceChat's receiver default; .defaultToSpeaker would invert that and + // make earpiece unreachable. This is only a launch-time baseline — WebKit re-pins the session per call anyway. const launch = [ - ' // Default call audio to the loudspeaker (iOS uses the quiet earpiece otherwise).', + ' // bzcAudioLaunch: baseline voice-call audio session (earpiece-capable; the AudioRoute plugin and', + ' // the JS meet UI force the speaker per call). Keep in sync with mobile/plugins/audio-route load().', ' do {', ' let audioSession = AVAudioSession.sharedInstance()', - ' try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP])', + ' try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])', ' try audioSession.setActive(true)', ' } catch { }', '', ].join('\n'); const m = s.match(/func\s+application\([^)]*didFinishLaunchingWithOptions[^)]*\)\s*->\s*Bool\s*\{[^\n]*\n/); if (m) { const idx = m.index + m[0].length; s = s.slice(0, idx) + launch + s.slice(idx); } - if (s !== orig && s.includes('.defaultToSpeaker')) { fs.writeFileSync(p, s); console.log(' AVAudioSession launch default-to-speaker injected'); } + if (s !== orig && s.includes('bzcAudioLaunch')) { fs.writeFileSync(p, s); console.log(' AVAudioSession launch baseline injected'); } else { console.log(' (AppDelegate pattern not matched — audio patch skipped)'); } } catch (e) { console.log(' (audio patch error, skipped: ' + (e && e.message) + ')');