//! Public API exposed to Dart via `flutter_rust_bridge`. //! //! Naming follows the FRB v2 convention: free functions at the crate //! API root, with `#[frb(sync)]` for synchronous calls and async fn //! signatures for async ones. Every input and output is an owned //! type whose layout is schema-controlled (no `tsclientlib`, no //! `cpal`, no `Connection` handles). use std::sync::OnceLock; use std::time::Duration; use flutter_rust_bridge::frb; use tokio::runtime::Runtime; use tokio::sync::{broadcast, mpsc}; use tracing::{info, warn}; use crate::frb_generated::StreamSink; use crate::BridgeError; /// Process-wide tokio runtime used to drive the async core. Created /// lazily on first use and never torn down — the application's /// process lifetime is the runtime's lifetime. fn runtime() -> &'static Runtime { static RT: OnceLock = OnceLock::new(); RT.get_or_init(|| { tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() .thread_name("chanora-rt") .build() .expect("tokio runtime") }) } fn task_join_error(task: &'static str, error: tokio::task::JoinError) -> BridgeError { warn!( target: "chanora_bridge", task, error = %error, "runtime task failed; surfacing in diagnostics" ); 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(|| { std::panic::set_hook(Box::new(|panic_info| { let message = panic_info .payload() .downcast_ref::<&'static str>() .map(|s| (*s).to_string()) .or_else(|| panic_info.payload().downcast_ref::().cloned()) .unwrap_or_else(|| "non-string panic payload".to_string()); let location = panic_info .location() .map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column())) .unwrap_or_else(|| "unknown".to_string()); warn!( target: "chanora_bridge", panic_message = %message, panic_location = %location, "panic captured for diagnostic export" ); })); }); } /// Process-wide session handle. One instance per process is enough /// for Alpha (DEC-006 single-connection invariant); the Mutex inside /// `ChanoraSession` enforces the connection-count invariant. fn session() -> &'static chanora_core::ChanoraSession { static S: OnceLock = OnceLock::new(); S.get_or_init(chanora_core::ChanoraSession::new) } /// Process-wide redacted-log sink. Captures the last N tracing /// records (already redacted) so the user-initiated diagnostic /// export has something to ship. Lazily created on first access. fn log_sink() -> &'static chanora_core::InMemoryLogSink { static SINK: OnceLock = OnceLock::new(); SINK.get_or_init(|| { chanora_core::InMemoryLogSink::new( chanora_core::DEFAULT_LOG_CAPACITY, chanora_core::Redactor::with_default_policy(), ) }) } /// Process-wide broadcast channel for [`BridgeEvent::PermissionState`] /// emissions published by the platform permission JNI hook (SDD-106 /// §5). Kept separate from the core `SessionEvent` stream because /// permission state is owned by the platform-bound bridge layer, not /// by `chanora_core` (which is platform-agnostic). The /// [`events_stream`] task fans this channel and the core session /// stream into the single Dart-facing sink. /// /// Capacity (64) is chosen empirically and should match the order of /// magnitude of other `BridgeEvent` broadcast channels — generous so /// a slow Dart subscriber misses at most the oldest queued event /// (`tokio::sync::broadcast` drops oldest, never blocks the /// producer) rather than impacting the JNI thread that produced it. fn permission_events() -> &'static broadcast::Sender { static TX: OnceLock> = OnceLock::new(); TX.get_or_init(|| broadcast::channel(64).0) } /// Publish a permission-state event onto the bridge's permission /// channel and clamp the audio engine's transmit selector /// accordingly (SDD-106 §5/§6, SRS-209). /// /// Called by the platform-side JNI hook (see /// [`crate::permission_jni`]). The call is non-blocking under /// normal operation: the transmit-selector clamp uses `AtomicU8` /// (see `chanora_audio::TransmitModeSelector::set_permission_state`) /// and the broadcast send drops oldest on a full channel rather /// than parking the JNI thread. The function never panics under /// normal operation; the only theoretical panic source is internal /// `tokio::sync::broadcast` invariants, and any such panic would be /// caught by the outer `catch_unwind` in the JNI entry point. /// A missing subscriber or a closed selector is logged at `warn` /// level and otherwise ignored. #[cfg_attr(not(target_os = "android"), allow(dead_code))] pub(crate) fn publish_permission_state(permission: String, state: PermissionStateKind) { // 1. Authoritative audio-engine clamp (SDD-106 §6). Only the // microphone permission drives the transmit gate; other // permissions (e.g. POST_NOTIFICATIONS per SDD-107 §6) ride // the same event surface but do not affect transmit. if permission == "android.permission.RECORD_AUDIO" { let selector = session().transmit_selector(); // SDD-106 §5: selector.set_permission_state uses // AtomicU8::store which is non-blocking; the JNI thread is // not parked. Safe to call from publishPermissionState. selector.set_permission_state(state.to_permission_gate()); } // 2. Fan out to Dart subscribers. Best-effort: a send error // means no current subscriber (Dart side not yet attached // or already torn down) which is fine. let _ = permission_events().send(BridgeEvent::PermissionState { permission, state }); } /// Default tracing filter. Suppresses the chatty /// `tsproto::resend` and `tsproto::packet_codec` paths that /// flood the diagnostic export during transient packet loss; /// users can still raise verbosity via `RUST_LOG=info`. const DEFAULT_LOG_FILTER: &str = "info,tsproto::resend=error,tsproto::packet_codec=error,tsclientlib=error,ts_bookkeeping::messages::s2c=error"; /// Initialise the bridge. Must be called once on Dart side before /// any other API call. Sets up panic logging. #[frb(init)] pub fn bridge_init() { flutter_rust_bridge::setup_default_user_utils(); // Always install the redacted in-memory log sink — diagnostic // export depends on it (DEC-016: user-initiated only, never // auto-upload). It runs alongside whatever platform sink // exists below; both consume the same `tracing` events. let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone()).with_sanitizer(); // On Android, also fan tracing output out to logcat so a user can // see protocol/audio diagnostics via `adb logcat -s chanora`. #[cfg(target_os = "android")] { use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; let android_layer = tracing_android::layer("chanora").ok(); let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER)); let _ = tracing_subscriber::registry() .with(filter) .with(android_layer) .with(redact_layer) .try_init(); } #[cfg(not(target_os = "android"))] { use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; let fmt_layer = tracing_subscriber::fmt::layer().with_target(true); let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER)); // Also tee tracing output to a rotating file in the OS's // standard log directory so developers and beta testers can // hand-inspect output without launching from a terminal. // The path is platform-conventional: // * Linux: ~/.local/state/app.chanora/chanora_flutter/chanora.log // * macOS: ~/Library/Logs/app.chanora.chanora_flutter/chanora.log // * Windows: %LOCALAPPDATA%\app.chanora\chanora_flutter\logs\chanora.log // Best-effort: if the directory cannot be created or the // file cannot be opened, the layer is silently dropped and // stderr remains the only sink. let file_writer = open_log_file(); if let Some(file) = file_writer { let file_layer = tracing_subscriber::fmt::layer() .with_target(true) .with_ansi(false) .with_writer(std::sync::Mutex::new(file)); let _ = tracing_subscriber::registry() .with(filter) .with(fmt_layer) .with(file_layer) .with(redact_layer) .try_init(); } else { let _ = tracing_subscriber::registry() .with(filter) .with(fmt_layer) .with(redact_layer) .try_init(); } } install_panic_diagnostic_hook(); info!(target: "chanora_bridge", "bridge initialised"); if let Some(p) = log_file_path() { info!(target: "chanora_bridge", path = %p.display(), "log file path"); } } /// Return the platform-conventional log-file path as a string, or /// an empty string if the platform does not have one (mobile). #[frb(sync)] pub fn log_file_path_str() -> String { log_file_path() .map(|p| p.display().to_string()) .unwrap_or_default() } /// Resolve the platform-conventional log-file path. Returns `None` /// if the platform conventions can't be honoured (e.g. neither /// `LOCALAPPDATA` nor `HOME` is set). fn log_file_path() -> Option { #[cfg(target_os = "windows")] { let base = std::env::var_os("LOCALAPPDATA").or_else(|| std::env::var_os("APPDATA"))?; Some( std::path::PathBuf::from(base) .join("app.chanora") .join("chanora_flutter") .join("logs") .join("chanora.log"), ) } #[cfg(target_os = "macos")] { let home = std::env::var_os("HOME")?; Some( std::path::PathBuf::from(home) .join("Library") .join("Logs") .join("app.chanora.chanora_flutter") .join("chanora.log"), ) } #[cfg(all( unix, not(target_os = "macos"), not(target_os = "android"), not(target_os = "ios") ))] { let base = std::env::var_os("XDG_STATE_HOME") .map(std::path::PathBuf::from) .or_else(|| { std::env::var_os("HOME") .map(|h| std::path::PathBuf::from(h).join(".local").join("state")) })?; Some( base.join("app.chanora") .join("chanora_flutter") .join("chanora.log"), ) } #[cfg(target_os = "ios")] { None } #[cfg(target_os = "android")] { // Android log file location is set up via the bridge's // Java-side init that writes the chosen path into an env // var (not currently wired; P1 follow-up). For now we // return None and rely on logcat. None } } /// Open the platform log file in append mode, rotating once on /// startup so each launch begins with a fresh file. Best-effort: /// returns `None` on any I/O error. #[cfg(not(target_os = "android"))] fn open_log_file() -> Option { let path = log_file_path()?; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } // Rotate at every launch (not on size). A single session can // emit huge logs when a chatty subsystem floods (the original // bug that surfaced this: tsclientlib emitting one warning per // outbound voice frame while server-side muted). Truncating on // every launch keeps per-session disk use bounded by what one // session can produce in its lifetime; the on-launch rotate // also gives the previous session's log a stable home at // `chanora.log.1` for post-mortem inspection. // // Two generations kept: `chanora.log.1` (previous launch) and // `chanora.log.2` (the one before that). Older generations // are deleted to keep disk use bounded across many launches. if path.exists() { let g1 = path.with_extension("log.1"); let g2 = path.with_extension("log.2"); // .2 is dropped; .1 becomes .2; current becomes .1. let _ = std::fs::remove_file(&g2); let _ = std::fs::rename(&g1, &g2); let _ = std::fs::rename(&path, &g1); } // Open fresh (truncate if rename somehow failed so we never // append onto a stale file). std::fs::OpenOptions::new() .create(true) .write(true) .truncate(true) .open(&path) .ok() } // ---------- DTOs ---------- /// Channel as seen by Dart. Matches `chanora_protocol::ChannelInfo` /// but with primitive `u64` ids so the Dart side gets `BigInt`s /// without any wrapper-type ceremony. #[derive(Debug, Clone)] pub struct BridgeChannel { /// Stable channel id. pub id: u64, /// Parent channel id; 0 means top-level. pub parent: u64, /// Display name. pub name: String, /// Server-side ordering hint. pub order: i64, /// True when the server marks the channel as password-protected. pub has_password: bool, /// Talk power threshold required to speak in this channel. /// None means no talk-power restriction. pub needed_talk_power: Option, } /// Client as seen by Dart. #[derive(Debug, Clone)] pub struct BridgeClient { /// Stable client id. pub id: u64, /// Channel id the client is currently in. pub channel: u64, /// Nickname. pub name: String, /// True when this client has muted microphone/input capture. pub input_muted: bool, /// True when this client has muted speaker/output audio. pub output_muted: bool, /// True when recent inbound voice activity was observed for this client. pub is_speaking: bool, /// True for TeamSpeak ServerQuery clients. pub is_server_query: bool, /// Current talk power value assigned by the server. pub talk_power: i32, /// True when the server has granted talk power regardless of numeric value. pub talk_power_granted: bool, } /// Rich profile and live connection details for one online client. #[derive(Debug, Clone)] pub struct BridgeClientProfile { /// Stable online client id. pub id: u64, /// Channel the client is currently in. pub channel: u64, /// Nickname. pub name: String, /// TeamSpeak unique id. pub unique_id: String, /// Stable TeamSpeak database id, when visible. pub database_id: Option, /// ISO country code, when visible. pub country_code: String, /// User description, when visible. pub description: String, /// Client version string. pub version: String, /// Client platform string. pub platform: String, /// Account creation time as Unix seconds. pub created_unix_seconds: Option, /// Last connection time as Unix seconds. pub last_connected_unix_seconds: Option, /// Total historical connections. pub connections_total: Option, /// Current online duration in seconds. pub online_seconds: Option, /// Current idle time in milliseconds. pub idle_milliseconds: Option, /// Current ping in milliseconds. pub ping_milliseconds: Option, /// Current ping deviation in milliseconds. pub ping_deviation_milliseconds: Option, /// Client address. Empty when permission-gated. pub client_address: String, /// Resolved server group names. pub server_groups: Vec, /// Resolved channel group name. pub channel_group: String, /// TeamSpeak avatar file path suffix. pub avatar_path: String, /// Downloaded bytes this month. pub bytes_downloaded_month: Option, /// Uploaded bytes this month. pub bytes_uploaded_month: Option, /// Downloaded bytes across all time. pub bytes_downloaded_total: Option, /// Uploaded bytes across all time. pub bytes_uploaded_total: Option, /// Client-to-server total packet loss ratio. pub packet_loss_client_to_server_total: Option, /// Server-to-client total packet loss ratio. pub packet_loss_server_to_client_total: Option, } /// Server snapshot as seen by Dart. #[derive(Debug, Clone)] pub struct BridgeSnapshot { /// Server name. pub server_name: String, /// Welcome banner text. pub welcome_message: String, /// Server platform (e.g. "Linux"). pub platform: String, /// Server version string. pub version: String, /// Channels currently known. pub channels: Vec, /// Clients currently known. pub clients: Vec, /// Our own client id. Useful for the UI to highlight our row /// in the client list and to know which channel we are in /// without trusting the optimistic local state. pub own_client_id: u64, } impl From for BridgeClientProfile { fn from(profile: chanora_core::ClientProfile) -> Self { Self { id: profile.id.0, channel: profile.channel.0, name: profile.name, unique_id: profile.unique_id, database_id: profile.database_id, country_code: profile.country_code, description: profile.description, version: profile.version, platform: profile.platform, created_unix_seconds: profile.created_unix_seconds, last_connected_unix_seconds: profile.last_connected_unix_seconds, connections_total: profile.connections_total, online_seconds: profile.online_seconds, idle_milliseconds: profile.idle_milliseconds, ping_milliseconds: profile.ping_milliseconds, ping_deviation_milliseconds: profile.ping_deviation_milliseconds, client_address: profile.client_address, server_groups: profile.server_groups, channel_group: profile.channel_group, avatar_path: profile.avatar_path, bytes_downloaded_month: profile.bytes_downloaded_month, bytes_uploaded_month: profile.bytes_uploaded_month, bytes_downloaded_total: profile.bytes_downloaded_total, bytes_uploaded_total: profile.bytes_uploaded_total, packet_loss_client_to_server_total: profile.packet_loss_client_to_server_total, packet_loss_server_to_client_total: profile.packet_loss_server_to_client_total, } } } impl From for BridgeSnapshot { fn from(s: chanora_core::ServerSnapshot) -> Self { Self { server_name: s.server_name, welcome_message: s.welcome_message, platform: s.platform, version: s.version, channels: s .channels .into_iter() .map(|c| BridgeChannel { id: c.id.0, parent: c.parent.0, name: c.name, order: c.order, has_password: c.has_password, needed_talk_power: c.needed_talk_power, }) .collect(), clients: s .clients .into_iter() .map(|c| BridgeClient { id: c.id.0, channel: c.channel.0, name: c.name, input_muted: c.input_muted, output_muted: c.output_muted, is_speaking: c.is_speaking, is_server_query: c.is_server_query, talk_power: c.talk_power, talk_power_granted: c.talk_power_granted, }) .collect(), own_client_id: s.own_client_id, } } } // ---------- Commands ---------- /// Connect to a TeamSpeak-compatible server and return the initial /// state snapshot. Honours the DEC-006 single-connection invariant /// via [`BridgeError::AlreadyConnected`]. /// /// `password` is optional — pass an empty string for servers that /// don't require one. pub async fn connect( host: String, nickname: String, password: String, ) -> Result { let cfg = chanora_core::ConnectConfig { address: host, nickname, password: if password.is_empty() { None } else { Some(password) }, identity: None, ready_timeout: Duration::from_secs(15), resolved_address: None, }; let snap = match runtime() .spawn(async move { session().connect(cfg).await }) .await .map_err(|e| task_join_error("connect", e))? { Ok(snap) => snap, Err(chanora_core::CoreError::AlreadyConnected) => runtime() .spawn(async { session().snapshot().await }) .await .map_err(|e| task_join_error("connect.snapshot", e))??, Err(e) => return Err(e.into()), }; Ok(snap.into()) } /// Warm server address resolution for the active host field. This is /// intentionally fire-and-forget from the UI perspective: it schedules /// Rust-side prefetch work and never opens a TS3 session. pub async fn prefetch_server(host: String) -> Result<(), BridgeError> { runtime() .spawn(async move { session().prefetch_server(host).await }) .await .map_err(|e| task_join_error("prefetch_server", e))??; Ok(()) } /// Re-fetch a fresh snapshot from the active connection. pub async fn snapshot() -> Result { let snap = runtime() .spawn(async { session().snapshot().await }) .await .map_err(|e| task_join_error("snapshot", e))??; Ok(snap.into()) } /// Fetch richer profile and live connection details for one online client. pub async fn client_profile(client_id: u64) -> Result { let profile = runtime() .spawn(async move { session().client_profile(client_id).await }) .await .map_err(|e| task_join_error("client_profile", e))??; Ok(profile.into()) } /// Disconnect from the server. No-op if not connected. pub async fn disconnect() -> Result<(), BridgeError> { runtime() .spawn(async { session().disconnect().await }) .await .map_err(|e| task_join_error("disconnect", e))??; Ok(()) } /// True if a connection is currently active. pub async fn is_connected() -> bool { runtime() .spawn(async { session().is_connected().await }) .await .unwrap_or(false) } // ---------- Audio commands (Beta) ---------- // `start_audio` was removed per SDD-094 — voice activation now // flows through `voice_join` / `voice_leave`, which transparently // drive `AudioEngine::ensure_running` / `shutdown_if_idle`. /// Handle iOS AVAudioSession route changes (SDD-100). #[frb(sync)] pub fn handle_route_change(route: BridgeAudioRoute) { dispatch_platform_audio_event(PlatformAudioEvent::RouteChanged(route.into())); } /// Handle iOS AVAudioSession media-services reset with the current /// route class. Called by AppDelegate after rebuilding the session. /// /// `route_class` is the Swift-side route class string (e.g. "Speaker"). #[frb(sync)] pub fn handle_media_services_reset_with_route(route_class: String) { let route = chanora_audio::AudioRoute::from_route_class(&route_class); dispatch_platform_audio_event(PlatformAudioEvent::MediaServicesResetWithRoute(route)); } /// Handle iOS AVAudioSession interruption begin (SDD-101). #[frb(sync)] pub fn handle_interruption_began() { dispatch_platform_audio_event(PlatformAudioEvent::InterruptionBegan); } /// Handle iOS AVAudioSession interruption end (SDD-101). #[frb(sync)] pub fn handle_interruption_ended(should_resume: bool) { dispatch_platform_audio_event(PlatformAudioEvent::InterruptionEnded { should_resume }); } /// Set the focused/on-screen push-to-talk hold state. /// /// Binding capture chooses which physical key drives PTT, while this /// command carries the actual press/release edge for fallback focused /// keyboard handling and touch controls. pub async fn set_ptt(active: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_ptt(active).await }) .await .map_err(|e| task_join_error("set_ptt", e))??; Ok(()) } // ---------- v1 voice lifecycle (SDD-094/095/096) ---------- /// Voice transmit mode mirror (SDD-095). Schema-controlled enum; /// the wire encoding matches [`chanora_core::TransmitMode::as_u8`]. #[derive(Debug, Clone, Copy)] pub enum BridgeTransmitMode { /// Push-to-talk (default). Ptt, /// Continuous transmission while in channel and not muted. Continuous, /// Voice-activity detection — reserved per DEC-030; v1 behaves /// as `Continuous`. VoiceActivity, } impl From for chanora_core::TransmitMode { fn from(m: BridgeTransmitMode) -> Self { match m { BridgeTransmitMode::Ptt => Self::Ptt, BridgeTransmitMode::Continuous => Self::Continuous, BridgeTransmitMode::VoiceActivity => Self::VoiceActivity, } } } impl From for BridgeTransmitMode { fn from(m: chanora_core::TransmitMode) -> Self { match m { chanora_core::TransmitMode::Ptt => Self::Ptt, chanora_core::TransmitMode::Continuous => Self::Continuous, chanora_core::TransmitMode::VoiceActivity => Self::VoiceActivity, } } } fn transmit_mode_from_u8(v: u8) -> BridgeTransmitMode { chanora_core::TransmitMode::from_u8(v) .unwrap_or_default() .into() } /// Join a voice channel (SDD-094). Moves the user to `channel_id`, /// brings up the audio engine if needed, and emits /// `BridgeEvent::VoiceState`. `password` may be empty. pub async fn voice_join(channel_id: u64, password: String) -> Result<(), BridgeError> { let pw = if password.is_empty() { None } else { Some(password) }; runtime() .spawn(async move { session().voice_join(channel_id, pw).await }) .await .map_err(|e| task_join_error("voice_join", e))??; Ok(()) } /// Leave the current voice channel (SDD-094). Tears down the audio /// engine and emits `BridgeEvent::VoiceState`. pub async fn voice_leave() -> Result<(), BridgeError> { runtime() .spawn(async { session().voice_leave().await }) .await .map_err(|e| task_join_error("voice_leave", e))??; Ok(()) } /// Set the active transmit mode (SDD-095). pub async fn set_transmit_mode(mode: BridgeTransmitMode) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_transmit_mode(mode.into()).await }) .await .map_err(|e| task_join_error("set_transmit_mode", e))??; Ok(()) } /// Read the active transmit mode. pub async fn get_transmit_mode() -> BridgeTransmitMode { runtime() .spawn(async { session().transmit_mode() }) .await .unwrap_or(chanora_core::TransmitMode::Ptt) .into() } /// Update the release-tail in milliseconds (SDD-096). Values are /// clamped to `0..=500` on the Rust side; passing anything larger /// silently saturates. pub async fn set_release_tail_ms(ms: u32) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_release_tail_ms(ms).await }) .await .map_err(|e| task_join_error("set_release_tail_ms", e))??; Ok(()) } /// Read the current release-tail in milliseconds. pub async fn get_release_tail_ms() -> u32 { runtime() .spawn(async { session().release_tail_ms() }) .await .unwrap_or(200) } /// Engage or release the hard-mute clamp (SDD-094). When `true` /// the audio engine transmits nothing regardless of mode. pub async fn set_hard_mute(muted: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_hard_mute(muted).await }) .await .map_err(|e| task_join_error("set_hard_mute", e))??; Ok(()) } /// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings; /// the bridge never carries raw key codes. #[derive(Debug, Clone, Copy)] pub enum BridgePttInputClass { /// No binding is active. None, /// A keyboard key. Keyboard, /// A mouse side button (Mouse4 / Mouse5). MouseSideButton, } /// Privacy-safe PTT capability descriptor for the UI. #[derive(Debug, Clone)] pub struct BridgePttDescriptor { /// Stable capability level name. pub level: String, /// Stable backend identifier. pub backend_id: String, /// Coarse bound input class; empty when no binding is active. pub bound_input_class: String, } impl From for BridgePttDescriptor { fn from(desc: chanora_core::PttDescriptorSnapshot) -> Self { Self { level: desc.level, backend_id: desc.backend_id, bound_input_class: desc.bound_input_class, } } } /// Persisted PTT binding display state for the UI. #[derive(Debug, Clone)] pub struct BridgePttBinding { /// Stable input category string (`""`, `"keyboard"`, or /// `"mouse-side-button"`). pub input_class: String, /// Display-only key label; empty when no binding is active. pub key_label: String, } impl From for BridgePttBinding { fn from(binding: chanora_core::PersistedPttBinding) -> Self { Self { input_class: binding.input_class, key_label: binding.key_label, } } } impl From for chanora_core::PttInputClass { fn from(c: BridgePttInputClass) -> Self { match c { BridgePttInputClass::None => chanora_core::PttInputClass::None, BridgePttInputClass::Keyboard => chanora_core::PttInputClass::Keyboard, BridgePttInputClass::MouseSideButton => chanora_core::PttInputClass::MouseSideButton, } } } /// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The /// platform_key string is opaque to the bridge — it identifies the /// bound key inside the platform backend and never appears in any /// log record or diagnostic export (DEC-027 enforced at the /// diagnostics-sanitizer layer). pub async fn set_ptt_binding( input_class: BridgePttInputClass, platform_key: String, ) -> Result<(), BridgeError> { let binding = chanora_core::PttBinding { input_class: input_class.into(), platform_key, }; runtime() .spawn(async move { session().set_ptt_binding(binding).await }) .await .map_err(|e| task_join_error("set_ptt_binding", e))??; Ok(()) } /// Read the current PTT capability descriptor. Matches the privacy-safe /// `BridgeEvent::PttCapability` event shape; useful for the initial UI /// render before the first event arrives. pub async fn ptt_descriptor() -> BridgePttDescriptor { runtime() .spawn(async { session().ptt_descriptor().await }) .await .map(Into::into) .unwrap_or_else(|_| BridgePttDescriptor { level: String::new(), backend_id: String::new(), bound_input_class: String::new(), }) } /// Return the persisted PTT binding so the UI can hydrate its display /// state at launch (e.g. show "PTT: Space" next to the badge before the /// user re-opens the binding dialog). Empty strings mean no binding has /// been persisted yet. pub async fn get_ptt_binding() -> BridgePttBinding { runtime() .spawn(async { session().get_ptt_binding().await }) .await .map(Into::into) .unwrap_or_else(|_| BridgePttBinding { input_class: String::new(), key_label: String::new(), }) } /// Move our own client to `channel_id`. Optional channel password /// for password-protected channels — pass an empty string when not /// required. pub async fn move_to_channel(channel_id: u64, password: String) -> Result<(), BridgeError> { let pw = if password.is_empty() { None } else { Some(password) }; runtime() .spawn(async move { session().move_to_channel(channel_id, pw).await }) .await .map_err(|e| task_join_error("move_to_channel", e))??; Ok(()) } /// Toggle self input-mute (microphone) on the server. Independent /// of push-to-talk: a muted client never transmits regardless of /// PTT state. pub async fn set_input_muted(muted: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_self_muted(Some(muted), None).await }) .await .map_err(|e| task_join_error("set_input_muted", e))??; Ok(()) } /// Toggle self output-mute (speaker). Mutes locally *and* informs /// the server. The server uses this for the channel icon next to /// the client name; the local mute kicks in immediately even /// before the server acknowledges. pub async fn set_output_muted(muted: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_self_muted(None, Some(muted)).await }) .await .map_err(|e| task_join_error("set_output_muted", e))??; Ok(()) } /// Set master output gain. `1.0` is unity, `0.0` is silent. Values /// above `1.0` amplify and can clip downstream. Errors when audio /// is not started. pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_output_gain(gain).await }) .await .map_err(|e| task_join_error("set_output_gain", e))??; Ok(()) } /// Set per-client output volume (SRS-075). `1.0` is unity, `0.0` /// mutes. No-op when client has no active voice queue. Volume is /// applied directly to the tsclientlib AudioQueue and takes effect /// immediately on the next render callback. pub async fn set_client_volume(client_id: u64, volume: f32) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_client_volume(client_id, volume).await }) .await .map_err(|e| task_join_error("set_client_volume", e))??; Ok(()) } /// Send a text message to the specified target. pub async fn send_chat_message( message: String, target: BridgeMessageTarget, ) -> Result<(), BridgeError> { let target_core: chanora_core::MessageTarget = match target { BridgeMessageTarget::Server => chanora_core::MessageTarget::Server, BridgeMessageTarget::Channel => chanora_core::MessageTarget::Channel, BridgeMessageTarget::Client(id) => chanora_core::MessageTarget::Client(id), BridgeMessageTarget::Poke(id) => chanora_core::MessageTarget::Poke(id), }; runtime() .spawn(async move { session().send_text_message(message, target_core).await }) .await .map_err(|e| task_join_error("send_chat_message", e))??; Ok(()) } /// Statistics from the audio engine. #[derive(Debug, Clone)] pub struct BridgeAudioStats { /// Number of Opus frames sent since audio started. pub frames_sent: u32, /// Number of inbound voice packets decoded. pub frames_received: u32, /// Current push-to-talk state. pub ptt_active: bool, /// Current microphone input level in dBFS (-120.0 = silence, 0.0 = clipping). pub input_level: f32, } /// Bridge route class for P1 audio-processing policy. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeAudioRoute { /// Built-in speakerphone. Speaker, /// Built-in receiver/earpiece. Earpiece, /// Wired or USB headset. WiredHeadset, /// Bluetooth HFP duplex route. BluetoothHfp, /// Bluetooth A2DP output-only route. BluetoothA2dp, /// Unknown route. Unknown, } /// Bridge iOS voice-processing mode. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeIosVoiceProcessingMode { /// Shipping VPIO path. PlatformVoiceProcessing, /// Experimental Sonora path. SonoraExperimental, } /// Bridge processing backend. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeAudioBackend { /// Platform voice processing. PlatformVoiceProcessing, /// Sonora backend. Sonora, /// WebRTC APM backend. WebrtcApm, /// No-op backend. Noop, } /// Bridge VAD backend. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeVadBackend { /// Silero ONNX VAD. SileroOnnx, /// WebRTC fallback VAD. WebrtcVad, /// Debug energy VAD. EnergyDebug, /// VAD disabled. Disabled, } /// Bridge effect owner for AEC/NS/AGC. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeEffectOwner { /// Platform-owned effect. Platform, /// Sonora-owned effect. Sonora, /// WebRTC APM-owned effect. WebrtcApm, /// Conservative route-managed setting. Conservative, /// Disabled. Off, } /// P1 audio-processing configuration DTO. #[derive(Debug, Clone)] pub struct BridgeAudioProcessingConfig { /// Route class. pub route: BridgeAudioRoute, /// iOS voice-processing mode. pub ios_mode: BridgeIosVoiceProcessingMode, /// Processing backend. pub processing_backend: BridgeAudioBackend, /// VAD backend. pub vad_backend: BridgeVadBackend, /// AEC owner. pub aec: BridgeEffectOwner, /// Noise suppression owner. pub ns: BridgeEffectOwner, /// AGC owner. pub agc: BridgeEffectOwner, /// High-pass filter enabled. pub hpf_enabled: bool, /// Limiter enabled. pub limiter_enabled: bool, /// VAD hangover in ms. pub vad_hangover_ms: u32, /// VAD pre-roll in ms. pub vad_pre_roll_ms: u32, /// Minimum transmit duration in ms. pub vad_min_tx_ms: u32, /// Debug WAV dump enabled. pub debug_wav_dump_enabled: bool, } /// P1 audio-processing stats DTO. #[derive(Debug, Clone)] pub struct BridgeAudioProcessingStats { /// Input dBFS. pub input_dbfs: f32, /// Render dBFS. pub render_dbfs: f32, /// Processed capture dBFS. pub processed_dbfs: f32, /// Latest VAD probability. pub vad_probability: f32, /// VAD active. pub vad_active: bool, /// Currently transmitting. pub transmitting: bool, /// VAD backend. pub vad_backend: BridgeVadBackend, /// Fallback VAD active. pub vad_fallback_active: bool, /// Processing backend. pub processing_backend: BridgeAudioBackend, /// iOS voice-processing mode. pub ios_voice_processing_mode: BridgeIosVoiceProcessingMode, /// Audio route. pub audio_route: BridgeAudioRoute, /// Actual sample rate. pub actual_sample_rate_hz: u32, /// Actual IO buffer frames. pub actual_io_buffer_frames: u32, /// Input overruns. pub input_overruns: u64, /// Output underruns. pub output_underruns: u64, /// Callback xruns. pub callback_xruns: u64, /// Clipped samples. pub clipped_samples: u64, /// Number of effectively silent processed capture frames. pub zero_frames: u64, /// Number of processed capture frames. pub capture_frames: u64, /// Input callbacks carrying 10 ms of audio. pub callbacks_10ms: u64, /// Input callbacks carrying 20 ms of audio. pub callbacks_20ms: u64, /// Input callbacks carrying other sizes. pub callbacks_other: u64, /// Sonora enabled. pub sonora_enabled: bool, /// Platform voice processing enabled. pub platform_voice_processing_enabled: bool, } impl From for chanora_core::AudioRoute { fn from(route: BridgeAudioRoute) -> Self { match route { BridgeAudioRoute::Speaker => Self::Speaker, BridgeAudioRoute::Earpiece => Self::Earpiece, BridgeAudioRoute::WiredHeadset => Self::WiredHeadset, BridgeAudioRoute::BluetoothHfp => Self::BluetoothHfp, BridgeAudioRoute::BluetoothA2dp => Self::BluetoothA2dp, BridgeAudioRoute::Unknown => Self::Unknown, } } } impl From for BridgeAudioRoute { fn from(route: chanora_core::AudioRoute) -> Self { match route { chanora_core::AudioRoute::Speaker => Self::Speaker, chanora_core::AudioRoute::Earpiece => Self::Earpiece, chanora_core::AudioRoute::WiredHeadset => Self::WiredHeadset, chanora_core::AudioRoute::BluetoothHfp => Self::BluetoothHfp, chanora_core::AudioRoute::BluetoothA2dp => Self::BluetoothA2dp, chanora_core::AudioRoute::Unknown => Self::Unknown, } } } impl From for chanora_core::IosVoiceProcessingMode { fn from(mode: BridgeIosVoiceProcessingMode) -> Self { match mode { BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => Self::PlatformVoiceProcessing, BridgeIosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental, } } } impl From for BridgeIosVoiceProcessingMode { fn from(mode: chanora_core::IosVoiceProcessingMode) -> Self { match mode { chanora_core::IosVoiceProcessingMode::PlatformVoiceProcessing => { Self::PlatformVoiceProcessing } chanora_core::IosVoiceProcessingMode::SonoraExperimental => Self::SonoraExperimental, } } } impl From for chanora_core::AudioBackend { fn from(backend: BridgeAudioBackend) -> Self { match backend { BridgeAudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing, BridgeAudioBackend::Sonora => Self::Sonora, BridgeAudioBackend::WebrtcApm => Self::WebrtcApm, BridgeAudioBackend::Noop => Self::Noop, } } } impl From for BridgeAudioBackend { fn from(backend: chanora_core::AudioBackend) -> Self { match backend { chanora_core::AudioBackend::PlatformVoiceProcessing => Self::PlatformVoiceProcessing, chanora_core::AudioBackend::Sonora => Self::Sonora, chanora_core::AudioBackend::WebrtcApm => Self::WebrtcApm, chanora_core::AudioBackend::Noop => Self::Noop, } } } impl From for chanora_core::VadBackend { fn from(backend: BridgeVadBackend) -> Self { match backend { BridgeVadBackend::SileroOnnx => Self::SileroOnnx, BridgeVadBackend::WebrtcVad => Self::WebrtcVad, BridgeVadBackend::EnergyDebug => Self::EnergyDebug, BridgeVadBackend::Disabled => Self::Disabled, } } } impl From for BridgeVadBackend { fn from(backend: chanora_core::VadBackend) -> Self { match backend { chanora_core::VadBackend::SileroOnnx => Self::SileroOnnx, chanora_core::VadBackend::WebrtcVad => Self::WebrtcVad, chanora_core::VadBackend::EnergyDebug => Self::EnergyDebug, chanora_core::VadBackend::Disabled => Self::Disabled, } } } impl From for chanora_core::EffectOwner { fn from(owner: BridgeEffectOwner) -> Self { match owner { BridgeEffectOwner::Platform => Self::Platform, BridgeEffectOwner::Sonora => Self::Sonora, BridgeEffectOwner::WebrtcApm => Self::WebrtcApm, BridgeEffectOwner::Conservative => Self::Conservative, BridgeEffectOwner::Off => Self::Off, } } } impl From for BridgeEffectOwner { fn from(owner: chanora_core::EffectOwner) -> Self { match owner { chanora_core::EffectOwner::Platform => Self::Platform, chanora_core::EffectOwner::Sonora => Self::Sonora, chanora_core::EffectOwner::WebrtcApm => Self::WebrtcApm, chanora_core::EffectOwner::Conservative => Self::Conservative, chanora_core::EffectOwner::Off => Self::Off, } } } impl From for chanora_core::AudioProcessingConfig { fn from(config: BridgeAudioProcessingConfig) -> Self { Self { route: config.route.into(), ios_mode: config.ios_mode.into(), processing_backend: config.processing_backend.into(), vad_backend: config.vad_backend.into(), aec: config.aec.into(), ns: config.ns.into(), agc: config.agc.into(), hpf_enabled: config.hpf_enabled, limiter_enabled: config.limiter_enabled, vad_hangover_ms: config.vad_hangover_ms, vad_pre_roll_ms: config.vad_pre_roll_ms, vad_min_tx_ms: config.vad_min_tx_ms, debug_wav_dump_enabled: config.debug_wav_dump_enabled, } } } impl From for BridgeAudioProcessingConfig { fn from(c: chanora_core::AudioProcessingConfig) -> Self { Self { route: c.route.into(), ios_mode: c.ios_mode.into(), processing_backend: c.processing_backend.into(), vad_backend: c.vad_backend.into(), aec: c.aec.into(), ns: c.ns.into(), agc: c.agc.into(), hpf_enabled: c.hpf_enabled, limiter_enabled: c.limiter_enabled, vad_hangover_ms: c.vad_hangover_ms, vad_pre_roll_ms: c.vad_pre_roll_ms, vad_min_tx_ms: c.vad_min_tx_ms, debug_wav_dump_enabled: c.debug_wav_dump_enabled, } } } impl From for BridgeAudioProcessingStats { fn from(stats: chanora_core::AudioProcessingStats) -> Self { Self { input_dbfs: stats.input_dbfs, render_dbfs: stats.render_dbfs, processed_dbfs: stats.processed_dbfs, vad_probability: stats.vad_probability, vad_active: stats.vad_active, transmitting: stats.transmitting, vad_backend: stats.vad_backend.into(), vad_fallback_active: stats.vad_fallback_active, processing_backend: stats.processing_backend.into(), ios_voice_processing_mode: stats.ios_voice_processing_mode.into(), audio_route: stats.audio_route.into(), actual_sample_rate_hz: stats.actual_sample_rate_hz, actual_io_buffer_frames: stats.actual_io_buffer_frames, input_overruns: stats.input_overruns, output_underruns: stats.output_underruns, callback_xruns: stats.callback_xruns, clipped_samples: stats.clipped_samples, zero_frames: stats.zero_frames, capture_frames: stats.capture_frames, callbacks_10ms: stats.callbacks_10ms, callbacks_20ms: stats.callbacks_20ms, callbacks_other: stats.callbacks_other, sonora_enabled: stats.sonora_enabled, platform_voice_processing_enabled: stats.platform_voice_processing_enabled, } } } // ---------- Diagnostics (A.3) ---------- /// User-initiated diagnostic export. Returns a multi-line text /// blob, redacted per the production policy, that the user can /// share or copy. DEC-016 forbids automatic uploads — this is the /// only path that surfaces logs. /// /// SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3: when an /// Android voice session is open this export embeds the /// `[audio.android]` verification-matrix fragment (requested / /// achieved stream config, per-effect engagement, latency tier). /// On non-Android targets or before a voice session opens the /// section is omitted. Per SDD-090 every field in that section is /// a device-side technical scalar — no PII admitted. #[frb(sync)] pub fn export_diagnostics() -> String { let metadata = vec![ ( "crate_version".to_string(), env!("CARGO_PKG_VERSION").to_string(), ), ("target_os".to_string(), std::env::consts::OS.to_string()), ( "target_arch".to_string(), std::env::consts::ARCH.to_string(), ), ]; // SDD-116 item 3: pull the latest Android voice-audio // diagnostics snapshot from the process-global slot published // by AndroidVoiceUnit::open(). Returns None on non-Android and // before any voice session has opened. let android_audio_yaml = chanora_audio::mobile_voice_backend::current_android_audio_diagnostics() .map(|d| d.to_yaml_fragment()); let audio_health = session().audio_processing_stats_if_ready(); let network_info = runtime().block_on(async { session().network_diagnostics_summary().await }); let protocol_events = runtime().block_on(async { session().protocol_events_snapshot().await }); let android_audio_yaml = android_audio_yaml.map(|mut yaml| { if let Some(stats) = audio_health { yaml.push_str(&format!( " health:\n\ \x20\x20\x20\x20capture_frames: {}\n\ \x20\x20\x20\x20zero_frames: {}\n\ \x20\x20\x20\x20callbacks_10ms: {}\n\ \x20\x20\x20\x20callbacks_20ms: {}\n\ \x20\x20\x20\x20callbacks_other: {}\n\ \x20\x20\x20\x20callback_xruns: {}\n\ \x20\x20\x20\x20clipped_samples: {}\n", stats.capture_frames, stats.zero_frames, stats.callbacks_10ms, stats.callbacks_20ms, stats.callbacks_other, stats.callback_xruns, stats.clipped_samples, )); } yaml }); match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) { Ok(exp) => exp .with_android_audio(android_audio_yaml) .with_network_info(Some(network_info)) .with_protocol_events(protocol_events) .to_text(), Err(e) => format!("(diagnostic export failed: {e})"), } } // ---------- Storage (A.2) ---------- /// Wire the identity persistence store to a platform-private /// directory. Should be called once on app start after Flutter has /// resolved `getApplicationSupportDirectory()` (or equivalent). /// /// Subsequent [`connect`] calls will reuse the persisted identity, /// or generate-and-persist a fresh one on first use. This keeps the /// server-visible UID stable across app restarts. /// /// Beta caveat: the identity is stored as a plain file (mode 0600 /// on Unix). It is *not* encrypted at rest. RISK-PoC-002 documents /// this gap; the v0.4 storage rework lands the proper Secret /// Service + Android Keystore + iOS Keychain backends. pub async fn init_storage(dir: String) -> Result<(), BridgeError> { runtime() .spawn(async move { session().init_storage(&dir).await }) .await .map_err(|e| task_join_error("init_storage", e))??; Ok(()) } /// Bookmark DTO mirroring [`chanora_core::Bookmark`]. #[derive(Debug, Clone)] pub struct BridgeBookmark { /// Row id assigned by SQLite. Use `0` when adding new rows; /// the returned id is then meaningful. pub id: i64, /// User-facing label. pub display_name: String, /// `hostname[:port]` or TSDNS name. pub host: String, /// Nickname to use for this bookmark. pub nickname: String, /// Optional remembered password. Empty string = none. pub password: String, } impl From for BridgeBookmark { fn from(b: chanora_core::Bookmark) -> Self { Self { id: b.id, display_name: b.display_name, host: b.host, nickname: b.nickname, password: b.password.unwrap_or_default(), } } } impl From for chanora_core::Bookmark { fn from(b: BridgeBookmark) -> Self { chanora_core::Bookmark { id: b.id, display_name: b.display_name, host: b.host, nickname: b.nickname, password: if b.password.is_empty() { None } else { Some(b.password) }, } } } /// List persisted bookmarks. pub async fn list_bookmarks() -> Result, BridgeError> { let v = runtime() .spawn(async { session().list_bookmarks().await }) .await .map_err(|e| task_join_error("list_bookmarks", e))??; Ok(v.into_iter().map(Into::into).collect()) } /// Insert a bookmark and return its assigned id. The `id` field on /// the input is ignored. pub async fn add_bookmark(b: BridgeBookmark) -> Result { let core_b: chanora_core::Bookmark = b.into(); let id = runtime() .spawn(async move { session().add_bookmark(core_b).await }) .await .map_err(|e| task_join_error("add_bookmark", e))??; Ok(id) } /// Update an existing bookmark. pub async fn update_bookmark(b: BridgeBookmark) -> Result<(), BridgeError> { let core_b: chanora_core::Bookmark = b.into(); runtime() .spawn(async move { session().update_bookmark(core_b).await }) .await .map_err(|e| task_join_error("update_bookmark", e))??; Ok(()) } /// Delete a bookmark by id. pub async fn delete_bookmark(id: i64) -> Result<(), BridgeError> { runtime() .spawn(async move { session().delete_bookmark(id).await }) .await .map_err(|e| task_join_error("delete_bookmark", e))??; Ok(()) } // ---------- Connectivity (A.6.1) ---------- /// Coarse OS-reported network state. Mirrors /// [`chanora_core::NetworkState`] across the bridge. #[derive(Debug, Clone, Copy)] pub enum BridgeNetworkState { /// No signal seen yet. Unknown, /// OS reports a usable network. Online, /// OS reports no network. Offline, } impl From for chanora_core::NetworkState { fn from(s: BridgeNetworkState) -> Self { match s { BridgeNetworkState::Unknown => chanora_core::NetworkState::Unknown, BridgeNetworkState::Online => chanora_core::NetworkState::Online, BridgeNetworkState::Offline => chanora_core::NetworkState::Offline, } } } /// Notify the core of the latest OS-reported connectivity state. /// Called by the Flutter side from `connectivity_plus` callbacks. /// The core's supervisor uses this to (a) pre-charge the watchdog /// on Offline and (b) short-circuit reconnect backoff on Online. #[frb(sync)] pub fn set_network_state(state: BridgeNetworkState) { session().set_network_state(state.into()); } // ---------- Events (A.6) ---------- /// Lifecycle event surfaced to Dart. Schema-controlled mirror of /// [`chanora_core::SessionEvent`] — no core types cross the bridge. #[derive(Debug, Clone)] pub enum BridgeEvent { /// Initial connect succeeded, or a reconnect attempt succeeded. Connected { /// Server name reported by the server snapshot. server_name: String, }, /// Connection lost; supervisor will retry. Lost { /// Reason classification from the protocol layer. reason: String, }, /// Supervisor is sleeping before its next reconnect attempt. Reconnecting { /// 1-based attempt counter for the current outage. attempt: u32, /// Seconds the supervisor will sleep before this attempt. delay_secs: u32, }, /// Session ended (user-requested disconnect or unrecoverable). Disconnected { /// Reason classification. reason: String, }, /// Audio engine started. AudioStarted, /// Audio engine stopped. AudioStopped, /// Detected desktop Push-to-Talk capability (gen2 v0.9.3, /// DEC-023..028). The fields carry only privacy-safe values per /// DEC-027: the capability level, a stable backend identifier, /// and the bound input class. No key codes, scan codes, or /// virtual-key values cross this boundary. PttCapability { /// Stable capability identifier (`"L0Focused"`, /// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`, /// `"L3GlobalWithMouseButtons"`, or `"L4DeviceAware"`). level: String, /// Stable backend identifier (e.g. `"focused"`). backend_id: String, /// Coarse bound input class (e.g. `"keyboard"`, /// `"mouse-side-button"`); empty when no binding is active. bound_input_class: String, }, /// Voice subsystem state snapshot (SDD-094). The Flutter /// VoiceBar listens to this stream. VoiceState { /// True when the session is currently joined to a voice /// channel and the audio engine is running. in_channel: bool, /// Active transmit mode. transmit_mode: BridgeTransmitMode, /// True when the hard-mute clamp is engaged. mute: bool, /// Current release-tail in milliseconds (0..=500). release_tail_ms: u32, /// Last confirmed authoritative channel id. current_channel_id: Option, /// Non-authoritative pending join target channel id. pending_target_channel_id: Option, /// Whether a new join intent is currently allowed. can_join: bool, /// Whether leave intent is currently allowed. can_leave: bool, /// Join projection sync state. join_sync_state: BridgeVoiceJoinSyncState, /// Last stable join error code, if any. join_error_code: Option, }, /// iOS audio interruption state (SDD-101). InterruptionState { /// True when interruption began, false when it ended. began: bool, /// Resume recommendation from the platform. False on begin. should_resume: bool, }, /// SDD-106 §5: resolved platform-level permission state. On /// Android this is published by the JNI hook in /// [`crate::permission_jni`] whenever /// `AndroidPermissionRequester` reports a state transition /// (initial grant, denial, permanent denial, or mid-session /// revocation). The audio engine's `TransmitModeSelector` /// observes the `RECORD_AUDIO` variant of this event as an /// authoritative clamp on the transmit gate per SDD-106 §6 /// (and SRS-209's listen-only fail-safe). /// /// Carries the canonical Android permission string in /// `permission` (e.g. `"android.permission.RECORD_AUDIO"`) /// and the resolved state in `state`. No raw user input, /// timestamps, or other PII cross this boundary. PermissionState { /// Canonical Android permission identifier. permission: String, /// Resolved permission state. state: PermissionStateKind, }, /// A text message received from the server. ChatMessage { /// Client id of the sender. sender_id: u64, /// Nickname of the sender. sender_name: String, /// Message content. message: String, /// Target scope (server/channel/private/poke). target: BridgeMessageTarget, /// Poke notification strength, present only for poke messages. poke_strength: Option, }, /// Human-readable server activity surfaced from protocol bookkeeping events. ServerActivity { /// TeamSpeak-style activity line. message: String, }, /// Audio route changed (speaker/earpiece/BT/wired). AudioRouteChanged { /// New audio output route. route: BridgeAudioRoute, }, /// A client moved to a different channel. ClientMoved { /// Unique client identifier. client_id: u64, /// Destination channel. new_channel_id: u64, }, /// A new client connected. ClientJoined { /// Unique client identifier. client_id: u64, /// Channel the client joined. channel_id: u64, /// Display nickname. name: String, /// Microphone muted state. input_muted: bool, /// Speaker muted state. output_muted: bool, /// True for server query (bot) clients. is_server_query: bool, /// Client's talk power value. talk_power: i32, /// Whether the server granted temporary talk power. talk_power_granted: bool, }, /// A client disconnected. ClientLeft { /// Unique client identifier. client_id: u64, /// Display nickname at time of disconnect. name: String, }, /// Client properties changed. ClientUpdated { /// Unique client identifier. client_id: u64, /// Microphone muted state. input_muted: bool, /// Speaker muted state. output_muted: bool, /// True for server query (bot) clients. is_server_query: bool, /// Client's talk power value. talk_power: i32, /// Whether the server granted temporary talk power. talk_power_granted: bool, }, /// A new channel appeared. ChannelAdded { /// Unique channel identifier. id: u64, /// Parent channel ID. parent: u64, /// Channel name. name: String, /// Predecessor channel ID within the same parent (TeamSpeak /// linked-list ordering hint). Zero means first child. order: i64, /// Whether the channel requires a password. has_password: bool, /// Talk power required to speak; `None` means no restriction. needed_talk_power: Option, }, /// A channel was deleted. ChannelRemoved { /// Channel identifier. id: u64, }, /// Channel properties changed. ChannelUpdated { /// Unique channel identifier. id: u64, /// Channel name. name: String, /// Whether the channel requires a password. has_password: bool, /// Talk power required to speak; `None` means no restriction. needed_talk_power: Option, }, } /// Bridge message target scope. #[derive(Debug, Clone, Copy)] pub enum BridgeMessageTarget { /// Broadcast to entire server. Server, /// Broadcast to current channel. Channel, /// Private message to a specific client. Client(u64), /// Poke a specific client. Poke(u64), } impl From for BridgeMessageTarget { fn from(t: chanora_core::MessageTarget) -> Self { match t { chanora_core::MessageTarget::Server => Self::Server, chanora_core::MessageTarget::Channel => Self::Channel, chanora_core::MessageTarget::Client(id) => Self::Client(id), chanora_core::MessageTarget::Poke(id) => Self::Poke(id), } } } /// Bridge poke notification strength. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgePokeStrength { /// Poke should be surfaced at full strength. Strong, /// Poke is rate-limited but below overflow severity. Suppressed, /// Poke remains suppressed after repeated suppressed pokes. SuppressedOverflow, } impl From for BridgePokeStrength { fn from(strength: chanora_core::PokeStrength) -> Self { match strength { chanora_core::PokeStrength::Strong => Self::Strong, chanora_core::PokeStrength::Suppressed => Self::Suppressed, chanora_core::PokeStrength::SuppressedOverflow => Self::SuppressedOverflow, } } } /// Bridge mirror of core join projection sync state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeVoiceJoinSyncState { /// Reducer is ready to accept channel actions. Ready, /// Reducer is waiting on initial snapshot reconciliation. SynchronizingInitialSnapshot, /// Reducer is waiting on reconnect snapshot reconciliation. SynchronizingReconnect, } /// Bridge mirror of stable join error/status codes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BridgeVoiceJoinErrorCode { /// Duplicate same-target join intent was coalesced. DuplicateSameTargetCoalesced, /// A different target was requested while one is already pending. JoinAlreadyPendingDifferentTarget, /// Join denied by server policy/permission. JoinDenied, /// Join failed due to protocol-level error. JoinProtocolFailure, /// Join failed due to transport/network error. JoinNetworkFailure, /// Join timed out awaiting confirmation. JoinTimeout, /// Pending join was superseded by user leave. JoinSupersededByLeave, /// Stale join outcome was ignored. JoinStaleOutcomeIgnored, /// Authoritative membership reconciled to different channel. JoinReconciledDifferentChannel, /// Join command was rejected before send acceptance. JoinCommandRejectedBeforeSend, /// Join intent rejected while reducer synchronizing. JoinCannotStartWhileSynchronizing, } /// Schema-controlled mirror of the Kotlin /// `AndroidPermissionRequester.PermissionState` sealed class /// (SDD-106 §5). Crosses the bridge as an enum so the Dart side can /// `switch` on it exhaustively without parsing strings. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PermissionStateKind { /// Permission granted by the user; capture may proceed. Granted, /// Permission denied (re-promptable). Denied, /// Permission permanently denied — the UI is expected to /// deep-link to system settings (SDD-106 §3). PermanentlyDenied, /// Any state string that did not match the contract above. /// Treated identically to `Denied` by the transmit clamp /// (fail-safe per SRS-209). Unknown, } impl PermissionStateKind { /// Map the Kotlin-side `PermissionState.toString()` value to /// the bridge enum. Unrecognised strings fall back to /// [`PermissionStateKind::Unknown`]. #[frb(ignore)] pub fn from_kotlin_str(s: &str) -> Self { match s { "Granted" => Self::Granted, "Denied" => Self::Denied, "PermanentlyDenied" => Self::PermanentlyDenied, _ => Self::Unknown, } } /// Mirror into the audio-engine clamp type (SDD-106 §6). #[frb(ignore)] pub fn to_permission_gate(self) -> chanora_audio::PermissionGate { match self { Self::Granted => chanora_audio::PermissionGate::Granted, Self::Denied => chanora_audio::PermissionGate::Denied, Self::PermanentlyDenied => chanora_audio::PermissionGate::PermanentlyDenied, Self::Unknown => chanora_audio::PermissionGate::Unknown, } } } impl From for BridgeEvent { fn from(e: chanora_core::SessionEvent) -> Self { match e { chanora_core::SessionEvent::Connected { server_name } => { BridgeEvent::Connected { server_name } } chanora_core::SessionEvent::Lost { reason } => BridgeEvent::Lost { reason }, chanora_core::SessionEvent::Reconnecting { attempt, delay_secs, } => BridgeEvent::Reconnecting { attempt, delay_secs, }, chanora_core::SessionEvent::Disconnected { reason } => { BridgeEvent::Disconnected { reason } } chanora_core::SessionEvent::AudioStarted => BridgeEvent::AudioStarted, chanora_core::SessionEvent::AudioStopped => BridgeEvent::AudioStopped, chanora_core::SessionEvent::PttCapability { level, backend_id, bound_input_class, } => BridgeEvent::PttCapability { level, backend_id, bound_input_class, }, chanora_core::SessionEvent::VoiceState { in_channel, transmit_mode, mute, release_tail_ms, current_channel_id, pending_target_channel_id, can_join, can_leave, join_sync_state, join_error_code, } => BridgeEvent::VoiceState { in_channel, transmit_mode: transmit_mode_from_u8(transmit_mode), mute, release_tail_ms, current_channel_id, pending_target_channel_id, can_join, can_leave, join_sync_state: map_join_sync_state(join_sync_state), join_error_code: join_error_code.map(map_join_error_code), }, chanora_core::SessionEvent::InterruptionState { began, should_resume, } => BridgeEvent::InterruptionState { began, should_resume, }, chanora_core::SessionEvent::ChatMessage { sender_id, sender_name, message, target, poke_strength, } => BridgeEvent::ChatMessage { sender_id, sender_name, message, target: target.into(), poke_strength: poke_strength.map(Into::into), }, chanora_core::SessionEvent::ServerActivity { message } => { BridgeEvent::ServerActivity { message } } chanora_core::SessionEvent::AudioRouteChanged { route } => { BridgeEvent::AudioRouteChanged { route: route.into(), } } chanora_core::SessionEvent::ClientMoved { client_id, new_channel_id, } => BridgeEvent::ClientMoved { client_id, new_channel_id, }, chanora_core::SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted, } => BridgeEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted, }, chanora_core::SessionEvent::ClientLeft { client_id, name } => { BridgeEvent::ClientLeft { client_id, name } } chanora_core::SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted, } => BridgeEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted, }, chanora_core::SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power, } => BridgeEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power, }, chanora_core::SessionEvent::ChannelRemoved { id } => BridgeEvent::ChannelRemoved { id }, chanora_core::SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power, } => BridgeEvent::ChannelUpdated { id, name, has_password, needed_talk_power, }, } } } fn map_join_sync_state(state: chanora_core::VoiceJoinSyncState) -> BridgeVoiceJoinSyncState { match state { chanora_core::VoiceJoinSyncState::Ready => BridgeVoiceJoinSyncState::Ready, chanora_core::VoiceJoinSyncState::SynchronizingInitialSnapshot => { BridgeVoiceJoinSyncState::SynchronizingInitialSnapshot } chanora_core::VoiceJoinSyncState::SynchronizingReconnect => { BridgeVoiceJoinSyncState::SynchronizingReconnect } } } fn map_join_error_code(code: chanora_core::VoiceJoinErrorCode) -> BridgeVoiceJoinErrorCode { match code { chanora_core::VoiceJoinErrorCode::DuplicateSameTargetCoalesced => { BridgeVoiceJoinErrorCode::DuplicateSameTargetCoalesced } chanora_core::VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget => { BridgeVoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget } chanora_core::VoiceJoinErrorCode::JoinDenied => BridgeVoiceJoinErrorCode::JoinDenied, chanora_core::VoiceJoinErrorCode::JoinProtocolFailure => { BridgeVoiceJoinErrorCode::JoinProtocolFailure } chanora_core::VoiceJoinErrorCode::JoinNetworkFailure => { BridgeVoiceJoinErrorCode::JoinNetworkFailure } chanora_core::VoiceJoinErrorCode::JoinTimeout => BridgeVoiceJoinErrorCode::JoinTimeout, chanora_core::VoiceJoinErrorCode::JoinSupersededByLeave => { BridgeVoiceJoinErrorCode::JoinSupersededByLeave } chanora_core::VoiceJoinErrorCode::JoinStaleOutcomeIgnored => { BridgeVoiceJoinErrorCode::JoinStaleOutcomeIgnored } chanora_core::VoiceJoinErrorCode::JoinReconciledDifferentChannel => { BridgeVoiceJoinErrorCode::JoinReconciledDifferentChannel } chanora_core::VoiceJoinErrorCode::JoinCommandRejectedBeforeSend => { BridgeVoiceJoinErrorCode::JoinCommandRejectedBeforeSend } chanora_core::VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing => { BridgeVoiceJoinErrorCode::JoinCannotStartWhileSynchronizing } } } /// Subscribe to lifecycle events. Each call yields a fresh /// subscription; multiple subscribers are supported. On slow /// consumers, events are dropped rather than blocking the supervisor /// (consistent with `tokio::sync::broadcast::Receiver` semantics). pub fn events_stream(sink: StreamSink) -> Result<(), BridgeError> { let mut rx = session().subscribe_events(); let mut perm_rx = permission_events().subscribe(); runtime().spawn(async move { loop { tokio::select! { core_evt = rx.recv() => match core_evt { Ok(evt) => { if sink.add(BridgeEvent::from(evt)).is_err() { info!(target: "chanora_bridge", "events_stream: dart sink closed"); return; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { warn!(target: "chanora_bridge", "events_stream: lagged, dropped {n} events"); continue; } Err(tokio::sync::broadcast::error::RecvError::Closed) => { info!(target: "chanora_bridge", "events_stream: source closed"); return; } }, // SDD-106 §5: forward platform permission events // onto the same Dart-facing sink so subscribers see // a unified stream. perm_evt = perm_rx.recv() => match perm_evt { Ok(evt) => { if sink.add(evt).is_err() { info!(target: "chanora_bridge", "events_stream: dart sink closed"); return; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { warn!(target: "chanora_bridge", "events_stream: permission stream lagged, dropped {n} events"); continue; } Err(tokio::sync::broadcast::error::RecvError::Closed) => { // Permission channel never closes (static OnceLock sender), // but handle defensively. info!(target: "chanora_bridge", "events_stream: permission source closed"); return; } }, } } }); Ok(()) } /// Read audio statistics. Errors if no connection or audio not started. pub async fn audio_stats() -> Result { let (s, r, p, lvl) = runtime() .spawn(async { session().audio_stats().await }) .await .map_err(|e| task_join_error("audio_stats", e))??; Ok(BridgeAudioStats { frames_sent: s, frames_received: r, ptt_active: p, input_level: lvl, }) } /// Subscribe to real-time microphone input level at ~30 Hz. /// Values are dBFS (-120 = silence, 0 = clipping). The stream ends /// when the Dart subscriber cancels, the session is dropped, or /// the session becomes persistently unavailable. pub fn input_level_stream(sink: StreamSink) -> Result<(), BridgeError> { runtime().spawn(async move { let mut interval = tokio::time::interval(Duration::from_millis(33)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut consecutive_errors = 0u32; loop { interval.tick().await; let level = match session().audio_stats().await { Ok((_, _, _, lvl)) => { consecutive_errors = 0; lvl } Err(_) => { consecutive_errors += 1; if consecutive_errors >= 10 { return; } -120.0 } }; if sink.add(level).is_err() { return; } } }); Ok(()) } /// Apply the P1 audio-processing config. pub async fn set_audio_processing_config( config: BridgeAudioProcessingConfig, ) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_audio_processing_config(config.into()).await }) .await .map_err(|e| task_join_error("set_audio_processing_config", e))??; Ok(()) } /// Read the current audio-processing config. /// /// Returns the live config as last applied to the audio engine. /// Returns a default config when no session is active. pub async fn get_audio_processing_config() -> Result { let config = runtime() .spawn(async { session().get_audio_processing_config().await }) .await .map_err(|e| task_join_error("get_audio_processing_config", e))??; Ok(config.into()) } /// Read P1 audio-processing diagnostics. pub async fn audio_processing_stats() -> Result { let stats = runtime() .spawn(async { session().audio_processing_stats().await }) .await .map_err(|e| task_join_error("audio_processing_stats", e))??; Ok(stats.into()) } /// Audio device info from the platform. #[derive(Debug, Clone)] pub struct BridgeAudioDevice { /// Stable platform-reported device identifier. pub id: String, /// Human-readable device name. pub name: String, /// Additional device details useful for disambiguation. pub details: String, /// True if the OS reports this as the default device. pub is_default: bool, /// True if Chanora currently has this device pinned. pub is_selected: bool, } /// List of available audio devices. #[derive(Debug, Clone)] pub struct BridgeAudioDeviceList { /// Available input devices. pub input_devices: Vec, /// Available output devices. pub output_devices: Vec, } /// List available audio input and output devices from the platform. pub async fn list_audio_devices() -> Result { let (list, selected_input, selected_output) = runtime() .spawn(async move { let selected_input = session().preferred_input_device().await; let selected_output = session().preferred_output_device().await; let list = chanora_audio::list_audio_devices(); (list, selected_input, selected_output) }) .await .map_err(|e| task_join_error("list_audio_devices", e))?; Ok(BridgeAudioDeviceList { input_devices: list .input_devices .into_iter() .map(|d| BridgeAudioDevice { is_selected: selected_input.as_ref().is_some_and(|id| id == &d.id), id: d.id, name: d.name, details: d.details, is_default: d.is_default, }) .collect(), output_devices: list .output_devices .into_iter() .map(|d| BridgeAudioDevice { is_selected: selected_output.as_ref().is_some_and(|id| id == &d.id), id: d.id, name: d.name, details: d.details, is_default: d.is_default, }) .collect(), }) } /// Set the preferred input device by id. Takes effect on next /// `start_audio`. pub async fn set_input_device(id: Option) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_input_device(id).await }) .await .map_err(|e| task_join_error("set_input_device", e))??; Ok(()) } /// Set the preferred output device by id. pub async fn set_output_device(id: Option) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_output_device(id).await }) .await .map_err(|e| task_join_error("set_output_device", e))??; Ok(()) } /// Configure the VAD model path. pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> { if path.trim().is_empty() { return Err(BridgeError::InvalidCommand( "vad model path must not be empty".to_string(), )); } runtime() .spawn(async move { session().set_vad_model_path(path).await }) .await .map_err(|e| task_join_error("set_vad_model_path", e))??; Ok(()) } /// Enable or disable audio debug WAV dumping. pub async fn enable_audio_debug_wav_dump(enabled: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_audio_debug_wav_dump(enabled).await }) .await .map_err(|e| task_join_error("enable_audio_debug_wav_dump", e))? .map_err(|e| BridgeError::Unmapped(format!("enable_audio_debug_wav_dump: {e}")))?; Ok(()) } /// Select the iOS voice-processing mode. pub async fn set_ios_voice_processing_mode( mode: BridgeIosVoiceProcessingMode, ) -> Result<(), BridgeError> { let config = BridgeAudioProcessingConfig { route: BridgeAudioRoute::Speaker, ios_mode: mode, processing_backend: match mode { BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => { BridgeAudioBackend::PlatformVoiceProcessing } BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeAudioBackend::WebrtcApm, }, vad_backend: BridgeVadBackend::SileroOnnx, aec: match mode { BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform, BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm, }, ns: match mode { BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform, BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm, }, agc: match mode { BridgeIosVoiceProcessingMode::PlatformVoiceProcessing => BridgeEffectOwner::Platform, BridgeIosVoiceProcessingMode::SonoraExperimental => BridgeEffectOwner::WebrtcApm, }, hpf_enabled: true, limiter_enabled: true, vad_hangover_ms: 500, vad_pre_roll_ms: 160, vad_min_tx_ms: 200, debug_wav_dump_enabled: false, }; set_audio_processing_config(config).await } /// Set the preferred audio output route (Android/iOS). #[frb(sync)] pub fn set_audio_output_route(route: BridgeAudioRoute) { 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) { dispatch_platform_audio_event(PlatformAudioEvent::Lifecycle { state }); }