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.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 <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
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user