527 lines
20 KiB
Rust
527 lines
20 KiB
Rust
//! Cross-platform transmit-mode selector (SAD-083).
|
|
//!
|
|
//! Single writer of `transmit_active` other than the missed-key-up
|
|
//! watchdog (SAD-079). Computes the desired gate state from four
|
|
//! lock-free inputs:
|
|
//!
|
|
//! * `mode` — current [`TransmitMode`]
|
|
//! * `in_channel` — true when the session is in a voice channel
|
|
//! * `hard_mute` — final clamp; forces `false` regardless of mode
|
|
//! * `ptt_held` — raw key state (via [`crate::ReleaseTailTimer`]
|
|
//! on PTT mode)
|
|
//!
|
|
//! Hard-mute is a final clamp; leaving the channel forces the gate
|
|
//! to `false`. `VoiceActivity` is driven by Rust-owned VAD state.
|
|
//!
|
|
//! All four inputs are stored as atomics so any thread can update
|
|
//! them without taking a lock. After each update we call
|
|
//! [`TransmitModeSelector::recompute`] which writes the resolved
|
|
//! desired value through the [`AudioTransmitGate`].
|
|
|
|
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
|
|
|
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 {
|
|
gate: std::sync::RwLock<AudioTransmitGate>,
|
|
mode: AtomicU8,
|
|
in_channel: AtomicBool,
|
|
hard_mute: AtomicBool,
|
|
ptt_held: AtomicBool,
|
|
voice_activity_open: 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
|
|
/// press has been "stuck" for the timeout. In Continuous
|
|
/// mode `ptt_held` is never written, so the watchdog never
|
|
/// fires — that is the desired behaviour (Continuous is
|
|
/// supposed to keep transmitting indefinitely).
|
|
ptt_held_tx: watch::Sender<bool>,
|
|
}
|
|
|
|
impl TransmitModeSelector {
|
|
/// Construct a selector wired to `gate`. Defaults:
|
|
/// [`TransmitMode::Ptt`], not in channel, not muted, key
|
|
/// released. The gate's initial value is left untouched until
|
|
/// the first mutating call (which then writes the resolved
|
|
/// value).
|
|
pub fn new(gate: AudioTransmitGate) -> Self {
|
|
let (ptt_held_tx, _rx) = watch::channel(false);
|
|
Self {
|
|
gate: std::sync::RwLock::new(gate),
|
|
mode: AtomicU8::new(TransmitMode::default().as_u8()),
|
|
in_channel: AtomicBool::new(false),
|
|
hard_mute: AtomicBool::new(false),
|
|
ptt_held: AtomicBool::new(false),
|
|
voice_activity_open: 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 /
|
|
/// ptt_held are preserved; the new gate is immediately
|
|
/// updated to the resolved value.
|
|
pub fn replace_gate(&self, gate: AudioTransmitGate) {
|
|
if let Ok(mut g) = self.gate.write() {
|
|
*g = gate;
|
|
}
|
|
self.recompute();
|
|
}
|
|
|
|
/// Update the selected mode and re-evaluate the gate.
|
|
pub fn set_mode(&self, m: TransmitMode) {
|
|
self.mode.store(m.as_u8(), Ordering::Relaxed);
|
|
self.recompute();
|
|
}
|
|
|
|
/// Current selected mode.
|
|
pub fn mode(&self) -> TransmitMode {
|
|
TransmitMode::from_u8(self.mode.load(Ordering::Relaxed)).unwrap_or_default()
|
|
}
|
|
|
|
/// Update channel membership and re-evaluate.
|
|
pub fn set_in_channel(&self, v: bool) {
|
|
self.in_channel.store(v, Ordering::Relaxed);
|
|
self.recompute();
|
|
}
|
|
|
|
/// Current channel-membership flag.
|
|
pub fn in_channel(&self) -> bool {
|
|
self.in_channel.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Final-clamp hard mute. When `true` the gate is forced to
|
|
/// `false` regardless of mode.
|
|
pub fn set_hard_mute(&self, v: bool) {
|
|
self.hard_mute.store(v, Ordering::Relaxed);
|
|
self.recompute();
|
|
}
|
|
|
|
/// Current hard-mute flag.
|
|
pub fn hard_mute(&self) -> bool {
|
|
self.hard_mute.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// PTT key state (set by the platform input backend through
|
|
/// [`crate::ReleaseTailTimer`]).
|
|
pub fn set_ptt_held(&self, v: bool) {
|
|
self.ptt_held.store(v, Ordering::Relaxed);
|
|
// Best-effort publish for the missed-key-up watchdog;
|
|
// watch::Sender::send returns Err only when there are no
|
|
// receivers, which is fine.
|
|
let _ = self.ptt_held_tx.send(v);
|
|
self.recompute();
|
|
}
|
|
|
|
/// Current PTT-held flag.
|
|
pub fn ptt_held(&self) -> bool {
|
|
self.ptt_held.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Rust-owned VAD gate input for VoiceActivity mode.
|
|
pub fn set_voice_activity_open(&self, v: bool) {
|
|
self.voice_activity_open.store(v, Ordering::Relaxed);
|
|
self.recompute();
|
|
}
|
|
|
|
/// Current Rust-owned VAD gate state.
|
|
pub fn voice_activity_open(&self) -> bool {
|
|
self.voice_activity_open.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Subscribe to `ptt_held` transitions. Used by the
|
|
/// missed-key-up watchdog (SAD-079) so it fires on the actual
|
|
/// PTT-key-down lifetime, not on the resolved `transmit_active`
|
|
/// (which is supposed to stay `true` indefinitely in
|
|
/// Continuous mode).
|
|
pub fn subscribe_ptt_held(&self) -> watch::Receiver<bool> {
|
|
self.ptt_held_tx.subscribe()
|
|
}
|
|
|
|
/// Shared snapshot of the underlying gate. Provided for the
|
|
/// audio engine's hot read path. Returns a clone so callers
|
|
/// don't hold the internal lock.
|
|
pub fn gate(&self) -> AudioTransmitGate {
|
|
self.gate
|
|
.read()
|
|
.expect("selector gate lock poisoned")
|
|
.clone()
|
|
}
|
|
|
|
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;
|
|
}
|
|
if !self.in_channel.load(Ordering::Relaxed) {
|
|
return false;
|
|
}
|
|
match self.mode() {
|
|
TransmitMode::Ptt => self.ptt_held.load(Ordering::Relaxed),
|
|
TransmitMode::Continuous => true,
|
|
TransmitMode::VoiceActivity => self.voice_activity_open.load(Ordering::Relaxed),
|
|
}
|
|
}
|
|
|
|
/// Recompute the desired gate state and publish it. Exposed
|
|
/// for tests; callers normally trigger this implicitly through
|
|
/// the setter methods.
|
|
pub fn recompute(&self) {
|
|
let desired = self.compute();
|
|
if let Ok(g) = self.gate.read() {
|
|
g.set(desired);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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::*;
|
|
|
|
fn fresh() -> (AudioTransmitGate, TransmitModeSelector) {
|
|
let g = AudioTransmitGate::new(false);
|
|
let s = TransmitModeSelector::new(g.clone());
|
|
(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();
|
|
s.set_mode(TransmitMode::Ptt);
|
|
s.set_ptt_held(true);
|
|
assert!(!g.load(), "no channel -> false");
|
|
s.set_in_channel(true);
|
|
assert!(g.load(), "channel + key -> true");
|
|
s.set_ptt_held(false);
|
|
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();
|
|
s.set_mode(TransmitMode::Continuous);
|
|
s.set_in_channel(true);
|
|
assert!(g.load(), "channel + continuous -> true");
|
|
s.set_ptt_held(true);
|
|
assert!(g.load());
|
|
s.set_ptt_held(false);
|
|
assert!(g.load(), "continuous independent of key state");
|
|
}
|
|
|
|
/// SWE4-UV-037: voice-activity mode follows VAD state.
|
|
#[test]
|
|
fn voice_activity_requires_vad_open() {
|
|
let (g, s) = fresh();
|
|
s.set_mode(TransmitMode::VoiceActivity);
|
|
s.set_in_channel(true);
|
|
assert!(!g.load());
|
|
s.set_voice_activity_open(true);
|
|
assert!(g.load());
|
|
s.set_voice_activity_open(false);
|
|
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();
|
|
s.set_mode(TransmitMode::Continuous);
|
|
s.set_in_channel(true);
|
|
assert!(g.load());
|
|
s.set_hard_mute(true);
|
|
assert!(!g.load(), "hard mute clamps to false");
|
|
s.set_hard_mute(false);
|
|
assert!(g.load());
|
|
}
|
|
|
|
/// SWE4-UV-037: leaving the channel forces the gate to false.
|
|
#[test]
|
|
fn leaving_channel_forces_false() {
|
|
let (g, s) = fresh();
|
|
s.set_mode(TransmitMode::Continuous);
|
|
s.set_in_channel(true);
|
|
assert!(g.load());
|
|
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");
|
|
}
|
|
}
|