diff --git a/mobile/scripts/inject-audio.js b/mobile/scripts/inject-audio.js index 6be7ac9..1aad213 100644 --- a/mobile/scripts/inject-audio.js +++ b/mobile/scripts/inject-audio.js @@ -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); diff --git a/server/public/home.html b/server/public/home.html index 2d61b8f..fc3243e 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -1158,7 +1158,7 @@ -