speaker: native AudioRoute plugin for real earpiece<->speaker toggle on iOS (batch144)
- ios-patch.sh now injects an AudioRoutePlugin (CAPBridgedPlugin, Capacitor 7 auto-registers it)
into AppDelegate.swift with setSpeaker({on}) -> AVAudioSession.overrideOutputAudioPort. Tolerant/
build-safe: if it doesn't register, the web call just no-ops (can't crash or fail the build).
- web: nativeAudioRoute()/bzApplyRoute() drive the plugin; toggleSpeakerphone + the on-join/on-tap
unlock now actually switch the route on iOS (setSinkId can't). canRouteAudio() shows the toggle
when the native plugin is present. Dormant until the next Codemagic build ships the plugin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,31 @@
|
||||
// Inject an AVAudioSession "default to the loudspeaker" setup into the freshly-generated Capacitor iOS
|
||||
// AppDelegate, so calls don't route to the quiet earpiece. Run on Codemagic (macOS) from ios-patch.sh:
|
||||
// node mobile/scripts/inject-audio.js mobile/ios/App/App/AppDelegate.swift
|
||||
// TOLERANT by design: if the file is missing or the template doesn't match, it prints a note and exits 0
|
||||
// so it can NEVER fail the build. First-pass fix — if iOS WebRTC re-grabs the session mid-call on device,
|
||||
// we follow up with a plugin that re-asserts .overrideOutputAudioPort(.speaker).
|
||||
// Patch the freshly-generated Capacitor iOS AppDelegate for call audio. Run on Codemagic (macOS) from
|
||||
// ios-patch.sh: node mobile/scripts/inject-audio.js mobile/ios/App/App/AppDelegate.swift
|
||||
// It does two things, both TOLERANT (exits 0 and no-ops if the template doesn't match, so it can NEVER
|
||||
// fail the build):
|
||||
// 1. At launch, set the AVAudioSession so calls DEFAULT to the loudspeaker (iOS uses the quiet earpiece).
|
||||
// 2. Append an AudioRoute Capacitor plugin (Capacitor 7 auto-registers any CAPBridgedPlugin in the app
|
||||
// target) so the web can switch earpiece<->speaker at runtime via
|
||||
// window.Capacitor.Plugins.AudioRoute.setSpeaker({ on: true|false }).
|
||||
// If the plugin fails to register for any reason, the web call simply no-ops — it cannot crash the app.
|
||||
const fs = require('fs');
|
||||
const p = process.argv[2];
|
||||
if (!p || !fs.existsSync(p)) { console.log(' (AppDelegate.swift not found — AVAudioSession patch skipped)'); process.exit(0); }
|
||||
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('AVAudioSession')) { console.log(' AVAudioSession already patched'); process.exit(0); }
|
||||
if (s.includes('AudioRoutePlugin')) { console.log(' audio already patched'); process.exit(0); }
|
||||
const orig = s;
|
||||
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');
|
||||
const inject = [
|
||||
|
||||
// ---- imports ----
|
||||
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');
|
||||
}
|
||||
if (!s.includes('import Capacitor')) {
|
||||
if (s.includes('import UIKit')) s = s.replace('import UIKit', 'import UIKit\nimport Capacitor');
|
||||
}
|
||||
|
||||
// ---- 1) launch: default call audio to the loudspeaker ----
|
||||
const launch = [
|
||||
' // Default call audio to the loudspeaker (iOS uses the quiet earpiece otherwise).',
|
||||
' do {',
|
||||
' let audioSession = AVAudioSession.sharedInstance()',
|
||||
@@ -23,14 +35,40 @@ try {
|
||||
'',
|
||||
].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) + inject + s.slice(idx); }
|
||||
if (s !== orig && s.includes('AVAudioSession')) {
|
||||
fs.writeFileSync(p, s);
|
||||
console.log(' AVAudioSession default-to-speaker injected into AppDelegate');
|
||||
} else {
|
||||
console.log(' (AppDelegate pattern not matched — AVAudioSession patch skipped)');
|
||||
}
|
||||
if (m && !s.includes('.defaultToSpeaker')) { const idx = m.index + m[0].length; s = s.slice(0, idx) + launch + s.slice(idx); }
|
||||
|
||||
// ---- 2) append the AudioRoute plugin (earpiece/speaker toggle) ----
|
||||
const plugin = [
|
||||
'',
|
||||
'// Earpiece <-> speaker toggle for calls. Capacitor 7 auto-registers any CAPBridgedPlugin in the app',
|
||||
'// target, so the web calls window.Capacitor.Plugins.AudioRoute.setSpeaker({ on: true|false }).',
|
||||
'@objc(AudioRoutePlugin)',
|
||||
'public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin {',
|
||||
' public let identifier = "AudioRoutePlugin"',
|
||||
' public let jsName = "AudioRoute"',
|
||||
' public let pluginMethods: [CAPPluginMethod] = [',
|
||||
' CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise)',
|
||||
' ]',
|
||||
' @objc func setSpeaker(_ call: CAPPluginCall) {',
|
||||
' let on = call.getBool("on") ?? true',
|
||||
' let session = AVAudioSession.sharedInstance()',
|
||||
' do {',
|
||||
' try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])',
|
||||
' try session.setActive(true)',
|
||||
' try session.overrideOutputAudioPort(on ? .speaker : .none)',
|
||||
' call.resolve(["route": on ? "speaker" : "earpiece"])',
|
||||
' } catch {',
|
||||
' call.reject(error.localizedDescription)',
|
||||
' }',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
s = s + plugin;
|
||||
|
||||
if (s !== orig) { fs.writeFileSync(p, s); console.log(' AVAudioSession default-to-speaker + AudioRoute plugin injected'); }
|
||||
else { console.log(' (AppDelegate pattern not matched — audio patch skipped)'); }
|
||||
} catch (e) {
|
||||
console.log(' (AVAudioSession patch error, skipped: ' + (e && e.message) + ')');
|
||||
console.log(' (audio patch error, skipped: ' + (e && e.message) + ')');
|
||||
}
|
||||
process.exit(0);
|
||||
|
||||
Reference in New Issue
Block a user