Ride on Android: the same BLE stack, over JNI
G-4 said port to Android without rewriting the core, and nothing in crates/core, crates/ble or crates/fit needed touching (NFR-5) — the Android work is two files of glue and a Gradle project. btleplug's Android backend is a hybrid crate: the GATT work happens in Java and Rust drives it over JNI. `platform::init` has to run once with a JNIEnv, and it cannot come from Rust's own startup — JNI resolves classes with the calling thread's class loader, and a thread Rust spawned has only the bootstrap loader. So MainActivity.onCreate calls into src/android.rs, before super.onCreate: TauriActivity's super chain synchronously starts the thread that runs `run()`, which builds AppState and starts scanning while we are still in onCreate. Lose that race and droidplug's global_adapter() — an `expect` — panics inside the scan task, silently, for the life of the process. Failing soft here is not enough for the same reason, so init sets a READY flag and devices.rs asks before every call in. Bluetooth switched off at launch then reads as an ordinary "no adapter", which the connection screen already knows how to show, and onResume retries so switching it on and coming back works. The Java half is not a maven dependency. Upstream tells you to publish a 0.1.1-SNAPSHOT artifact to mavenLocal by hand, which no CI runner can reproduce and which drifts from the crate silently — the failure is a NoSuchMethodError at the first scan, not a build error. Instead sync-android-sources.sh lifts the classes out of the btleplug and jni-utils crate sources at exactly the versions in Cargo.lock, so a mismatch is impossible by construction. gen/ stays generated and untracked, so everything hand-written lives in src-tauri/android/ and is copied back after each `tauri android init`. check-android-sources.sh fails the build if a source exists only under gen/ or differs from its tracked copy: both are files git has never seen and the next init deletes, and the resulting APK builds, installs, and behaves as though they were never written. Permissions are split at API 31, because asking for one the platform does not know is a permanent denial. neverForLocation on BLUETOOTH_SCAN is a promise we can keep honestly: every scan filters by service UUID, so no location permission is needed on Android 12+. Also: tracing to logcat, since Android has no stdout and the default writer drops every line into a closed fd. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
import java.util.Properties
|
||||
|
||||
/**
|
||||
* Tracked replacement for the app module's build script.
|
||||
*
|
||||
* `tauri android init` regenerates gen/android from scratch, so this file is
|
||||
* the source of truth and scripts/sync-android-sources.sh copies it back into
|
||||
* place afterwards. It differs from Tauri's generated version in two ways:
|
||||
* release signing is read from a keystore.properties written by CI, and the
|
||||
* BLE Java sources synced in from the btleplug/jni-utils crates are kept out of
|
||||
* R8's reach.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("rust")
|
||||
}
|
||||
|
||||
val tauriProperties = Properties().apply {
|
||||
val propFile = file("tauri.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// Release signing. CI writes gen/android/keystore.properties from secrets; when
|
||||
// it is absent (any local build) the release variant simply stays debug-signed
|
||||
// rather than failing the build.
|
||||
val keystoreProperties = Properties().apply {
|
||||
val propFile = rootProject.file("keystore.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
namespace = "paris.tourolle.bikecontrol"
|
||||
|
||||
defaultConfig {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
applicationId = "paris.tourolle.bikecontrol"
|
||||
// 24 is Tauri's floor and also btleplug's: the jni-utils Java support
|
||||
// classes use java.util.function, which arrived in API 24.
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keystoreProperties.getProperty("storeFile")?.let {
|
||||
storeFile = file(it)
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
packaging {
|
||||
jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
if (keystoreProperties.getProperty("storeFile") != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
.toList().toTypedArray()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
// requestPermissions via the Activity Result API (MainActivity.kt).
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
|
||||
// btleplug's Java backend and the jni-utils support classes are NOT maven
|
||||
// dependencies: they are copied into app/src/main/java from the crate
|
||||
// sources by scripts/sync-android-sources.sh, so the Java always matches
|
||||
// the Rust in Cargo.lock. Upstream's suggested route — a
|
||||
// 0.1.1-SNAPSHOT artifact published to mavenLocal by hand — cannot be
|
||||
// reproduced on a CI runner and drifts silently from the crate.
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
@@ -0,0 +1,24 @@
|
||||
# Keep rules for the BLE stack.
|
||||
#
|
||||
# Every class below is reached only from Rust, over JNI, by name. R8 sees no
|
||||
# reference to any of it and would strip or rename the lot — and the failure is
|
||||
# invisible until the first scan on a *release* build, where btleplug's
|
||||
# `platform::init` throws ClassNotFound and the app finds no trainer. Debug
|
||||
# builds are unminified, so this cannot be caught by testing locally.
|
||||
|
||||
# btleplug's Android backend. `register_native_methods` binds by exact method
|
||||
# name and signature, and the exception classes are looked up so the Rust side
|
||||
# can map them onto btleplug::Error.
|
||||
-keep class com.nonpolynomial.btleplug.** { *; }
|
||||
|
||||
# jni-utils: the Future/Stream/Waker plumbing btleplug's Java calls back into.
|
||||
-keep class io.github.gedgygedgy.rust.** { *; }
|
||||
|
||||
# The Rust side calls these on the Android framework classes it is handed.
|
||||
-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`.
|
||||
-keepclasseswithmembernames class paris.tourolle.bikecontrol.MainActivity {
|
||||
native <methods>;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Authoritative AndroidManifest for BikeControl.
|
||||
|
||||
This is NOT a manifest-merger fragment. Gradle reads only
|
||||
gen/android/app/src/main/AndroidManifest.xml, and `tauri android init`
|
||||
regenerates that file from tauri.conf.json — dropping anything it does not
|
||||
know about. scripts/sync-android-sources.sh copies this file over the
|
||||
generated one, so this is the complete manifest and the single source of
|
||||
truth.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Tauri's dev server; harmless in release, where the assets are local. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!--
|
||||
BLE (FR-1). Android split these permissions at API 31, so both eras are
|
||||
declared and each is capped to the era it belongs to.
|
||||
|
||||
`neverForLocation` on BLUETOOTH_SCAN is the load-bearing one: it is a
|
||||
promise that we do not derive location from scan results, and it is what
|
||||
lets the app scan on API 31+ *without* holding a location permission. We
|
||||
can make that promise honestly because every scan is filtered by service
|
||||
UUID (FTMS / the Click's Zwift service) — see `bikecontrol-ble::scan`.
|
||||
Below API 31 there is no such escape hatch: a BLE scan legally required
|
||||
ACCESS_FINE_LOCATION, so it is declared with maxSdkVersion="30" and asked
|
||||
for at runtime only on those versions.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- No trainer, no app. Let the store and the installer say so up front. -->
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.bikecontrol"
|
||||
android:hardwareAccelerated="true"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/main_activity_title"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true"
|
||||
android:resizeableActivity="true"
|
||||
android:screenOrientation="fullSensor"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|screenLayout|density|locale|uiMode">
|
||||
<!--
|
||||
Every size-affecting change is in `configChanges` on purpose. The
|
||||
activity handles them itself instead of being recreated, so a
|
||||
rotation, a fold, or entering split-screen mid-ride re-measures
|
||||
the webview (see ui/src/lib/viewport.ts) rather than tearing down
|
||||
the process that owns the BLE links and the ride loop. Dropping
|
||||
`screenSize` or `density` here would restart the app on rotation
|
||||
and disconnect the trainer.
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Used by the dialog plugin when the rider exports a FIT file. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,117 @@
|
||||
package paris.tourolle.bikecontrol
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
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,
|
||||
* 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).
|
||||
*/
|
||||
class MainActivity : TauriActivity() {
|
||||
companion object {
|
||||
init {
|
||||
// btleplug's Android backend registers its native methods against
|
||||
// Java classes, so the Rust library has to be in the process before
|
||||
// `initBtleplug` can be called. `TauriActivity` loads it too, but
|
||||
// later than we need and `System.loadLibrary` is idempotent, so
|
||||
// doing it here is free.
|
||||
System.loadLibrary("bikecontrol_app_lib")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implemented in Rust (`src-tauri/src/android.rs`). Hands btleplug a JNIEnv
|
||||
* so it can register its native methods and cache its classes.
|
||||
*
|
||||
* It must be called from a Java thread, because class lookup uses the
|
||||
* *calling* thread's class loader: from a Rust-spawned thread the app's
|
||||
* classes are simply not visible and btleplug fails with ClassNotFound at
|
||||
* the first scan rather than here.
|
||||
*/
|
||||
private external fun initBtleplug()
|
||||
|
||||
/**
|
||||
* 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
|
||||
* permanent denial, so the two eras must never be mixed.
|
||||
*/
|
||||
private val blePermissions: Array<String>
|
||||
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH,
|
||||
Manifest.permission.BLUETOOTH_ADMIN,
|
||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
)
|
||||
}
|
||||
|
||||
private val permissionRequest =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { }
|
||||
|
||||
private fun missingPermissions(): Array<String> =
|
||||
blePermissions.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}.toTypedArray()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// BEFORE super.onCreate, and the order is load-bearing. TauriActivity's
|
||||
// super chain registers WryLifecycleObserver on ProcessLifecycleOwner,
|
||||
// which synchronously dispatches onCreate -> Rust.create(); tao's
|
||||
// ndk_glue::create then spawns a thread and runs our `run()` on it. That
|
||||
// thread builds the AppState and starts the scan loop while we are still
|
||||
// in onCreate. If it reaches btleplug first, droidplug's global_adapter()
|
||||
// is an `expect` and the scan task panics — silently, for the life of the
|
||||
// process. Initialising here means the race cannot be lost.
|
||||
initBtleplug()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// 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
|
||||
// than a WAKE_LOCK: it needs no permission and it lapses automatically
|
||||
// when the app goes to the background, so there is nothing to leak.
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
val missing = missingPermissions()
|
||||
if (missing.isNotEmpty()) {
|
||||
permissionRequest.launch(missing)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask again on the way back in. A rider who declined, went to Settings and
|
||||
* granted it, or simply dismissed the dialog, gets another chance without
|
||||
* having to reinstall. Android stops showing the dialog once the user has
|
||||
* refused twice, so this cannot become a loop.
|
||||
*/
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// Retry the backend too. The usual reason it failed in onCreate is that
|
||||
// Bluetooth was switched off at launch, and the fix for that happens in
|
||||
// Settings — i.e. while we are in the background. The call is idempotent
|
||||
// (btleplug guards the native-method registration on a OnceCell and only
|
||||
// the adapter is retried), so calling it on every resume costs nothing
|
||||
// once it has succeeded.
|
||||
initBtleplug()
|
||||
|
||||
val missing = missingPermissions()
|
||||
if (missing.isNotEmpty()) {
|
||||
permissionRequest.launch(missing)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Kept in step with the `bg` custom property in ui/src/app.css. XML comments
|
||||
cannot contain a double hyphen, so it cannot be named here literally. -->
|
||||
<resources>
|
||||
<color name="bikecontrol_bg">#FF05070A</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
`tauri android init` generates these from tauri.conf.json's productName; they
|
||||
are restated here because this file is copied over the generated one and both
|
||||
names are referenced by the tracked AndroidManifest.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">BikeControl</string>
|
||||
<string name="main_activity_title">BikeControl</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
The app theme, defined here rather than left to `tauri android init`.
|
||||
|
||||
Two reasons. The manifest names `@style/Theme.bikecontrol` explicitly, and a
|
||||
generated theme whose name is derived from productName is not something to
|
||||
bet the build on. And the window background matters: without it, launching
|
||||
shows a white window until the webview paints, which on a near-black UI reads
|
||||
as a flash of light in a dark room. Matching the background custom property
|
||||
from ui/src/app.css means the app appears to start already drawn.
|
||||
-->
|
||||
<resources>
|
||||
<style name="Theme.bikecontrol" parent="Theme.MaterialComponents.NoActionBar">
|
||||
<item name="android:windowBackground">@color/bikecontrol_bg</item>
|
||||
<item name="android:statusBarColor">@color/bikecontrol_bg</item>
|
||||
<item name="android:navigationBarColor">@color/bikecontrol_bg</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user