feat(android): Bluetooth audio routing + network change detection + per-arch APK builds

Bluetooth: wire existing AudioRouteManager SCO support through both app
variants. Replace binary speaker toggle with 3-way route cycling
(Earpiece → Speaker → Bluetooth). Tauri side adds JNI bridge functions
(start/stop/query SCO, device availability) and Oboe stream restart.

Network awareness: integrate Android ConnectivityManager to detect
WiFi/cellular transitions and feed them to AdaptiveQualityController
via lock-free AtomicU8 signaling. Enables proactive quality downgrade
and FEC boost on network handoffs.

Build: add --arch flag to build-tauri-android.sh supporting arm64,
armv7, or all (separate per-arch APKs for smaller tester binaries).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-12 16:07:41 +04:00
parent 29cd23fe39
commit 4c1ad841e1
15 changed files with 1050 additions and 105 deletions

View File

@@ -96,3 +96,134 @@ pub fn is_speakerphone_on() -> Result<bool, String> {
.map_err(|e| format!("isSpeakerphoneOn: {e}"))?;
Ok(on)
}
// ─── Bluetooth SCO routing ──────────────────────────────────────────────────
/// Start Bluetooth SCO (Synchronous Connection Oriented) audio routing.
///
/// Turns off the loudspeaker, then opens the SCO link so both capture and
/// playout move to the connected Bluetooth headset. Requires that a SCO-
/// capable device is paired and connected (check [`is_bluetooth_available`]
/// first). The caller must restart Oboe streams after this call.
#[allow(deprecated)]
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 SCO.
env.call_method(
&am,
"setSpeakerphoneOn",
"(Z)V",
&[JValue::Bool(0)],
)
.map_err(|e| format!("setSpeakerphoneOn(false): {e}"))?;
env.call_method(&am, "startBluetoothSco", "()V", &[])
.map_err(|e| format!("startBluetoothSco: {e}"))?;
env.call_method(
&am,
"setBluetoothScoOn",
"(Z)V",
&[JValue::Bool(1)],
)
.map_err(|e| format!("setBluetoothScoOn(true): {e}"))?;
tracing::info!("AudioManager: Bluetooth SCO started");
Ok(())
}
/// Stop Bluetooth SCO audio routing, returning audio to the earpiece.
///
/// Safe to call even if SCO is not currently active (no-ops in that case).
/// The caller must restart Oboe streams after this call.
#[allow(deprecated)]
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)?;
let is_on = env
.call_method(&am, "isBluetoothScoOn", "()Z", &[])
.and_then(|v| v.z())
.unwrap_or(false);
if is_on {
env.call_method(
&am,
"setBluetoothScoOn",
"(Z)V",
&[JValue::Bool(0)],
)
.map_err(|e| format!("setBluetoothScoOn(false): {e}"))?;
env.call_method(&am, "stopBluetoothSco", "()V", &[])
.map_err(|e| format!("stopBluetoothSco: {e}"))?;
}
tracing::info!(was_on = is_on, "AudioManager: Bluetooth SCO stopped");
Ok(())
}
/// Query whether Bluetooth SCO audio is currently active.
#[allow(deprecated)]
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)?;
env.call_method(&am, "isBluetoothScoOn", "()Z", &[])
.and_then(|v| v.z())
.map_err(|e| format!("isBluetoothScoOn: {e}"))
}
/// Check whether a Bluetooth SCO-capable device is currently connected.
///
/// Iterates `AudioManager.getDevices(GET_DEVICES_OUTPUTS)` and looks for
/// `TYPE_BLUETOOTH_SCO` (7).
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
if device_type == 7 {
return Ok(true);
}
}
Ok(false)
}

View File

