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)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
mod engine;
|
mod engine;
|
||||||
pub mod mode_stack;
|
|
||||||
pub mod mobile_voice_backend;
|
pub mod mobile_voice_backend;
|
||||||
|
pub mod mode_stack;
|
||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
pub mod ptt_backends;
|
pub mod ptt_backends;
|
||||||
pub mod release_tail;
|
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 release_tail::{ReleaseTailTimer, DEFAULT_TAIL_MS, MAX_TAIL_MS};
|
||||||
pub use transmit_mode::TransmitMode;
|
pub use transmit_mode::TransmitMode;
|
||||||
pub use transmit_selector::TransmitModeSelector;
|
pub use transmit_selector::{PermissionGate, TransmitModeSelector};
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,64 @@ use tokio::sync::watch;
|
|||||||
use crate::ptt::AudioTransmitGate;
|
use crate::ptt::AudioTransmitGate;
|
||||||
use crate::transmit_mode::TransmitMode;
|
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`
|
/// Selector that maps user/session state to the `transmit_active`
|
||||||
/// gate (SAD-083).
|
/// gate (SAD-083).
|
||||||
pub struct TransmitModeSelector {
|
pub struct TransmitModeSelector {
|
||||||
@@ -34,6 +92,23 @@ pub struct TransmitModeSelector {
|
|||||||
in_channel: AtomicBool,
|
in_channel: AtomicBool,
|
||||||
hard_mute: AtomicBool,
|
hard_mute: AtomicBool,
|
||||||
ptt_held: 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
|
/// Watch channel mirroring `ptt_held` transitions. The
|
||||||
/// missed-key-up watchdog (SAD-079) subscribes here rather
|
/// missed-key-up watchdog (SAD-079) subscribes here rather
|
||||||
/// than to the gate, so it only fires when an actual PTT key
|
/// than to the gate, so it only fires when an actual PTT key
|
||||||
@@ -58,10 +133,36 @@ impl TransmitModeSelector {
|
|||||||
in_channel: AtomicBool::new(false),
|
in_channel: AtomicBool::new(false),
|
||||||
hard_mute: AtomicBool::new(false),
|
hard_mute: AtomicBool::new(false),
|
||||||
ptt_held: 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,
|
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`]
|
/// Rewire the selector to a fresh [`AudioTransmitGate`]
|
||||||
/// (typically the gate exposed by a newly-started
|
/// (typically the gate exposed by a newly-started
|
||||||
/// [`crate::AudioEngine`]). Cached mode / channel / mute /
|
/// [`crate::AudioEngine`]). Cached mode / channel / mute /
|
||||||
@@ -144,6 +245,17 @@ impl TransmitModeSelector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn compute(&self) -> bool {
|
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) {
|
if self.hard_mute.load(Ordering::Relaxed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -169,6 +281,12 @@ impl TransmitModeSelector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -178,6 +296,7 @@ mod tests {
|
|||||||
(g, s)
|
(g, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SWE4-UV-037 (PTT + gate): PTT requires both in-channel and key-held.
|
||||||
#[test]
|
#[test]
|
||||||
fn ptt_requires_channel_and_key() {
|
fn ptt_requires_channel_and_key() {
|
||||||
let (g, s) = fresh();
|
let (g, s) = fresh();
|
||||||
@@ -190,6 +309,7 @@ mod tests {
|
|||||||
assert!(!g.load(), "key released -> false");
|
assert!(!g.load(), "key released -> false");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SWE4-UV-037: continuous-transmit mode ignores PTT key state.
|
||||||
#[test]
|
#[test]
|
||||||
fn continuous_ignores_ptt_held() {
|
fn continuous_ignores_ptt_held() {
|
||||||
let (g, s) = fresh();
|
let (g, s) = fresh();
|
||||||
@@ -202,6 +322,7 @@ mod tests {
|
|||||||
assert!(g.load(), "continuous independent of key state");
|
assert!(g.load(), "continuous independent of key state");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SWE4-UV-037: voice-activity mode matches continuous in v1.
|
||||||
#[test]
|
#[test]
|
||||||
fn voice_activity_matches_continuous_v1() {
|
fn voice_activity_matches_continuous_v1() {
|
||||||
let (g, s) = fresh();
|
let (g, s) = fresh();
|
||||||
@@ -210,6 +331,8 @@ mod tests {
|
|||||||
assert!(g.load());
|
assert!(g.load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SWE4-UV-037 / SWE4-UV-041: hard-mute clamps the transmit
|
||||||
|
/// gate to false (mirrors the Android permission-revoked clamp).
|
||||||
#[test]
|
#[test]
|
||||||
fn hard_mute_clamps() {
|
fn hard_mute_clamps() {
|
||||||
let (g, s) = fresh();
|
let (g, s) = fresh();
|
||||||
@@ -222,6 +345,7 @@ mod tests {
|
|||||||
assert!(g.load());
|
assert!(g.load());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SWE4-UV-037: leaving the channel forces the gate to false.
|
||||||
#[test]
|
#[test]
|
||||||
fn leaving_channel_forces_false() {
|
fn leaving_channel_forces_false() {
|
||||||
let (g, s) = fresh();
|
let (g, s) = fresh();
|
||||||
@@ -231,4 +355,155 @@ mod tests {
|
|||||||
s.set_in_channel(false);
|
s.set_in_channel(false);
|
||||||
assert!(!g.load());
|
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