Files
chanora/apps/chanora_flutter/ios/Runner/AppDelegate.swift
T
Edison Jwa a589ac953f fix(ios,macos): preserve Silero @_cdecl exports across Xcode Archive
The Apple CoreML Silero VAD backend resolves six @_cdecl Swift symbols
via dlsym(RTLD_DEFAULT) at runtime in the Rust audio crate. Local
flutter build paths preserved those symbols, but Xcode Archive (the
path used for TestFlight and App Store uploads) silently stripped them
through two independent mechanisms, causing Rust to fall back to
WebRTC VAD on every shipped build.

Both stripping mechanisms are now neutralised:

* ld dead-strip: OTHER_LDFLAGS now whitelists each of the six
  chanora_silero_vad_* symbols via repeated `-Xlinker -exported_symbol`
  pairs in ios/Flutter/{Release,Debug}.xcconfig and
  macos/Flutter/Flutter-{Release,Debug}.xcconfig.
* install-time strip: STRIP_STYLE is set to `non-global` in the same
  four xcconfigs so the post-link strip phase no longer drops exported
  global text symbols from the Archive product. Cost: ~264 bytes per
  binary; verified `xcrun strip` vs `xcrun strip -x` behaviour.

Self-test wired into both AppDelegates: at launch on a utility queue,
ChanoraSileroSelfTest resolves all six symbols through dlsym (the same
path the Rust runtime uses, not a direct call that would mask the bug
class) and exercises create → reset → process → destroy. Result is
logged via NSLog and surfaces in Console.app / idevicesyslog.

A post-link verify_silero_exports.sh build phase runs nm -gU on the
final Archive binary and fails the build if any of the six symbols are
missing. Empirically caught the original Archive regression that
flutter build --no-codesign did not.

CocoaPods bridge podspecs now emit a proper .dSYM via dsymutil so
TestFlight crash reports are symbolicated; Cargo.toml release profile
sets `debug = true` because dsymutil needs DWARF in the input dylib.

macOS chanora_bridge.podspec PATH inserts /opt/homebrew/opt/rustup/bin
ahead of /opt/homebrew/bin so rustup's cargo (which has the
x86_64-apple-darwin target installed) wins over the homebrew rust
formula that is aarch64-only.

iOS Podfile target renamed from `Runner` to `Chanora` to match the
Xcode target name shipped in the project (the workspace and scheme
already referenced Chanora; the Podfile mismatch produced lint
warnings during `pod install`).

ITSAppUsesNonExemptEncryption=false declared in both Info.plist files
so TestFlight and App Store Connect uploads skip the export-compliance
prompt; Chanora uses only platform-provided TLS.

.gitignore now covers Xcode archive bundles, IPA exports, dSYM
directories, the local macOS release zip, and agent/tooling state
directories so generated TestFlight artifacts no longer appear in
git status.

End-to-end verified by headless archive:
  xcodebuild -workspace Runner.xcworkspace -scheme Runner \
    -configuration Release -destination 'generic/platform=iOS' \
    -archivePath /tmp/chanora.xcarchive archive CODE_SIGNING_ALLOWED=NO
nm -gU on the resulting .app/Chanora binary shows all six
chanora_silero_vad_* symbols present.
2026-06-07 23:12:07 +09:00

333 lines
14 KiB
Swift

