//! 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, 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, } 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 { 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"); } }