@@ -775,6 +775,71 @@ async fn is_speakerphone_on() -> Result<bool, String> {
}
}
// ─── Bluetooth SCO routing (Android-specific, no-op on desktop) ─────────────
/// Enable or disable Bluetooth SCO audio routing. Like speakerphone toggling,
/// this requires an Oboe stream restart so AAudio picks up the new route.
#[tauri::command]
#[allow(unused_variables)]
async fn set_bluetooth_sco(on: bool) -> Result<(), String> {
#[cfg(target_os = "android")]
{
if on {
android_audio::start_bluetooth_sco()?;
} else {
android_audio::stop_bluetooth_sco()?;
}
if wzp_native::is_loaded() && wzp_native::audio_is_running() {
tracing::info!(on, "set_bluetooth_sco: restarting Oboe for route change");
tokio::task::spawn_blocking(|| {
wzp_native::audio_stop();
wzp_native::audio_start()
.map_err(|code| format!("audio_start after BT toggle: code {code}"))
})
.await
.map_err(|e| format!("spawn_blocking join: {e}"))??;
tracing::info!("set_bluetooth_sco: Oboe restarted");
}
Ok(())
}
#[cfg(not(target_os = "android"))]
{
Ok(())
}
}
/// Check whether a Bluetooth SCO device is currently connected and available.
#[tauri::command]
async fn is_bluetooth_available() -> Result<bool, String> {
#[cfg(target_os = "android")]
{
android_audio::is_bluetooth_available()
}
#[cfg(not(target_os = "android"))]
{
Ok(false)
}
}
/// Return the current audio route as a string: "bluetooth", "speaker", or "earpiece".
#[tauri::command]
async fn get_audio_route() -> Result<String, String> {
#[cfg(target_os = "android")]
{
if android_audio::is_bluetooth_sco_on()? {
return Ok("bluetooth".into());
}
if android_audio::is_speakerphone_on()? {
return Ok("speaker".into());
}
Ok("earpiece".into())
}
#[cfg(not(target_os = "android"))]
{
Ok("earpiece".into())
}
}
// ─── Call history commands ───────────────────────────────────────────────────
#[tauri::command]
@@ -1892,6 +1957,7 @@ pub fn run() {
hangup_call,
deregister,
set_speakerphone, is_speakerphone_on,
set_bluetooth_sco, is_bluetooth_available, get_audio_route,
get_call_history, get_recent_contacts, clear_call_history,
set_dred_verbose_logs, get_dred_verbose_logs,
set_call_debug_logs, get_call_debug_logs,

View File

@@ -872,12 +872,11 @@ function showCallScreen() {
}
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250);
// Sync the Speaker/Earpiece label with the OS state (Android only; on
// desktop the command is a no-op returning false so we land on "Earpiece"
// which is fine because desktop has no routing concept).
invoke<boolean>("is_speakerphone_on")
.then((on) => { speakerphoneOn = !!on; updateSpkLabel(); })
.catch(() => { speakerphoneOn = false; updateSpkLabel(); });
// Sync the audio route label with the OS state (Android only; on desktop
// get_audio_route returns "earpiece" so we land on the default).
invoke<string>("get_audio_route")
.then((route) => { currentAudioRoute = (route as AudioRoute) || "earpiece"; updateRouteLabel(); })
.catch(() => { currentAudioRoute = "earpiece"; updateRouteLabel(); });
}
function showConnectScreen() {
@@ -898,38 +897,74 @@ micBtn.addEventListener("click", async () => {
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
});
// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn + then
// stops and restarts the Oboe streams so AAudio reconfigures with the new
// routing. The Rust-side Tauri command handles the restart, we just swap
// the button label.
// Audio routing (Android) — cycles between earpiece, speaker, and Bluetooth
// SCO. Each transition calls the corresponding Tauri command which sets the
// AudioManager state and restarts Oboe streams so AAudio picks up the new
// route. On desktop all commands are no-ops.
//
// Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class
// (which would tint the button red); that was a bug in 0178cbd that made
// earpiece mode look like playback was off. A separate `.speaker-on` class
// is available for css styling if we want to visually indicate loud mode.
let speakerphoneOn = false;
let speakerphoneBusy = false;
function updateSpkLabel() {
spkBtn.classList.toggle("speaker-on", speakerphoneOn);
// earpiece mode look like playback was off.
type AudioRoute = "earpiece" | "speaker" | "bluetooth";
let currentAudioRoute: AudioRoute = "earpiece";
let routeBusy = false;
function updateRouteLabel() {
spkBtn.classList.remove("speaker-on", "bt-on");
spkBtn.classList.remove("muted");
spkIcon.textContent = speakerphoneOn ? "🔊 Speaker" : "🔈 Earpiece";
switch (currentAudioRoute) {
case "speaker":
spkIcon.textContent = "🔊 Speaker";
spkBtn.classList.add("speaker-on");
break;
case "bluetooth":
spkIcon.textContent = "🎧 BT";
spkBtn.classList.add("bt-on");
break;
default:
spkIcon.textContent = "🔈 Earpiece";
break;
}
}
spkBtn.addEventListener("click", async () => {
if (speakerphoneBusy) return; // debounce — the restart takes ~60ms
speakerphoneBusy = true;
const next = !speakerphoneOn;
async function cycleAudioRoute() {
if (routeBusy) return; // debounce — Oboe restart takes ~60-400ms
routeBusy = true;
spkBtn.disabled = true;
try {
await invoke("set_speakerphone", { on: next });
speakerphoneOn = next;
updateSpkLabel();
const btAvailable = await invoke<boolean>("is_bluetooth_available");
const routes: AudioRoute[] = btAvailable
? ["earpiece", "speaker", "bluetooth"]
: ["earpiece", "speaker"];
const idx = routes.indexOf(currentAudioRoute);
const next = routes[(idx + 1) % routes.length];
// Tear down current route
if (currentAudioRoute === "bluetooth") {
await invoke("set_bluetooth_sco", { on: false });
}
// Activate next route
if (next === "speaker") {
await invoke("set_speakerphone", { on: true });
} else if (next === "bluetooth") {
await invoke("set_speakerphone", { on: false });
await invoke("set_bluetooth_sco", { on: true });
} else {
// earpiece — turn everything off
await invoke("set_speakerphone", { on: false });
}
currentAudioRoute = next;
updateRouteLabel();
} catch (e) {
console.error("set_speakerphone failed:", e);
console.error("cycleAudioRoute failed:", e);
} finally {
spkBtn.disabled = false;
speakerphoneBusy = false;
routeBusy = false;
}
});
}
spkBtn.addEventListener("click", cycleAudioRoute);
hangupBtn.addEventListener("click", async () => {
userDisconnected = true;
// Use the new hangup_call command instead of raw disconnect —
@@ -1002,7 +1037,7 @@ async function pollStatus() {
micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
// NB: spkBtn label is driven by the Android audio routing state
// (speakerphoneOn / updateSpkLabel), not by the engine's spk_muted.
// (currentAudioRoute / updateRouteLabel), not by the engine's spk_muted.
// Skip that here so pollStatus doesn't clobber the routing UI.
callTimer.textContent = formatDuration(st.call_duration_secs);

View File

@@ -1083,7 +1083,10 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
color: white;
}
/* Speaker routing button (non-muted earpiece state should not look red) */
/* Audio routing button — highlight color depends on active route */
#spk-btn.speaker-on .icon {
color: var(--accent);
}
#spk-btn.bt-on .icon {
color: #60a5fa; /* blue-400 for Bluetooth */
}