diff --git a/src-tauri/android/app/proguard-bikecontrol.pro b/src-tauri/android/app/proguard-bikecontrol.pro index 596b007..c3a4da8 100644 --- a/src-tauri/android/app/proguard-bikecontrol.pro +++ b/src-tauri/android/app/proguard-bikecontrol.pro @@ -18,7 +18,14 @@ -keepclassmembers class * extends android.bluetooth.BluetoothGattCallback { *; } -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 { native ; } + +# 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(); +} diff --git a/src-tauri/android/src/main/java/paris/tourolle/bikecontrol/MainActivity.kt b/src-tauri/android/src/main/java/paris/tourolle/bikecontrol/MainActivity.kt index af5564c..386eff0 100644 --- a/src-tauri/android/src/main/java/paris/tourolle/bikecontrol/MainActivity.kt +++ b/src-tauri/android/src/main/java/paris/tourolle/bikecontrol/MainActivity.kt @@ -1,6 +1,12 @@ package paris.tourolle.bikecontrol 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.Bundle import android.view.WindowManager @@ -12,12 +18,14 @@ import android.content.pm.PackageManager * BikeControl's Android entry point. * * `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. * * 1. Loading the Rust library and initialising btleplug's Android backend. * 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() { companion object { @@ -42,6 +50,24 @@ class MainActivity : TauriActivity() { */ 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. * 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 }.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?) { // BEFORE super.onCreate, and the order is load-bearing. TauriActivity's // super chain registers WryLifecycleObserver on ProcessLifecycleOwner, @@ -79,6 +176,12 @@ class MainActivity : TauriActivity() { 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 // 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 diff --git a/src-tauri/src/android.rs b/src-tauri/src/android.rs index 559615c..600bbe9 100644 --- a/src-tauri/src/android.rs +++ b/src-tauri/src/android.rs @@ -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 = OnceLock::new(); +static JVM: OnceLock = 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 { + 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. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 69ae8de..3dea3ec 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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) // --------------------------------------------------------------------------- diff --git a/src-tauri/src/devices.rs b/src-tauri/src/devices.rs index a7fa590..298bf57 100644 --- a/src-tauri/src/devices.rs +++ b/src-tauri/src/devices.rs @@ -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, tx: watch::Sender