From cc9c1dbb397d275652ff6869f4976362e4907d52 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 19 Aug 2026 18:46:38 +0200 Subject: [PATCH] Initialise jni-utils, or every connect panics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanning worked on the tablet and connecting did not, and the reason was one missing call. btleplug's Android backend hands Rust its results as Java future objects wrapped by jni-utils, and every one of those wrappers resolves its class through a cache. Only `jni_utils::init` fills that cache. droidplug does not call it — its own `init` registers droidplug's classes and assumes the application has already done jni-utils' — and nothing else did either, so the cache held droidplug's seven classes and none of jni-utils' ten. That split the BLE stack in half exactly where the symptom appeared. Scan results arrive on a plain JNI callback and never touch a future, so scanning was perfect. `connect` is the first path that awaits one, and `JFuture::from_env` unwraps `get_class("…/future/Future")` — None — straight into a panic on the runtime thread, taking the trainer supervisor task with it. What reached the log was "trainer command dropped — supervisor queue full or closed", which describes the corpse rather than the cause; the panic itself only appeared under RustStdoutStderr, and only because the Android target routes stdout to logcat. The GATT link was fine throughout, which is what made this confusing to read: Android logged `onClientConnectionState … status=0 connected=true` for the trainer a second *after* the task waiting for it had died. Pinned to 0.1.1 deliberately. The cache is a static inside jni-utils, so a second copy at a different version is a second, empty cache and the panic comes back. Verified on the tablet (Android 16, aarch64): both Click pods connect on their own, and the trainer reaches state=Controlling with its FTMS capabilities read back. Zero panics in the process log. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + src-tauri/Cargo.toml | 5 +++++ src-tauri/src/android.rs | 13 +++++++++++++ 3 files changed, 19 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 561008b..4d95a6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,6 +245,7 @@ dependencies = [ "btleplug", "chrono", "jni 0.19.0", + "jni-utils", "keepawake", "libc", "roxmltree", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 66c4422..07e0b9c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,3 +53,8 @@ keepawake = "0.6.0" btleplug = { workspace = true } jni = "0.19" libc = "0.2" +# Pinned to the version btleplug 0.11 resolves to, and it must stay that way: +# the class cache `init` populates is a static *inside this crate*, so a second +# copy at a different version would be a second, empty cache and `connect` +# would go back to panicking. +jni-utils = "0.1.1" diff --git a/src-tauri/src/android.rs b/src-tauri/src/android.rs index 98327f0..6ad2620 100644 --- a/src-tauri/src/android.rs +++ b/src-tauri/src/android.rs @@ -55,6 +55,19 @@ pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_initBtleplug let _ = JVM.set(vm); } + // btleplug's futures are Java objects wrapped by `jni-utils`, and every one + // of its wrappers resolves its class through a cache that only + // `jni_utils::init` fills. droidplug does not call it — its own `init` + // registers droidplug's classes and assumes the application has already + // done this one. Miss it and scanning still works, because scan results + // arrive on a plain callback, while the first `connect` panics on an + // unwrapped `None` deep inside jni-utils, killing the task that was + // connecting. Order matters: this must come first. + if let Err(e) = jni_utils::init(&env) { + tracing::error!("jni-utils failed to initialise: {e}"); + return; + } + match btleplug::platform::init(&env) { Ok(()) => { READY.store(true, Ordering::Release);