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).
This commit is contained in:
EdisonJwa
2026-05-18 12:32:01 +08:00
parent 56222d190e
commit 7966a7c8c6
12 changed files with 594 additions and 33 deletions
+175 -16
View File
@@ -11,6 +11,7 @@ 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;
@@ -49,7 +50,63 @@ fn log_sink() -> &'static chanora_core::InMemoryLogSink {
})
}
// ---------- Bridge lifecycle ----------
/// 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
@@ -686,6 +743,14 @@ pub struct BridgeAudioStats {
/// 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![
@@ -699,8 +764,14 @@ pub fn export_diagnostics() -> 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.to_text(),
Ok(exp) => exp.with_android_audio(android_audio_yaml).to_text(),
Err(e) => format!("(diagnostic export failed: {e})"),
}
}
@@ -920,6 +991,71 @@ pub enum BridgeEvent {
/// 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 {
@@ -981,24 +1117,47 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
/// (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 {
match rx.recv().await {
Ok(evt) => {
if sink.add(BridgeEvent::from(evt)).is_err() {
// Dart side closed the sink — stop the bridge task.
info!(target: "chanora_bridge", "events_stream: dart sink closed");
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;
}
}
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;
}
},
}
}
});