From c02d4e6df3ea26c4fe49021a17c81ccf3a820d0f Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Sun, 31 May 2026 22:29:38 +0900 Subject: [PATCH] fix(bridge): serialize iOS audio lifecycle events --- crates/chanora_bridge/Cargo.toml | 2 +- crates/chanora_bridge/src/api.rs | 100 +++++++++++++++++++++++-------- 2 files changed, 75 insertions(+), 27 deletions(-) diff --git a/crates/chanora_bridge/Cargo.toml b/crates/chanora_bridge/Cargo.toml index 1ee3b6b..2c3e72d 100644 --- a/crates/chanora_bridge/Cargo.toml +++ b/crates/chanora_bridge/Cargo.toml @@ -23,7 +23,7 @@ thiserror.workspace = true serde.workspace = true tracing.workspace = true tracing-subscriber = { version = "0.3", features = ["env-filter"] } -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] } # Android: route `tracing` output to logcat so a user can see protocol # and audio diagnostics via `adb logcat -s chanora`. Also brings in the diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index f42ca38..196d0b0 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -11,7 +11,7 @@ use std::time::Duration; use flutter_rust_bridge::frb; use tokio::runtime::Runtime; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, mpsc}; use tracing::{info, warn}; use crate::frb_generated::StreamSink; @@ -42,6 +42,73 @@ fn task_join_error(task: &'static str, error: tokio::task::JoinError) -> BridgeE BridgeError::Unmapped(format!("join: {error}")) } +enum PlatformAudioEvent { + RouteChanged(chanora_audio::AudioRoute), + MediaServicesResetWithRoute(chanora_audio::AudioRoute), + InterruptionBegan, + InterruptionEnded { should_resume: bool }, + AudioOutputRoute(chanora_audio::AudioRoute), + Lifecycle { state: String }, +} + +impl PlatformAudioEvent { + async fn process(self) { + match self { + Self::RouteChanged(route) => { + if let Err(e) = session().ios_handle_route_change(route).await { + warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed"); + } + } + Self::MediaServicesResetWithRoute(route) => { + if let Err(e) = session().ios_handle_media_services_reset(route).await { + warn!(target: "chanora_bridge", error = %e, "iOS media-services reset (with route) handling failed"); + } + } + Self::InterruptionBegan => { + if let Err(e) = session().ios_handle_interruption_began().await { + warn!(target: "chanora_bridge", error = %e, "iOS interruption-began handling failed"); + } + } + Self::InterruptionEnded { should_resume } => { + if let Err(e) = session().ios_handle_interruption_ended(should_resume).await { + warn!(target: "chanora_bridge", error = %e, "iOS interruption-ended handling failed"); + } + } + Self::AudioOutputRoute(route) => { + if let Err(e) = session().ios_handle_route_change(route).await { + warn!(target: "chanora_bridge", error = %e, "audio output route handling failed"); + } + } + Self::Lifecycle { state } => { + session().record_lifecycle_event(&state).await; + } + } + } +} + +fn platform_audio_events() -> &'static mpsc::UnboundedSender { + static TX: OnceLock> = OnceLock::new(); + TX.get_or_init(|| { + let (tx, mut rx) = mpsc::unbounded_channel::(); + runtime().spawn(async move { + while let Some(event) = rx.recv().await { + event.process().await; + } + }); + tx + }) +} + +fn dispatch_platform_audio_event(event: PlatformAudioEvent) { + if let Err(e) = platform_audio_events().send(event) { + warn!( + target: "chanora_bridge", + error = %e, + "ordered platform audio event dispatch failed" + ); + } +} + fn install_panic_diagnostic_hook() { static INSTALLED: OnceLock<()> = OnceLock::new(); let _ = INSTALLED.get_or_init(|| { @@ -615,11 +682,7 @@ pub async fn is_connected() -> bool { /// Handle iOS AVAudioSession route changes (SDD-100). #[frb(sync)] pub fn handle_route_change(route: BridgeAudioRoute) { - let result = - runtime().block_on(async { session().ios_handle_route_change(route.into()).await }); - if let Err(e) = result { - warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed"); - } + dispatch_platform_audio_event(PlatformAudioEvent::RouteChanged(route.into())); } /// Handle iOS AVAudioSession media-services reset with the current @@ -629,30 +692,19 @@ pub fn handle_route_change(route: BridgeAudioRoute) { #[frb(sync)] pub fn handle_media_services_reset_with_route(route_class: String) { let route = chanora_audio::AudioRoute::from_route_class(&route_class); - let result = - runtime().block_on(async { session().ios_handle_media_services_reset(route).await }); - if let Err(e) = result { - warn!(target: "chanora_bridge", error = %e, "iOS media-services reset (with route) handling failed"); - } + dispatch_platform_audio_event(PlatformAudioEvent::MediaServicesResetWithRoute(route)); } /// Handle iOS AVAudioSession interruption begin (SDD-101). #[frb(sync)] pub fn handle_interruption_began() { - let result = runtime().block_on(async { session().ios_handle_interruption_began().await }); - if let Err(e) = result { - warn!(target: "chanora_bridge", error = %e, "iOS interruption-began handling failed"); - } + dispatch_platform_audio_event(PlatformAudioEvent::InterruptionBegan); } /// Handle iOS AVAudioSession interruption end (SDD-101). #[frb(sync)] pub fn handle_interruption_ended(should_resume: bool) { - let result = - runtime().block_on(async { session().ios_handle_interruption_ended(should_resume).await }); - if let Err(e) = result { - warn!(target: "chanora_bridge", error = %e, "iOS interruption-ended handling failed"); - } + dispatch_platform_audio_event(PlatformAudioEvent::InterruptionEnded { should_resume }); } /// Set the focused/on-screen push-to-talk hold state. @@ -2139,14 +2191,10 @@ pub async fn set_ios_voice_processing_mode( /// Set the preferred audio output route (Android/iOS). #[frb(sync)] pub fn set_audio_output_route(route: BridgeAudioRoute) { - runtime().block_on(async { - let _ = session().ios_handle_route_change(route.into()).await; - }); + dispatch_platform_audio_event(PlatformAudioEvent::AudioOutputRoute(route.into())); } /// Called from Flutter when the app enters background/foreground. #[frb(sync)] pub fn record_lifecycle_event(state: String) { - runtime().block_on(async { - session().record_lifecycle_event(&state).await; - }); + dispatch_platform_audio_event(PlatformAudioEvent::Lifecycle { state }); }