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:
@@ -18,6 +18,7 @@ crate-type = ["cdylib", "staticlib", "rlib"]
|
||||
[dependencies]
|
||||
chanora_core = { path = "../../core/chanora_core" }
|
||||
chanora_protocol = { path = "../chanora_protocol" }
|
||||
chanora_audio = { path = "../chanora_audio" }
|
||||
flutter_rust_bridge = "=2.12.0"
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Build script for `chanora_bridge`.
|
||||
//!
|
||||
//! Android-only: emit `cargo:rustc-link-lib=dylib=c++_shared` so the
|
||||
//! produced `libchanora_bridge.so` cdylib carries a `DT_NEEDED
|
||||
//! libc++_shared.so` ELF entry. Without this, Android's per-library
|
||||
//! namespace dynamic linker (API 24+) does NOT auto-resolve
|
||||
//! C++ runtime symbols like `__cxa_pure_virtual` even when
|
||||
//! `libc++_shared.so` is co-located in jniLibs/<abi>/ and already
|
||||
//! loaded into the process via a prior `System.loadLibrary("c++_shared")`.
|
||||
//!
|
||||
//! Trace: SDD-105 (AndroidJniBootstrap — the bridge must load cleanly
|
||||
//! before MainActivity.onCreate continues). Companion to SDD-118 item 6
|
||||
//! extended (stages libc++_shared.so into jniLibs/<abi>/).
|
||||
//!
|
||||
//! Reference: https://developer.android.com/ndk/guides/cpp-support
|
||||
//! and the namespace-isolation behavior introduced in API 24.
|
||||
|
||||
fn main() {
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
if target_os == "android" {
|
||||
// Dynamic linkage against the NDK shared C++ runtime. The runtime
|
||||
// .so is provided by the NDK sysroot at link time (cargo-ndk puts
|
||||
// the per-target sysroot lib dir on the linker search path) and
|
||||
// staged into jniLibs/<abi>/ at packaging time by the Gradle
|
||||
// task `:app:copyRustBridgeJniLibs<Profile>` per SDD-118 item 6
|
||||
// (extended).
|
||||
println!("cargo:rustc-link-lib=dylib=c++_shared");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1418,6 +1418,14 @@ impl SseDecode for crate::api::BridgeEvent {
|
||||
should_resume: var_shouldResume,
|
||||
};
|
||||
}
|
||||
10 => {
|
||||
let mut var_permission = <String>::sse_decode(deserializer);
|
||||
let mut var_state = <crate::api::PermissionStateKind>::sse_decode(deserializer);
|
||||
return crate::api::BridgeEvent::PermissionState {
|
||||
permission: var_permission,
|
||||
state: var_state,
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
@@ -1555,6 +1563,20 @@ impl SseDecode for Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for crate::api::PermissionStateKind {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
let mut inner = <i32>::sse_decode(deserializer);
|
||||
return match inner {
|
||||
0 => crate::api::PermissionStateKind::Granted,
|
||||
1 => crate::api::PermissionStateKind::Denied,
|
||||
2 => crate::api::PermissionStateKind::PermanentlyDenied,
|
||||
3 => crate::api::PermissionStateKind::Unknown,
|
||||
_ => unreachable!("Invalid variant for PermissionStateKind: {}", inner),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl SseDecode for (String, String) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||
@@ -1836,6 +1858,12 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
|
||||
should_resume.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart(),
|
||||
crate::api::BridgeEvent::PermissionState { permission, state } => [
|
||||
10.into_dart(),
|
||||
permission.into_into_dart().into_dart(),
|
||||
state.into_into_dart().into_dart(),
|
||||
]
|
||||
.into_dart(),
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
@@ -1935,6 +1963,29 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeTransmitMode>
|
||||
self
|
||||
}
|
||||
}
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
impl flutter_rust_bridge::IntoDart for crate::api::PermissionStateKind {
|
||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||
match self {
|
||||
Self::Granted => 0.into_dart(),
|
||||
Self::Denied => 1.into_dart(),
|
||||
Self::PermanentlyDenied => 2.into_dart(),
|
||||
Self::Unknown => 3.into_dart(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||
for crate::api::PermissionStateKind
|
||||
{
|
||||
}
|
||||
impl flutter_rust_bridge::IntoIntoDart<crate::api::PermissionStateKind>
|
||||
for crate::api::PermissionStateKind
|
||||
{
|
||||
fn into_into_dart(self) -> crate::api::PermissionStateKind {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
@@ -2110,6 +2161,11 @@ impl SseEncode for crate::api::BridgeEvent {
|
||||
<bool>::sse_encode(began, serializer);
|
||||
<bool>::sse_encode(should_resume, serializer);
|
||||
}
|
||||
crate::api::BridgeEvent::PermissionState { permission, state } => {
|
||||
<i32>::sse_encode(10, serializer);
|
||||
<String>::sse_encode(permission, serializer);
|
||||
<crate::api::PermissionStateKind>::sse_encode(state, serializer);
|
||||
}
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
@@ -2242,6 +2298,24 @@ impl SseEncode for Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for crate::api::PermissionStateKind {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
<i32>::sse_encode(
|
||||
match self {
|
||||
crate::api::PermissionStateKind::Granted => 0,
|
||||
crate::api::PermissionStateKind::Denied => 1,
|
||||
crate::api::PermissionStateKind::PermanentlyDenied => 2,
|
||||
crate::api::PermissionStateKind::Unknown => 3,
|
||||
_ => {
|
||||
unimplemented!("");
|
||||
}
|
||||
},
|
||||
serializer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl SseEncode for (String, String) {
|
||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||
|
||||
@@ -20,9 +20,15 @@
|
||||
//!
|
||||
//! ## Boundary discipline
|
||||
//!
|
||||
//! Every type in this module is `Serialize + Deserialize` over owned
|
||||
//! primitives or `String`s. No `tsclientlib`, `cpal`, or backend
|
||||
//! types may appear in the public surface (SAD-067, SDD-079).
|
||||
//! Every type in this module is built from owned primitives or
|
||||
//! `String`s — no `tsclientlib`, `cpal`, or backend types may
|
||||
//! appear in the public surface (SAD-067, SDD-079). Cross-language
|
||||
//! serialisation is handled by `flutter_rust_bridge`'s generated
|
||||
//! glue, so most public DTOs and the `BridgeEvent` enum intentionally
|
||||
//! do *not* carry `serde::{Serialize, Deserialize}` derives — FRB
|
||||
//! emits its own SSE encoders/decoders. `BridgeError` carries serde
|
||||
//! derives historically; new bridge types should follow the
|
||||
//! FRB-only convention unless an explicit non-FRB consumer is added.
|
||||
//!
|
||||
//! Note: this crate cannot use `#![forbid(unsafe_code)]` because the
|
||||
//! FRB-generated glue (in `frb_generated`) legitimately uses unsafe
|
||||
@@ -37,6 +43,9 @@ mod frb_generated;
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_init;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod permission_jni;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors raised at the bridge boundary. Production code must keep
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Android JNI entry point for the platform permission requester.
|
||||
//!
|
||||
//! Trace: SDD-106 §5/§6, SRS-209.
|
||||
//!
|
||||
//! `AndroidPermissionRequester` (Kotlin) resolves the runtime
|
||||
//! `RECORD_AUDIO` permission state and forwards every transition to
|
||||
//! Dart over the existing MethodChannel. Per SDD-106 §5/§6 the
|
||||
//! authoritative consumer of that state is the Rust audio engine's
|
||||
//! `TransmitModeSelector`, not the Dart UI — Dart-only delivery is
|
||||
//! insufficient because the UI may not have re-rendered by the time
|
||||
//! the audio thread next reads the gate.
|
||||
//!
|
||||
//! This module exposes a JNI function that the Kotlin
|
||||
//! `MainActivity.stateChangeListener` calls in addition to the
|
||||
//! MethodChannel. The function:
|
||||
//!
|
||||
//! 1. Decodes the `permission` and `state` strings out of the JVM.
|
||||
//! 2. Maps the `state` string to [`crate::api::PermissionStateKind`]
|
||||
//! (unrecognised values map to `Unknown` — fail-safe per SRS-209).
|
||||
//! 3. Calls [`crate::api::publish_permission_state`] which:
|
||||
//! a. clamps the audio engine's transmit gate when the
|
||||
//! permission identifier is `RECORD_AUDIO` (SDD-106 §6); and
|
||||
//! b. broadcasts a `BridgeEvent::PermissionState` so any Dart
|
||||
//! subscriber observes the same authoritative state.
|
||||
//!
|
||||
//! ## Panic-safety
|
||||
//!
|
||||
//! Every call site is wrapped in [`std::panic::catch_unwind`]. A
|
||||
//! Rust panic must never unwind into the JVM (UB). Panics are
|
||||
//! logged via the `log` facade and otherwise swallowed; the JNI
|
||||
//! function returns `void`.
|
||||
|
||||
use std::panic::catch_unwind;
|
||||
|
||||
use jni::objects::{JClass, JString};
|
||||
use jni::JNIEnv;
|
||||
use log::{error, warn};
|
||||
|
||||
use crate::api::{publish_permission_state, PermissionStateKind};
|
||||
|
||||
/// JNI entry point called from
|
||||
/// `app.chanora.chanora_flutter.MainActivity.publishPermissionState`.
|
||||
///
|
||||
/// Symbol mangling note (mirrors SDD-105's `initChanoraContext`):
|
||||
/// the literal `_` inside the package segment `chanora_flutter` is
|
||||
/// escaped as `_1` in the JNI symbol — this is JNI's package-name
|
||||
/// encoding for `_`.
|
||||
///
|
||||
/// Trace: SDD-106 §5.
|
||||
#[no_mangle]
|
||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_MainActivity_publishPermissionState<
|
||||
'local,
|
||||
>(
|
||||
mut env: JNIEnv<'local>,
|
||||
_class: JClass<'local>,
|
||||
permission: JString<'local>,
|
||||
state: JString<'local>,
|
||||
) {
|
||||
// Wrap the entire body in catch_unwind: a panic across the JNI
|
||||
// boundary is undefined behaviour. We log and swallow on panic.
|
||||
let result = catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let permission_str: String = match env.get_string(&permission) {
|
||||
Ok(s) => s.into(),
|
||||
Err(e) => {
|
||||
warn!("publishPermissionState: invalid permission string: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let state_str: String = match env.get_string(&state) {
|
||||
Ok(s) => s.into(),
|
||||
Err(e) => {
|
||||
warn!("publishPermissionState: invalid state string: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let kind = PermissionStateKind::from_kotlin_str(&state_str);
|
||||
// SDD-106 §5/§6: hands off to the bridge's shared publisher
|
||||
// which performs the audio-engine clamp and event fan-out.
|
||||
publish_permission_state(permission_str, kind);
|
||||
}));
|
||||
if let Err(_panic) = result {
|
||||
// Do NOT propagate the panic payload across the FFI
|
||||
// boundary. `_panic` may carry a non-UnwindSafe payload.
|
||||
error!(
|
||||
target: "chanora_bridge",
|
||||
"publishPermissionState: panic caught at JNI boundary; swallowed"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user