//! 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; 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") }) } /// 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(500, 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"; /// 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(); } } 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")] { // iOS sandbox: write the log to the app's Documents // directory so it persists across launches and can be // pulled via Xcode -> Devices and Simulators -> Download // Container, OR via Files.app on the device (the app // appears under "On My iPhone" once we declare // UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace // in Info.plist — done in a follow-up). // // HOME on iOS resolves to the app sandbox root; Documents // is the standard user-visible subdirectory. let home = std::env::var_os("HOME")?; Some( std::path::PathBuf::from(home) .join("Documents") .join("chanora.log"), ) } #[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, } /// 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 for TeamSpeak ServerQuery clients. pub is_server_query: bool, } /// 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 BridgeSnapshot { fn from(s: chanora_protocol::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, }) .collect(), clients: s .clients .into_iter() .map(|c| BridgeClient { id: c.id.0, channel: c.channel.0, name: c.name, is_server_query: c.is_server_query, }) .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), }; let snap = runtime() .spawn(async move { session().connect(cfg).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(snap.into()) } /// 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| BridgeError::Unmapped(format!("join: {e}")))??; Ok(snap.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| BridgeError::Unmapped(format!("join: {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() { let result = runtime().block_on(async { session().ios_handle_route_change().await }); if let Err(e) = result { warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed"); } } /// 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"); } } /// 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"); } } /// Set the push-to-talk state. /// /// Superseded in v1 by [`set_transmit_mode`] + the binding capture /// dialog. Retained so legacy callers and integration tests keep /// working; the new VoiceBar UI no longer invokes this. pub async fn set_ptt(active: bool) -> Result<(), BridgeError> { runtime() .spawn(async move { session().set_ptt(active).await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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, } 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| BridgeError::Unmapped(format!("join: {e}")))??; Ok(()) } /// Read the current PTT capability descriptor. Returns a /// `(level, backend_id, bound_input_class)` triple matching the /// privacy-safe `BridgeEvent::PttCapability` event shape; useful /// for the initial UI render before the first event arrives. pub async fn ptt_descriptor() -> (String, String, String) { runtime() .spawn(async { session().ptt_descriptor().await }) .await .unwrap_or_else(|_| (String::new(), String::new(), String::new())) } /// Return the persisted PTT binding as a /// `(input_class, platform_key)` pair 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() -> (String, String) { runtime() .spawn(async { session().get_ptt_binding().await }) .await .unwrap_or_else(|_| (String::new(), 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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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, } // ---------- 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()); match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) { Ok(exp) => exp.with_android_audio(android_audio_yaml).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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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| BridgeError::Unmapped(format!("join: {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, /// Snapshot probe observed a change in channel/client counts. /// UI uses this to drive an auto-refresh without active /// polling. SnapshotChanged { /// Latest channel count. channels: u32, /// Latest client count. clients: u32, }, /// 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, }, /// 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, }, } /// 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::SnapshotChanged { channels, clients } => { BridgeEvent::SnapshotChanged { channels, clients } } 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, } => BridgeEvent::VoiceState { in_channel, transmit_mode: transmit_mode_from_u8(transmit_mode), mute, release_tail_ms, }, chanora_core::SessionEvent::InterruptionState { began, should_resume, } => BridgeEvent::InterruptionState { began, should_resume, }, } } } /// 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) = runtime() .spawn(async { session().audio_stats().await }) .await .map_err(|e| BridgeError::Unmapped(format!("join: {e}")))??; Ok(BridgeAudioStats { frames_sent: s, frames_received: r, ptt_active: p, }) }