From 7e8dc400dcbddaf7a46b0aa811a56712a756b662 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Sun, 12 Apr 2026 16:46:56 +0400 Subject: [PATCH] fix(bluetooth): wait for SCO link before Oboe restart + detect A2DP devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for Bluetooth audio not working: 1. is_bluetooth_available() now checks for TYPE_BLUETOOTH_A2DP (8) in addition to TYPE_BLUETOOTH_SCO (7) — many headsets only register as A2DP until SCO is explicitly started. 2. set_bluetooth_sco(on=true) polls isBluetoothScoOn() for up to 3s before restarting Oboe. startBluetoothSco() is async — the SCO link takes 500ms-2s to establish. Without waiting, Oboe opens against earpiece and audio goes nowhere. 3. Frontend skips redundant set_speakerphone(false) when transitioning to BT — start_bluetooth_sco() handles speaker-off internally, avoiding a double Oboe restart. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/src/android_audio.rs | 10 ++++++---- desktop/src-tauri/src/lib.rs | 19 +++++++++++++++++++ desktop/src/main.ts | 8 +++++--- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/desktop/src-tauri/src/android_audio.rs b/desktop/src-tauri/src/android_audio.rs index 3f4d960..5418da6 100644 --- a/desktop/src-tauri/src/android_audio.rs +++ b/desktop/src-tauri/src/android_audio.rs @@ -185,10 +185,11 @@ pub fn is_bluetooth_sco_on() -> Result { .map_err(|e| format!("isBluetoothScoOn: {e}")) } -/// Check whether a Bluetooth SCO-capable device is currently connected. +/// Check whether a Bluetooth audio device is currently connected. /// /// Iterates `AudioManager.getDevices(GET_DEVICES_OUTPUTS)` and looks for -/// `TYPE_BLUETOOTH_SCO` (7). +/// 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 { let (vm, activity) = jvm_and_activity()?; let mut env = vm @@ -220,8 +221,9 @@ pub fn is_bluetooth_available() -> Result { .call_method(&device, "getType", "()I", &[]) .and_then(|v| v.i()) .unwrap_or(0); - // TYPE_BLUETOOTH_SCO = 7 - if device_type == 7 { + // 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); } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1e87e89..4ed8a7c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -779,6 +779,10 @@ async fn is_speakerphone_on() -> Result { /// Enable or disable Bluetooth SCO audio routing. Like speakerphone toggling, /// this requires an Oboe stream restart so AAudio picks up the new route. +/// +/// `startBluetoothSco()` is asynchronous — the SCO link takes 500ms-2s to +/// establish. We poll `isBluetoothScoOn()` up to 3 seconds before restarting +/// Oboe, so the streams open against the BT device rather than earpiece. #[tauri::command] #[allow(unused_variables)] async fn set_bluetooth_sco(on: bool) -> Result<(), String> { @@ -786,6 +790,21 @@ async fn set_bluetooth_sco(on: bool) -> Result<(), String> { { if on { android_audio::start_bluetooth_sco()?; + // Wait for SCO link to actually connect before restarting Oboe. + // startBluetoothSco() is async — jumping straight to Oboe restart + // would open streams against earpiece, not the BT device. + let mut connected = false; + for i in 0..30 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + if android_audio::is_bluetooth_sco_on().unwrap_or(false) { + tracing::info!(polls = i + 1, "set_bluetooth_sco: SCO connected"); + connected = true; + break; + } + } + if !connected { + tracing::warn!("set_bluetooth_sco: SCO did not connect within 3s, proceeding anyway"); + } } else { android_audio::stop_bluetooth_sco()?; } diff --git a/desktop/src/main.ts b/desktop/src/main.ts index f74d41f..ebb9fc0 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -939,15 +939,17 @@ async function cycleAudioRoute() { const idx = routes.indexOf(currentAudioRoute); const next = routes[(idx + 1) % routes.length]; - // Tear down current route + // Tear down current route, then activate next. + // start_bluetooth_sco() already calls setSpeakerphoneOn(false) + // internally, so we skip the separate speakerphone toggle when + // transitioning to BT to avoid a redundant Oboe restart. 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 }); + // BT start handles speaker-off internally + waits for SCO link await invoke("set_bluetooth_sco", { on: true }); } else { // earpiece — turn everything off