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:
@@ -18,7 +18,14 @@
|
|||||||
-keepclassmembers class * extends android.bluetooth.BluetoothGattCallback { *; }
|
-keepclassmembers class * extends android.bluetooth.BluetoothGattCallback { *; }
|
||||||
-keepclassmembers class * extends android.bluetooth.le.ScanCallback { *; }
|
-keepclassmembers class * extends android.bluetooth.le.ScanCallback { *; }
|
||||||
|
|
||||||
# Our own JNI entry point, called from MainActivity as an `external fun`.
|
# Our own JNI entry points, called from MainActivity as `external fun`.
|
||||||
-keepclasseswithmembernames class paris.tourolle.bikecontrol.MainActivity {
|
-keepclasseswithmembernames class paris.tourolle.bikecontrol.MainActivity {
|
||||||
native <methods>;
|
native <methods>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The one call that goes the other way. Rust resolves it by name and signature
|
||||||
|
# at runtime, so R8 renaming it would leave the "turn Bluetooth on" button doing
|
||||||
|
# nothing in release builds and working perfectly in debug ones.
|
||||||
|
-keepclassmembers class paris.tourolle.bikecontrol.MainActivity {
|
||||||
|
public void requestBluetoothEnable();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
package paris.tourolle.bikecontrol
|
package paris.tourolle.bikecontrol
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
@@ -12,12 +18,14 @@ import android.content.pm.PackageManager
|
|||||||
* BikeControl's Android entry point.
|
* BikeControl's Android entry point.
|
||||||
*
|
*
|
||||||
* `tauri android init` generates a bare `class MainActivity : TauriActivity()`.
|
* `tauri android init` generates a bare `class MainActivity : TauriActivity()`.
|
||||||
* This tracked replacement adds the three things the app cannot start without,
|
* This tracked replacement adds the four things the app cannot start without,
|
||||||
* and nothing else — everything above the JNI line still lives in Rust.
|
* and nothing else — everything above the JNI line still lives in Rust.
|
||||||
*
|
*
|
||||||
* 1. Loading the Rust library and initialising btleplug's Android backend.
|
* 1. Loading the Rust library and initialising btleplug's Android backend.
|
||||||
* 2. Asking for the BLE runtime permissions.
|
* 2. Asking for the BLE runtime permissions.
|
||||||
* 3. Keeping the screen awake while the app is in front (NFR-11).
|
* 3. Reporting whether the Bluetooth radio is actually switched on, which
|
||||||
|
* btleplug has no concept of, and offering to switch it on.
|
||||||
|
* 4. Keeping the screen awake while the app is in front (NFR-11).
|
||||||
*/
|
*/
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -42,6 +50,24 @@ class MainActivity : TauriActivity() {
|
|||||||
*/
|
*/
|
||||||
private external fun initBtleplug()
|
private external fun initBtleplug()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand Rust a reference to this activity, so it can call back the other way
|
||||||
|
* — specifically [requestBluetoothEnable], which only the UI thread of a
|
||||||
|
* live activity may do.
|
||||||
|
*/
|
||||||
|
private external fun nativeSetActivity(activity: MainActivity)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell Rust whether the radio is on.
|
||||||
|
*
|
||||||
|
* btleplug has no notion of a *disabled* adapter: `getDefaultAdapter()`
|
||||||
|
* hands back a perfectly good object whose scans quietly fail, so without
|
||||||
|
* this the rider is told "no trainer found" when the truth is "Bluetooth is
|
||||||
|
* off". Pushed from here rather than polled from Rust because the state
|
||||||
|
* arrives as a broadcast anyway.
|
||||||
|
*/
|
||||||
|
private external fun nativeBluetoothStateChanged(enabled: Boolean)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BLE permissions, split at API 31 exactly as the manifest declares them.
|
* BLE permissions, split at API 31 exactly as the manifest declares them.
|
||||||
* Asking for a permission the platform version does not know results in a
|
* Asking for a permission the platform version does not know results in a
|
||||||
@@ -66,6 +92,77 @@ class MainActivity : TauriActivity() {
|
|||||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Adapter state.
|
||||||
|
|
||||||
|
private val bluetoothAdapter: BluetoothAdapter?
|
||||||
|
get() = getSystemService(BluetoothManager::class.java)?.adapter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rider can reach the Bluetooth toggle without leaving us — the quick
|
||||||
|
* settings shade is an overlay, not a new activity, so `onResume` never
|
||||||
|
* fires. Only the broadcast catches that.
|
||||||
|
*/
|
||||||
|
private val bluetoothStateReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (intent?.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
|
||||||
|
reportBluetoothState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val enableBluetoothRequest =
|
||||||
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
// Whatever the rider chose, the answer is in the adapter, not in the
|
||||||
|
// result code — declining leaves it off, and some OEM dialogs report
|
||||||
|
// success before the radio has finished coming up.
|
||||||
|
reportBluetoothState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reportBluetoothState() {
|
||||||
|
nativeBluetoothStateChanged(bluetoothAdapter?.isEnabled == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called *from Rust*, on a Rust thread, when the rider asks to turn the
|
||||||
|
* radio on from the connection screen.
|
||||||
|
*
|
||||||
|
* The thread hop is done here rather than at the call site because an
|
||||||
|
* `ActivityResultLauncher` may only be launched from the main thread, and
|
||||||
|
* Rust has no cheap way to get there. Silently does nothing if the radio is
|
||||||
|
* already on, or if BLUETOOTH_CONNECT has not been granted — since API 31
|
||||||
|
* the enable intent requires it, and firing it without throws.
|
||||||
|
*/
|
||||||
|
@Suppress("unused") // called by name over JNI
|
||||||
|
fun requestBluetoothEnable() {
|
||||||
|
runOnUiThread {
|
||||||
|
if (bluetoothAdapter?.isEnabled != false) return@runOnUiThread
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return@runOnUiThread
|
||||||
|
}
|
||||||
|
enableBluetoothRequest.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
this,
|
||||||
|
bluetoothStateReceiver,
|
||||||
|
IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
reportBluetoothState()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
unregisterReceiver(bluetoothStateReceiver)
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// BEFORE super.onCreate, and the order is load-bearing. TauriActivity's
|
// BEFORE super.onCreate, and the order is load-bearing. TauriActivity's
|
||||||
// super chain registers WryLifecycleObserver on ProcessLifecycleOwner,
|
// super chain registers WryLifecycleObserver on ProcessLifecycleOwner,
|
||||||
@@ -79,6 +176,12 @@ class MainActivity : TauriActivity() {
|
|||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Both land in Rust statics, so they are safe at any point after the
|
||||||
|
// library is loaded. Doing them here means the scan loop knows the state
|
||||||
|
// of the radio from close to its first pass rather than from onStart.
|
||||||
|
nativeSetActivity(this)
|
||||||
|
reportBluetoothState()
|
||||||
|
|
||||||
// NFR-11 on Android. A rider mid-interval is not "idle" just because
|
// NFR-11 on Android. A rider mid-interval is not "idle" just because
|
||||||
// they have not touched the screen, and a screen that blanks takes the
|
// they have not touched the screen, and a screen that blanks takes the
|
||||||
// numbers they are pacing off with it. This is the window flag rather
|
// numbers they are pacing off with it. This is the window flag rather
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
//! The Android side of the JNI boundary (G-4).
|
//! The Android side of the JNI boundary (G-4).
|
||||||
//!
|
//!
|
||||||
//! Two jobs, both of which exist because Android is not a normal Unix process:
|
//! Three jobs, all of which exist because Android is not a normal Unix process:
|
||||||
//! initialising btleplug's Java backend, and getting `tracing` output somewhere
|
//! initialising btleplug's Java backend, tracking a radio that the rider can
|
||||||
//! a person can read it.
|
//! 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
|
//! Everything above this file is platform-agnostic — `bikecontrol-ble` uses the
|
||||||
//! same `btleplug::platform::Manager` on every target (NFR-5).
|
//! same `btleplug::platform::Manager` on every target (NFR-5).
|
||||||
|
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
use std::io;
|
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::objects::{GlobalRef, JClass, JObject};
|
||||||
use jni::JNIEnv;
|
use jni::sys::jboolean;
|
||||||
|
use jni::{JNIEnv, JavaVM};
|
||||||
use tracing_subscriber::fmt::MakeWriter;
|
use tracing_subscriber::fmt::MakeWriter;
|
||||||
|
|
||||||
/// `paris.tourolle.bikecontrol.MainActivity.initBtleplug()`.
|
/// `paris.tourolle.bikecontrol.MainActivity.initBtleplug()`.
|
||||||
@@ -72,6 +75,95 @@ pub fn ready() -> bool {
|
|||||||
READY.load(Ordering::Acquire)
|
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.
|
// Logging.
|
||||||
|
|
||||||
|
|||||||
@@ -595,6 +595,22 @@ pub fn trainer_controllable(state: State<'_, AppState>) -> bool {
|
|||||||
state.lock().devices.trainer_controllable()
|
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)
|
// Controller (Zwift Click)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.
|
/// and telling an Android rider to check BlueZ is worse than saying nothing.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const ADAPTER_HINT: &str = "Check Bluetooth is switched on and BikeControl is allowed to use it.";
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
const ADAPTER_HINT: &str = "Check the radio is on and BlueZ is running.";
|
const ADAPTER_HINT: &str = "Check the radio is on and BlueZ is running.";
|
||||||
#[cfg(not(any(target_os = "android", target_os = "linux")))]
|
#[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
|
// 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.
|
// rather than a dead end, and the rider gets told which knob to turn.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
if !crate::android::ready() {
|
if !crate::android::ready() || crate::android::bluetooth_enabled() == Some(false) {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
let _ = tx.send(ScanSnapshot {
|
let _ = tx.send(ScanSnapshot {
|
||||||
devices: Vec::new(),
|
devices: Vec::new(),
|
||||||
error: Some("Bluetooth is unavailable. Check it is switched on.".into()),
|
error: Some(BLUETOOTH_OFF.into()),
|
||||||
generation,
|
generation,
|
||||||
});
|
});
|
||||||
tokio::time::sleep(ADAPTER_RETRY).await;
|
tokio::time::sleep(ADAPTER_RETRY).await;
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ pub fn run() {
|
|||||||
commands::disconnect_device,
|
commands::disconnect_device,
|
||||||
commands::forget_device,
|
commands::forget_device,
|
||||||
commands::trainer_controllable,
|
commands::trainer_controllable,
|
||||||
|
commands::request_bluetooth_enable,
|
||||||
// controller
|
// controller
|
||||||
commands::controller_status,
|
commands::controller_status,
|
||||||
commands::connect_controller,
|
commands::connect_controller,
|
||||||
|
|||||||
Reference in New Issue
Block a user