spawn_blocking uses arbitrary thread-pool threads that don't have the Android JNI context initialized, causing ndk_context::android_context() to panic. Switch to run_on_main_thread (where the context is always valid) via a oneshot channel, with a 2s timeout. Panic is caught and forwarded as an Err so the debug log captures it rather than crashing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
408 lines
15 KiB
Rust
408 lines
15 KiB
Rust
//! Runtime bridge to Android's `AudioManager` for in-call audio routing.
|
|
//!
|
|
//! We own a quinn+Oboe VoIP pipeline entirely from Rust, but routing the
|
|
//! playout stream between earpiece / loudspeaker / Bluetooth headset has to
|
|
//! happen at the JVM level because those toggles are AudioManager-only.
|
|
//! This module uses the global JavaVM handle that `ndk_context` exposes
|
|
//! (populated by Tauri's mobile runtime) + the `jni` crate to reach into
|
|
//! the Android framework without needing a Tauri plugin.
|
|
//!
|
|
//! All callers must be inside an Android target (`#[cfg(target_os = "android")]`).
|
|
|
|
#![cfg(target_os = "android")]
|
|
|
|
use jni::JavaVM;
|
|
use jni::objects::{JObject, JString, JValue};
|
|
|
|
/// Grab the JavaVM + current Activity from the ndk_context that Tauri's
|
|
/// mobile runtime sets up at process startup.
|
|
fn jvm_and_activity() -> Result<(JavaVM, JObject<'static>), String> {
|
|
let ctx = ndk_context::android_context();
|
|
let vm_ptr = ctx.vm() as *mut jni::sys::JavaVM;
|
|
if vm_ptr.is_null() {
|
|
return Err("ndk_context: JavaVM pointer is null".into());
|
|
}
|
|
let vm = unsafe { JavaVM::from_raw(vm_ptr) }.map_err(|e| format!("JavaVM::from_raw: {e}"))?;
|
|
let activity_ptr = ctx.context() as jni::sys::jobject;
|
|
if activity_ptr.is_null() {
|
|
return Err("ndk_context: activity pointer is null".into());
|
|
}
|
|
// SAFETY: ndk_context guarantees the pointer lives for the process
|
|
// lifetime; we wrap it as a JObject<'static> for convenience.
|
|
let activity: JObject<'static> = unsafe { JObject::from_raw(activity_ptr) };
|
|
Ok((vm, activity))
|
|
}
|
|
|
|
/// Get Android's `AudioManager` via `activity.getSystemService("audio")`.
|
|
fn audio_manager<'local>(
|
|
env: &mut jni::AttachGuard<'local>,
|
|
activity: &JObject<'local>,
|
|
) -> Result<JObject<'local>, String> {
|
|
let svc_name: JString<'local> = env
|
|
.new_string("audio")
|
|
.map_err(|e| format!("new_string(audio): {e}"))?;
|
|
let am = env
|
|
.call_method(
|
|
activity,
|
|
"getSystemService",
|
|
"(Ljava/lang/String;)Ljava/lang/Object;",
|
|
&[JValue::Object(&svc_name)],
|
|
)
|
|
.and_then(|v| v.l())
|
|
.map_err(|e| format!("getSystemService(audio): {e}"))?;
|
|
if am.is_null() {
|
|
return Err("getSystemService returned null".into());
|
|
}
|
|
Ok(am)
|
|
}
|
|
|
|
fn has_permission(permission: &str) -> Result<bool, String> {
|
|
let (vm, activity) = jvm_and_activity()?;
|
|
let mut env = vm
|
|
.attach_current_thread()
|
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
let permission = env
|
|
.new_string(permission)
|
|
.map_err(|e| format!("new_string(permission): {e}"))?;
|
|
let result = env
|
|
.call_method(
|
|
&activity,
|
|
"checkSelfPermission",
|
|
"(Ljava/lang/String;)I",
|
|
&[JValue::Object(&permission)],
|
|
)
|
|
.and_then(|v| v.i())
|
|
.map_err(|e| format!("checkSelfPermission: {e}"))?;
|
|
Ok(result == 0)
|
|
}
|
|
|
|
pub fn has_record_audio_permission() -> Result<bool, String> {
|
|
has_permission("android.permission.RECORD_AUDIO")
|
|
}
|
|
|
|
/// Set `AudioManager.MODE_IN_COMMUNICATION`. Call when a VoIP call starts.
|
|
/// This tells the audio policy to route through the communication device
|
|
/// path (earpiece/BT SCO) instead of the media path (speaker/BT A2DP).
|
|
pub fn set_audio_mode_communication() -> Result<(), String> {
|
|
let (vm, activity) = jvm_and_activity()?;
|
|
let mut env = vm
|
|
.attach_current_thread()
|
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
let am = audio_manager(&mut env, &activity)?;
|
|
// MODE_IN_COMMUNICATION = 3
|
|
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(3)])
|
|
.map_err(|e| format!("setMode(MODE_IN_COMMUNICATION): {e}"))?;
|
|
tracing::info!("AudioManager: mode set to MODE_IN_COMMUNICATION");
|
|
Ok(())
|
|
}
|
|
|
|
/// Run `set_audio_mode_communication` on Tauri's main thread, where the
|
|
/// Android context is initialized. Calling it from arbitrary Tokio blocking
|
|
/// workers panics inside `ndk_context::android_context()`.
|
|
pub async fn set_audio_mode_communication_on_main(
|
|
app: tauri::AppHandle,
|
|
) -> Result<(), String> {
|
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
app.run_on_main_thread(move || {
|
|
let result = std::panic::catch_unwind(set_audio_mode_communication)
|
|
.map_err(|panic| {
|
|
if let Some(s) = panic.downcast_ref::<&str>() {
|
|
format!("panic: {s}")
|
|
} else if let Some(s) = panic.downcast_ref::<String>() {
|
|
format!("panic: {s}")
|
|
} else {
|
|
"panic: unknown".to_string()
|
|
}
|
|
})
|
|
.and_then(|r| r);
|
|
let _ = tx.send(result);
|
|
})
|
|
.map_err(|e| format!("run_on_main_thread: {e}"))?;
|
|
|
|
tokio::time::timeout(std::time::Duration::from_secs(2), rx)
|
|
.await
|
|
.map_err(|_| "set_audio_mode_communication timed out after 2s".to_string())?
|
|
.map_err(|_| "set_audio_mode_communication result channel closed".to_string())?
|
|
}
|
|
|
|
/// Restore `AudioManager.MODE_NORMAL`. Call when a VoIP call ends.
|
|
pub fn set_audio_mode_normal() -> Result<(), String> {
|
|
let (vm, activity) = jvm_and_activity()?;
|
|
let mut env = vm
|
|
.attach_current_thread()
|
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
let am = audio_manager(&mut env, &activity)?;
|
|
// MODE_NORMAL = 0
|
|
env.call_method(&am, "setMode", "(I)V", &[JValue::Int(0)])
|
|
.map_err(|e| format!("setMode(MODE_NORMAL): {e}"))?;
|
|
tracing::info!("AudioManager: mode set to MODE_NORMAL");
|
|
Ok(())
|
|
}
|
|
|
|
/// Switch between loud speaker (`true`) and earpiece/handset (`false`).
|
|
pub fn set_speakerphone(on: bool) -> Result<(), String> {
|
|
let (vm, activity) = jvm_and_activity()?;
|
|
let mut env = vm
|
|
.attach_current_thread()
|
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
let am = audio_manager(&mut env, &activity)?;
|
|
|
|
env.call_method(
|
|
&am,
|
|
"setSpeakerphoneOn",
|
|
"(Z)V",
|
|
&[JValue::Bool(if on { 1 } else { 0 })],
|
|
)
|
|
.map_err(|e| format!("setSpeakerphoneOn({on}): {e}"))?;
|
|
|
|
tracing::info!(on, "AudioManager.setSpeakerphoneOn");
|
|
Ok(())
|
|
}
|
|
|
|
/// Query the current speakerphone state. Returns true if routing is on the
|
|
/// loud speaker, false if on earpiece / BT headset / wired headset.
|
|
pub fn is_speakerphone_on() -> Result<bool, String> {
|
|
let (vm, activity) = jvm_and_activity()?;
|
|
let mut env = vm
|
|
.attach_current_thread()
|
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
let am = audio_manager(&mut env, &activity)?;
|
|
|
|
let on = env
|
|
.call_method(&am, "isSpeakerphoneOn", "()Z", &[])
|
|
.and_then(|v| v.z())
|
|
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
|
|
Ok(on)
|
|
}
|
|
|
|
// ─── Bluetooth SCO routing ──────────────────────────────────────────────────
|
|
|
|
/// Start Bluetooth SCO audio routing.
|
|
///
|
|
/// On API 31+ uses `setCommunicationDevice()` which is the modern way to
|
|
/// route voice audio to a specific device. Falls back to the deprecated
|
|
/// `startBluetoothSco()` path on older APIs.
|
|
///
|
|
/// The caller must restart Oboe streams after this call.
|
|
pub fn start_bluetooth_sco() -> Result<(), String> {
|
|
let (vm, activity) = jvm_and_activity()?;
|
|
let mut env = vm
|
|
.attach_current_thread()
|
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
let am = audio_manager(&mut env, &activity)?;
|
|
|
|
// Ensure speaker is off — mutually exclusive with BT.
|
|
env.call_method(&am, "setSpeakerphoneOn", "(Z)V", &[JValue::Bool(0)])
|
|
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
|
|
|
|
// Try modern API first (API 31+): setCommunicationDevice(AudioDeviceInfo)
|
|
// Find a BT SCO or BLE device from getAvailableCommunicationDevices()
|
|
let used_modern = try_set_communication_device(&mut env, &am, true)?;
|
|
|
|
if !used_modern {
|
|
// Fallback: deprecated startBluetoothSco (API < 31)
|
|
tracing::info!("start_bluetooth_sco: falling back to deprecated startBluetoothSco");
|
|
env.call_method(&am, "startBluetoothSco", "()V", &[])
|
|
.map_err(|e| format!("startBluetoothSco: {e}"))?;
|
|
}
|
|
|
|
tracing::info!(used_modern, "AudioManager: Bluetooth SCO started");
|
|
Ok(())
|
|
}
|
|
|
|
/// Stop Bluetooth SCO audio routing, returning audio to the earpiece.
|
|
///
|
|
/// The caller must restart Oboe streams after this call.
|
|
pub fn stop_bluetooth_sco() -> Result<(), String> {
|
|
let (vm, activity) = jvm_and_activity()?;
|
|
let mut env = vm
|
|
.attach_current_thread()
|
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
let am = audio_manager(&mut env, &activity)?;
|
|
|
|
// Modern API: clearCommunicationDevice() (API 31+)
|
|
let cleared = try_set_communication_device(&mut env, &am, false)?;
|
|
|
|
if !cleared {
|
|
// Fallback: deprecated stopBluetoothSco
|
|
env.call_method(&am, "stopBluetoothSco", "()V", &[])
|
|
.map_err(|e| format!("stopBluetoothSco: {e}"))?;
|
|
}
|
|
|
|
tracing::info!(cleared, "AudioManager: Bluetooth SCO stopped");
|
|
Ok(())
|
|
}
|
|
|
|
/// Try to use the modern `setCommunicationDevice` / `clearCommunicationDevice`
|
|
/// API (Android 12 / API 31+). Returns `true` if the modern API was used.
|
|
fn try_set_communication_device(
|
|
env: &mut jni::AttachGuard<'_>,
|
|
am: &JObject<'_>,
|
|
enable: bool,
|
|
) -> Result<bool, String> {
|
|
// Check SDK_INT >= 31 (Android 12)
|
|
let sdk_int = env
|
|
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")
|
|
.and_then(|v| v.i())
|
|
.unwrap_or(0);
|
|
|
|
if sdk_int < 31 {
|
|
return Ok(false);
|
|
}
|
|
|
|
if !enable {
|
|
// clearCommunicationDevice()
|
|
env.call_method(am, "clearCommunicationDevice", "()V", &[])
|
|
.map_err(|e| format!("clearCommunicationDevice: {e}"))?;
|
|
tracing::info!("clearCommunicationDevice: done");
|
|
return Ok(true);
|
|
}
|
|
|
|
// getAvailableCommunicationDevices() → List<AudioDeviceInfo>
|
|
let device_list = env
|
|
.call_method(
|
|
am,
|
|
"getAvailableCommunicationDevices",
|
|
"()Ljava/util/List;",
|
|
&[],
|
|
)
|
|
.and_then(|v| v.l())
|
|
.map_err(|e| format!("getAvailableCommunicationDevices: {e}"))?;
|
|
|
|
let size = env
|
|
.call_method(&device_list, "size", "()I", &[])
|
|
.and_then(|v| v.i())
|
|
.unwrap_or(0);
|
|
|
|
// Find first BT device: TYPE_BLUETOOTH_SCO (7), TYPE_BLUETOOTH_A2DP (8),
|
|
// TYPE_BLE_HEADSET (26), TYPE_BLE_SPEAKER (27)
|
|
for i in 0..size {
|
|
let device = env
|
|
.call_method(
|
|
&device_list,
|
|
"get",
|
|
"(I)Ljava/lang/Object;",
|
|
&[JValue::Int(i)],
|
|
)
|
|
.and_then(|v| v.l())
|
|
.map_err(|e| format!("list.get({i}): {e}"))?;
|
|
|
|
let device_type = env
|
|
.call_method(&device, "getType", "()I", &[])
|
|
.and_then(|v| v.i())
|
|
.unwrap_or(0);
|
|
|
|
// BT SCO = 7, A2DP = 8, BLE headset = 26, BLE speaker = 27
|
|
if matches!(device_type, 7 | 8 | 26 | 27) {
|
|
let ok = env
|
|
.call_method(
|
|
am,
|
|
"setCommunicationDevice",
|
|
"(Landroid/media/AudioDeviceInfo;)Z",
|
|
&[JValue::Object(&device)],
|
|
)
|
|
.and_then(|v| v.z())
|
|
.unwrap_or(false);
|
|
|
|
tracing::info!(device_type, ok, "setCommunicationDevice: set BT device");
|
|
return Ok(ok);
|
|
}
|
|
}
|
|
|
|
tracing::warn!("setCommunicationDevice: no BT device in available list");
|
|
Ok(false)
|
|
}
|
|
|
|
/// Query whether Bluetooth audio is currently the active communication device.
|
|
///
|
|
/// On API 31+ checks `getCommunicationDevice()` type. Falls back to the
|
|
/// deprecated `isBluetoothScoOn()` on older APIs.
|
|
pub fn is_bluetooth_sco_on() -> Result<bool, String> {
|
|
let (vm, activity) = jvm_and_activity()?;
|
|
let mut env = vm
|
|
.attach_current_thread()
|
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
let am = audio_manager(&mut env, &activity)?;
|
|
|
|
let sdk_int = env
|
|
.get_static_field("android/os/Build$VERSION", "SDK_INT", "I")
|
|
.and_then(|v| v.i())
|
|
.unwrap_or(0);
|
|
|
|
if sdk_int >= 31 {
|
|
// getCommunicationDevice() → AudioDeviceInfo (nullable)
|
|
let device = env
|
|
.call_method(
|
|
am,
|
|
"getCommunicationDevice",
|
|
"()Landroid/media/AudioDeviceInfo;",
|
|
&[],
|
|
)
|
|
.and_then(|v| v.l())
|
|
.unwrap_or(JObject::null());
|
|
if device.is_null() {
|
|
return Ok(false);
|
|
}
|
|
let device_type = env
|
|
.call_method(&device, "getType", "()I", &[])
|
|
.and_then(|v| v.i())
|
|
.unwrap_or(0);
|
|
// BT SCO = 7, A2DP = 8, BLE headset = 26, BLE speaker = 27
|
|
return Ok(matches!(device_type, 7 | 8 | 26 | 27));
|
|
}
|
|
|
|
// Fallback: deprecated API
|
|
env.call_method(&am, "isBluetoothScoOn", "()Z", &[])
|
|
.and_then(|v| v.z())
|
|
.map_err(|e| format!("isBluetoothScoOn: {e}"))
|
|
}
|
|
|
|
/// Check whether a Bluetooth audio device is currently connected.
|
|
///
|
|
/// Iterates `AudioManager.getDevices(GET_DEVICES_OUTPUTS)` and looks for
|
|
/// any Bluetooth device type. Many headsets only register as A2DP until
|
|
/// SCO is explicitly started, so we check for both SCO and A2DP types.
|
|
pub fn is_bluetooth_available() -> Result<bool, String> {
|
|
let (vm, activity) = jvm_and_activity()?;
|
|
let mut env = vm
|
|
.attach_current_thread()
|
|
.map_err(|e| format!("attach_current_thread: {e}"))?;
|
|
let am = audio_manager(&mut env, &activity)?;
|
|
|
|
// AudioManager.GET_DEVICES_OUTPUTS = 2
|
|
let devices = env
|
|
.call_method(
|
|
&am,
|
|
"getDevices",
|
|
"(I)[Landroid/media/AudioDeviceInfo;",
|
|
&[JValue::Int(2)],
|
|
)
|
|
.and_then(|v| v.l())
|
|
.map_err(|e| format!("getDevices(OUTPUTS): {e}"))?;
|
|
|
|
let arr = jni::objects::JObjectArray::from(devices);
|
|
let len = env
|
|
.get_array_length(&arr)
|
|
.map_err(|e| format!("get_array_length: {e}"))?;
|
|
|
|
for i in 0..len {
|
|
let device = env
|
|
.get_object_array_element(&arr, i)
|
|
.map_err(|e| format!("get_object_array_element({i}): {e}"))?;
|
|
let device_type = env
|
|
.call_method(&device, "getType", "()I", &[])
|
|
.and_then(|v| v.i())
|
|
.unwrap_or(0);
|
|
// TYPE_BLUETOOTH_SCO = 7, TYPE_BLUETOOTH_A2DP = 8
|
|
if device_type == 7 || device_type == 8 {
|
|
tracing::info!(
|
|
device_type,
|
|
idx = i,
|
|
"is_bluetooth_available: found BT device"
|
|
);
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|