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,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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user