diff --git a/mobile/package.json b/mobile/package.json index c1ac6a8..e4ccc2b 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -10,6 +10,7 @@ "ios": "cap open ios" }, "dependencies": { + "audio-route": "file:plugins/audio-route", "@capacitor-community/safe-area": "^7.0.0", "@capacitor/android": "^7.0.0", "@capacitor/app": "^7.0.0", diff --git a/mobile/plugins/audio-route/AudioRoutePlugin.podspec b/mobile/plugins/audio-route/AudioRoutePlugin.podspec new file mode 100644 index 0000000..33586ab --- /dev/null +++ b/mobile/plugins/audio-route/AudioRoutePlugin.podspec @@ -0,0 +1,17 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'AudioRoutePlugin' + s.version = package['version'] + s.summary = package['description'] + s.license = package['license'] + s.homepage = 'https://bizgaze.com' + s.author = 'BizGaze' + s.source = { :git => 'https://bizgaze.com/audio-route.git', :tag => s.version.to_s } + s.source_files = 'ios/Sources/**/*.{swift,h,m}' + s.ios.deployment_target = '14.0' + s.dependency 'Capacitor' + s.swift_version = '5.1' +end diff --git a/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift new file mode 100644 index 0000000..cc8e54b --- /dev/null +++ b/mobile/plugins/audio-route/ios/Sources/AudioRoutePlugin/AudioRoutePlugin.swift @@ -0,0 +1,35 @@ +import Foundation +import Capacitor +import AVFoundation + +// Earpiece <-> speaker toggle for calls. Registered by cap sync as a normal Capacitor plugin package, so it +// appears as window.Capacitor.Plugins.AudioRoute (unlike an app-embedded class, which gets stripped/not +// discovered in release builds). setSpeaker({on}) overrides the AVAudioSession output port. +@objc(AudioRoutePlugin) +public class AudioRoutePlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "AudioRoutePlugin" + public let jsName = "AudioRoute" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "setSpeaker", returnType: CAPPluginReturnPromise) + ] + + override public func load() { + // Default calls to the loudspeaker when the plugin initialises (iOS uses the quiet earpiece otherwise). + let session = AVAudioSession.sharedInstance() + try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetooth, .allowBluetoothA2DP]) + try? session.setActive(true) + } + + @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) + } + } +} diff --git a/mobile/plugins/audio-route/package.json b/mobile/plugins/audio-route/package.json new file mode 100644 index 0000000..602014d --- /dev/null +++ b/mobile/plugins/audio-route/package.json @@ -0,0 +1,26 @@ +{ + "name": "audio-route", + "version": "1.0.0", + "description": "iOS earpiece/speaker audio route toggle for Biz Connect", + "main": "dist/plugin.cjs.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "author": "BizGaze", + "license": "MIT", + "files": [ + "dist/", + "ios/", + "AudioRoutePlugin.podspec" + ], + "capacitor": { + "ios": { + "src": "ios" + } + }, + "devDependencies": { + "@capacitor/core": "^7.0.0" + }, + "peerDependencies": { + "@capacitor/core": "^7.0.0" + } +} diff --git a/mobile/scripts/inject-audio.js b/mobile/scripts/inject-audio.js index 1aad213..29b166c 100644 --- a/mobile/scripts/inject-audio.js +++ b/mobile/scripts/inject-audio.js @@ -1,30 +1,20 @@ -// 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. +// Patch the freshly-generated Capacitor iOS AppDelegate so calls DEFAULT to the loudspeaker at launch +// (iOS uses the quiet earpiece otherwise). Run on Codemagic (macOS) from ios-patch.sh: +// node mobile/scripts/inject-audio.js mobile/ios/App/App/AppDelegate.swift +// TOLERANT: exits 0 and no-ops if the template doesn't match, so it can NEVER fail the build. +// NOTE: the earpiece<->speaker TOGGLE is provided by the local Capacitor plugin package mobile/plugins/ +// audio-route (registered by cap sync, like @capacitor/share). This file only sets the launch default. 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); } + if (s.includes('.defaultToSpeaker')) { console.log(' audio launch-default 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 {', @@ -35,38 +25,8 @@ try { '', ].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'); } + 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'); } else { console.log(' (AppDelegate pattern not matched — audio patch skipped)'); } } catch (e) { console.log(' (audio patch error, skipped: ' + (e && e.message) + ')');