diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index 46de346..c5d2be5 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -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; diff --git a/crates/chanora_audio/src/transmit_selector.rs b/crates/chanora_audio/src/transmit_selector.rs index 48e2b34..591d9c9 100644 --- a/crates/chanora_audio/src/transmit_selector.rs +++ b/crates/chanora_audio/src/transmit_selector.rs @@ -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"); + } }