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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user