Files
chanora/crates/chanora_bridge/src/api.rs
T
EdisonJwa 7966a7c8c6 feat(bridge,android): BridgeEvent::PermissionState + JNI publish hook + c++_shared link
Per SDD-106 §5 add BridgeEvent::PermissionState{permission, state}
with the PermissionStateKind enum (Granted, Denied, PermanentlyDenied,
Unknown). The Kotlin side publishes mid-session permission changes
through a new JNI entry point Java_app_chanora_chanora_1flutter
_MainActivity_publishPermissionState routed by the new
permission_jni.rs module; the Rust audio engine subscribes and
authoritatively clamps the transmit gate (see SDD-106 §6).

Adds crates/chanora_bridge/build.rs to emit
cargo:rustc-link-lib=dylib=c++_shared on Android so libchanora_bridge
.so carries DT_NEEDED libc++_shared.so; this is required by Android
API 24+ per-library linker namespaces to resolve __cxa_pure_virtual
and friends at System.loadLibrary time.

Includes the FRB-regenerated Dart counterparts so each commit is
independently buildable.

Trace: SDD-105, SDD-106 §5, SDD-118 item 6 (extended).
2026-05-18 12:32:01 +08:00

1179 lines
43 KiB
Rust

//! 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<Runtime> = 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<chanora_core::ChanoraSession> = 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<chanora_core::InMemoryLogSink> = 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<BridgeEvent> {
static TX: OnceLock<broadcast::Sender<BridgeEvent>> = 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<std::path::PathBuf> {
#[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<std::fs::File> {
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<BridgeChannel>,
/// Clients currently known.
pub clients: Vec<BridgeClient>,
/// 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<chanora_protocol::ServerSnapshot> 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<BridgeSnapshot, BridgeError> {
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<BridgeSnapshot, BridgeError> {
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<BridgeTransmitMode> 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<chanora_core::TransmitMode> 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<BridgePttInputClass> 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<chanora_core::Bookmark> 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<BridgeBookmark> 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<Vec<BridgeBookmark>, 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<i64, BridgeError> {
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<BridgeNetworkState> 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<chanora_core::SessionEvent> 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<BridgeEvent>) -> 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<BridgeAudioStats, BridgeError> {
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,
})
}