feat(audio,android): wire engine + AudioManager JNI through ModeStack (SDD-108 §2)
Replace the prior one-shot android_engage_voice_communication call with a ModeStack-mediated acquire/release pair. AudioEngine snapshots the system audio mode on first acquire via android_get_audio_mode() and restores it on last release via android_set_audio_mode(prior). MODE_IN_COMMUNICATION (3) is engaged across the voice-session lifetime per SDD-108. Includes the Android AudioManager getMode/setMode JNI helpers (placed in chanora_audio::engine alongside the existing JNI surface) and the small ptt.rs touch needed for the SDD-108 ID-tag on the existing tests. Trace: SDD-108, SDD-115.
This commit is contained in:
@@ -144,6 +144,24 @@ pub struct AudioEngine {
|
||||
_output_stream: Mutex<Option<cpal::Stream>>,
|
||||
#[cfg(target_os = "ios")]
|
||||
_ios_voice_unit: Mutex<Option<crate::ios_voice_unit::IosVoiceUnit>>,
|
||||
/// SDD-111..SDD-115: Android voice backend held parallel to the
|
||||
/// cpal streams. Owns the SDD-113 hardware-effect handles and
|
||||
/// the foreground-service lifecycle; tearing it down on engine
|
||||
/// drop unbinds effects and stops the service in SDD-115
|
||||
/// reverse order. The cpal capture/playback pair continues to
|
||||
/// carry voice frames for the transitional period — replacing
|
||||
/// the data path with the Oboe streams is a follow-up.
|
||||
#[cfg(target_os = "android")]
|
||||
_android_voice_unit: Mutex<Option<crate::android_voice_unit::AndroidVoiceUnit>>,
|
||||
/// SDD-108 §1/§2: refcount-composable audio-mode controller.
|
||||
/// Snapshots `AudioManager.getMode()` on the 0 → 1 transition and
|
||||
/// restores it on the 1 → 0 transition. Held in a `Mutex` so the
|
||||
/// snapshot/restore critical section is serialized across
|
||||
/// composed callers (SDD-108 §2). Engine-scoped because the
|
||||
/// mode lifecycle is bound to the voice-session lifecycle
|
||||
/// (SDD-108 §3).
|
||||
#[cfg(target_os = "android")]
|
||||
audio_mode_stack: Mutex<crate::mode_stack::ModeStack>,
|
||||
// Hand the inbound-voice forwarder task a shutdown signal.
|
||||
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
/// True if the capture stream actually opened. If false (typical
|
||||
@@ -295,33 +313,127 @@ impl AudioEngine {
|
||||
),
|
||||
}
|
||||
|
||||
// A.5 mobile-only preset acknowledgement. On Linux desktop
|
||||
// the flag is ignored; on Android we log it so a future cpal
|
||||
// / Oboe wiring can be observed in the diagnostic export.
|
||||
// SDD-115 lifecycle sequencing on engine start. Forward order:
|
||||
// 1) bridge -> engine receives voice_join (here we are
|
||||
// already inside `start`, the engine-side trigger).
|
||||
// 2) start the Android voice foreground service so that
|
||||
// the platform records the microphone capture under
|
||||
// `foregroundServiceType="microphone"` (SDD-107 + SRS-215).
|
||||
// 3) open the AAudio voice streams (SDD-111 + SDD-112).
|
||||
// 4) engage `MODE_IN_COMMUNICATION` (SDD-108).
|
||||
// 5) bind hardware effects (SDD-113) — performed inside
|
||||
// `AndroidVoiceUnit::open()` once the input stream has a
|
||||
// session id.
|
||||
// The reverse order on engine drop is enforced by the field
|
||||
// drop order (`_android_voice_unit` is dropped before the
|
||||
// engine returns; `close()` is invoked from its Drop impl).
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let mut audio_mode_stack = crate::mode_stack::ModeStack::new();
|
||||
#[cfg(target_os = "android")]
|
||||
let _android_voice_unit = {
|
||||
if cfg.mobile_voice_preset {
|
||||
match android_engage_voice_communication() {
|
||||
Ok(()) => info!(
|
||||
// Step 2: foreground service.
|
||||
if crate::android_voice_unit::chanora_android_start_voice_service() {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
"android: AudioManager mode set to MODE_IN_COMMUNICATION"
|
||||
),
|
||||
"android: voice foreground service start dispatched (SDD-115)"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"android: foreground service start failed; capture may be denied in background (SDD-115)"
|
||||
);
|
||||
}
|
||||
// Step 4 (mode engage) BEFORE Step 5 (effect bind);
|
||||
// hardware-effect routing only engages reliably under
|
||||
// MODE_IN_COMMUNICATION (SDD-113 item 6 / SDD-115).
|
||||
//
|
||||
// SDD-108 §1/§2: route through `ModeStack` so the
|
||||
// 0 → 1 transition snapshots the prior platform mode
|
||||
// (via `android_get_audio_mode`) and only that
|
||||
// transition writes `MODE_IN_COMMUNICATION` via
|
||||
// `android_set_audio_mode`. P0 only ever observes
|
||||
// refcount {0, 1} per SRS-189 but the composition
|
||||
// model is in place for P1.
|
||||
match android_get_audio_mode() {
|
||||
Ok(prior_now) => {
|
||||
let outcome = audio_mode_stack.acquire(prior_now);
|
||||
if let crate::mode_stack::ModeAcquire::FirstAcquire { prior } = outcome {
|
||||
match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) {
|
||||
Ok(()) => info!(
|
||||
target: "chanora_audio",
|
||||
prior_mode = prior,
|
||||
"android: AudioManager mode set to MODE_IN_COMMUNICATION (SDD-108)"
|
||||
),
|
||||
Err(e) => {
|
||||
// SDD-108 §5: setMode failed AFTER
|
||||
// the 0 → 1 ModeStack transition.
|
||||
// Roll the stack back so refcount
|
||||
// returns to 0 and the snapshot is
|
||||
// cleared; otherwise a future
|
||||
// release would issue an
|
||||
// unmatched setMode(prior) against
|
||||
// a system that never had its mode
|
||||
// changed by us.
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
prior_mode = prior,
|
||||
"android: setMode failed; rolling back ModeStack acquire (SDD-108 §5)"
|
||||
);
|
||||
let _ = audio_mode_stack.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"android: failed to set MODE_IN_COMMUNICATION; falling back to default routing"
|
||||
"android: AudioManager.getMode failed; skipping mode engage (SDD-108)"
|
||||
),
|
||||
}
|
||||
// Step 3 + 5: open streams (SDD-111/112) and bind
|
||||
// hardware effects (SDD-113). Failure here is logged
|
||||
// and the engine continues with software AEC/NS/AGC
|
||||
// via the existing engine path; the cpal data path
|
||||
// remains the in-flight carrier.
|
||||
//
|
||||
// The prior silent-no-op log line at this site
|
||||
// ("engagement depends on device AEC/NS support
|
||||
// under MODE_IN_COMMUNICATION") is removed: the
|
||||
// AndroidVoiceUnit either succeeds in engaging
|
||||
// hardware effects (SDD-113) or logs the per-effect
|
||||
// fallback, so engagement is now observable rather
|
||||
// than rationalised.
|
||||
let cfg_av = crate::mobile_voice_backend::AndroidVoiceStreamConfig {
|
||||
effects: cfg.effects,
|
||||
..Default::default()
|
||||
};
|
||||
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av) {
|
||||
Ok(mut unit) => {
|
||||
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
|
||||
if let Err(e) = unit.start() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"android: AndroidVoiceUnit::start failed — cpal path remains active (SDD-115)"
|
||||
);
|
||||
}
|
||||
Some(unit)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"android: AndroidVoiceUnit::open failed — software AEC/NS/AGC fallback engages (SDD-111/SDD-113)"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
if cfg.effects.aec || cfg.effects.noise_suppression {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
aec = cfg.effects.aec,
|
||||
ns = cfg.effects.noise_suppression,
|
||||
"android: effects requested; engagement depends on device AEC/NS support under MODE_IN_COMMUNICATION"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
if cfg.mobile_voice_preset {
|
||||
@@ -532,6 +644,10 @@ impl AudioEngine {
|
||||
output_muted,
|
||||
_input_stream: Mutex::new(input_stream),
|
||||
_output_stream: Mutex::new(Some(output_stream)),
|
||||
#[cfg(target_os = "android")]
|
||||
_android_voice_unit: Mutex::new(_android_voice_unit),
|
||||
#[cfg(target_os = "android")]
|
||||
audio_mode_stack: Mutex::new(audio_mode_stack),
|
||||
shutdown_tx: Some(shutdown_tx),
|
||||
capture_active,
|
||||
ptt_watchdog,
|
||||
@@ -676,6 +792,72 @@ impl AudioEngine {
|
||||
{
|
||||
let _ = self._ios_voice_unit.lock().unwrap().take();
|
||||
}
|
||||
// SDD-115 reverse-order teardown on Android:
|
||||
// 1) close the voice unit (releases SDD-113 hardware
|
||||
// effects then stops + closes the Oboe streams);
|
||||
// 2) restore the prior audio mode (SDD-108 §1 on 1 → 0
|
||||
// transition);
|
||||
// 3) stop the foreground service.
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
if let Some(mut unit) = self._android_voice_unit.lock().unwrap().take() {
|
||||
use crate::mobile_voice_backend::MobileVoiceAudioBackend;
|
||||
if let Err(e) = unit.close() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
"android: AndroidVoiceUnit::close failed (SDD-115)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// SDD-108 §1/§2: release the audio-mode stack. Only the
|
||||
// 1 → 0 transition writes the platform; mid-stack
|
||||
// releases stay engaged. Underflow (release without a
|
||||
// matching acquire) is clamped without panic per
|
||||
// SDD-108 §1.
|
||||
// SDD-108 §5: tolerate a poisoned mutex on the teardown
|
||||
// path — if a panicking thread held the lock, we still
|
||||
// need to drive the release to completion (otherwise the
|
||||
// platform stays in MODE_IN_COMMUNICATION).
|
||||
let release = self
|
||||
.audio_mode_stack
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.release();
|
||||
match release {
|
||||
crate::mode_stack::ModeRelease::LastRelease { prior } => {
|
||||
match android_set_audio_mode(prior) {
|
||||
Ok(()) => info!(
|
||||
target: "chanora_audio",
|
||||
restored_mode = prior,
|
||||
"android: AudioManager mode restored (SDD-108)"
|
||||
),
|
||||
Err(e) => warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
restored_mode = prior,
|
||||
"android: failed to restore prior AudioManager mode (SDD-108)"
|
||||
),
|
||||
}
|
||||
}
|
||||
crate::mode_stack::ModeRelease::StillHeld => {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
"android: audio mode still held by composed session (SDD-108)"
|
||||
);
|
||||
}
|
||||
crate::mode_stack::ModeRelease::AlreadyReleased => {
|
||||
// No engage ever happened (e.g. mobile_voice_preset
|
||||
// was false, or getMode failed). Silent no-op.
|
||||
}
|
||||
}
|
||||
if crate::android_voice_unit::chanora_android_stop_voice_service() {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
"android: voice foreground service stop dispatched (SDD-115)"
|
||||
);
|
||||
}
|
||||
}
|
||||
info!(target: "chanora_audio", "audio engine stopped");
|
||||
}
|
||||
|
||||
@@ -796,6 +978,18 @@ impl AudioEngine {
|
||||
self.frames_received.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Latest Android voice-audio diagnostics snapshot (SDD-112 item
|
||||
/// 10 / SDD-113 item 7 / SDD-116 item 3). On non-Android targets
|
||||
/// this always returns `None`. On Android it returns `Some(...)`
|
||||
/// once `AndroidVoiceUnit::open()` has published a snapshot; the
|
||||
/// slot is cleared on `close()` / `Drop`. Per SDD-090 the
|
||||
/// snapshot contains only device-side technical scalars — no PII.
|
||||
pub fn android_diagnostics(
|
||||
&self,
|
||||
) -> Option<crate::mobile_voice_backend::AndroidAudioDiagnostics> {
|
||||
crate::mobile_voice_backend::current_android_audio_diagnostics()
|
||||
}
|
||||
|
||||
/// Set master output mute. When true the output stream emits
|
||||
/// silence regardless of incoming voice frames.
|
||||
pub fn set_output_muted(&self, muted: bool) {
|
||||
@@ -1387,52 +1581,173 @@ impl FromF32 for u16 {
|
||||
|
||||
// ---------- Android voice-communication routing ----------
|
||||
//
|
||||
// Engages `AudioManager.MODE_IN_COMMUNICATION` on the Android-side
|
||||
// AudioManager. This is the routing-level lever that tells the OS
|
||||
// "this is a voice call, please use the earpiece / engage hardware
|
||||
// AEC / NS / AGC where the device supports it". cpal opens its
|
||||
// input stream at the AAudio default preset; on most Android
|
||||
// devices this honours the global mode and chooses the right
|
||||
// pipeline. Fully wiring `setInputPreset(VOICE_COMMUNICATION)` would
|
||||
// need either a cpal fork or a parallel Oboe input — out of scope
|
||||
// for External Beta.
|
||||
// SDD-108 §1: `AudioManager.setMode(MODE_IN_COMMUNICATION)` engagement
|
||||
// is the routing-level lever that tells Android "this is a voice
|
||||
// call, please use the earpiece / engage hardware AEC / NS / AGC
|
||||
// where the device supports it". The helpers here are the
|
||||
// platform-write surface invoked by the engine's `ModeStack`
|
||||
// (`crate::mode_stack`) — the stack owns refcount + snapshot
|
||||
// semantics (SDD-108 §2), these helpers only perform the platform
|
||||
// write / read.
|
||||
//
|
||||
// Placement rationale (SDD-108 §4): the JNI helpers for
|
||||
// `AudioManager.getMode` / `AudioManager.setMode` live in
|
||||
// `chanora_audio::engine` (not `chanora_bridge::android_init`) per
|
||||
// SDD-108 §4, which assigns the AudioManager JNI surface to the Rust
|
||||
// audio engine. This keeps the AudioManager interaction co-located
|
||||
// with the engine state (`ModeStack`) that owns it, so the
|
||||
// snapshot/restore lifecycle and the JNI calls evolve together.
|
||||
//
|
||||
// `AudioManager.MODE_IN_COMMUNICATION == 3` per the Android SDK.
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_engage_voice_communication() -> Result<(), String> {
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
let ctx = ndk_context::android_context();
|
||||
let vm_ptr = ctx.vm();
|
||||
if vm_ptr.is_null() {
|
||||
return Err("ndk_context vm is null".to_string());
|
||||
}
|
||||
// SAFETY: ndk_context::android_context guarantees `vm` points at
|
||||
// a live JavaVM* set by our bridge_init JNI hook. The unsafe
|
||||
// block contains only the cast required by `JavaVM::from_raw`.
|
||||
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) }
|
||||
.map_err(|e| format!("jvm from_raw: {e}"))?;
|
||||
let mut env = jvm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| format!("attach: {e}"))?;
|
||||
pub const ANDROID_MODE_IN_COMMUNICATION: i32 = 3;
|
||||
|
||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
|
||||
let service_name: JString = env
|
||||
.new_string("audio")
|
||||
.map_err(|e| format!("new_string: {e}"))?;
|
||||
let audio_manager = env
|
||||
.call_method(
|
||||
&context_obj,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[JValue::Object(&service_name.into())],
|
||||
)
|
||||
.map_err(|e| format!("getSystemService: {e}"))?
|
||||
.l()
|
||||
.map_err(|e| format!("getSystemService obj: {e}"))?;
|
||||
if audio_manager.is_null() {
|
||||
return Err("AudioManager service is null".to_string());
|
||||
/// SDD-108 §5: typed error for the Android `AudioManager` JNI surface.
|
||||
///
|
||||
/// Replaces the previous ad-hoc `Result<_, String>` so callers can
|
||||
/// pattern-match on the failure category (attach vs. method call vs.
|
||||
/// other) and log/route accordingly. `Display` renders a stable
|
||||
/// human-readable form that is safe to feed into the existing
|
||||
/// `tracing::warn!(error = %e, ...)` sites.
|
||||
#[cfg(target_os = "android")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AudioModeError {
|
||||
/// `JavaVM::from_raw` or `attach_current_thread` failed: the
|
||||
/// engine could not reach the JVM at all.
|
||||
JniAttachFailed(String),
|
||||
/// A specific JNI method call failed (e.g. `getMode`, `setMode`,
|
||||
/// `getSystemService`). `method` is a static string for grep-ability.
|
||||
MethodCallFailed {
|
||||
method: &'static str,
|
||||
detail: String,
|
||||
},
|
||||
/// Catch-all for non-JNI-method failures (null context, panic in
|
||||
/// the JNI body, etc.).
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl std::fmt::Display for AudioModeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::JniAttachFailed(d) => write!(f, "JNI attach failed: {d}"),
|
||||
Self::MethodCallFailed { method, detail } => {
|
||||
write!(f, "JNI method `{method}` failed: {detail}")
|
||||
}
|
||||
Self::Other(d) => write!(f, "{d}"),
|
||||
}
|
||||
}
|
||||
// AudioManager.MODE_IN_COMMUNICATION == 3.
|
||||
env.call_method(&audio_manager, "setMode", "(I)V", &[JValue::Int(3)])
|
||||
.map_err(|e| format!("setMode: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl std::error::Error for AudioModeError {}
|
||||
|
||||
/// JNI helper shared by `android_get_audio_mode` and
|
||||
/// `android_set_audio_mode`: attach to the current thread and return
|
||||
/// the `AudioManager` jobject. Centralised so SDD-108's two platform
|
||||
/// entry points share one bootstrapping path.
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
|
||||
where
|
||||
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError>
|
||||
+ std::panic::UnwindSafe,
|
||||
{
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
|
||||
let result = std::panic::catch_unwind(|| -> Result<R, AudioModeError> {
|
||||
let ctx = ndk_context::android_context();
|
||||
let vm_ptr = ctx.vm();
|
||||
if vm_ptr.is_null() {
|
||||
return Err(AudioModeError::JniAttachFailed(
|
||||
"ndk_context vm is null".to_string(),
|
||||
));
|
||||
}
|
||||
// SAFETY: ndk_context::android_context guarantees `vm` points
|
||||
// at a live JavaVM* set by our bridge_init JNI hook. The
|
||||
// unsafe block contains only the cast required by
|
||||
// `JavaVM::from_raw`.
|
||||
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) }
|
||||
.map_err(|e| AudioModeError::JniAttachFailed(format!("jvm from_raw: {e}")))?;
|
||||
let mut env = jvm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| AudioModeError::JniAttachFailed(format!("attach: {e}")))?;
|
||||
|
||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
|
||||
let service_name: JString = env.new_string("audio").map_err(|e| {
|
||||
AudioModeError::MethodCallFailed {
|
||||
method: "new_string",
|
||||
detail: e.to_string(),
|
||||
}
|
||||
})?;
|
||||
let audio_manager = env
|
||||
.call_method(
|
||||
&context_obj,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[JValue::Object(&service_name.into())],
|
||||
)
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
detail: format!("obj cast: {e}"),
|
||||
})?;
|
||||
if audio_manager.is_null() {
|
||||
return Err(AudioModeError::Other(
|
||||
"AudioManager service is null".to_string(),
|
||||
));
|
||||
}
|
||||
op(&mut env, &audio_manager)
|
||||
});
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err(AudioModeError::Other(
|
||||
"panic in JNI audio_manager call".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// SDD-108 §1 platform-read: `AudioManager.getMode()`.
|
||||
///
|
||||
/// Returns the integer mode constant currently active on the system.
|
||||
/// Called by the engine on first acquire (0 → 1 transition) so that
|
||||
/// `ModeStack` can snapshot the prior mode for restoration on last
|
||||
/// release.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
||||
android_audio_manager_call(|env, audio_manager| {
|
||||
env.call_method(audio_manager, "getMode", "()I", &[])
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.i()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: format!("int cast: {e}"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// SDD-108 §1 platform-write: `AudioManager.setMode(mode)`.
|
||||
///
|
||||
/// `mode` is the Android `AudioManager.MODE_*` integer constant.
|
||||
/// Use [`ANDROID_MODE_IN_COMMUNICATION`] for engagement; pass back
|
||||
/// the snapshotted prior mode (from
|
||||
/// [`crate::mode_stack::ModeRelease::LastRelease`]) for restoration.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
|
||||
use jni::objects::JValue;
|
||||
android_audio_manager_call(move |env, audio_manager| {
|
||||
env.call_method(audio_manager, "setMode", "(I)V", &[JValue::Int(mode)])
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "setMode",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -337,6 +337,9 @@ async fn run_watchdog(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// SWE4-UV-035: `PttCapabilityLevel::as_str()` mapping is
|
||||
/// unambiguous and stable (used by diagnostic sanitization and
|
||||
/// the capability badge).
|
||||
#[test]
|
||||
fn level_as_str_is_stable() {
|
||||
assert_eq!(PttCapabilityLevel::L0Focused.as_str(), "L0Focused");
|
||||
@@ -350,6 +353,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// SWE4-UV-035 / SWE4-UV-046: level → is_global classification;
|
||||
/// `L0Focused` (the pinned Android level per SDD-110) must
|
||||
/// report not-global so no global hotkey binding is attempted.
|
||||
#[test]
|
||||
fn level_is_global_classification() {
|
||||
assert!(!PttCapabilityLevel::L0Focused.is_global());
|
||||
@@ -359,6 +365,8 @@ mod tests {
|
||||
assert!(PttCapabilityLevel::L4DeviceAware.is_global());
|
||||
}
|
||||
|
||||
/// SWE4-UV-038 / DEC-027: `PttBackendDescriptor::focused()` only
|
||||
/// carries sanitization-safe fields (no key codes / key syms).
|
||||
#[test]
|
||||
fn focused_descriptor_carries_only_safe_fields() {
|
||||
let d = PttBackendDescriptor::focused();
|
||||
@@ -371,6 +379,7 @@ mod tests {
|
||||
let _: Option<&str> = d.bound_input_class;
|
||||
}
|
||||
|
||||
/// SWE4-UV-037: `AudioTransmitGate::set/load` round-trips.
|
||||
#[test]
|
||||
fn gate_set_and_load_roundtrip() {
|
||||
let g = AudioTransmitGate::new(false);
|
||||
@@ -381,6 +390,8 @@ mod tests {
|
||||
assert!(!g.load());
|
||||
}
|
||||
|
||||
/// SWE4-UV-037: `AudioTransmitGate::subscribe()` watch channel
|
||||
/// delivers every transition observed by the gate.
|
||||
#[tokio::test(flavor = "current_thread", start_paused = true)]
|
||||
async fn gate_subscribe_observes_transitions() {
|
||||
let g = AudioTransmitGate::new(false);
|
||||
@@ -393,6 +404,8 @@ mod tests {
|
||||
assert!(!*rx.borrow_and_update());
|
||||
}
|
||||
|
||||
/// SWE4-UV-039: `MissedKeyUpWatchdog` clamps `AudioTransmitGate`
|
||||
/// to false after the configured timeout (missed key-up safety).
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn watchdog_clears_transmit_after_timeout() {
|
||||
let g = AudioTransmitGate::new(false);
|
||||
|
||||
Reference in New Issue
Block a user