//! Android voice audio backend (SDD-111..SDD-115). //! //! Implements the `MobileVoiceAudioBackend` trait against //! `oboe-rs 0.6.x`, which wraps Google's Oboe C++ library on top of //! AAudio (Android 8.1+) and OpenSL ES (legacy). The backend owns //! the lifetime of a paired voice input / output stream pair and //! drives the SDD-113 hardware-effect attach via JNI. //! //! ## Manifest contract (SDD-114) //! //! This module assumes the Android manifest already declares //! (landed by Wave 2B-1): //! //! - `INTERNET`, `RECORD_AUDIO`, `FOREGROUND_SERVICE`, //! `FOREGROUND_SERVICE_MICROPHONE`, `POST_NOTIFICATIONS`, //! `MODIFY_AUDIO_SETTINGS`, `BLUETOOTH_CONNECT` //! - `` //! //! Regressions against the manifest are SDD-114 violations and are //! caught by the CI assertion described in SDD-114 item 4. This //! file does NOT mutate the manifest. //! //! ## Callback safety //! //! Oboe audio callbacks run on real-time threads. Under `panic=abort` //! a panic in an audio callback aborts the process. Every callback //! path in this module: //! //! 1. Catches unwinds at the FFI boundary (`std::panic::catch_unwind`). //! 2. Never blocks (no mutex/RwLock acquisition; only `try_send` on //! bounded channels). //! 3. Marshals events (error/disconnect, focus change, route change) //! to a regular tokio task via an `mpsc::UnboundedSender` so all //! engine-state mutation happens off the audio thread (SDD-115). #![cfg(target_os = "android")] use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Mutex}; use audiopus::coder::Encoder as OpusEncoder; use tracing::{debug, info, warn}; use crate::audio_event_queue::{AudioCommand, AudioEventQueue}; use crate::mobile_voice_backend::{ clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after, next_sharing_mode_after, publish_android_audio_diagnostics, AchievedInputPreset, AchievedPerformanceMode, AchievedSharingMode, AndroidAudioDiagnostics, AndroidVoiceStreamConfig, AudioSessionId, BackendError, BackendEvent, BackendEventRx, BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend, SharingModeChoice, VoiceAudioParams, }; use tsclientlib::audio::AudioHandler; use crate::{engine::SessionAudioId, AudioError}; use tokio::sync::mpsc; use oboe::{ AudioInputCallback, AudioInputStreamSafe, AudioOutputCallback, AudioOutputStreamSafe, AudioStream, AudioStreamAsync, AudioStreamBase, AudioStreamBuilder, AudioStreamSafe, DataCallbackResult, Input as OboeInput, InputPreset, Mono, Output as OboeOutput, PerformanceMode, SessionId, SharingMode, Stereo, Usage, }; use crate::processor::AudioProcessor; // `BackendEvent` / `BackendEventRx` / `BackendEventTx` moved to // `mobile_voice_backend` so the trait can expose `take_event_rx` // (SDD-111 item 1) cross-platform. // --- Render-reference buffer for AEC (SDD-111 / SDD-120) --------- // // The output (render) callback writes the audio that will be played // into this ring buffer. The capture callback reads the latest render // frame and feeds it to WebRTC APM's `process_render` so AEC can // subtract the speaker output from the microphone input. // // 4 slots × 10 ms × 48 kHz mono f32. One slot is always being written // by the render callback; the capture callback reads the slot that was // most recently completed. const RENDER_REF_SLOTS: usize = 4; const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES; const ANDROID_RENDER_PULL_SAMPLES: usize = crate::frame::FRAME_20MS_SAMPLES * 2; const ANDROID_RENDER_RING_CAPACITY: usize = ANDROID_RENDER_PULL_SAMPLES * 5; type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer; // --- Capture state for Oboe input callback (SDD-111 / SDD-120) ---- // // Enhanced with WebRTC APM (AEC/NS/AGC) and VAD (voice activity // detection). Oboe delivers 48 kHz mono i16 PCM in variable-size // chunks. We accumulate into 10 ms frames, then: // // 1. i16 → f32 conversion // 2. Read render reference (for AEC) // 3. WebRtcApmProcessor::process_render + process_capture // 4. VAD → VoiceActivityStateMachine → TransmitModeSelector // 5. f32 → i16 conversion + mic gain // 6. Accumulate to 20 ms → Opus encode → send struct AndroidCaptureState { encoder: OpusEncoder, pcm_accum: Vec, opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME], voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender, transmit_active: Arc, mic_gain: f32, voice_activity_selector: Option>, vad_detector: crate::vad::WebRtcFallbackVad, silero_vad_worker: Option, current_vad_backend: crate::VadBackend, silero_model_epoch: u64, capture_frame_seq: u64, vad_state: crate::voice_activity::VoiceActivityStateMachine, webrtc_apm_processor: crate::processor::WebRtcApmProcessor, audio_processing_config: Arc>, audio_processing_stats: Arc, render_reference: Arc, input_sample_rate_hz: u32, resample_pos: f64, resample_last: i16, resample_scratch: Vec, pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES], pending_10ms_len: usize, fallback_warned_backend: Option, } impl AndroidCaptureState { fn new( voice_out_tx: mpsc::Sender, transmit_active: Arc, frames_sent: Arc, mic_gain: f32, voice_activity_selector: Option>, audio_processing_config: Arc>, audio_processing_stats: Arc, render_reference: Arc, input_sample_rate_hz: u32, ) -> Result { let encoder = crate::opus_voice::new_voip_encoder("android")?; // Seed the processor from the current shared config snapshot. // `open()` may later resolve Platform-owned stages to WebRTC // fallback (or keep them hardware-owned) once hardware-effect // binding completes; that resolved config is pushed back into // the live processor before the streams are started. let webrtc_apm_config = audio_processing_config .lock() .map(|cfg| webrtc_apm_config_from_audio_config(&cfg)) .unwrap_or_default(); Ok(Self { encoder, pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2), opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME], voice_out_tx: crate::opus_voice::start_out_packet_worker( voice_out_tx, frames_sent.clone(), "android", )?, transmit_active, mic_gain, voice_activity_selector, vad_detector: crate::vad::WebRtcFallbackVad::default(), silero_vad_worker: None, current_vad_backend: crate::VadBackend::WebrtcVad, silero_model_epoch: crate::vad::silero_model_epoch(), capture_frame_seq: 0, vad_state: crate::voice_activity::VoiceActivityStateMachine::default(), webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config( webrtc_apm_config, )?, audio_processing_config, audio_processing_stats, render_reference, input_sample_rate_hz: input_sample_rate_hz.max(1), resample_pos: 0.0, resample_last: 0, resample_scratch: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2), pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES], pending_10ms_len: 0, fallback_warned_backend: None, }) } /// Consume i16 mono frames from Oboe. Accumulate to 10 ms chunks, /// process each through WebRTC APM + VAD, then encode 20 ms frames. fn ingest_i16(&mut self, samples: &[i16]) { self.audio_processing_stats .record_callback_frames(samples.len() as u64); if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ { self.resample_capture_to_48k(samples); let resampled = std::mem::take(&mut self.resample_scratch); self.ingest_48k_i16(&resampled); self.resample_scratch = resampled; return; } self.ingest_48k_i16(samples); } fn ingest_48k_i16(&mut self, samples: &[i16]) { let mut offset = 0; while offset < samples.len() { let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len; let take = remaining.min(samples.len() - offset); self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take] .copy_from_slice(&samples[offset..offset + take]); self.pending_10ms_len += take; offset += take; if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES { let frame = self.pending_10ms; self.process_10ms_capture_frame(&frame); self.encode_complete_20ms_frames(); self.pending_10ms_len = 0; } } if !self.transmit_active.load(Ordering::Relaxed) { self.pcm_accum.clear(); return; } self.encode_complete_20ms_frames(); } fn encode_complete_20ms_frames(&mut self) { while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES { let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES]; frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]); self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES); match self.encoder.encode(&frame, &mut self.opus_out[..]) { Ok(len) => { crate::opus_voice::send_voip_frame( &self.voice_out_tx, &self.opus_out, len, || { warn!( target: "chanora_audio", "android Oboe: voice_out queue full; dropping frame" ); }, || { debug!( target: "chanora_audio", "android Oboe: voice_out closed; capture pipeline stopping" ); }, ); } Err(e) => { warn!( target: "chanora_audio", error = %e, "android Oboe opus encode failed" ); } } } } fn resample_capture_to_48k(&mut self, samples: &[i16]) -> usize { let result = crate::capture_resampler::resample_capture_to_48k( samples, self.input_sample_rate_hz, &mut self.resample_pos, &mut self.resample_last, &mut self.resample_scratch, ); if result.dropped { self.audio_processing_stats.increment_callback_xrun(); } result.output_len } fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) { self.input_sample_rate_hz = sample_rate_hz.max(1); } fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) { if self.fallback_warned_backend != Some(failed_backend) { self.fallback_warned_backend = Some(failed_backend); // Warm-up period: ONNX workers need ~100ms to process first frame. // Don't flag as a problem if the capture has just started. if self.capture_frame_seq < 128 { info!( target: "chanora_audio", backend = failed_backend.as_str(), seq = self.capture_frame_seq, "android: VAD backend warming up; using WebRTC fallback" ); } else { warn!( target: "chanora_audio", backend = failed_backend.as_str(), "android: VAD backend unavailable; using WebRTC fallback for runtime detection" ); } } } fn process_10ms_capture_frame(&mut self, samples: &[i16; crate::frame::FRAME_10MS_SAMPLES]) { let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES]; for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) { *dst = crate::frame::i16_to_f32(src); } let input_dbfs = crate::frame::dbfs(&frame); let render_ref = self.render_reference.read_latest(); self.webrtc_apm_processor.process_render(&render_ref); self.webrtc_apm_processor.process_capture(&mut frame); let voice_activity_mode = self .voice_activity_selector .as_ref() .map(|selector| selector.mode() == crate::TransmitMode::VoiceActivity) .unwrap_or(false); if !voice_activity_mode { self.silero_vad_worker = None; self.current_vad_backend = crate::VadBackend::Disabled; self.fallback_warned_backend = None; self.audio_processing_stats.set_vad_fallback_active(false); } let (vad_hangover, vad_backend) = self .audio_processing_config .try_lock() .map(|cfg| (cfg.vad_hangover_ms, cfg.vad_backend)) .unwrap_or(( crate::voice_activity::VAD_HANGOVER_MS, crate::VadBackend::WebrtcVad, )); if voice_activity_mode { self.vad_state.configure( crate::voice_activity::VAD_OPEN_AFTER_MS, vad_hangover, crate::voice_activity::VAD_MIN_TX_MS, ); } // VAD backend switching only while VoiceActivity mode is active. let silero_epoch = crate::vad::silero_model_epoch(); let silero_changed = voice_activity_mode && vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch; if voice_activity_mode && (vad_backend != self.current_vad_backend || silero_changed) { self.current_vad_backend = vad_backend; self.silero_model_epoch = silero_epoch; self.fallback_warned_backend = None; match vad_backend { crate::VadBackend::SileroOnnx => { self.silero_vad_worker = None; self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx); self.audio_processing_stats.set_vad_fallback_active(true); } _ => { self.silero_vad_worker = None; } } self.vad_state.reset(); } let (vad_probability, active) = if voice_activity_mode { self.capture_frame_seq = self.capture_frame_seq.wrapping_add(1); let capture_seq = self.capture_frame_seq; let mut used_fallback_vad = false; let vad = if vad_backend == crate::VadBackend::Disabled { crate::vad::VadOutput { probability: 1.0, speech: true, } } else if vad_backend == crate::VadBackend::SileroOnnx { if let Some(worker) = self.silero_vad_worker.as_ref() { let enqueued = worker.try_send(capture_seq, &frame); if !worker.is_stale(capture_seq) { let p = worker.latest_probability(); crate::vad::VadOutput { probability: p, speech: p >= 0.5, } } else if enqueued { crate::vad::VadOutput { probability: 0.0, speech: false, } } else { used_fallback_vad = true; self.mark_vad_fallback_active(vad_backend); crate::vad::VoiceActivityDetector::process_10ms( &mut self.vad_detector, &frame, ) } } else { used_fallback_vad = true; self.mark_vad_fallback_active(vad_backend); crate::vad::VoiceActivityDetector::process_10ms( &mut self.vad_detector, &frame, ) } } else { crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame) }; self.audio_processing_stats .set_vad_fallback_active(used_fallback_vad); (vad.probability, self.vad_state.update(vad.speech)) } else { (0.0, false) }; if let Some(sel) = &self.voice_activity_selector { sel.set_voice_activity_open(voice_activity_mode && active); } self.audio_processing_stats.update_capture( input_dbfs, crate::frame::dbfs(&frame), vad_probability, voice_activity_mode && active, self.transmit_active.load(Ordering::Relaxed), ); self.audio_processing_stats .record_capture_frame(frame.iter().all(|sample| sample.abs() < 1.0e-6)); if !self.transmit_active.load(Ordering::Relaxed) { return; } if crate::capture_accumulator::append_processed_i16_bounded( &mut self.pcm_accum, &frame, self.mic_gain, ) { self.audio_processing_stats.increment_callback_xrun(); } } } struct InputCallback { state: Arc>, audio_processing_stats: Arc, event_tx: BackendEventTx, } impl AudioInputCallback for InputCallback { type FrameType = (i16, Mono); fn on_audio_ready( &mut self, _stream: &mut dyn AudioInputStreamSafe, frames: &[i16], ) -> DataCallbackResult { let _ = catch_unwind(AssertUnwindSafe(|| match self.state.try_lock() { Ok(mut s) => s.ingest_i16(frames), Err(std::sync::TryLockError::WouldBlock) => { self.audio_processing_stats.increment_callback_xrun(); } Err(std::sync::TryLockError::Poisoned(e)) => { warn!(target: "chanora_audio", "android: capture state poisoned: {e}"); } })); DataCallbackResult::Continue } fn on_error_after_close(&mut self, _stream: &mut dyn AudioInputStreamSafe, error: oboe::Error) { if matches!(error, oboe::Error::Disconnected) { let _ = self.event_tx.send(BackendEvent::Disconnected); } else { warn!(target: "chanora_audio", error = ?error, "android: input stream error_after_close"); } } } // --- Output callback wiring (SDD-111 / SDD-120) ---- // // Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32 // from `AudioHandler::fill_buffer`, applies output gain + mute, and // writes stereo f32 directly to the Oboe output buffer. struct OutputCallback { pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer, output_gain: Arc, output_muted: Arc, event_tx: BackendEventTx, render_reference: Arc, audio_processing_stats: Arc, pending_render_ref: [f32; crate::frame::FRAME_10MS_SAMPLES], pending_render_ref_len: usize, } impl AudioOutputCallback for OutputCallback { type FrameType = (f32, Stereo); fn on_audio_ready( &mut self, _stream: &mut dyn AudioOutputStreamSafe, frames: &mut [(f32, f32)], ) -> DataCallbackResult { let _ = catch_unwind(AssertUnwindSafe(|| { self.pcm_consumer.drain_stereo_into_zero_filling(frames); let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed)); let muted = self.output_muted.load(Ordering::Relaxed); if muted { for frame in frames.iter_mut() { *frame = (0.0, 0.0); } } else if gain != 1.0 { for (left, right) in frames.iter_mut() { *left *= gain; *right *= gain; } } let mut sum_squares = 0.0_f32; for (left, right) in frames.iter() { sum_squares += left * left + right * right; } let sample_count = frames.len() * 2; let dbfs = if sample_count == 0 { -120.0 } else { let rms = (sum_squares / sample_count as f32).sqrt(); if rms <= 0.000_001 { -120.0 } else { 20.0 * rms.log10() } }; self.audio_processing_stats .update_render(dbfs, frames.len() as u32); for (left, right) in frames.iter() { self.pending_render_ref[self.pending_render_ref_len] = (left + right) * 0.5; self.pending_render_ref_len += 1; if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES { self.render_reference.write(&self.pending_render_ref); self.pending_render_ref_len = 0; } } })); DataCallbackResult::Continue } fn on_error_after_close( &mut self, _stream: &mut dyn AudioOutputStreamSafe, error: oboe::Error, ) { if matches!(error, oboe::Error::Disconnected) { let _ = self.event_tx.send(BackendEvent::Disconnected); } else { warn!(target: "chanora_audio", error = ?error, "android: output stream error_after_close"); } } } // --- The backend itself ------------------------------------------ /// Android voice-audio backend (SDD-111). Owns one input + one /// output Oboe stream and the JNI handles for SDD-113 hardware /// effects. pub struct AndroidVoiceUnit { input: Option>, output: Option>, render_producer_shutdown: Arc, // Recorded achieved values (SDD-112). input_perf: AchievedPerformanceMode, input_share: AchievedSharingMode, output_perf: AchievedPerformanceMode, output_share: AchievedSharingMode, input_sample_rate: i32, input_frames_per_burst: i32, session_id: Option, // SDD-113 hardware effect handles. Each is a JNI `GlobalRef` // we keep alive for the lifetime of the input stream. hw_effects: HardwareEffectHandles, event_tx: BackendEventTx, event_rx: Option, } #[derive(Default)] struct HardwareEffectHandles { aec: Option, ns: Option, agc: Option, } type AndroidGlobalObject = jni::refs::Global>; impl AndroidVoiceUnit { /// Open the input + output streams (SDD-111 + SDD-112) and, /// once a session id is available, attach SDD-113 hardware /// effects. The engine is expected to have already issued /// `setMode(MODE_IN_COMMUNICATION)` (SDD-108) per the SDD-115 /// sequencing rules. /// /// `params` bundles the engine-owned state shared with the Oboe /// audio callbacks (SDD-120 amendment: Android Oboe-only audio /// path — capture pipeline, playback pull, and PTT gate). pub(crate) fn open( cfg: &AndroidVoiceStreamConfig, params: VoiceAudioParams, ) -> Result { let (event_tx, event_rx) = mpsc::unbounded_channel(); // Shared render-reference buffer (for AEC). The output // callback writes; the capture callback reads. let render_ref_buf = RenderReferenceBuffer::new(); let render_ref_for_capture = render_ref_buf.clone(); // Clone the APM config Arc before params is partially moved // into the capture state constructor below. let apm_config_clone = params.audio_processing_config.clone(); let audio_processing_stats = params.audio_processing_stats.clone(); let capture_state = Arc::new(Mutex::new( AndroidCaptureState::new( params.voice_out_tx, params.transmit_active, params.frames_sent, params.mic_gain, params.voice_activity_selector, params.audio_processing_config, audio_processing_stats.clone(), render_ref_for_capture, cfg.sample_rate, ) .map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?, )); // --- Open input stream (SDD-112) --------------------------- let input_builder = AudioStreamBuilder::default() .set_direction::() .set_sample_rate(cfg.sample_rate as i32) .set_channel_count::() .set_format::() .set_performance_mode(if cfg.request_low_latency { PerformanceMode::LowLatency } else { PerformanceMode::None }) .set_sharing_mode(if cfg.request_exclusive { SharingMode::Exclusive } else { SharingMode::Shared }) // SDD-113 item 1 amends SDD-112: input stream MUST be // opened with session id allocation so platform effects // can attach. .set_session_id(SessionId::Allocate) .set_input_preset(InputPreset::VoiceCommunication) .set_usage(Usage::VoiceCommunication); let input_cb = InputCallback { state: capture_state.clone(), audio_processing_stats: audio_processing_stats.clone(), event_tx: event_tx.clone(), }; let input_builder = input_builder.set_callback(input_cb); let mut input_stream = match input_builder.open_stream() { Ok(s) => Some(s), Err(e) => { // Input-preset fallback ladder (SDD-112 item 6) X // Sharing-mode ladder (SDD-112 item 7), explored // independently via the pure helpers so all // (preset × sharing) rungs are reachable. warn!( target: "chanora_audio", error = ?e, "android: primary input stream open failed; entering fallback ladder" ); match Self::open_input_fallback( cfg, &event_tx, capture_state.clone(), audio_processing_stats.clone(), ) { Ok(s) => Some(s), Err(fallback_err) => { warn!( target: "chanora_audio", error = %fallback_err, "android: input unavailable after all fallbacks; continuing listen-only with output stream" ); None } } } }; let input_perf = input_stream .as_ref() .map(|s| perf_from_oboe(s.get_performance_mode())) .unwrap_or(AchievedPerformanceMode::None); let input_share = input_stream .as_ref() .map(|s| share_from_oboe(s.get_sharing_mode())) .unwrap_or(AchievedSharingMode::Shared); let input_sample_rate = input_stream .as_ref() .map(|s| s.get_sample_rate()) .unwrap_or(0); let input_frames_per_burst = input_stream .as_mut() .map(|s| s.get_frames_per_burst()) .unwrap_or(0); // The oboe-rs fork (edisonjwa/oboe-rs 0.6.2) fixes the // get_session_id() panic with unwrap_or_default(), but the // SessionId enum still only models builder parameters (None = // -1, Allocate = 0). The actual system audio session ID (>0) // written by AAudio to mSessionId after stream open cannot be // expressed in the current enum; get_raw_session_id() is a // follow-up addition to the fork. // // For now: hardware effects require the system session ID. // WebRTC APM software processing handles AEC/NS/AGC/HPF. let session_id: Option = input_stream.as_ref().and_then(|s| s.get_raw_session_id()); // --- Open output stream (SDD-112) -------------------------- let output_builder = AudioStreamBuilder::default() .set_direction::() .set_sample_rate(cfg.sample_rate as i32) .set_channel_count::() .set_format::() .set_performance_mode(if cfg.request_low_latency { PerformanceMode::LowLatency } else { PerformanceMode::None }) .set_sharing_mode(if cfg.request_exclusive { SharingMode::Exclusive } else { SharingMode::Shared }) // Usage::Game avoids forcing the Legacy (OpenSL ES) data path // that Usage::VoiceCommunication triggers on most devices. // Android audio routing is already handled by // AudioManager.MODE_IN_COMMUNICATION on the Flutter side. .set_usage(Usage::Game) .set_content_type(oboe::ContentType::Sonification); let render_ref_for_output = render_ref_buf.clone(); let event_queue = params.event_producer.queue(); let render_ring = crate::android_render_ring::AndroidRenderRing::new(ANDROID_RENDER_RING_CAPACITY); let output_cb = OutputCallback { pcm_consumer: render_ring.consumer(), output_gain: params.output_gain.clone(), output_muted: params.output_muted.clone(), event_tx: event_tx.clone(), render_reference: render_ref_for_output, audio_processing_stats: audio_processing_stats.clone(), pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES], pending_render_ref_len: 0, }; let output_builder = output_builder.set_callback(output_cb); let mut output_stream = match output_builder.open_stream() { Ok(s) => s, Err(e) => { warn!( target: "chanora_audio", error = ?e, "android: primary output stream open failed; retrying with Shared sharing mode" ); Self::open_output_fallback( cfg, &event_tx, render_ring.consumer(), params.output_gain.clone(), params.output_muted.clone(), audio_processing_stats.clone(), render_ref_buf, )? } }; let render_producer_shutdown = Self::spawn_render_producer( params.handler, AudioEventQueue::consumer(&event_queue), render_ring.producer(), ); let output_frames_per_burst = output_stream.get_frames_per_burst(); if output_frames_per_burst > 0 { let desired = output_frames_per_burst * 2; match output_stream.set_buffer_size_in_frames(desired) { Ok(actual) => { debug!( target: "chanora_audio", desired, actual, "android: output buffer size tuned" ); } Err(e) => { warn!( target: "chanora_audio", error = ?e, "android: output buffer size tuning failed; using device default" ); } } } let output_perf = perf_from_oboe(output_stream.get_performance_mode()); let output_share = share_from_oboe(output_stream.get_sharing_mode()); let output_sample_rate = output_stream.get_sample_rate(); // SDD-112 / SRS-210: structured "stream opened" event with // achieved values. No PII; only platform-reported scalars. info!( target: "chanora_audio", event = "audio.android.stream_opened", input_perf = %input_perf, input_share = %input_share, input_sample_rate, input_frames_per_burst, output_perf = %output_perf, output_share = %output_share, output_sample_rate, output_frames_per_burst, session_id = ?session_id, "android: voice streams opened" ); // --- SDD-113 hardware effects ----------------------------- let hw_effects = if let Some(sid) = session_id { attach_hardware_effects(sid, &cfg.effects) } else { warn!( target: "chanora_audio", "android: no session id from input stream; hardware effects not bound — engine software AEC/NS/AGC will engage" ); HardwareEffectHandles::default() }; // --- SDD-113 config resolution: hardware-available? ------- // Android gives the user a choice between hardware (JNI) and // software (WebRTC APM) effects. The AudioProcessingConfig's // EffectOwner fields encode that choice: // Platform → prefer hardware; software fallback if missing // WebrtcApm → always software WebRTC APM // Off → disable effect entirely // // Here we resolve Platform → WebrtcApm for each effect whose // hardware binding failed (or wasn't attempted). This is read // by the capture callback's WebRtcApmProcessor. { use crate::audio_processing::EffectOwner; let mut apm_cfg = apm_config_clone.lock().unwrap(); let hw_aec = hw_effects.aec.is_some(); let hw_ns = hw_effects.ns.is_some(); let hw_agc = hw_effects.agc.is_some(); if apm_cfg.aec == EffectOwner::Platform && !hw_aec { apm_cfg.aec = EffectOwner::WebrtcApm; } if apm_cfg.ns == EffectOwner::Platform && !hw_ns { apm_cfg.ns = EffectOwner::WebrtcApm; } if apm_cfg.agc == EffectOwner::Platform && !hw_agc { apm_cfg.agc = EffectOwner::WebrtcApm; } if apm_cfg.processing_backend == crate::audio_processing::AudioBackend::PlatformVoiceProcessing && (!hw_aec || !hw_ns || !hw_agc) { apm_cfg.processing_backend = crate::audio_processing::AudioBackend::WebrtcApm; } info!( target: "chanora_audio", aec = ?apm_cfg.aec, ns = ?apm_cfg.ns, agc = ?apm_cfg.agc, hpf = apm_cfg.hpf_enabled, hw_aec, hw_ns, hw_agc, session_id, "android: audio processing config resolved (hardware effects: aec={hw_aec} ns={hw_ns} agc={hw_agc})" ); } if let Ok(mut capture) = capture_state.lock() { capture.set_input_sample_rate_hz(input_sample_rate.max(1) as u32); let resolved_cfg = apm_config_clone .lock() .map(|cfg| webrtc_apm_config_from_audio_config(&cfg)) .unwrap_or_default(); capture.webrtc_apm_processor.apply_config(resolved_cfg); } // --- SDD-112 item 10 / SDD-113 item 7 / SDD-116 item 3 --- // Publish the diagnostics snapshot. Per-effect engagement is // derived from (a) the JNI handle (`Hardware`) or (b) the // requested-effects mask (`Software` fallback) or (c) neither // (`None`). The achieved input preset readback is `Unknown` // until the oboe-rs wrapper exposes a getter (SWE4-UV-052 // follow-through). No PII per SDD-090. let aec = effect_engagement(cfg.effects.aec, hw_effects.aec.is_some()); let ns = effect_engagement(cfg.effects.noise_suppression, hw_effects.ns.is_some()); let agc = effect_engagement(cfg.effects.agc, hw_effects.agc.is_some()); let diagnostics = AndroidAudioDiagnostics { // SDD-112 items 4..7: requested-side pinned values. requested_performance_mode: if cfg.request_low_latency { "LowLatency" } else { "None" }, requested_usage: "VoiceCommunication", requested_content_type: "Speech", requested_input_preset: "VoiceCommunication", requested_sharing_mode: if cfg.request_exclusive { "Exclusive" } else { "Shared" }, requested_sample_rate_hz: cfg.sample_rate, // SDD-112 item 4 / 7 achieved side. achieved_performance_mode: input_perf, achieved_sharing_mode: input_share, achieved_input_preset: AchievedInputPreset::Unknown, achieved_sample_rate_hz: input_sample_rate.max(0) as u32, achieved_frames_per_burst: input_frames_per_burst.max(0) as u32, // SDD-113 item 7 / SRS-212 per-effect engagement. aec, ns, agc, // SDD-116 / SRS-210 latency-tier classification. latency_tier: latency_tier_for(input_perf), }; publish_android_audio_diagnostics(diagnostics); params .audio_processing_stats .set_actual_sample_rate_hz(input_sample_rate.max(0) as u32); Ok(Self { input: input_stream, output: Some(output_stream), render_producer_shutdown, input_perf, input_share, output_perf, output_share, input_sample_rate, input_frames_per_burst, session_id, hw_effects, event_tx, event_rx: Some(event_rx), }) } fn open_input_fallback( cfg: &AndroidVoiceStreamConfig, event_tx: &BackendEventTx, capture_state: Arc>, audio_processing_stats: Arc, ) -> Result, BackendError> { // SDD-112 items 6 & 7: explore (preset × sharing) independently // via the pure helpers in `mobile_voice_backend`. Primary // attempt (VoiceCommunication × Exclusive) was tried by the // caller; here we walk the remaining rungs of both ladders. let presets = [ InputPresetChoice::VoiceCommunication, InputPresetChoice::VoicePerformance, InputPresetChoice::Generic, ]; let sharings = [SharingModeChoice::Exclusive, SharingModeChoice::Shared]; // Track attempted presets / sharings so the helpers' order // statements are honoured in tests and operations. let mut attempted_presets: Vec = Vec::new(); while let Some(preset_choice) = next_input_preset_after(&attempted_presets) { attempted_presets.push(preset_choice); let mut attempted_sharings: Vec = Vec::new(); while let Some(sharing_choice) = next_sharing_mode_after(&attempted_sharings) { attempted_sharings.push(sharing_choice); // Skip the rung the primary attempt already used. if matches!(preset_choice, InputPresetChoice::VoiceCommunication) && matches!(sharing_choice, SharingModeChoice::Exclusive) { continue; } let preset = match preset_choice { InputPresetChoice::VoiceCommunication => InputPreset::VoiceCommunication, InputPresetChoice::VoicePerformance => InputPreset::VoicePerformance, InputPresetChoice::Generic => InputPreset::Generic, }; let sharing = match sharing_choice { SharingModeChoice::Exclusive => SharingMode::Exclusive, SharingModeChoice::Shared => SharingMode::Shared, }; let cb = InputCallback { state: capture_state.clone(), audio_processing_stats: audio_processing_stats.clone(), event_tx: event_tx.clone(), }; let builder = AudioStreamBuilder::default() .set_direction::() .set_sample_rate(cfg.sample_rate as i32) .set_channel_count::() .set_format::() .set_performance_mode(PerformanceMode::LowLatency) .set_sharing_mode(sharing) .set_session_id(SessionId::Allocate) .set_input_preset(preset) .set_usage(Usage::VoiceCommunication) .set_callback(cb); match builder.open_stream() { Ok(s) => return Ok(s), Err(e) => warn!( target: "chanora_audio", error = ?e, ?preset_choice, ?sharing_choice, "android: input fallback rung failed" ), } } } let _ = (presets, sharings); // documented coverage source Err(BackendError::OpenFailed( "all input preset / sharing fallbacks exhausted".to_string(), )) } fn open_output_fallback( cfg: &AndroidVoiceStreamConfig, event_tx: &BackendEventTx, pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer, output_gain: Arc, output_muted: Arc, audio_processing_stats: Arc, render_reference: Arc, ) -> Result, BackendError> { let cb = OutputCallback { pcm_consumer, output_gain, output_muted, event_tx: event_tx.clone(), render_reference, audio_processing_stats, pending_render_ref: [0.0_f32; crate::frame::FRAME_10MS_SAMPLES], pending_render_ref_len: 0, }; let builder = AudioStreamBuilder::default() .set_direction::() .set_sample_rate(cfg.sample_rate as i32) .set_channel_count::() .set_format::() .set_performance_mode(PerformanceMode::LowLatency) .set_sharing_mode(SharingMode::Shared) // Same Usage::Game rationale as primary output builder above. .set_usage(Usage::Game) .set_content_type(oboe::ContentType::Sonification) .set_callback(cb); builder .open_stream() .map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}"))) } fn spawn_render_producer( mut handler: AudioHandler, event_consumer: crate::audio_event_queue::AudioEventConsumer, pcm_producer: crate::android_render_ring::AndroidRenderRingProducer, ) -> Arc { let shutdown = Arc::new(AtomicBool::new(false)); let shutdown_for_task = shutdown.clone(); tokio::spawn(async move { let mut pull_scratch = vec![0.0_f32; ANDROID_RENDER_PULL_SAMPLES]; let mut interval = tokio::time::interval(std::time::Duration::from_millis(20)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; if shutdown_for_task.load(Ordering::Relaxed) { break; } for cmd in event_consumer.drain_controls() { match cmd { AudioCommand::SetVolume(id, vol) => { if let Some(q) = handler.get_mut_queues().get_mut(&id) { q.volume = vol; } } AudioCommand::RemoveClient(id) => { handler.get_mut_queues().remove(&id); } } } for pkt in event_consumer.drain_packets(50) { if let Err(e) = handler.handle_packet(pkt.client_id, pkt.data) { debug!(target: "chanora_audio", error = %e, "decode failed"); } } pull_scratch.fill(0.0); let _ = handler.fill_buffer(&mut pull_scratch); pcm_producer.push_frame_lossy(&pull_scratch); } }); shutdown } /// Clone of the event sender, for JNI focus / SCO listeners /// registered on the engine's behalf. pub fn event_sender(&self) -> BackendEventTx { self.event_tx.clone() } } fn webrtc_apm_config_from_audio_config( config: &crate::AudioProcessingConfig, ) -> crate::processor::webrtc_apm::WebRtcApmConfig { crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(config) } impl MobileVoiceAudioBackend for AndroidVoiceUnit { fn start(&mut self) -> Result<(), BackendError> { if let Some(s) = self.input.as_mut() { if let Err(e) = s.start() { warn!( target: "chanora_audio", error = ?e, "android: input start failed; continuing listen-only with output stream" ); self.input = None; } } if let Some(s) = self.output.as_mut() { s.start() .map_err(|e| BackendError::LifecycleFailed(format!("output start: {e:?}")))?; } Ok(()) } fn stop(&mut self) -> Result<(), BackendError> { if let Some(s) = self.input.as_mut() { // best-effort stop — log on failure but continue // tearing down the rest of the pair. if let Err(e) = s.stop() { warn!(target: "chanora_audio", error = ?e, "android: input stop failed"); } } if let Some(s) = self.output.as_mut() { if let Err(e) = s.stop() { warn!(target: "chanora_audio", error = ?e, "android: output stop failed"); } } Ok(()) } fn close(&mut self) -> Result<(), BackendError> { // SDD-115 reverse order: release hardware effects FIRST, // then close streams. self.render_producer_shutdown.store(true, Ordering::Relaxed); release_hardware_effects(&mut self.hw_effects); self.stop().ok(); // Dropping the Option drops the underlying AudioStreamAsync // which Oboe-safe-closes the stream. self.input = None; self.output = None; // SDD-116: clear the diagnostics slot so a stale snapshot // does not survive past the voice session. clear_android_audio_diagnostics(); Ok(()) } fn session_id(&self) -> Option { self.session_id } fn take_event_rx(&mut self) -> Option { self.event_rx.take() } fn achieved_sample_rate(&self) -> u32 { self.input_sample_rate.max(0) as u32 } fn achieved_input_preset(&self) -> AchievedInputPreset { // The oboe-rs wrapper does not expose a preset-readback as of // 0.6.x; record `Unknown` until a readback path lands // (SWE4-UV-052 follow-through). AchievedInputPreset::Unknown } fn achieved_frames_per_burst(&self) -> u32 { self.input_frames_per_burst.max(0) as u32 } fn achieved_input_performance_mode(&self) -> AchievedPerformanceMode { self.input_perf } fn achieved_input_sharing_mode(&self) -> AchievedSharingMode { self.input_share } fn achieved_output_performance_mode(&self) -> AchievedPerformanceMode { self.output_perf } fn achieved_output_sharing_mode(&self) -> AchievedSharingMode { self.output_share } } impl Drop for AndroidVoiceUnit { fn drop(&mut self) { // Belt-and-braces: if `close()` was not called explicitly, // tear hardware effects down here so the JNI globals are // released before the stream's session id evaporates. // Wrap in catch_unwind so a panic during Drop cannot unwind // into the JVM (SDD-115 callback safety). let _ = catch_unwind(AssertUnwindSafe(|| { self.render_producer_shutdown.store(true, Ordering::Relaxed); release_hardware_effects(&mut self.hw_effects); // SDD-116: clear the diagnostics slot on Drop too. clear_android_audio_diagnostics(); })); } } // --- helpers ----------------------------------------------------- /// Derive per-effect engagement (SDD-113 item 7) from the request /// mask and the JNI binding outcome. Hardware: JNI ref retained. /// Software: requested but hardware binding failed → engine /// software AEC/NS/AGC carries it. None: not requested. fn effect_engagement(requested: bool, hardware_bound: bool) -> EffectEngagement { match (requested, hardware_bound) { (true, true) => EffectEngagement { engaged: true, engine: EffectEngine::Hardware, }, (true, false) => EffectEngagement { engaged: true, engine: EffectEngine::Software, }, (false, _) => EffectEngagement { engaged: false, engine: EffectEngine::None, }, } } fn perf_from_oboe(p: PerformanceMode) -> AchievedPerformanceMode { match p { PerformanceMode::LowLatency => AchievedPerformanceMode::LowLatency, PerformanceMode::PowerSaving => AchievedPerformanceMode::PowerSaving, PerformanceMode::PowerSavingOffloaded => AchievedPerformanceMode::PowerSaving, PerformanceMode::None => AchievedPerformanceMode::None, } } fn share_from_oboe(s: SharingMode) -> AchievedSharingMode { match s { SharingMode::Exclusive => AchievedSharingMode::Exclusive, SharingMode::Shared => AchievedSharingMode::Shared, } } // --- SDD-113 hardware-effect binding ----------------------------- // // We attach `AcousticEchoCanceler`, `NoiseSuppressor`, // `AutomaticGainControl` against the input stream's session id via // JNI. Per-effect failure falls back to the engine's software path; // failure is **never** propagated to the user (SDD-113 item 5). fn attach_hardware_effects( session_id: AudioSessionId, effects: &crate::AudioEffects, ) -> HardwareEffectHandles { // SDD-115 callback safety: even on the (assumed) non-realtime // open/close paths, wrap the JNI body in `catch_unwind` so a // panic during teardown cannot unwind into the JVM. let result = catch_unwind(AssertUnwindSafe(|| { attach_hardware_effects_inner(session_id, effects) })); match result { Ok(h) => h, Err(_) => { warn!( target: "chanora_audio", "android: attach_hardware_effects panicked; caught at FFI boundary (software fallback engages)" ); HardwareEffectHandles::default() } } } fn attach_hardware_effects_inner( session_id: AudioSessionId, effects: &crate::AudioEffects, ) -> HardwareEffectHandles { with_android_env("hardware effects", |env| { let mut handles = HardwareEffectHandles::default(); if effects.aec { handles.aec = create_effect( env, "android/media/audiofx/AcousticEchoCanceler", session_id, "AEC", ); } if effects.noise_suppression { handles.ns = create_effect( env, "android/media/audiofx/NoiseSuppressor", session_id, "NS", ); } if effects.agc { handles.agc = create_effect( env, "android/media/audiofx/AutomaticGainControl", session_id, "AGC", ); } handles }) .unwrap_or_default() } fn with_android_env( operation: &str, op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R, ) -> Option { let ctx = ndk_context::android_context(); if ctx.vm().is_null() { warn!( target: "chanora_audio", operation, "android: ndk_context vm null; JNI call skipped" ); return None; } let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) }; match jvm.attach_current_thread(|env| Ok::(op(env))) { Ok(value) => Some(value), Err(e) => { warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed"); None } } } /// SDD-113 item 3: probe the static `isAvailable()` on each effect /// class before calling `create(int)`. Returns `false` on any JNI /// failure so the caller engages the software fallback. fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, label: &str) -> bool { match env.call_static_method( class, jni::jni_str!("isAvailable"), jni::jni_sig!("()Z"), &[], ) { Ok(v) => match v.z() { Ok(b) => b, Err(e) => { let _ = env.exception_clear(); warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() return cast failed"); false } }, Err(e) => { let _ = env.exception_clear(); warn!(target: "chanora_audio", error = %e, effect = label, "android: isAvailable() threw"); false } } } fn create_effect( env: &mut jni::Env<'_>, fqcn: &str, session_id: AudioSessionId, label: &str, ) -> Option { use jni::objects::JValue; // Class.create(int) -> ClassInstance|null let class = match env.find_class(jni::strings::JNIString::new(fqcn)) { Ok(c) => c, Err(e) => { warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages"); return None; } }; // SDD-113 item 3: probe isAvailable() before create(int). if !effect_is_available(env, &class, label) { info!( target: "chanora_audio", effect = label, "android: hardware effect not available on this device — software fallback engages" ); return None; } let create_sig = match jni::signature::RuntimeMethodSignature::from_str(format!("(I)L{fqcn};")) { Ok(sig) => sig, Err(e) => { warn!(target: "chanora_audio", error = %e, effect = label, "android: create() signature parse failed"); return None; } }; let inst = match env.call_static_method( &class, jni::jni_str!("create"), create_sig.method_signature(), &[JValue::Int(session_id)], ) { Ok(v) => match v.l() { Ok(o) => o, Err(e) => { warn!(target: "chanora_audio", error = %e, effect = label, "android: create() return cast failed"); return None; } }, Err(e) => { // Likely an exception in JNI — clear so the next JNI // call doesn't immediately abort. let _ = env.exception_clear(); warn!(target: "chanora_audio", error = %e, effect = label, "android: create() threw — software fallback engages"); return None; } }; if inst.is_null() { warn!(target: "chanora_audio", effect = label, "android: create() returned null (unsupported on device) — software fallback engages"); return None; } // setEnabled(true) -> int (success code) if let Err(e) = env.call_method( &inst, jni::jni_str!("setEnabled"), jni::jni_sig!("(Z)I"), &[JValue::Bool(jni::sys::JNI_TRUE)], ) { let _ = env.exception_clear(); warn!(target: "chanora_audio", error = %e, effect = label, "android: setEnabled(true) failed — software fallback engages"); return None; } match env.new_global_ref(&inst) { Ok(g) => { info!(target: "chanora_audio", effect = label, session_id, "android: hardware effect bound (SDD-113)"); Some(g) } Err(e) => { warn!(target: "chanora_audio", error = %e, effect = label, "android: new_global_ref failed"); None } } } fn release_hardware_effects(handles: &mut HardwareEffectHandles) { let result = catch_unwind(AssertUnwindSafe(|| release_hardware_effects_inner(handles))); if result.is_err() { warn!( target: "chanora_audio", "android: release_hardware_effects panicked; caught at FFI boundary" ); } } fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) { let aec = handles.aec.take(); let ns = handles.ns.take(); let agc = handles.agc.take(); if aec.is_none() && ns.is_none() && agc.is_none() { return; } let _ = with_android_env("release hardware effects", |env| { for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] { if let Some(g) = effect { let _ = env.call_method( g.as_obj(), jni::jni_str!("setEnabled"), jni::jni_sig!("(Z)I"), &[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)], ); env.exception_clear(); let _ = env.call_method( g.as_obj(), jni::jni_str!("release"), jni::jni_sig!("()V"), &[], ); env.exception_clear(); drop(g); info!(target: "chanora_audio", effect = label, "android: hardware effect released"); } } }); } // --- Process-global BackendEvent sender for JNI callbacks -------- // // Kotlin-side listeners (audio focus, Bluetooth SCO, device route // changes) need to publish events into the Rust engine's event // channel. Since the engine's `BackendEventTx` is created at voice // start, we store it here as a process-global so the JNI callbacks // can reach it without holding a direct Rust reference. // // Cleared on voice stop; the Kotlin listeners are idempotent when // no sender is registered (they log and continue). static GLOBAL_BACKEND_EVENT_TX: std::sync::OnceLock< std::sync::Mutex>>, > = std::sync::OnceLock::new(); fn global_event_tx_slot( ) -> &'static std::sync::Mutex>> { GLOBAL_BACKEND_EVENT_TX.get_or_init(|| std::sync::Mutex::new(None)) } pub(crate) fn register_global_event_sender(tx: BackendEventTx) { if let Ok(mut g) = global_event_tx_slot().lock() { *g = Some(tx); } } pub(crate) fn clear_global_event_sender() { if let Ok(mut g) = global_event_tx_slot().lock() { *g = None; } } fn try_send_backend_event(event: BackendEvent) { if let Ok(g) = global_event_tx_slot().lock() { if let Some(tx) = g.as_ref() { if tx.send(event).is_err() { warn!( target: "chanora_audio", "android: global BackendEvent channel closed; event dropped" ); } } } } // --- SDD-115 foreground-service JNI helpers ---------------------- // // The Kotlin class `AndroidVoiceForegroundService` (Wave 2B-2) // exposes `@JvmStatic fun start(Context)` / `fun stop(Context)`. // These Rust helpers reach across JNI to invoke those entry points. // IMPORTANT: never call these from an audio callback thread; route // invocation through a regular tokio task. const ANDROID_VOICE_FG_SERVICE_FQCN: &str = "app/chanora/chanora_flutter/AndroidVoiceForegroundService"; /// SDD-115 forward step 2: start the voice foreground service. /// Returns true on a clean JNI invocation (no exception). Callers /// should treat this as best-effort; `onStartCommand` runs /// asynchronously on the Android side. pub fn chanora_android_start_voice_service() -> bool { call_voice_service_static("start") } /// SDD-115 reverse step 4: stop the voice foreground service. pub fn chanora_android_stop_voice_service() -> bool { call_voice_service_static("stop") } fn call_voice_service_static(method: &str) -> bool { use jni::objects::{JObject, JValue}; let ctx = ndk_context::android_context(); if ctx.context().is_null() { warn!( target: "chanora_audio", method, "android: ndk_context not initialised; voice service call skipped" ); return false; } with_android_env("voice foreground service", |env| { // SAFETY: ndk_context::context() is the application Context // jobject; valid global ref for process lifetime. let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) }; let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) { Some(c) => c, None => return false, }; match env.call_static_method( &class, jni::strings::JNIString::new(method), jni::jni_sig!("(Landroid/content/Context;)V"), &[JValue::Object(&context_obj)], ) { Ok(_) => { info!(target: "chanora_audio", method, "android: voice foreground service call dispatched"); true } Err(e) => { env.exception_clear(); warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed"); false } } }) .unwrap_or(false) } fn load_app_class<'local>( env: &mut jni::Env<'local>, context_obj: &jni::objects::JObject<'local>, slash_name: &str, ) -> Option> { match env.find_class(jni::strings::JNIString::new(slash_name)) { Ok(c) => return Some(c), Err(e) => { let _ = env.exception_clear(); debug!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader"); } } let loader = match env .call_method( context_obj, jni::jni_str!("getClassLoader"), jni::jni_sig!("()Ljava/lang/ClassLoader;"), &[], ) .and_then(|v| v.l()) { Ok(loader) => loader, Err(e) => { let _ = env.exception_clear(); warn!(target: "chanora_audio", error = %e, "android: Context.getClassLoader failed"); return None; } }; let dotted_name = slash_name.replace('/', "."); let class_name = match env.new_string(&dotted_name) { Ok(s) => s, Err(e) => { let _ = env.exception_clear(); warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: class-name string allocation failed"); return None; } }; let class_name_obj = jni::objects::JObject::from(class_name); match env .call_method( &loader, jni::jni_str!("loadClass"), jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"), &[jni::objects::JValue::Object(&class_name_obj)], ) .and_then(|v| v.l()) { Ok(class_obj) => match env.cast_local::(class_obj) { Ok(class) => Some(class), Err(e) => { env.exception_clear(); warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass returned non-Class object"); None } }, Err(e) => { let _ = env.exception_clear(); warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed"); None } } } // --- SDD-109 / SDD-110 JNI callbacks: focus + SCO events ---------- // // Kotlin-side OnAudioFocusChangeListener and BroadcastReceiver for // ACTION_SCO_AUDIO_STATE_UPDATED call these Rust entry points via // JNI. Each function marshals the platform event into a BackendEvent // and posts it through the global event sender registered by the // engine at voice start. // // SDD-115 callback safety: every entry point is wrapped in // catch_unwind so a panic in the Rust engine can never unwind // into the JVM. /// SDD-109: audio focus change published by Kotlin's /// `AndroidAudioFocusController`. `state` is the `focusChange` /// value from `OnAudioFocusChangeListener`. /// /// Symbol naming: JNI function declared in /// `app.chanora.chanora_flutter.AndroidAudioFocusController`. #[no_mangle] pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange< 'local, >( _env: jni::EnvUnowned<'local>, _class: jni::objects::JClass<'local>, state: jni::sys::jint, ) { let _ = catch_unwind(AssertUnwindSafe(|| { // AUDIOFOCUS_LOSS = -1, LOSS_TRANSIENT = -2, LOSS_TRANSIENT_CAN_DUCK = -3, // GAIN = 1 (AudioManager.AUDIOFOCUS_REQUEST_GRANTED is also 1, but we only // call this from the listener callback so the values are well-known). let event = match state { -1 => BackendEvent::FocusLost, -2 => BackendEvent::FocusTransient, -3 => BackendEvent::FocusTransientCanDuck, 1 | 2 | 3 | 4 => BackendEvent::FocusGain, _ => { warn!( target: "chanora_audio", state, "android: unknown audio focus change value; treating as FocusLost" ); BackendEvent::FocusLost } }; try_send_backend_event(event); })); } /// SDD-110: Bluetooth SCO state change published by Kotlin's /// `AndroidBluetoothScoController`. `state` is the `STATE` /// value from `ACTION_SCO_AUDIO_STATE_UPDATED`: /// - `ACTION_SCO_AUDIO_STATE_UPDATED` is always fired with `EXTRA_SCO_AUDIO_STATE` /// - `SCO_STATE_CONNECTING = 0`, `SCO_STATE_CONNECTED = 1`, `SCO_STATE_DISCONNECTED = 2` /// /// Symbol naming: JNI function declared in /// `app.chanora.chanora_flutter.AndroidBluetoothScoController`. #[no_mangle] pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange< 'local, >( _env: jni::EnvUnowned<'local>, _class: jni::objects::JClass<'local>, state: jni::sys::jint, ) { let _ = catch_unwind(AssertUnwindSafe(|| { try_send_backend_event(BackendEvent::BluetoothScoStateChanged(state)); })); } /// SDD-115 integration: start the Android audio focus listener. /// Called by the engine after the voice unit is started. Uses JNI /// to invoke `AndroidAudioFocusController.start(Context)`. pub fn chanora_android_request_audio_focus() -> bool { call_static_void_context( "app/chanora/chanora_flutter/AndroidAudioFocusController", "start", ) } /// SDD-115 integration: stop the Android audio focus listener. /// Called by the engine on voice stop. pub fn chanora_android_abandon_audio_focus() -> bool { call_static_void_context( "app/chanora/chanora_flutter/AndroidAudioFocusController", "stop", ) } /// SDD-115 integration: start Bluetooth SCO. /// Called by the engine after the voice unit is started. pub fn chanora_android_start_bluetooth_sco() -> bool { call_static_void_context( "app/chanora/chanora_flutter/AndroidBluetoothScoController", "start", ) } /// SDD-115 integration: stop Bluetooth SCO. /// Called by the engine on voice stop. pub fn chanora_android_stop_bluetooth_sco() -> bool { call_static_void_context( "app/chanora/chanora_flutter/AndroidBluetoothScoController", "stop", ) } fn call_static_void_context(fqcn: &str, method: &str) -> bool { use jni::objects::{JObject, JValue}; let ctx = ndk_context::android_context(); if ctx.context().is_null() { warn!( target: "chanora_audio", class = fqcn, method, "android: ndk_context not initialised; call skipped" ); return false; } with_android_env("static context call", |env| { let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) }; let class = match load_app_class(env, &context_obj, fqcn) { Some(c) => c, None => return false, }; match env.call_static_method( &class, jni::strings::JNIString::new(method), jni::jni_sig!("(Landroid/content/Context;)V"), &[JValue::Object(&context_obj)], ) { Ok(_) => { info!(target: "chanora_audio", class = fqcn, method, "android: dispatched"); true } Err(e) => { env.exception_clear(); warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed"); false } } }) .unwrap_or(false) }