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
Generated
+1
View File
@@ -410,6 +410,7 @@ dependencies = [
name = "chanora_bridge"
version = "0.2.0-beta.1"
dependencies = [
"chanora_audio",
"chanora_core",
"chanora_protocol",
"flutter_rust_bridge",
+54 -2
View File
@@ -9,8 +9,9 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'api.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `log_file_path`, `log_sink`, `open_log_file`, `runtime`, `session`, `transmit_mode_from_u8`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These functions are ignored because they are not marked as `pub`: `log_file_path`, `log_sink`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `transmit_mode_from_u8`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
/// Return the platform-conventional log-file path as a string, or
/// an empty string if the platform does not have one (mobile).
@@ -160,6 +161,14 @@ Future<void> setOutputGain({required double gain}) =>
/// 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.
String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics();
/// Wire the identity persistence store to a platform-private
@@ -455,6 +464,28 @@ sealed class BridgeEvent with _$BridgeEvent {
/// Resume recommendation from the platform. False on begin.
required bool shouldResume,
}) = BridgeEvent_InterruptionState;
/// 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.
const factory BridgeEvent.permissionState({
/// Canonical Android permission identifier.
required String permission,
/// Resolved permission state.
required PermissionStateKind state,
}) = BridgeEvent_PermissionState;
}
/// Coarse OS-reported network state. Mirrors
@@ -555,3 +586,24 @@ enum BridgeTransmitMode {
/// as `Continuous`.
voiceActivity,
}
/// 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.
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,
}
@@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -68,7 +68,8 @@ return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChang
return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapability != null:
return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that);case _:
return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that);case _:
return orElse();
}
@@ -86,7 +87,7 @@ return interruptionState(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,}){
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected():
@@ -99,7 +100,8 @@ return audioStopped(_that);case BridgeEvent_SnapshotChanged():
return snapshotChanged(_that);case BridgeEvent_PttCapability():
return pttCapability(_that);case BridgeEvent_VoiceState():
return voiceState(_that);case BridgeEvent_InterruptionState():
return interruptionState(_that);}
return interruptionState(_that);case BridgeEvent_PermissionState():
return permissionState(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
@@ -113,7 +115,7 @@ return interruptionState(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,}){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -126,7 +128,8 @@ return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChang
return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapability != null:
return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that);case _:
return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that);case _:
return null;
}
@@ -143,7 +146,7 @@ return interruptionState(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -155,7 +158,8 @@ return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged !=
return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability() when pttCapability != null:
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case _:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case _:
return orElse();
}
@@ -173,7 +177,7 @@ return interruptionState(_that.began,_that.shouldResume);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected():
return connected(_that.serverName);case BridgeEvent_Lost():
@@ -185,7 +189,8 @@ return audioStopped();case BridgeEvent_SnapshotChanged():
return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability():
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState():
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case BridgeEvent_InterruptionState():
return interruptionState(_that.began,_that.shouldResume);}
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
return permissionState(_that.permission,_that.state);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -199,7 +204,7 @@ return interruptionState(_that.began,_that.shouldResume);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -211,7 +216,8 @@ return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged !=
return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability() when pttCapability != null:
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case _:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case _:
return null;
}
@@ -845,6 +851,76 @@ as bool,
}
}
/// @nodoc
class BridgeEvent_PermissionState extends BridgeEvent {
const BridgeEvent_PermissionState({required this.permission, required this.state}): super._();
/// Canonical Android permission identifier.
final String permission;
/// Resolved permission state.
final PermissionStateKind state;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeEvent_PermissionStateCopyWith<BridgeEvent_PermissionState> get copyWith => _$BridgeEvent_PermissionStateCopyWithImpl<BridgeEvent_PermissionState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_PermissionState&&(identical(other.permission, permission) || other.permission == permission)&&(identical(other.state, state) || other.state == state));
}
@override
int get hashCode => Object.hash(runtimeType,permission,state);
@override
String toString() {
return 'BridgeEvent.permissionState(permission: $permission, state: $state)';
}
}
/// @nodoc
abstract mixin class $BridgeEvent_PermissionStateCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
factory $BridgeEvent_PermissionStateCopyWith(BridgeEvent_PermissionState value, $Res Function(BridgeEvent_PermissionState) _then) = _$BridgeEvent_PermissionStateCopyWithImpl;
@useResult
$Res call({
String permission, PermissionStateKind state
});
}
/// @nodoc
class _$BridgeEvent_PermissionStateCopyWithImpl<$Res>
implements $BridgeEvent_PermissionStateCopyWith<$Res> {
_$BridgeEvent_PermissionStateCopyWithImpl(this._self, this._then);
final BridgeEvent_PermissionState _self;
final $Res Function(BridgeEvent_PermissionState) _then;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? permission = null,Object? state = null,}) {
return _then(BridgeEvent_PermissionState(
permission: null == permission ? _self.permission : permission // ignore: cast_nullable_to_non_nullable
as String,state: null == state ? _self.state : state // ignore: cast_nullable_to_non_nullable
as PermissionStateKind,
));
}
}
// dart format on
@@ -1238,6 +1238,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
began: dco_decode_bool(raw[1]),
shouldResume: dco_decode_bool(raw[2]),
);
case 10:
return BridgeEvent_PermissionState(
permission: dco_decode_String(raw[1]),
state: dco_decode_permission_state_kind(raw[2]),
);
default:
throw Exception("unreachable");
}
@@ -1320,6 +1325,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return raw as Uint8List;
}
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return PermissionStateKind.values[raw as int];
}
@protected
(String, String) dco_decode_record_string_string(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1556,6 +1567,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
began: var_began,
shouldResume: var_shouldResume,
);
case 10:
var var_permission = sse_decode_String(deserializer);
var var_state = sse_decode_permission_state_kind(deserializer);
return BridgeEvent_PermissionState(
permission: var_permission,
state: var_state,
);
default:
throw UnimplementedError('');
}
@@ -1676,6 +1694,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return deserializer.buffer.getUint8List(len_);
}
@protected
PermissionStateKind sse_decode_permission_state_kind(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
var inner = sse_decode_i_32(deserializer);
return PermissionStateKind.values[inner];
}
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
@@ -1895,6 +1922,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_i_32(9, serializer);
sse_encode_bool(began, serializer);
sse_encode_bool(shouldResume, serializer);
case BridgeEvent_PermissionState(
permission: final permission,
state: final state,
):
sse_encode_i_32(10, serializer);
sse_encode_String(permission, serializer);
sse_encode_permission_state_kind(state, serializer);
}
}
@@ -2004,6 +2038,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
serializer.buffer.putUint8List(self);
}
@protected
void sse_encode_permission_state_kind(
PermissionStateKind self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.index, serializer);
}
@protected
void sse_encode_record_string_string(
(String, String) self,
@@ -87,6 +87,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@@ -187,6 +190,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
PermissionStateKind sse_decode_permission_state_kind(
SseDeserializer deserializer,
);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
@@ -314,6 +322,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_permission_state_kind(
PermissionStateKind self,
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string(
(String, String) self,
@@ -89,6 +89,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@@ -189,6 +192,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
PermissionStateKind sse_decode_permission_state_kind(
SseDeserializer deserializer,
);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
@@ -316,6 +324,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_permission_state_kind(
PermissionStateKind self,
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string(
(String, String) self,
+1
View File
@@ -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
+29
View File
@@ -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");
}
}
+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;
}
},
}
}
});
@@ -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) {
+12 -3
View File
@@ -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"
);
}
}