import UIKit
import Flutter
import AVFoundation
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var iosAudioLifecycleChannel: FlutterMethodChannel?
private var iosPlatformChannel: FlutterMethodChannel?
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
DispatchQueue.global(qos: .utility).async {
ChanoraSileroSelfTest.run()
}
// Configure the iOS AVAudioSession **category + mode** at
// app-launch time, but DEFER setActive(true) until the scene
// is foregrounded. Calling setActive in didFinishLaunching is
// racy on iOS 17+ devices: if the user launches the app from a
// cold state, the UIApplication isn't yet `.active` and
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
// (561017449) — the iOS audio policy server refuses to grant
// the audio session because the app is not yet considered the
// foreground priority owner. Symptom in production builds:
// 'AVAudioSession setup failed: Error 561017449 "Session
// activation failed"' in NSLog, after which the audio engine
// is unusable until the user backgrounds + foregrounds the
// app.
//
// The category itself can be set whenever; only the active
// state needs to be deferred. We listen for
// didBecomeActiveNotification and activate then. Most
// production iOS voice apps (Discord, Zoom, FaceTime) follow
// this same shape.
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
// Mode rationale (May 2026, .voiceChat reinstated):
//
// We previously used .default mode after discovering that
// .voiceChat routed output through iOS's in-call audio
// channel, which made speaker output barely audible. That
// bug was caused by cpal's RemoteIO unit binding to a stale
// physical transducer — after migrating to coreaudio-rs +
// kAudioUnitSubType_VoiceProcessingIO (see
// crates/chanora_audio/src/ios_voice_unit.rs) the route
// binding is correct under either mode because VPIO re-binds
// on overrideOutputAudioPort.
//
// .voiceChat advantages over .default:
// * Tells iOS this is a VoIP session — other apps' audio
// is properly ducked/paused instead of competing.
// * Enables correct Bluetooth HFP negotiation without
// manual workarounds.
// * iOS treats the audio session as a "call" for priority
// purposes (won't be interrupted by notification sounds).
// * System-level CallKit integration (lock-screen controls).
//
// .defaultToSpeaker ensures output goes to the main speaker
// (not the earpiece) by default when no headphones are
// connected, compensating for the in-call channel's tendency
// to route to the earpiece.
//
// References:
// * https://github.com/twilio/video-quickstart-ios/issues/522
// * https://stackoverflow.com/questions/79834998 (Daily.co)
//
// Options:
// .defaultToSpeaker : route output to the main speaker
// (not the earpiece) by default
// when no headphones are connected.
// .allowBluetoothHFP : permit Bluetooth Hands-Free
// Profile headsets as both input
// and output.
// .allowBluetoothA2DP : permit higher-quality A2DP
// output-only Bluetooth devices.
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
)
// Match VPIO / Opus frame cadence to reduce callback pressure.
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
logAudioSessionState(context: "setCategory")
} catch {
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
}
// Activate the session once the app is actually foreground. The
// notification fires immediately after the cold-launch settles,
// and again on every resume-from-background — both safe
// moments to call setActive(true). Repeated activation while
// already-active is a no-op per the docs.
NotificationCenter.default.addObserver(
self,
selector: #selector(activateAudioSession),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange(_:)),
name: AVAudioSession.routeChangeNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleInterruption(_:)),
name: AVAudioSession.interruptionNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMediaServicesReset(_:)),
name: AVAudioSession.mediaServicesWereResetNotification,
object: nil
)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// Called by `didBecomeActiveNotification` (cold-launch settle +
/// every resume-from-background). Activates the AVAudioSession.
/// Repeated activation is a no-op when the session is already
/// active so this is safe to call on every foreground.
@objc private func activateAudioSession() {
do {
try AVAudioSession.sharedInstance().setActive(true, options: [])
NSLog("chanora_flutter: AVAudioSession activated on foreground")
// Read back the ACTUAL session state. preferredSampleRate /
// preferredIOBufferDuration are hints; iOS may pick something
// else depending on hardware + currently-engaged effects.
// Without these we can't tell whether VPIO is running at
// 48 kHz mono (what our render callback assumes) or at e.g.
// 44.1 kHz (which would explain the user's broken playback
// \u2014 our render callback would be writing samples at the
// wrong rate, causing pitch + timing artifacts).
logAudioSessionState(context: "setActive")
let s = AVAudioSession.sharedInstance()
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog(
"chanora_flutter: AVAudioSession actual: " +
"sampleRate=\(s.sampleRate) " +
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
"inputs=[\(ins)] " +
"outputVolume=\(s.outputVolume)"
)
} catch {
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
}
}
/// Reads back the actual AVAudioSession state and logs it for
/// SDD-098 compliance. Called after both setCategory and setActive
/// to verify that the session accepted the requested configuration.
private func logAudioSessionState(context: String) {
let s = AVAudioSession.sharedInstance()
NSLog("chanora_flutter: [\(context)] category=\(s.category.rawValue) mode=\(s.mode.rawValue) options=\(s.categoryOptions.rawValue) route outputs=\(s.currentRoute.outputs.map { "\($0.portType.rawValue)" })")
if s.sampleRate != 48000.0 {
NSLog("chanora_flutter: WARNING: actual sample rate \(s.sampleRate) != requested 48000")
}
if s.ioBufferDuration > 0.025 {
NSLog("chanora_flutter: WARNING: IO buffer duration \(s.ioBufferDuration) > 25ms, may cause latency")
}
}
@objc private func handleRouteChange(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
else {
return
}
let routeDescription = AVAudioSession.sharedInstance().currentRoute
let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",")
NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)")
// P1: Send the detailed route class to Rust on every route change,
// not just device plug/unplug. This covers:
// - .newDeviceAvailable / .oldDeviceUnavailable (headset plug/unplug)
// - .override (speaker/earpiece toggle)
// - .categoryChange (session category changed)
// - .wakeFromSleep (device woke from sleep)
// - .routeConfigurationChange (BT HFP connect/disconnect)
// The Rust side uses the route class to recompute the processing
// policy (route_policy.rs) and reset AEC delay state if needed.
let routeClass = classifyAudioRoute(routeDescription)
NSLog("chanora_flutter: route class=\(routeClass) reason=\(reason.rawValue)")
iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: routeClass)
}
@objc private func handleInterruption(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
else {
return
}
switch type {
case .began:
NSLog("chanora_flutter: audio interruption began")
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionBegan", arguments: nil)
case .ended:
let shouldResume = (userInfo[AVAudioSessionInterruptionOptionKey] as? UInt)
.map { $0 & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 }
?? false
NSLog("chanora_flutter: audio interruption ended shouldResume=\(shouldResume)")
iosAudioLifecycleChannel?.invokeMethod("handleInterruptionEnded", arguments: shouldResume)
@unknown default:
break
}
}
@objc private func handleMediaServicesReset(_ notification: Notification) {
NSLog("chanora_flutter: media services reset")
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
)
try session.setPreferredIOBufferDuration(0.02)
try session.setPreferredSampleRate(48000.0)
try session.setActive(true, options: [])
logAudioSessionState(context: "mediaServicesWereReset")
} catch {
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
}
// P1: After rebuilding the session, send the current route class to
// Rust so it can recompute the processing policy and reset the
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
// which calls ios_restart_voice_unit.
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
}
override func applicationWillResignActive(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleWillResignActive", arguments: nil)
}
override func applicationDidEnterBackground(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleDidEnterBackground", arguments: nil)
}
override func applicationWillEnterForeground(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleWillEnterForeground", arguments: nil)
}
override func applicationWillTerminate(_ application: UIApplication) {
iosAudioLifecycleChannel?.invokeMethod("handleWillTerminate", arguments: nil)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
iosAudioLifecycleChannel = FlutterMethodChannel(
name: "chanora/ios_audio_lifecycle",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
iosPlatformChannel = FlutterMethodChannel(
name: "chanora/ios_platform",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
iosPlatformChannel?.setMethodCallHandler { call, result in
switch call.method {
case "getMicrophonePermissionState":
result(self.microphonePermissionStateString())
case "requestMicrophonePermission":
AVAudioSession.sharedInstance().requestRecordPermission { granted in
DispatchQueue.main.async {
result(granted ? "Granted" : self.microphonePermissionStateString())
}
}
case "openAppSettings":
guard let url = URL(string: UIApplication.openSettingsURLString) else {
result(false)
return
}
UIApplication.shared.open(url, options: [:]) { opened in
result(opened)
}
default:
result(FlutterMethodNotImplemented)
}
}
}
private func classifyAudioRoute(_ route: AVAudioSessionRouteDescription) -> String {
for output in route.outputs {
switch output.portType {
case .builtInReceiver:
return "Earpiece"
case .builtInSpeaker:
return "Speaker"
case .headphones, .usbAudio:
return "WiredHeadset"
case .bluetoothHFP:
return "BluetoothHfp"
case .bluetoothA2DP:
return "BluetoothA2dp"
default:
break
}
}
return "Unknown"
}
private func microphonePermissionStateString() -> String {
switch AVAudioSession.sharedInstance().recordPermission {
case .granted:
return "Granted"
case .denied:
return "Denied"
case .undetermined:
return "NotDetermined"
@unknown default:
return "Unknown"
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}