speaker: make AudioRoute a real Capacitor plugin PACKAGE so it registers (device log: has:0)

Device telemetry proved the app-embedded AudioRoute class never registered (not in
Capacitor.Plugins) — appending a CAPBridgedPlugin to AppDelegate.swift gets stripped/undiscovered
in release builds. The plugins that DO register (Share, Camera, Filesystem) are all npm packages
wired by cap sync. So AudioRoute is now a local plugin package (mobile/plugins/audio-route,
file: dep in mobile/package.json) with a podspec + CAPBridgedPlugin Swift — cap sync adds its pod
and Capacitor registers it like the others. load() sets the launch speaker default; setSpeaker({on})
overrides the output port. inject-audio.js no longer injects the plugin class (would duplicate);
it keeps only the AppDelegate launch default as a fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 17:06:16 +05:30
parent a574816eaa
commit c6f236ecf9
5 changed files with 88 additions and 49 deletions
+1
View File
@@ -10,6 +10,7 @@
"ios": "cap open ios" "ios": "cap open ios"
}, },
"dependencies": { "dependencies": {
"audio-route": "file:plugins/audio-route",
"@capacitor-community/safe-area": "^7.0.0", "@capacitor-community/safe-area": "^7.0.0",
"@capacitor/android": "^7.0.0", "@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0", "@capacitor/app": "^7.0.0",
@@ -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
@@ -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)
}
}
}
+26
View File
@@ -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"
}
}
+9 -49
View File
@@ -1,30 +1,20 @@
// Patch the freshly-generated Capacitor iOS AppDelegate for call audio. Run on Codemagic (macOS) from // Patch the freshly-generated Capacitor iOS AppDelegate so calls DEFAULT to the loudspeaker at launch
// ios-patch.sh: node mobile/scripts/inject-audio.js mobile/ios/App/App/AppDelegate.swift // (iOS uses the quiet earpiece otherwise). Run on Codemagic (macOS) from ios-patch.sh:
// It does two things, both TOLERANT (exits 0 and no-ops if the template doesn't match, so it can NEVER // node mobile/scripts/inject-audio.js mobile/ios/App/App/AppDelegate.swift
// fail the build): // 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). // NOTE: the earpiece<->speaker TOGGLE is provided by the local Capacitor plugin package mobile/plugins/
// 2. Append an AudioRoute Capacitor plugin (Capacitor 7 auto-registers any CAPBridgedPlugin in the app // audio-route (registered by cap sync, like @capacitor/share). This file only sets the launch default.
// 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 fs = require('fs');
const p = process.argv[2]; 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('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; const orig = s;
// ---- imports ----
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');
} }
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 = [ const launch = [
' // Default call audio to the loudspeaker (iOS uses the quiet earpiece otherwise).', ' // Default call audio to the loudspeaker (iOS uses the quiet earpiece otherwise).',
' do {', ' do {',
@@ -35,38 +25,8 @@ try {
'', '',
].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 && !s.includes('.defaultToSpeaker')) { 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'); }
// ---- 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)'); } 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) + ')');