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:
2026-08-05 19:52:33 +02:00
co-authored by Claude Opus 5
parent dff3dc8367
commit 0679a1f524
15 changed files with 793 additions and 8 deletions
+11
View File
@@ -9,8 +9,19 @@ dist/
# Tauri
src-tauri/target/
# All of gen/ is generated and none of it is tracked. `tauri android init`
# rebuilds gen/android from scratch, so the hand-maintained Android files — the
# manifest with the BLE permissions, MainActivity, the app gradle script,
# ProGuard rules, the theme — live in src-tauri/android/ and are copied in by
# scripts/sync-android-sources.sh after every init.
src-tauri/gen/
# Android signing material. A keystore in the repo is a signing key given away;
# CI writes both of these from secrets.
*.jks
*.keystore
src-tauri/android/keystore.properties
# Editors / OS
.DS_Store
*.swp
Generated
+3
View File
@@ -242,8 +242,11 @@ dependencies = [
"bikecontrol-ble",
"bikecontrol-core",
"bikecontrol-fit",
"btleplug",
"chrono",
"jni 0.19.0",
"keepawake",
"libc",
"roxmltree",
"serde",
"serde_json",
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Fail if any hand-written Android source has ended up only in gen/android.
#
# src-tauri/gen/ is not tracked and `tauri android init` rewrites it, so a
# Kotlin file edited *there* is a file that will be silently deleted by the next
# init and has never existed in git. That failure is quiet in exactly the wrong
# way: the build keeps working locally for as long as nobody re-inits, and then
# a CI release APK ships without whatever the file did.
#
# Every source under gen/android/app/src/main/java must therefore be one of:
#
# 1. Tauri's own, under <pkg>/generated/ and carrying the AUTO-GENERATED
# banner. Regenerated on every init; not ours to keep.
# 2. A copy of a tracked file in src-tauri/android/, byte for byte. If it
# differs, the edit was made in gen/ and is about to be lost.
# 3. Vendored from a crate by sync-android-sources.sh — the btleplug and
# jni-utils Java backends.
#
# Anything else is hand-written and untracked, and this exits non-zero.
#
# scripts/check-android-sources.sh
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TRACKED="$ROOT/src-tauri/android/src/main/java"
GEN="$ROOT/src-tauri/gen/android/app/src/main/java"
if [ ! -d "$GEN" ]; then
echo "️ No generated Android project — nothing to check."
echo " (run 'cargo tauri android init' then scripts/sync-android-sources.sh)"
exit 0
fi
fail=0
drift=0
while IFS= read -r file; do
rel="${file#"$GEN"/}"
case "$rel" in
# 3. Vendored from the crates.
com/nonpolynomial/btleplug/*|io/github/gedgygedgy/rust/*)
continue
;;
esac
# 1. Tauri's own.
if [ "${rel#*/generated/}" != "$rel" ] && head -8 "$file" | grep -q 'AUTO-GENERATED'; then
continue
fi
# 2. Ours — must exist in src-tauri/android and match.
if [ -f "$TRACKED/$rel" ]; then
if ! cmp -s "$TRACKED/$rel" "$file"; then
echo "$rel differs from the tracked copy."
echo " Edited in gen/ — the next 'tauri android init' will delete it."
echo " Move the change into src-tauri/android/src/main/java/$rel"
drift=1
fi
continue
fi
echo "$rel is hand-written and untracked."
echo " Move it to src-tauri/android/src/main/java/$rel and add it to the"
echo " copy list in scripts/sync-android-sources.sh."
fail=1
done < <(find "$GEN" -type f \( -name '*.kt' -o -name '*.java' \))
# The other direction: a tracked source the sync script never copies is dead
# code that looks live.
while IFS= read -r file; do
rel="${file#"$TRACKED"/}"
if [ ! -f "$GEN/$rel" ]; then
echo "❌ src-tauri/android/src/main/java/$rel is tracked but never reached gen/."
echo " Add it to scripts/sync-android-sources.sh, or delete it."
fail=1
fi
done < <(find "$TRACKED" -type f \( -name '*.kt' -o -name '*.java' \) 2>/dev/null || true)
if [ "$fail" = 1 ] || [ "$drift" = 1 ]; then
exit 1
fi
echo "✅ Every hand-written Android source is tracked in src-tauri/android/"
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# Populate src-tauri/gen/android with the parts `tauri android init` cannot know
# about.
#
# `tauri android init` regenerates gen/android from tauri.conf.json, and gen/ is
# not tracked (see .gitignore). Anything hand-maintained therefore lives under
# src-tauri/android/ and is copied in by this script after every init. Run it
# between `tauri android init` and `tauri android build`.
#
# Two kinds of thing are copied:
#
# 1. Our own files — AndroidManifest.xml (BLE permissions), MainActivity.kt,
# the app build.gradle.kts (signing config + BLE Java sources), ProGuard
# keep rules.
#
# 2. btleplug's Android backend, which is a *hybrid* Rust/Java crate: the Rust
# side registers native methods on Java classes that must be compiled into
# the APK. Upstream tells you to publish a SNAPSHOT maven artifact; we
# instead lift the Java straight out of the crate sources that Cargo has
# already downloaded, keyed on the exact versions in Cargo.lock. That makes
# a Java/Rust version mismatch — the failure mode here is a
# NoSuchMethodError at first scan, not a build error — impossible by
# construction.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="$ROOT/src-tauri/android"
GEN="$ROOT/src-tauri/gen/android"
APP="$GEN/app/src/main"
PKG_PATH="paris/tourolle/bikecontrol"
if [ ! -d "$GEN" ]; then
echo "$GEN does not exist — run 'cargo tauri android init' first." >&2
exit 1
fi
echo "Syncing Android sources into gen/android…"
# ---------------------------------------------------------------------------
# 1. Our own files.
mkdir -p "$APP/java/$PKG_PATH"
cp "$SRC/src/main/java/$PKG_PATH/MainActivity.kt" "$APP/java/$PKG_PATH/MainActivity.kt"
echo " ✓ MainActivity.kt"
# Gradle reads ONLY the gen/ copy — there is no manifest-merger hook for our
# entries — so this tracked file must be the complete manifest.
cp "$SRC/src/main/AndroidManifest.xml" "$APP/AndroidManifest.xml"
echo " ✓ AndroidManifest.xml"
cp "$SRC/app/build.gradle.kts" "$GEN/app/build.gradle.kts"
echo " ✓ app/build.gradle.kts"
# build.gradle.kts globs **/*.pro, so dropping this in app/ is enough.
cp "$SRC/app/proguard-bikecontrol.pro" "$GEN/app/proguard-bikecontrol.pro"
echo " ✓ proguard-bikecontrol.pro"
if [ -d "$SRC/src/main/res" ]; then
for dir in "$SRC/src/main/res"/*/; do
[ -d "$dir" ] || continue
name="$(basename "$dir")"
mkdir -p "$APP/res/$name"
cp "$dir"/* "$APP/res/$name/"
echo " ✓ res/$name"
done
fi
# ---------------------------------------------------------------------------
# 2. btleplug's Java backend, at the versions Cargo.lock pins.
crate_version() {
# First `version = "x"` line after the crate's `name =` line in Cargo.lock.
awk -v pkg="name = \"$1\"" '
$0 == pkg { found = 1; next }
found && /^version = / { gsub(/[",]/, "", $3); print $3; exit }
' "$ROOT/Cargo.lock"
}
crate_src() {
local name="$1" version="$2" dir
for dir in "${CARGO_HOME:-$HOME/.cargo}"/registry/src/*/"$name-$version"; do
[ -d "$dir" ] && { printf '%s' "$dir"; return 0; }
done
return 1
}
BTLEPLUG_VERSION="$(crate_version btleplug)"
JNI_UTILS_VERSION="$(crate_version jni-utils)"
if [ -z "$BTLEPLUG_VERSION" ] || [ -z "$JNI_UTILS_VERSION" ]; then
echo "❌ Could not read btleplug/jni-utils versions from Cargo.lock." >&2
exit 1
fi
# jni-utils is an Android-only dependency of btleplug, so a plain `cargo fetch`
# for the host will not have downloaded it.
if ! crate_src jni-utils "$JNI_UTILS_VERSION" >/dev/null; then
echo " ↓ fetching Android-target crate sources"
(cd "$ROOT" && cargo fetch --target aarch64-linux-android >/dev/null)
fi
BTLEPLUG_SRC="$(crate_src btleplug "$BTLEPLUG_VERSION")" || {
echo "❌ btleplug $BTLEPLUG_VERSION sources not found in the cargo registry." >&2
exit 1
}
JNI_UTILS_SRC="$(crate_src jni-utils "$JNI_UTILS_VERSION")" || {
echo "❌ jni-utils $JNI_UTILS_VERSION sources not found in the cargo registry." >&2
exit 1
}
# Drop any previous copy first: a class left behind from an older crate version
# would still compile and would still be found at runtime.
rm -rf "$APP/java/com/nonpolynomial" "$APP/java/io/github/gedgygedgy"
mkdir -p "$APP/java"
cp -r "$BTLEPLUG_SRC/src/droidplug/java/src/main/java/com" "$APP/java/"
echo " ✓ btleplug $BTLEPLUG_VERSION Java backend (com.nonpolynomial.btleplug)"
cp -r "$JNI_UTILS_SRC/java/src/main/java/io" "$APP/java/"
echo " ✓ jni-utils $JNI_UTILS_VERSION Java support (io.github.gedgygedgy.rust)"
echo "✅ Android sources synced"
+14 -1
View File
@@ -37,6 +37,19 @@ tracing-subscriber = { workspace = true }
# Desktop-only: the crate is a `compile_error!` on anything that is not
# Windows/Linux/macOS, and the mobile entry points (G-4) have their own
# platform APIs for this. `wakelock.rs` degrades to a no-op there.
# platform APIs for this. `wakelock.rs` degrades to a no-op there. On Android
# the equivalent is FLAG_KEEP_SCREEN_ON, set in MainActivity.kt.
[target.'cfg(any(windows, target_os = "linux", target_os = "macos"))'.dependencies]
keepawake = "0.6.0"
# Android-only (G-4). `src/android.rs` is the whole JNI surface: btleplug's Java
# backend has to be initialised from a Java thread, and `tracing` has to be
# pointed at logcat.
#
# `jni` is pinned to 0.19 because that is what btleplug 0.11 uses, and
# `platform::init` takes a `&JNIEnv` from *that* version — a 0.21 JNIEnv is a
# different type and would not compile.
[target.'cfg(target_os = "android")'.dependencies]
btleplug = { workspace = true }
jni = "0.19"
libc = "0.2"
+122
View File
@@ -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>
+121
View File
@@ -0,0 +1,121 @@
//! 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.
//!
//! 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 jni::objects::JClass;
use jni::JNIEnv;
use tracing_subscriber::fmt::MakeWriter;
/// `paris.tourolle.bikecontrol.MainActivity.initBtleplug()`.
///
/// btleplug's Android backend ("droidplug") is a hybrid Rust/Java crate: the
/// GATT work happens in Java and Rust drives it over JNI. Before any of it
/// works, `btleplug::platform::init` has to run once with a `JNIEnv` so it can
/// register its native methods on those Java classes and cache their class
/// references.
///
/// That call cannot be made 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 — `com.nonpolynomial.btleplug.…` is not on it. The lookup
/// would fail and the first scan would die with ClassNotFound, seconds after
/// launch and a long way from the cause. So the call comes the other way:
/// `MainActivity.onCreate` invokes this native method on the Android main
/// thread, whose class loader is the app's.
///
/// The symbol name is the JNI mangling of that method and is matched by the
/// runtime, not the compiler: if the Kotlin package or method name changes,
/// this silently stops being called. `MainActivity.kt` and this symbol are a
/// pair.
///
/// Failure is logged rather than fatal. A phone with Bluetooth switched off in
/// Settings fails here, and killing the app over that is worse than starting
/// and reporting "no trainer" on the connection screen — which is what the UI
/// already does for every other flavour of adapter trouble.
#[no_mangle]
pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_initBtleplug(
env: JNIEnv,
_class: JClass,
) {
match btleplug::platform::init(&env) {
Ok(()) => {
READY.store(true, Ordering::Release);
tracing::info!("btleplug Android backend initialised");
}
Err(e) => tracing::error!("btleplug Android backend failed to initialise: {e}"),
}
}
/// Whether `initBtleplug` has succeeded.
///
/// This has to be checked before *every* call into btleplug, because failing
/// soft here is not the same as failing soft downstream: droidplug's
/// `global_adapter()` is an `expect`, so a call made before a successful init
/// panics rather than returning `Err`. Inside the scan task that panic is
/// invisible — it kills the task and scanning is simply dead for the rest of the
/// process (NFR-4). Asking first turns that into an ordinary "no adapter",
/// which the connection screen already knows how to show.
///
/// Not a latch on our own logic: `MainActivity.onResume` retries the init, so
/// this flips to true if the rider switches Bluetooth on and comes back.
static READY: AtomicBool = AtomicBool::new(false);
pub fn ready() -> bool {
READY.load(Ordering::Acquire)
}
// ---------------------------------------------------------------------------
// Logging.
const ANDROID_LOG_INFO: i32 = 4;
const TAG: &str = "BikeControl";
#[link(name = "log")]
extern "C" {
fn __android_log_write(prio: i32, tag: *const libc::c_char, text: *const libc::c_char) -> i32;
}
/// A `tracing` writer that emits to logcat.
///
/// On Android there is no stdout: the default `tracing_subscriber` fmt writer
/// sends every line into a closed file descriptor, so a build that is failing
/// to find the trainer produces exactly no evidence. Routing through liblog
/// puts the same lines under `adb logcat -s BikeControl`.
#[derive(Default, Clone, Copy)]
pub struct Logcat;
impl io::Write for Logcat {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
// Interior NULs cannot reach liblog, and a log line is never worth
// failing a write over, so they are dropped rather than reported.
let text = String::from_utf8_lossy(buf);
let trimmed = text.trim_end();
if !trimmed.is_empty() {
if let (Ok(tag), Ok(msg)) = (CString::new(TAG), CString::new(trimmed)) {
// SAFETY: both pointers are NUL-terminated and outlive the call.
unsafe { __android_log_write(ANDROID_LOG_INFO, tag.as_ptr(), msg.as_ptr()) };
}
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for Logcat {
type Writer = Logcat;
fn make_writer(&'a self) -> Self::Writer {
Logcat
}
}
+31 -2
View File
@@ -37,6 +37,18 @@ use crate::trainer::{TrainerHandle, TrainerStatus};
const SCAN_WINDOW: Duration = Duration::from_millis(2500);
/// Poll interval while scanning is switched off.
const IDLE_POLL: Duration = Duration::from_millis(400);
/// How long to wait before looking for the adapter again. Longer than the scan
/// cadence: nothing the rider can do about a missing radio happens in 400 ms.
const ADAPTER_RETRY: Duration = Duration::from_secs(2);
/// What to suggest when there is no adapter. The remedy is platform-specific
/// 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.";
#[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")))]
const ADAPTER_HINT: &str = "Check the radio is on.";
/// Heart Rate Service, so an HRM in the room is labelled rather than "unknown".
const HEART_RATE_SERVICE: Uuid = Uuid::from_u128(0x0000180d_0000_1000_8000_00805f9b34fb);
@@ -543,6 +555,23 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
continue;
}
// Android only: the Java backend is initialised from MainActivity and
// calling btleplug before that has succeeded panics rather than erroring
// (see `android::ready`). Bluetooth switched off at launch is enough to
// 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() {
generation += 1;
let _ = tx.send(ScanSnapshot {
devices: Vec::new(),
error: Some("Bluetooth is unavailable. Check it is switched on.".into()),
generation,
});
tokio::time::sleep(ADAPTER_RETRY).await;
continue;
}
let adapter = match scan::default_adapter().await {
Ok(a) => a,
Err(e) => {
@@ -550,10 +579,10 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
generation += 1;
let _ = tx.send(ScanSnapshot {
devices: Vec::new(),
error: Some(format!("{e}. Check the radio is on and BlueZ is running.")),
error: Some(format!("{e}. {ADAPTER_HINT}")),
generation,
});
tokio::time::sleep(Duration::from_secs(2)).await;
tokio::time::sleep(ADAPTER_RETRY).await;
continue;
}
};
+26 -5
View File
@@ -5,6 +5,8 @@
//! lives in `bikecontrol-core`, and device I/O in `bikecontrol-ble` — the
//! webview reaches neither directly (§4.3).
#[cfg(target_os = "android")]
pub mod android;
pub mod backend;
pub mod commands;
pub mod controller;
@@ -23,14 +25,33 @@ use tauri::{Manager, RunEvent, WindowEvent};
use crate::state::AppState;
pub fn run() {
/// Set up `tracing` for whatever this platform calls "somewhere I can read it".
fn init_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,bikecontrol_app_lib=debug".into());
// Android has no stdout: the default writer would drop every line. See
// `android::Logcat`.
#[cfg(target_os = "android")]
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,bikecontrol_app_lib=debug".into()),
)
.with_env_filter(filter)
.with_ansi(false)
.with_writer(android::Logcat)
.init();
#[cfg(not(target_os = "android"))]
tracing_subscriber::fmt().with_env_filter(filter).init();
}
/// The entry point, on every platform.
///
/// `main.rs` calls this on desktop. On Android there is no `main`: the
/// attribute below generates the `start_app` symbol that Tauri's generated
/// Kotlin invokes, which is why the whole app lives in a lib crate.
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
init_tracing();
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(AppState::new())