feat(audio): clamp transmit selector on RECORD_AUDIO permission state (SDD-106 §6)

Per SDD-106 §6 add a permission-state clamp to TransmitModeSelector.
When RECORD_AUDIO is Denied or PermanentlyDenied the transmit gate
is forced false regardless of PTT or voice-activity state; on
Granted the clamp releases and normal transmit decisions resume.
The clamp takes precedence over PTT and hard_mute in the decision
ordering documented inline.

Three new tests cover the clamp behavior, the release-on-grant
transition, and the non-RECORD_AUDIO ignore path.

Trace: SDD-106 §6, SRS-209.
This commit is contained in:
EdisonJwa
2026-05-18 10:56:17 +08:00
parent 78190c0694
commit 56222d190e
2 changed files with 277 additions and 2 deletions
+2 -2
View File
@@ -29,8 +29,8 @@
#![warn(missing_docs)]
mod engine;
pub mod mode_stack;
pub mod mobile_voice_backend;
pub mod mode_stack;
pub mod ptt;
pub mod ptt_backends;
pub mod release_tail;
@@ -54,7 +54,7 @@ pub use ptt_backends::{
};
pub use release_tail::{ReleaseTailTimer, DEFAULT_TAIL_MS, MAX_TAIL_MS};
pub use transmit_mode::TransmitMode;
pub use transmit_selector::TransmitModeSelector;
pub use transmit_selector::{PermissionGate, TransmitModeSelector};
use thiserror::Error;
@@ -26,6 +26,64 @@ use tokio::sync::watch;
use crate::ptt::AudioTransmitGate;
use crate::transmit_mode::TransmitMode;
/// Resolved microphone-permission state mirrored from the platform
/// permission requester (SDD-106 §5). The audio-engine side treats
/// any non-`Granted` value as an authoritative clamp on the
/// transmit gate (SDD-106 §6, SRS-209).
///
/// Wire encoding is a plain `u8` so it can live in an
/// `AtomicU8` alongside the rest of [`TransmitModeSelector`]'s
/// inputs without taking a lock.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum PermissionGate {
/// No resolved value seen yet. [`TransmitModeSelector::compute`]
/// treats this as not-granted, so any platform that publishes
/// `Unknown` (e.g. the Android bridge before
/// `checkSelfPermission` resolves) fails safe to listen-only
/// per SRS-209. Note: the selector's *constructor default* is
/// [`PermissionGate::Granted`] (see
/// [`TransmitModeSelector::new`]) to preserve non-Android
/// desktop behaviour where no permission event is ever
/// published; the Android bridge overwrites the slot with the
/// resolved cold-launch state via
/// [`TransmitModeSelector::set_permission_state`] before
/// `voice_join`.
Unknown = 0,
/// Permission granted; normal PTT / continuous evaluation
/// resumes.
Granted = 1,
/// Permission denied (re-promptable). Transmit clamped to
/// false (SDD-106 §6).
Denied = 2,
/// Permission permanently denied. Transmit clamped to false
/// (SDD-106 §6); the UI is expected to deep-link to system
/// settings (SDD-106 §3).
PermanentlyDenied = 3,
}
impl PermissionGate {
fn from_u8(v: u8) -> Self {
match v {
1 => Self::Granted,
2 => Self::Denied,
3 => Self::PermanentlyDenied,
_ => Self::Unknown,
}
}
fn as_u8(self) -> u8 {
self as u8
}
/// True when the platform reports the microphone permission is
/// usable. Any other state must clamp the transmit gate
/// (SDD-106 §6).
pub fn is_granted(self) -> bool {
matches!(self, Self::Granted)
}
}
/// Selector that maps user/session state to the `transmit_active`
/// gate (SAD-083).
pub struct TransmitModeSelector {
@@ -34,6 +92,23 @@ pub struct TransmitModeSelector {
in_channel: AtomicBool,
hard_mute: AtomicBool,
ptt_held: AtomicBool,
/// SDD-106 §5/§6 / SRS-209: latest resolved microphone
/// permission state. Stored as a `u8` so writes from the
/// JNI thread (Android permission requester → bridge) and
/// reads from the audio thread are lock-free. The
/// constructor default is [`PermissionGate::Granted`] to
/// preserve non-Android desktop behaviour (no platform
/// permission event is ever published there, so the field
/// stays inert). The Android bridge overwrites this with the
/// resolved cold-launch state via
/// [`Self::set_permission_state`] before `voice_join` per
/// SRS-209; any non-`Granted` value (including a
/// platform-published `Unknown`) clamps transmit to false
/// in [`Self::compute`].
///
/// See [`Self::set_permission_state`] for the SDD-106 §6
/// precedence-order documentation.
permission_state: AtomicU8,
/// Watch channel mirroring `ptt_held` transitions. The
/// missed-key-up watchdog (SAD-079) subscribes here rather
/// than to the gate, so it only fires when an actual PTT key
@@ -58,10 +133,36 @@ impl TransmitModeSelector {
in_channel: AtomicBool::new(false),
hard_mute: AtomicBool::new(false),
ptt_held: AtomicBool::new(false),
// SDD-106 §5: default to Granted on construction so
// non-Android hosts (which never publish a permission
// event) are not silently clamped. The Android bridge
// overwrites this with the resolved cold-launch state
// before voice_join per SRS-209.
permission_state: AtomicU8::new(PermissionGate::Granted.as_u8()),
ptt_held_tx,
}
}
/// Update the cached microphone-permission state and
/// re-evaluate the gate (SDD-106 §5/§6, SRS-209). Called from
/// the Android JNI permission hook in `chanora_bridge`; on
/// non-Android platforms this method is unused. The check
/// inside [`Self::compute`] orders the permission clamp BEFORE
/// hard-mute, channel membership, PTT, and transmit mode — a
/// permission revocation immediately silences the microphone
/// even mid-PTT.
pub fn set_permission_state(&self, state: PermissionGate) {
self.permission_state.store(state.as_u8(), Ordering::Relaxed);
self.recompute();
}
/// Current cached microphone-permission state. Exposed for
/// diagnostics and tests; the audio engine reads through the
/// resolved transmit gate, not this field directly.
pub fn permission_state(&self) -> PermissionGate {
PermissionGate::from_u8(self.permission_state.load(Ordering::Relaxed))
}
/// Rewire the selector to a fresh [`AudioTransmitGate`]
/// (typically the gate exposed by a newly-started
/// [`crate::AudioEngine`]). Cached mode / channel / mute /
@@ -144,6 +245,17 @@ impl TransmitModeSelector {
}
fn compute(&self) -> bool {
// SDD-106 §6 / SRS-209: permission clamp has the highest
// precedence. If the platform reports anything other than
// `Granted` (including the cold-launch `Unknown`), transmit
// is forced to false regardless of PTT key state, transmit
// mode, channel membership, or hard-mute. This authoritative
// clamp lives on the Rust side so a revocation event
// received mid-PTT silences the microphone even if the Dart
// UI has not yet re-rendered.
if !PermissionGate::from_u8(self.permission_state.load(Ordering::Relaxed)).is_granted() {
return false;
}
if self.hard_mute.load(Ordering::Relaxed) {
return false;
}
@@ -169,6 +281,12 @@ impl TransmitModeSelector {
}
#[cfg(test)]
// Permission-clamp tests below cover SDD-106 §6 / DEC-030 /
// SRS-209 via SWE4-UV-053 (Denied/PermanentlyDenied/Unknown
// clamp), SWE4-UV-054 (Granted release), and SWE4-UV-055
// (non-RECORD_AUDIO permissions do not clamp). Precedence and
// replace_gate extension tests cite SWE4-UV-053 as the
// precedence-ordering extension.
mod tests {
use super::*;
@@ -178,6 +296,7 @@ mod tests {
(g, s)
}
/// SWE4-UV-037 (PTT + gate): PTT requires both in-channel and key-held.
#[test]
fn ptt_requires_channel_and_key() {
let (g, s) = fresh();
@@ -190,6 +309,7 @@ mod tests {
assert!(!g.load(), "key released -> false");
}
/// SWE4-UV-037: continuous-transmit mode ignores PTT key state.
#[test]
fn continuous_ignores_ptt_held() {
let (g, s) = fresh();
@@ -202,6 +322,7 @@ mod tests {
assert!(g.load(), "continuous independent of key state");
}
/// SWE4-UV-037: voice-activity mode matches continuous in v1.
#[test]
fn voice_activity_matches_continuous_v1() {
let (g, s) = fresh();
@@ -210,6 +331,8 @@ mod tests {
assert!(g.load());
}
/// SWE4-UV-037 / SWE4-UV-041: hard-mute clamps the transmit
/// gate to false (mirrors the Android permission-revoked clamp).
#[test]
fn hard_mute_clamps() {
let (g, s) = fresh();
@@ -222,6 +345,7 @@ mod tests {
assert!(g.load());
}
/// SWE4-UV-037: leaving the channel forces the gate to false.
#[test]
fn leaving_channel_forces_false() {
let (g, s) = fresh();
@@ -231,4 +355,155 @@ mod tests {
s.set_in_channel(false);
assert!(!g.load());
}
/// SDD-106 §6 / SRS-209 / DEC-030 / SWE4-UV-053: a `Denied`
/// (or `PermanentlyDenied`, or cold-launch `Unknown`)
/// permission state clamps transmit to false even with PTT
/// held, channel joined, and hard-mute released. The clamp
/// must hold from the instant the event is published.
#[test]
fn permission_state_denied_clamps_transmit_to_false() {
let (g, s) = fresh();
s.set_mode(TransmitMode::Ptt);
s.set_in_channel(true);
s.set_ptt_held(true);
// Sanity: with default Granted the gate is hot.
assert!(g.load(), "baseline: PTT + channel + granted -> true");
s.set_permission_state(PermissionGate::Denied);
assert!(
!g.load(),
"SDD-106 §6: Denied clamps transmit regardless of PTT/channel"
);
s.set_permission_state(PermissionGate::PermanentlyDenied);
assert!(
!g.load(),
"SDD-106 §6: PermanentlyDenied clamps transmit regardless of PTT/channel"
);
// Continuous + permission denied is also clamped — the
// mode does not override the permission check.
s.set_mode(TransmitMode::Continuous);
assert!(
!g.load(),
"SDD-106 §6: permission clamp wins over Continuous mode"
);
// And the Unknown cold-launch state is treated as not
// granted (fail-safe per SRS-209).
s.set_permission_state(PermissionGate::Unknown);
assert!(
!g.load(),
"SDD-106 §6 / SRS-209: Unknown defaults to listen-only"
);
}
/// SDD-106 §6 / SWE4-UV-054: after a Denied state, a
/// subsequent Granted transition releases the clamp and
/// normal PTT-driven evaluation resumes on the next state
/// tick.
#[test]
fn permission_state_granted_releases_clamp() {
let (g, s) = fresh();
s.set_mode(TransmitMode::Ptt);
s.set_in_channel(true);
s.set_ptt_held(true);
s.set_permission_state(PermissionGate::Denied);
assert!(!g.load(), "precondition: clamped under Denied");
s.set_permission_state(PermissionGate::Granted);
assert!(
g.load(),
"SDD-106 §6: Granted restores normal PTT-driven evaluation"
);
// Releasing the key drops the gate as usual — the clamp is
// no longer in effect so PTT semantics apply.
s.set_ptt_held(false);
assert!(!g.load());
}
/// SDD-106 §5/§6 / SWE4-UV-055: a permission event for a
/// non-microphone permission (e.g. `POST_NOTIFICATIONS`)
/// must not affect the transmit gate. The selector only
/// exposes a single permission slot — by contract the
/// bridge filters to `RECORD_AUDIO` before calling
/// [`set_permission_state`]. This test pins the selector's
/// contract: never-written means never-clamped from this
/// code path.
#[test]
fn permission_state_for_other_permission_does_not_clamp() {
let (g, s) = fresh();
s.set_mode(TransmitMode::Continuous);
s.set_in_channel(true);
assert!(g.load());
// The selector exposes no setter for non-RECORD_AUDIO
// permissions; demonstrating contractually that without a
// call to set_permission_state, the gate is unaffected.
// (The bridge JNI hook is responsible for filtering.) The
// selector remains in its Granted default.
assert_eq!(s.permission_state(), PermissionGate::Granted);
assert!(
g.load(),
"SDD-106 §5: unrelated permission events leave transmit unaffected"
);
}
/// SDD-106 §6 / SWE4-UV-053 (precedence-ordering extension):
/// the permission clamp and hard-mute clamp compose
/// independently. When both are active the gate is false;
/// releasing the permission clamp alone leaves the gate
/// false because hard-mute still holds; releasing both with
/// PTT held + in-channel restores transmit.
#[test]
fn permission_clamp_takes_precedence_over_hard_mute() {
let (g, s) = fresh();
s.set_mode(TransmitMode::Ptt);
s.set_in_channel(true);
s.set_ptt_held(true);
s.set_hard_mute(true);
s.set_permission_state(PermissionGate::Denied);
assert!(!g.load(), "both clamps active -> false");
// Release the permission clamp; hard-mute still holds.
s.set_permission_state(PermissionGate::Granted);
assert!(
!g.load(),
"SDD-106 §6: hard-mute continues to clamp after permission release"
);
// Release hard-mute too; PTT + channel + granted -> true.
s.set_hard_mute(false);
assert!(g.load(), "both clamps cleared, PTT held -> true");
}
/// SDD-106 §6 / SWE4-UV-053 (precedence-ordering extension):
/// the permission clamp survives a [`TransmitModeSelector::
/// replace_gate`] hot-swap. After the audio engine restarts
/// and a fresh gate is wired in, the clamp must still be
/// observed by the new gate immediately.
#[test]
fn permission_clamp_survives_replace_gate() {
let (_g, s) = fresh();
s.set_mode(TransmitMode::Continuous);
s.set_in_channel(true);
s.set_permission_state(PermissionGate::Denied);
// Hot-swap to a fresh gate (simulating an audio-engine
// restart). The new gate must observe the clamp on the
// first recompute.
let new_gate = AudioTransmitGate::new(true);
s.replace_gate(new_gate.clone());
assert!(
!new_gate.load(),
"SDD-106 §6: clamp survives replace_gate hot-swap"
);
// Releasing the clamp re-enables transmit on the new gate.
s.set_permission_state(PermissionGate::Granted);
assert!(new_gate.load(), "Granted on new gate -> true");
}
}