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 <noreply@anthropic.com>
This commit is contained in:
@@ -5,17 +5,20 @@ import AVFoundation
|
|||||||
// Earpiece <-> speaker toggle for calls. Registered by cap sync as a real Capacitor plugin package, so it
|
// 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).
|
// 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
|
// ROOT CAUSE (WebKit source: Source/WebCore/platform/audio/cocoa/MediaSessionManagerCocoa.mm, confirmed by
|
||||||
// route latches to the built-in Speaker on every later toggle — even though the category has NO
|
// device telemetry): in a WKWebView the app does NOT own the AVAudioSession — WebKit's media process does.
|
||||||
// .defaultToSpeaker. That fingerprints WebKit's WebRTC engine re-forcing the loudspeaker AFTER our override.
|
// While WebRTC capture is active it re-pins the session to .playAndRecord with mode .videoChat, and
|
||||||
// The earlier build ignored .override-reason route changes (to avoid a feedback loop) and so never corrected
|
// .videoChat auto-implies .defaultToSpeaker. overrideOutputAudioPort(.none) means "revert to the mode
|
||||||
// WebKit's re-force → stuck on speaker → earpiece silent.
|
// 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
|
// THE FIX: for earpiece, set mode .voiceChat FIRST, then overrideOutputAudioPort(.none). overrideOutputAudioPort
|
||||||
// changes but acting ONLY on a real mismatch — so our own override (which also fires a change) finds the
|
// is a *transient* override (Apple QA1754) that every route/category change resets, and WebKit reconfigures on
|
||||||
// route already correct and does nothing, terminating the loop. Capped per toggle so a persistent adversary
|
// capture start / setLocalDescription / ICE-connected / renegotiation — so we also observe routeChangeNotification
|
||||||
// can't thrash the audio. A ring-buffer route log (reason -> port) rides back in setSpeaker's result so the
|
// and re-assert (debounced, mismatch-only, capped) after WebKit's change settles. This reactive re-assert is the
|
||||||
// telemetry shows the exact battle.
|
// 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)
|
@objc(AudioRoutePlugin)
|
||||||
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||||
public let identifier = "AudioRoutePlugin"
|
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.
|
// 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 wantSpeaker = false // desired output; source of truth, re-applied on route changes
|
||||||
private var pending: DispatchWorkItem? // debounced re-assert
|
private var pending: DispatchWorkItem? // debounced re-assert
|
||||||
private var asserts = 0 // re-assert count this toggle (capped, anti-thrash)
|
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 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() {
|
override public func load() {
|
||||||
let session = AVAudioSession.sharedInstance()
|
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.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||||
try? session.setActive(true)
|
try? session.setActive(true)
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)),
|
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)
|
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() {
|
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 } // anti-thrash: give up rather than ping-pong forever
|
||||||
let session = AVAudioSession.sharedInstance()
|
let s = AVAudioSession.sharedInstance()
|
||||||
let onSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
|
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 {
|
do {
|
||||||
if wantSpeaker && !onSpeaker {
|
if wantSpeaker {
|
||||||
asserts += 1; try session.overrideOutputAudioPort(.speaker); log("assert>spk")
|
if !onSpeaker { asserts += 1; try s.overrideOutputAudioPort(.speaker); log("assert>spk") }
|
||||||
} else if !wantSpeaker && onSpeaker {
|
} else if onSpeaker {
|
||||||
asserts += 1; try session.overrideOutputAudioPort(.none); log("assert>none")
|
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") }
|
} catch { log("assertErr") }
|
||||||
}
|
}
|
||||||
@@ -100,9 +125,14 @@ public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
@objc func setSpeaker(_ call: CAPPluginCall) {
|
@objc func setSpeaker(_ call: CAPPluginCall) {
|
||||||
wantSpeaker = call.getBool("on") ?? true
|
wantSpeaker = call.getBool("on") ?? true
|
||||||
asserts = 0
|
asserts = 0
|
||||||
let session = AVAudioSession.sharedInstance()
|
let s = AVAudioSession.sharedInstance()
|
||||||
do {
|
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")
|
let out = diag(wantSpeaker ? "speaker" : "earpiece")
|
||||||
routeLog.removeAll() // reset the trail; next toggle reports what happened in between
|
routeLog.removeAll() // reset the trail; next toggle reports what happened in between
|
||||||
call.resolve(out)
|
call.resolve(out)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audio-route",
|
"name": "audio-route",
|
||||||
"version": "1.0.3",
|
"version": "1.0.4",
|
||||||
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
"description": "iOS earpiece/speaker audio route toggle for Biz Connect",
|
||||||
"main": "dist/plugin.cjs.js",
|
"main": "dist/plugin.cjs.js",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.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); }
|
if (!p || !fs.existsSync(p)) { console.log(' (AppDelegate.swift not found — audio patch skipped)'); process.exit(0); }
|
||||||
try {
|
try {
|
||||||
let s = fs.readFileSync(p, 'utf8');
|
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;
|
const orig = s;
|
||||||
if (!s.includes('import AVFoundation')) {
|
if (!s.includes('import AVFoundation')) {
|
||||||
if (s.includes('import Capacitor')) s = s.replace('import Capacitor', 'import Capacitor\nimport 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');
|
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 = [
|
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 {',
|
' do {',
|
||||||
' let audioSession = AVAudioSession.sharedInstance()',
|
' 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)',
|
' try audioSession.setActive(true)',
|
||||||
' } catch { }',
|
' } catch { }',
|
||||||
'',
|
'',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
const m = s.match(/func\s+application\([^)]*didFinishLaunchingWithOptions[^)]*\)\s*->\s*Bool\s*\{[^\n]*\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 (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)'); }
|
else { console.log(' (AppDelegate pattern not matched — audio patch skipped)'); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(' (audio patch error, skipped: ' + (e && e.message) + ')');
|
console.log(' (audio patch error, skipped: ' + (e && e.message) + ')');
|
||||||
|
|||||||
Reference in New Issue
Block a user