Make the Android BLE backend fail loudly and recoverably

Three defects on the path between MainActivity and the scan loop, all of
which presented as "no trainer found".

initBtleplug ran after super.onCreate, which is a race rather than a
clean ordering bug: the super chain dispatches Rust.create(), and tao's
ndk_glue spawns a thread to run `run()` on. That thread builds the
AppState and starts the scan loop concurrently. Reaching btleplug first
hits droidplug's global_adapter(), which is an `expect` — the scan task
panics and scanning is dead for the process, silently and only on some
phones. Initialising before super.onCreate means the race cannot be lost.

The same panic was reachable without any race, because init failure was
logged and shrugged off while every later call still went through to
`expect`. Failing soft is right; it just needed READY, so the call sites
can produce an ordinary "no adapter" instead of taking the task down
(NFR-4). MainActivity retries the init on resume, which is idempotent, so
a rider who launched with Bluetooth off recovers by going to Settings.

Neither of those covers a radio the rider switches off, which btleplug
does not model at all: getDefaultAdapter() returns a disabled adapter
whose scans just find nothing. MainActivity now watches
ACTION_STATE_CHANGED — the quick-settings shade never fires onResume —
and pushes the state to Rust, with requestBluetoothEnable coming back the
other way so the connection screen can offer the system dialog rather
than describing an empty room. Tri-state on purpose: unknown is not off,
or a rider with a working radio gets told to switch it on at launch.

Also: the adapter hint told Android riders to check BlueZ.

Verified on debug and release APKs for aarch64. Release matters
separately here — every one of these classes is reached only by name over
JNI, so R8 would strip or rename the lot and the failure would appear
only in a shipped build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 20:06:03 +02:00
co-authored by Claude Opus 5
parent 8fcee06d84
commit aa99b83c40
6 changed files with 234 additions and 11 deletions
+6 -2
View File
@@ -45,6 +45,10 @@ const ADAPTER_RETRY: Duration = Duration::from_secs(2);
/// and telling an Android rider to check BlueZ is worse than saying nothing.
#[cfg(target_os = "android")]
const ADAPTER_HINT: &str = "Check Bluetooth is switched on and BikeControl is allowed to use it.";
/// Shown when we *know* the radio is off, which is a different message: there is
/// one specific thing to do and `request_bluetooth_enable` will offer to do it.
#[cfg(target_os = "android")]
const BLUETOOTH_OFF: &str = "Bluetooth is switched off.";
#[cfg(target_os = "linux")]
const ADAPTER_HINT: &str = "Check the radio is on and BlueZ is running.";
#[cfg(not(any(target_os = "android", target_os = "linux")))]
@@ -561,11 +565,11 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
// land here; MainActivity retries on every resume, so this is a wait
// rather than a dead end, and the rider gets told which knob to turn.
#[cfg(target_os = "android")]
if !crate::android::ready() {
if !crate::android::ready() || crate::android::bluetooth_enabled() == Some(false) {
generation += 1;
let _ = tx.send(ScanSnapshot {
devices: Vec::new(),
error: Some("Bluetooth is unavailable. Check it is switched on.".into()),
error: Some(BLUETOOTH_OFF.into()),
generation,
});
tokio::time::sleep(ADAPTER_RETRY).await;