// 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 — audio patch skipped)'); process.exit(0); } try { let s = fs.readFileSync(p, 'utf8'); if (s.includes('AudioRoutePlugin')) { console.log(' audio already patched'); process.exit(0); } const orig = s; // ---- 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()', ' try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .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 && !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(' (audio patch error, skipped: ' + (e && e.message) + ')'); } process.exit(0);