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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user