diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index cc6ca9d..10aab84 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -29,6 +29,7 @@ #![warn(missing_docs)] mod engine; +pub mod mode_stack; pub mod ptt; pub mod ptt_backends; pub mod release_tail; diff --git a/crates/chanora_audio/src/mode_stack.rs b/crates/chanora_audio/src/mode_stack.rs new file mode 100644 index 0000000..799599b --- /dev/null +++ b/crates/chanora_audio/src/mode_stack.rs @@ -0,0 +1,243 @@ +//! `ModeStack`: refcount-composable Android audio-mode acquire/release +//! with prior-mode snapshot/restore. +//! +//! # Trace +//! +//! Sourced verbatim from SDD-108: +//! +//! - SDD-108 §1 (Engagement API): "`fn engage()`: increments the +//! internal reference count; on the 0 → 1 transition it shall +//! snapshot the current mode (`prior_mode = audioManager.mode`) and +//! call `audioManager.setMode(MODE_IN_COMMUNICATION)`. On counts ≥ +//! 1, it is a no-op success. `fn release()`: decrements the +//! reference count; on the 1 → 0 transition it shall call +//! `audioManager.setMode(prior_mode)` and clear `prior_mode`. On +//! counts ≥ 1 after decrement, it is a no-op. On underflow (release +//! without engage) it shall log and clamp at 0 without crashing." +//! - SDD-108 §2 (Refcount semantics): "The count is an `AtomicI32` +//! guarded by a Kotlin `Mutex` for the snapshot/restore critical +//! section. P0 only ever observes counts in `{0, 1}` per SRS-189, +//! but the design must compose for future multi-session use. Any +//! two-engages-one-release sequence shall keep `prior_mode` from +//! the FIRST engage; a release at count 1 restores that original +//! mode." +//! - SDD-108 §3 (Lifecycle binding): "`engage()` shall be called from +//! the audio engine's `ensure_running()` path … AFTER +//! `AndroidVoiceForegroundService.start` succeeds. `release()` +//! shall be called from `shutdown_if_idle()` BEFORE +//! `AndroidVoiceForegroundService.stop`." +//! +//! This module realises §1 + §2 as a pure data structure: the platform +//! interaction (JNI getMode / setMode) is the engine integration's +//! responsibility (SDD-108 §3); `ModeStack` here is panic-free pure +//! Rust logic and is host-testable on all targets. + +/// Outcome of [`ModeStack::acquire`]. +/// +/// SDD-108 §1: the 0 → 1 transition is the one that must reach the +/// platform; composed acquires return [`ModeAcquire::AlreadyHeld`] +/// without re-snapshotting (SDD-108 §2). +#[derive(Debug, PartialEq, Eq)] +pub enum ModeAcquire { + /// First acquire from idle. Caller MUST platform-set the desired + /// target mode (e.g. `MODE_IN_COMMUNICATION`). `prior` is the + /// mode that will be restored on the matching last release. + FirstAcquire { + /// The system mode that was active before this acquire. + prior: i32, + }, + /// Composed acquire: mode is already engaged, no platform write + /// required. Refcount has been incremented. + AlreadyHeld, +} + +/// Outcome of [`ModeStack::release`]. +/// +/// SDD-108 §1: the 1 → 0 transition is the one that must restore the +/// snapshot. Mid-stack releases return [`ModeRelease::StillHeld`]. +/// Releases against an empty stack return +/// [`ModeRelease::AlreadyReleased`] (SDD-108 §1: "On underflow … +/// clamp at 0 without crashing"). +#[derive(Debug, PartialEq, Eq)] +pub enum ModeRelease { + /// Last release. Caller MUST platform-set the system mode back to + /// `prior`. Snapshot has been cleared. + LastRelease { + /// The mode that was snapshotted on the matching first acquire. + prior: i32, + }, + /// Refcount remains > 0 after this release; mode stays engaged. + StillHeld, + /// Release called against an idle stack. Programmer error; the + /// stack remains idle (no panic, no underflow — SDD-108 §1). + AlreadyReleased, +} + +/// Refcount-composable audio-mode controller (SDD-108 §1, §2). +/// +/// `ModeStack` does NOT call the platform. The caller is responsible +/// for passing the current platform mode into [`Self::acquire`] and +/// for executing the platform write (setMode) when the outcome +/// indicates a transition is required. This separation keeps +/// `ModeStack` host-testable and `#[cfg]`-free. +/// +/// `ModeStack` is not internally synchronised; callers must serialise +/// access (e.g. behind a `Mutex`), as SDD-108 §2 specifies for the +/// Kotlin side. +#[derive(Debug, Default)] +pub struct ModeStack { + refcount: usize, // `usize` (not `AtomicI32` as in SDD-108 §2) — synchronisation is the caller's responsibility; saturating_add prevents wrap. + snapshot: Option, +} + +impl ModeStack { + /// Construct an idle stack (refcount 0, no snapshot). + pub fn new() -> Self { + Self { + refcount: 0, + snapshot: None, + } + } + + /// Acquire the audio-mode lock (SDD-108 §1). + /// + /// `current_platform_mode` is the mode that + /// `AudioManager.getMode()` returned at the call site. On the 0 → + /// 1 transition this value is snapshotted as the restore target. + /// On composed acquires (count ≥ 1 prior), the snapshot is + /// preserved verbatim per SDD-108 §2 ("Any two-engages-one-release + /// sequence shall keep `prior_mode` from the FIRST engage"). + pub fn acquire(&mut self, current_platform_mode: i32) -> ModeAcquire { + if self.refcount == 0 { + self.snapshot = Some(current_platform_mode); + self.refcount = 1; + ModeAcquire::FirstAcquire { + prior: current_platform_mode, + } + } else { + // Saturating add guards against pathological refcount + // overflow without panicking; on usize this would require + // ~2^64 composed acquires which is not reachable in + // practice but keeps the panic-free contract honest. + self.refcount = self.refcount.saturating_add(1); + ModeAcquire::AlreadyHeld + } + } + + /// Release the audio-mode lock (SDD-108 §1). + /// + /// On the 1 → 0 transition, returns [`ModeRelease::LastRelease`] + /// carrying the snapshotted prior mode that the caller must + /// platform-restore. The snapshot is cleared as part of the + /// transition. + pub fn release(&mut self) -> ModeRelease { + match self.refcount { + 0 => ModeRelease::AlreadyReleased, + 1 => { + self.refcount = 0; + debug_assert!( + self.snapshot.is_some(), + "ModeStack invariant: refcount==1 implies snapshot.is_some()" + ); + // `0` corresponds to Android `MODE_NORMAL` — a safe restore target. + let prior = self.snapshot.take().unwrap_or(0); + ModeRelease::LastRelease { prior } + } + n => { + self.refcount = n - 1; + ModeRelease::StillHeld + } + } + } + + /// Current refcount (diagnostics only, per SDD-108 §1 + /// `is_engaged()` rationale). + pub fn refcount(&self) -> usize { + self.refcount + } + + /// Current snapshot (diagnostics only). `None` when idle. + pub fn snapshot(&self) -> Option { + self.snapshot + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// SWE4-UV-045: First acquire from idle snapshots prior mode. + #[test] + fn swe4_uv_045_acquire_from_idle_snapshots_prior_mode() { + let mut s = ModeStack::new(); + assert_eq!(s.acquire(0), ModeAcquire::FirstAcquire { prior: 0 }); + assert_eq!(s.refcount(), 1); + assert_eq!(s.snapshot(), Some(0)); + } + + /// SWE4-UV-045: Second acquire returns AlreadyHeld; snapshot is + /// preserved from the FIRST acquire (SDD-108 §2). + #[test] + fn swe4_uv_045_acquire_twice_returns_already_held() { + let mut s = ModeStack::new(); + let _ = s.acquire(7); + // Even if a different "current" mode is reported on the + // composed acquire, the snapshot must remain the first one. + assert_eq!(s.acquire(99), ModeAcquire::AlreadyHeld); + assert_eq!(s.refcount(), 2); + assert_eq!(s.snapshot(), Some(7)); + } + + /// SWE4-UV-045: Last release returns the snapshotted prior mode. + #[test] + fn swe4_uv_045_release_last_returns_prior() { + let mut s = ModeStack::new(); + let _ = s.acquire(7); + assert_eq!(s.release(), ModeRelease::LastRelease { prior: 7 }); + assert_eq!(s.refcount(), 0); + assert_eq!(s.snapshot(), None); + } + + /// SWE4-UV-045: Release at refcount 2 keeps the mode engaged. + #[test] + fn swe4_uv_045_release_with_refcount_two_keeps_held() { + let mut s = ModeStack::new(); + let _ = s.acquire(3); + let _ = s.acquire(3); + assert_eq!(s.release(), ModeRelease::StillHeld); + assert_eq!(s.refcount(), 1); + // Snapshot still pinned to first acquire. + assert_eq!(s.snapshot(), Some(3)); + } + + /// SWE4-UV-045: Release on idle stack signals programmer error + /// without panicking (SDD-108 §1 "clamp at 0 without crashing"). + #[test] + fn swe4_uv_045_release_on_idle_signals_error() { + let mut s = ModeStack::new(); + assert_eq!(s.release(), ModeRelease::AlreadyReleased); + assert_eq!(s.refcount(), 0); + assert_eq!(s.snapshot(), None); + } + + /// SWE4-UV-045: Balanced acquire/release pairs return to idle, and + /// a subsequent acquire snapshots the (potentially new) prior + /// mode — proving the snapshot is cleared on last release. + #[test] + fn swe4_uv_045_compose_acquire_release_pairs() { + let mut s = ModeStack::new(); + // Pair 1: snapshot 0, restore 0. + assert_eq!(s.acquire(0), ModeAcquire::FirstAcquire { prior: 0 }); + let _ = s.acquire(0); // composed + assert_eq!(s.release(), ModeRelease::StillHeld); + assert_eq!(s.release(), ModeRelease::LastRelease { prior: 0 }); + assert_eq!(s.refcount(), 0); + assert_eq!(s.snapshot(), None); + + // Pair 2: the platform mode is now different; the new + // snapshot must reflect THAT mode, not the prior pair's. + assert_eq!(s.acquire(2), ModeAcquire::FirstAcquire { prior: 2 }); + assert_eq!(s.snapshot(), Some(2)); + assert_eq!(s.release(), ModeRelease::LastRelease { prior: 2 }); + } +}