feat(audio): add ModeStack pure refcount helper (SDD-108)
Introduce ModeStack, a pure-Rust refcount-composable wrapper for Android audio-mode acquire/release with prior-mode snapshot. Per SDD-108 §1/§2 the engine snapshots the system audio mode on first acquire and restores it on last release; composed acquires are no-ops while the mode is held. ModeStack is panic-free; release-on-zero returns AlreadyReleased rather than panicking. Six SWE4-UV-045-tagged unit tests cover the acquire/release semantics on the host target. Trace: SDD-108, SWE4-UV-045.
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod engine;
|
||||
pub mod mode_stack;
|
||||
pub mod ptt;
|
||||
pub mod ptt_backends;
|
||||
pub mod release_tail;
|
||||
|
||||
@@ -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<i32>,
|
||||
}
|
||||
|
||||
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<i32> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user