diff --git a/.gitignore b/.gitignore index d001257..af24624 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 4ff9898..561008b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -242,8 +242,11 @@ dependencies = [ "bikecontrol-ble", "bikecontrol-core", "bikecontrol-fit", + "btleplug", "chrono", + "jni 0.19.0", "keepawake", + "libc", "roxmltree", "serde", "serde_json", diff --git a/scripts/check-android-sources.sh b/scripts/check-android-sources.sh new file mode 100755 index 0000000..65deba4 --- /dev/null +++ b/scripts/check-android-sources.sh @@ -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 /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/" diff --git a/scripts/sync-android-sources.sh b/scripts/sync-android-sources.sh new file mode 100755 index 0000000..a740fcc --- /dev/null +++ b/scripts/sync-android-sources.sh @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9e8e3f4..66c4422 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/android/app/build.gradle.kts b/src-tauri/android/app/build.gradle.kts new file mode 100644 index 0000000..9aa7c9b --- /dev/null +++ b/src-tauri/android/app/build.gradle.kts @@ -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") diff --git a/src-tauri/android/app/proguard-bikecontrol.pro b/src-tauri/android/app/proguard-bikecontrol.pro new file mode 100644 index 0000000..596b007 --- /dev/null +++ b/src-tauri/android/app/proguard-bikecontrol.pro @@ -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 ; +} diff --git a/src-tauri/android/src/main/AndroidManifest.xml b/src-tauri/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c418732 --- /dev/null +++ b/src-tauri/android/src/main/AndroidManifest.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000..af5564c --- /dev/null +++ b/src-tauri/android/src/main/java/paris/tourolle/bikecontrol/MainActivity.kt @@ -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 + 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 = + 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) + } + } +} diff --git a/src-tauri/android/src/main/res/values/colors.xml b/src-tauri/android/src/main/res/values/colors.xml new file mode 100644 index 0000000..a4412d1 --- /dev/null +++ b/src-tauri/android/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + + #FF05070A + diff --git a/src-tauri/android/src/main/res/values/strings.xml b/src-tauri/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..d552bce --- /dev/null +++ b/src-tauri/android/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + BikeControl + BikeControl + diff --git a/src-tauri/android/src/main/res/values/themes.xml b/src-tauri/android/src/main/res/values/themes.xml new file mode 100644 index 0000000..0da634c --- /dev/null +++ b/src-tauri/android/src/main/res/values/themes.xml @@ -0,0 +1,19 @@ + + + + + diff --git a/src-tauri/src/android.rs b/src-tauri/src/android.rs new file mode 100644 index 0000000..559615c --- /dev/null +++ b/src-tauri/src/android.rs @@ -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 { + // 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 + } +} diff --git a/src-tauri/src/devices.rs b/src-tauri/src/devices.rs index 389a20b..a7fa590 100644 --- a/src-tauri/src/devices.rs +++ b/src-tauri/src/devices.rs @@ -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, tx: watch::Sender a, Err(e) => { @@ -550,10 +579,10 @@ async fn scan_loop(mut on: watch::Receiver, tx: watch::Sender