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
+98 -6
View File
@@ -1,18 +1,21 @@
//! The Android side of the JNI boundary (G-4).
//!
//! Two jobs, both of which exist because Android is not a normal Unix process:
//! initialising btleplug's Java backend, and getting `tracing` output somewhere
//! a person can read it.
//! Three jobs, all of which exist because Android is not a normal Unix process:
//! initialising btleplug's Java backend, tracking a radio that the rider can
//! switch off underneath us, and getting `tracing` output somewhere a person can
//! read it.
//!
//! Everything above this file is platform-agnostic — `bikecontrol-ble` uses the
//! same `btleplug::platform::Manager` on every target (NFR-5).
use std::ffi::CString;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::OnceLock;
use jni::objects::JClass;
use jni::JNIEnv;
use jni::objects::{GlobalRef, JClass, JObject};
use jni::sys::jboolean;
use jni::{JNIEnv, JavaVM};
use tracing_subscriber::fmt::MakeWriter;
/// `paris.tourolle.bikecontrol.MainActivity.initBtleplug()`.
@@ -72,6 +75,95 @@ pub fn ready() -> bool {
READY.load(Ordering::Acquire)
}
// ---------------------------------------------------------------------------
// Adapter state.
//
// btleplug models "which adapters exist", not "is the radio on", and on Android
// those are different questions: a disabled adapter is still returned by
// `getDefaultAdapter()` and its scans simply find nothing. Left to itself the
// connection screen would report an empty room to a rider whose Bluetooth is
// off. `MainActivity` watches ACTION_STATE_CHANGED and pushes the answer here.
const UNKNOWN: u8 = 0;
const OFF: u8 = 1;
const ON: u8 = 2;
static BLUETOOTH: AtomicU8 = AtomicU8::new(UNKNOWN);
/// The activity, for the one call that goes Rust -> Java.
///
/// A `GlobalRef` because the local ref handed to `nativeSetActivity` dies when
/// that call returns, and the JVM so any thread can attach to make the call.
static ACTIVITY: OnceLock<GlobalRef> = OnceLock::new();
static JVM: OnceLock<JavaVM> = OnceLock::new();
/// `MainActivity.nativeSetActivity(activity)`.
#[no_mangle]
pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_nativeSetActivity(
env: JNIEnv,
_this: JObject,
activity: JObject,
) {
match (env.get_java_vm(), env.new_global_ref(activity)) {
(Ok(vm), Ok(global)) => {
let _ = JVM.set(vm);
let _ = ACTIVITY.set(global);
}
_ => tracing::error!("could not retain the activity; cannot ask to enable Bluetooth"),
}
}
/// `MainActivity.nativeBluetoothStateChanged(enabled)`.
#[no_mangle]
pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_nativeBluetoothStateChanged(
_env: JNIEnv,
_this: JObject,
enabled: jboolean,
) {
let state = if enabled != 0 { ON } else { OFF };
if BLUETOOTH.swap(state, Ordering::Release) != state {
tracing::info!(enabled = state == ON, "Bluetooth adapter state changed");
}
}
/// `None` until the first report from Java.
///
/// The distinction matters at startup: the scan loop may run before the activity
/// has said anything, and "unknown" must not be treated as "off" — that would
/// put a spurious "switch Bluetooth on" in front of a rider whose radio is fine.
pub fn bluetooth_enabled() -> Option<bool> {
match BLUETOOTH.load(Ordering::Acquire) {
ON => Some(true),
OFF => Some(false),
_ => None,
}
}
/// Ask the rider to switch the radio on, via the system dialog.
///
/// Best-effort by construction: `MainActivity.requestBluetoothEnable` declines
/// quietly if the radio is already on or BLUETOOTH_CONNECT is missing, and this
/// side declines quietly if the activity is gone. Nothing here is on the ride
/// path — the worst case is that no dialog appears and the connection screen
/// goes on saying Bluetooth is off, which is still true and still actionable.
pub fn request_bluetooth_enable() {
let (Some(vm), Some(activity)) = (JVM.get(), ACTIVITY.get()) else {
tracing::warn!("no activity retained; cannot ask to enable Bluetooth");
return;
};
// Attaching is required because this runs on a Tauri command thread, not a
// Java one. Calling a method *on an object* needs no class lookup, so the
// class-loader problem that forces `initBtleplug` onto the main thread does
// not apply here.
let result = vm.attach_current_thread().and_then(|env| {
env.call_method(activity.as_obj(), "requestBluetoothEnable", "()V", &[])
.map(|_| ())
});
if let Err(e) = result {
tracing::warn!("failed to ask for Bluetooth: {e}");
}
}
// ---------------------------------------------------------------------------
// Logging.
+16
View File
@@ -595,6 +595,22 @@ pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
state.lock().devices.trainer_controllable()
}
/// Ask the platform to switch the Bluetooth radio on.
///
/// Only Android can answer this: there, a disabled radio is a normal state the
/// rider reaches by accident and can fix from inside the app. On desktop the
/// remedy is the system's business, so this is a no-op and the connection screen
/// keeps showing the adapter error.
///
/// Returns nothing on purpose. The rider may decline, and some OEM dialogs claim
/// success before the radio is up, so the only trustworthy answer is the one
/// that arrives on the device list a moment later (§4.3).
#[tauri::command]
pub fn request_bluetooth_enable() {
#[cfg(target_os = "android")]
crate::android::request_bluetooth_enable();
}
// ---------------------------------------------------------------------------
// Controller (Zwift Click)
// ---------------------------------------------------------------------------
+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;
+1
View File
@@ -98,6 +98,7 @@ pub fn run() {
commands::disconnect_device,
commands::forget_device,
commands::trainer_controllable,
commands::request_bluetooth_enable,
// controller
commands::controller_status,
commands::connect_controller,