From 5d4c95c821bdf6e5d892d47b4ba07dde5dde9985 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 9 Oct 2026 04:57:05 +0200 Subject: [PATCH 1/6] Join a nearby host through btleplug's Bluetooth central on Android (R76 slice 6) --- Cargo.lock | 267 +++++- crates/connetto-client/Cargo.toml | 10 +- crates/connetto-client/src/bluetooth.rs | 26 +- .../connetto-client/src/bluetooth/central.rs | 366 ++++++++ crates/connetto-client/src/builder/native.rs | 13 +- crates/connetto-peer-android/Cargo.toml | 31 +- crates/connetto-peer-android/README.md | 6 +- .../android/btleplug-LICENSE.md | 298 +++++++ .../android/build.gradle.kts | 3 + .../android/consumer-rules.pro | 4 + .../btleplug/android/impl/Adapter.java | 78 ++ .../android/impl/BluetoothException.java | 11 + .../impl/NoBluetoothAdapterException.java | 4 + .../impl/NoSuchCharacteristicException.java | 4 + .../android/impl/NotConnectedException.java | 4 + .../btleplug/android/impl/Peripheral.java | 830 ++++++++++++++++++ .../impl/PermissionDeniedException.java | 11 + .../btleplug/android/impl/ScanFilter.java | 21 + .../impl/UnexpectedCallbackException.java | 4 + .../UnexpectedCharacteristicException.java | 4 + .../github/gedgygedgy/rust/future/Future.java | 29 + .../rust/future/FutureException.java | 13 + .../gedgygedgy/rust/future/SimpleFuture.java | 78 ++ .../github/gedgygedgy/rust/ops/FnAdapter.java | 29 + .../gedgygedgy/rust/ops/FnBiFunction.java | 36 + .../gedgygedgy/rust/ops/FnBiFunctionImpl.java | 21 + .../gedgygedgy/rust/ops/FnFunction.java | 36 + .../gedgygedgy/rust/ops/FnFunctionImpl.java | 21 + .../gedgygedgy/rust/ops/FnRunnable.java | 35 + .../gedgygedgy/rust/ops/FnRunnableImpl.java | 21 + .../gedgygedgy/rust/panic/PanicException.java | 18 + .../gedgygedgy/rust/stream/QueueStream.java | 83 ++ .../github/gedgygedgy/rust/stream/Stream.java | 36 + .../gedgygedgy/rust/stream/StreamPoll.java | 15 + .../gedgygedgy/rust/task/PollResult.java | 19 + .../io/github/gedgygedgy/rust/task/Waker.java | 40 + .../rust/thread/LocalThreadChecker.java | 15 + .../rust/thread/LocalThreadException.java | 12 + crates/connetto-peer-android/src/android.rs | 79 +- crates/connetto-peer-android/src/lib.rs | 10 +- .../src/bin/connetto-android-proof.rs | 86 +- examples/dioxus-desktop-demo/Cargo.lock | 267 +++++- examples/dioxus-desktop-demo/src/main.rs | 72 ++ plans/master-implementation-plan.md | 3 +- 44 files changed, 2997 insertions(+), 72 deletions(-) create mode 100644 crates/connetto-client/src/bluetooth/central.rs create mode 100644 crates/connetto-peer-android/android/btleplug-LICENSE.md create mode 100644 crates/connetto-peer-android/android/consumer-rules.pro create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/Adapter.java create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/BluetoothException.java create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NoBluetoothAdapterException.java create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NoSuchCharacteristicException.java create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NotConnectedException.java create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/Peripheral.java create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/PermissionDeniedException.java create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/ScanFilter.java create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/UnexpectedCallbackException.java create mode 100644 crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/UnexpectedCharacteristicException.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/Future.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/FutureException.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/SimpleFuture.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnAdapter.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnBiFunction.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnBiFunctionImpl.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnFunction.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnFunctionImpl.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnRunnable.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnRunnableImpl.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/panic/PanicException.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/QueueStream.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/Stream.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/StreamPoll.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/task/PollResult.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/task/Waker.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/thread/LocalThreadChecker.java create mode 100644 crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/thread/LocalThreadException.java diff --git a/Cargo.lock b/Cargo.lock index 269350a3..2121314c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -734,6 +734,35 @@ dependencies = [ "objc2", ] +[[package]] +name = "bluez-async" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" +dependencies = [ + "bitflags 2.13.2", + "bluez-generated", + "dbus", + "dbus-tokio", + "futures", + "itertools 0.14.0", + "log", + "serde", + "serde-xml-rs", + "thiserror 2.0.20", + "tokio", + "uuid", +] + +[[package]] +name = "bluez-generated" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9676783265eadd6f11829982792c6f303f3854d014edfba384685dcf237dd062" +dependencies = [ + "dbus", +] + [[package]] name = "bollard" version = "0.21.1" @@ -816,6 +845,32 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "btleplug" +version = "0.13.4" +source = "git+https://github.com/LucaCappelletti94/btleplug?rev=5f6b3686849ed5617bebba136dc29ac253e71937#5f6b3686849ed5617bebba136dc29ac253e71937" +dependencies = [ + "async-trait", + "bitflags 2.13.2", + "bluez-async", + "dashmap", + "dbus", + "futures", + "jni 0.22.4", + "log", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "once_cell", + "static_assertions", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "uuid", + "windows 0.62.2", + "windows-future 0.3.2", +] + [[package]] name = "built" version = "0.8.1" @@ -1180,11 +1235,13 @@ dependencies = [ "apple-native-keyring-store", "axum", "base64 0.22.1", + "btleplug", "chacha20poly1305", "chrono", "connetto-app-attest", "connetto-core", "connetto-peer", + "connetto-peer-android", "connetto-schema", "connetto-server", "connetto-test-harness", @@ -1396,6 +1453,7 @@ dependencies = [ name = "connetto-peer-android" version = "0.0.0" dependencies = [ + "jni 0.22.4", "manganis", "thiserror 2.0.20", ] @@ -1957,12 +2015,50 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + [[package]] name = "defmt" version = "1.1.1" @@ -3847,6 +3943,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -4140,7 +4242,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -4783,6 +4885,15 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libfuzzer-sys" version = "0.4.13" @@ -5718,6 +5829,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-bluetooth" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b30b9eacc37434a61377866f68b27acef9fa5496f25cc9ca8b2549032e0394" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -7977,6 +8099,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "serde-xml-rs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2215ce3e6a77550b80a1c37251b7d294febaf42e36e21b7b411e0bf54d540d" +dependencies = [ + "log", + "serde", + "thiserror 2.0.20", + "xml", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -8546,6 +8680,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -8788,8 +8928,8 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", "windows-version", "x11-dl", ] @@ -9067,6 +9207,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -9988,8 +10129,8 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", "windows-implement", "windows-interface", ] @@ -10012,8 +10153,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] @@ -10072,11 +10213,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -10085,7 +10238,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core", + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", ] [[package]] @@ -10097,8 +10259,21 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -10107,9 +10282,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -10170,10 +10356,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -10183,6 +10379,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -10192,6 +10397,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -10294,6 +10508,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -10518,8 +10741,8 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", "windows-version", ] @@ -10604,6 +10827,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "y4m" version = "0.8.0" diff --git a/crates/connetto-client/Cargo.toml b/crates/connetto-client/Cargo.toml index 496de21f..9e9e310f 100644 --- a/crates/connetto-client/Cargo.toml +++ b/crates/connetto-client/Cargo.toml @@ -44,7 +44,7 @@ postgres-types = ["diesel/postgres_backend"] device-identity = ["native-auth", "connetto-core/device-cert", "dep:ring", "dep:core-foundation", "dep:rmp-serde", "dep:serde_bytes", "dep:connetto-app-attest"] # The peer link (R76): the device serves its identity to other devices over # a second, server-independent channel, and dials them. -peer = ["device-identity", "dep:connetto-peer"] +peer = ["device-identity", "dep:connetto-peer", "dep:btleplug", "dep:connetto-peer-android"] native-auth = [ "native-transport", "dep:reqwest", @@ -256,6 +256,14 @@ android_system_properties = { version = "0.1.6", optional = true } socket2 = "0.6" # The joined network's subnet, the targets its bind admits (R76). ipnet = "2.12" +# The joiner's Bluetooth central (R76 decision 22), pinned at the fork's fix +# of its Android init until a release carries deviceplug/btleplug#495. +btleplug = { git = "https://github.com/LucaCappelletti94/btleplug", rev = "5f6b3686849ed5617bebba136dc29ac253e71937", optional = true } +# The jni 0.22 virtual machine btleplug's init takes, and btleplug's Java +# bundled in its Gradle module (R76 decision 22). +connetto-peer-android = { path = "../connetto-peer-android", optional = true } +# The streams btleplug's central answers with (R76 decision 22). +futures-util = { version = "0.3", default-features = false, features = ["std"], optional = true } libsqlite3-sys = { version = ">=0.38, <0.39", features = [ "bundled-sqlcipher-vendored-openssl", ] } diff --git a/crates/connetto-client/src/bluetooth.rs b/crates/connetto-client/src/bluetooth.rs index 4dc8d418..65b1b4f2 100644 --- a/crates/connetto-client/src/bluetooth.rs +++ b/crates/connetto-client/src/bluetooth.rs @@ -28,9 +28,13 @@ use crate::hotspot::{ #[cfg(target_os = "android")] mod android; +#[cfg(target_os = "android")] +mod central; #[cfg(target_os = "android")] pub(crate) use android::AndroidBluetoothBackend; +#[cfg(target_os = "android")] +pub(crate) use central::BtleplugCentral; /// The pace of the machine's look while an exchange runs. const FAST_TICK: Duration = Duration::from_millis(20); @@ -200,10 +204,10 @@ pub enum PeripheralEvent { /// An event the joiner's central reports (R76). #[cfg_attr( - not(test), + not(any(test, target_os = "android")), expect( dead_code, - reason = "the central's backend arrives with btleplug (R76 decision 20), so only the proofs construct these events" + reason = "btleplug's central runs on Android only until R76's slice 8, so elsewhere only the proofs construct these events" ) )] #[derive(Clone, Debug)] @@ -214,8 +218,8 @@ pub enum CentralEvent { host: HostId, /// The beacon's service data. service_data: Vec, - /// The signal's strength. - rssi: i16, + /// The signal's strength, where the platform reports one. + rssi: Option, }, /// The host connected, with the negotiated packet size. Connected { @@ -1160,31 +1164,31 @@ impl Machine { HostRecord { prefix: beacon.prefix, last_seen: now, - rssi: Some(rssi), + rssi, tried: false, }, ); (self.emit)(ClientEvent::HostNearby { host, prefix: beacon.prefix, - rssi: Some(rssi), + rssi, }); } // A known prefix refreshes its signal. Some(record) if record.prefix == beacon.prefix => { record.last_seen = now; - record.rssi = Some(rssi); + record.rssi = rssi; } // A new prefix is a new host, untried again. Some(record) => { record.prefix = beacon.prefix; record.last_seen = now; - record.rssi = Some(rssi); + record.rssi = rssi; record.tried = false; (self.emit)(ClientEvent::HostNearby { host, prefix: beacon.prefix, - rssi: Some(rssi), + rssi, }); } } @@ -2220,7 +2224,7 @@ mod tests { self.central.push(CentralEvent::Seen { host, service_data: Beacon::of(host_fp).to_service_data().to_vec(), - rssi, + rssi: Some(rssi), }); } @@ -2722,7 +2726,7 @@ mod tests { pair.joiner_central.push(CentralEvent::Seen { host: device, service_data: Beacon::of(&pair.host_fp).to_service_data().to_vec(), - rssi: -40, + rssi: Some(-40), }); pair.joiner.tick(None, false); let (join_tx, mut join_rx) = oneshot::channel(); diff --git a/crates/connetto-client/src/bluetooth/central.rs b/crates/connetto-client/src/bluetooth/central.rs new file mode 100644 index 00000000..1d9a4421 --- /dev/null +++ b/crates/connetto-client/src/bluetooth/central.rs @@ -0,0 +1,366 @@ +//! The joiner's Bluetooth central over btleplug (R76 decision 22). +//! +//! btleplug runs on one thread of its own, prepared once for the platform +//! and driving its own runtime. The machine's polled calls reach it over a +//! channel, and its scan results, connections and notifications come back +//! through a shared queue the machine drains. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use btleplug::api::{ + Central as _, CentralEvent as BtEvent, Characteristic, Manager as _, Peripheral as _, + ScanFilter, WriteType, +}; +use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId}; +use connetto_peer::{INBOX_UUID, OUTBOX_UUID, SERVICE_UUID}; +use futures_util::StreamExt as _; +use parking_lot::Mutex; +use tokio::sync::mpsc; +use uuid::Uuid; + +use super::{BluetoothError, CentralBackend, CentralEvent, HostId}; + +/// The bound btleplug's thread gets to prepare and find its adapter. +const START_BOUND: Duration = Duration::from_secs(10); +/// The bound a connection and its service discovery each get. +const LINK_STEP_BOUND: Duration = Duration::from_secs(10); + +/// What the machine asks of btleplug's thread. +enum Command { + StartScan, + StopScan, + Connect(HostId), + Write(HostId, Vec), + Disconnect(HostId), +} + +/// The events btleplug's thread reports, drained by the machine. +type Queue = Arc>>; + +/// Why btleplug's central does not start. +#[derive(Debug, thiserror::Error)] +pub(crate) enum CentralError { + /// The platform's preparation of btleplug's thread failed. + #[error("btleplug's thread could not be prepared: {0}")] + Prepare(String), + /// The virtual machine refused a call while the thread was prepared. + #[error("the virtual machine refused: {0}")] + Jni(#[from] connetto_peer_android::jni::errors::Error), + /// btleplug refused a call. + #[error("btleplug refused: {0}")] + Btleplug(#[from] btleplug::Error), + /// The platform shows no Bluetooth adapter. + #[error("the platform shows no Bluetooth adapter")] + NoAdapter, + /// The thread or its runtime did not start. + #[error("btleplug's thread did not start: {0}")] + Thread(String), + /// The thread did not answer within its bound. + #[error("btleplug's thread did not answer within {0:?}")] + TimedOut(Duration), +} + +/// The joiner's central over btleplug (R76 decision 22). +pub(crate) struct BtleplugCentral { + commands: mpsc::UnboundedSender, + events: Queue, +} + +impl BtleplugCentral { + /// Start btleplug's thread, answering once it found its adapter. + /// + /// # Errors + /// + /// [`CentralError`] when the thread cannot be prepared, btleplug finds no + /// adapter, or the thread does not answer within its bound. + pub(crate) async fn start() -> Result { + let (commands, receiver) = mpsc::unbounded_channel(); + let events: Queue = Arc::new(Mutex::new(Vec::new())); + let (started, answer) = tokio::sync::oneshot::channel(); + let queue = Arc::clone(&events); + std::thread::Builder::new() + .name("connetto-btleplug".into()) + // A thread of its own, which no runtime owns, so its runtime + // blocks on btleplug's loop. + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(err) => { + let _ = started.send(Err(CentralError::Thread(err.to_string()))); + return; + } + }; + runtime.block_on(async move { + let adapter = match prepare().await { + Ok(adapter) => adapter, + Err(err) => { + let _ = started.send(Err(err)); + return; + } + }; + let _ = started.send(Ok(())); + drive(adapter, receiver, queue).await; + }); + }) + .map_err(|err| CentralError::Thread(err.to_string()))?; + match tokio::time::timeout(START_BOUND, answer).await { + Ok(Ok(Ok(()))) => Ok(Self { commands, events }), + Ok(Ok(Err(err))) => Err(err), + Ok(Err(_)) => Err(CentralError::Thread( + "the thread ended before it answered".into(), + )), + Err(_) => Err(CentralError::TimedOut(START_BOUND)), + } + } + + fn send(&self, command: Command) { + if self.commands.send(command).is_err() { + tracing::warn!("btleplug's thread has ended"); + } + } +} + +impl CentralBackend for BtleplugCentral { + fn start_scan(&self) { + self.send(Command::StartScan); + } + + fn stop_scan(&self) { + self.send(Command::StopScan); + } + + fn poll(&self) -> Vec { + core::mem::take(&mut *self.events.lock()) + } + + fn connect(&self, host: HostId) { + self.send(Command::Connect(host)); + } + + fn write(&self, host: HostId, bytes: &[u8]) -> Result<(), BluetoothError> { + self.commands + .send(Command::Write(host, bytes.to_vec())) + .map_err(|_| BluetoothError::Failed("btleplug's thread has ended".into())) + } + + fn disconnect(&self, host: HostId) { + self.send(Command::Disconnect(host)); + } +} + +/// Prepare the thread for btleplug and find its first adapter. +async fn prepare() -> Result { + prepare_thread()?; + let manager = Manager::new().await?; + manager + .adapters() + .await? + .into_iter() + .next() + .ok_or(CentralError::NoAdapter) +} + +/// Attach the thread to the virtual machine for good, point its class loader +/// at the application's so btleplug's lookups find its Java, and initialise +/// btleplug's Android half (R76 decision 22). +#[cfg(target_os = "android")] +fn prepare_thread() -> Result<(), CentralError> { + let vm = + connetto_peer_android::java_vm().map_err(|err| CentralError::Prepare(err.to_string()))?; + vm.attach_current_thread(|env| -> Result<(), CentralError> { + connetto_peer_android::use_application_class_loader(env)?; + btleplug::platform::init(env)?; + Ok(()) + }) +} + +/// The thread's loop: the machine's commands against btleplug, and +/// btleplug's events back to the machine, until the client drops its end. +async fn drive(adapter: Adapter, mut commands: mpsc::UnboundedReceiver, queue: Queue) { + let mut bt_events = match adapter.events().await { + Ok(events) => events, + Err(err) => { + tracing::warn!(%err, "btleplug's events could not be read"); + return; + } + }; + let mut hosts = Hosts::default(); + let mut links: HashMap>> = HashMap::new(); + loop { + tokio::select! { + command = commands.recv() => { + let Some(command) = command else { break }; + match command { + Command::StartScan => { + if let Err(err) = adapter.start_scan(ScanFilter::default()).await { + tracing::warn!(%err, "the scan did not start"); + } + } + Command::StopScan => { + if let Err(err) = adapter.stop_scan().await { + tracing::warn!(%err, "the scan did not stop"); + } + } + Command::Connect(host) => { + let Some(id) = hosts.id(host) else { + push(&queue, CentralEvent::Disconnected { host }); + continue; + }; + match adapter.peripheral(id).await { + Ok(peripheral) => { + let (writes, pending) = mpsc::unbounded_channel(); + links.insert(host, writes); + tokio::spawn(link(peripheral, host, pending, Arc::clone(&queue))); + } + Err(err) => { + tracing::warn!(%err, "the host is unknown to btleplug"); + push(&queue, CentralEvent::Disconnected { host }); + } + } + } + Command::Write(host, bytes) => { + if links.get(&host).is_none_or(|link| link.send(bytes).is_err()) { + push(&queue, CentralEvent::Disconnected { host }); + } + } + // The link's write queue closes, so its task disconnects. + Command::Disconnect(host) => { + links.remove(&host); + } + } + } + event = bt_events.next() => { + let Some(event) = event else { break }; + match event { + BtEvent::ServiceDataAdvertisement { id, service_data } => { + let Some(data) = service_data.get(&Uuid::from_bytes(SERVICE_UUID)) else { + continue; + }; + let rssi = rssi_of(&adapter, &id).await; + let host = hosts.host(id); + push(&queue, CentralEvent::Seen { host, service_data: data.clone(), rssi }); + } + BtEvent::DeviceDisconnected(id) => { + if let Some(host) = hosts.known(&id) && links.remove(&host).is_some() { + push(&queue, CentralEvent::Disconnected { host }); + } + } + _ => {} + } + } + } + } + let _ = adapter.stop_scan().await; +} + +/// The signal of the peripheral's last advertisement, where the platform +/// reports one. +async fn rssi_of(adapter: &Adapter, id: &PeripheralId) -> Option { + let peripheral = adapter.peripheral(id).await.ok()?; + peripheral.properties().await.ok()??.rssi +} + +/// One connection to a host: connect, find the two characteristics, +/// subscribe, then carry the machine's writes out in order and the host's +/// notifications in, until either side ends it. +async fn link( + peripheral: Peripheral, + host: HostId, + mut writes: mpsc::UnboundedReceiver>, + queue: Queue, +) { + let ready = async { + peripheral.connect_with_timeout(LINK_STEP_BOUND).await?; + peripheral + .discover_services_with_timeout(LINK_STEP_BOUND) + .await?; + let characteristics = peripheral.characteristics(); + let find = |uuid: [u8; 16]| -> Result { + characteristics + .iter() + .find(|characteristic| characteristic.uuid == Uuid::from_bytes(uuid)) + .cloned() + .ok_or(btleplug::Error::NoSuchCharacteristic) + }; + let inbox = find(INBOX_UUID)?; + let outbox = find(OUTBOX_UUID)?; + peripheral.subscribe(&outbox).await?; + let notifications = peripheral.notifications().await?; + Ok::<_, btleplug::Error>((inbox, outbox, notifications)) + } + .await; + let (inbox, outbox, mut notifications) = match ready { + Ok(ready) => ready, + Err(err) => { + tracing::warn!(%err, "the host's link did not settle"); + let _ = peripheral.disconnect().await; + push(&queue, CentralEvent::Disconnected { host }); + return; + } + }; + push( + &queue, + CentralEvent::Connected { + host, + mtu: peripheral.mtu(), + }, + ); + loop { + tokio::select! { + outgoing = writes.recv() => { + let Some(bytes) = outgoing else { break }; + if let Err(err) = peripheral.write(&inbox, &bytes, WriteType::WithResponse).await { + tracing::warn!(%err, "a chunk to the host did not go"); + break; + } + } + notification = notifications.next() => { + let Some(notification) = notification else { break }; + if notification.uuid == outbox.uuid { + push(&queue, CentralEvent::Chunk { host, bytes: notification.value }); + } + } + } + } + let _ = peripheral.disconnect().await; + push(&queue, CentralEvent::Disconnected { host }); +} + +fn push(queue: &Queue, event: CentralEvent) { + queue.lock().push(event); +} + +/// The client's keys for the peripherals btleplug names, so a host keeps one +/// key for as long as the client runs. +#[derive(Default)] +struct Hosts { + by_id: HashMap, + by_host: HashMap, + next: HostId, +} + +impl Hosts { + /// The peripheral's key, minted on its first sighting. + fn host(&mut self, id: PeripheralId) -> HostId { + if let Some(host) = self.by_id.get(&id) { + return *host; + } + self.next += 1; + self.by_id.insert(id.clone(), self.next); + self.by_host.insert(self.next, id); + self.next + } + + fn known(&self, id: &PeripheralId) -> Option { + self.by_id.get(id).copied() + } + + fn id(&self, host: HostId) -> Option<&PeripheralId> { + self.by_host.get(&host) + } +} diff --git a/crates/connetto-client/src/builder/native.rs b/crates/connetto-client/src/builder/native.rs index 5506f103..539374c3 100644 --- a/crates/connetto-client/src/builder/native.rs +++ b/crates/connetto-client/src/builder/native.rs @@ -931,9 +931,16 @@ where Arc::clone(&backend) as Arc, Arc::clone(&backend) as Arc, - // The Android central is btleplug's, which a later slice - // brings (R76 decision 20). - None, + // btleplug's central, absent where its thread + // does not start (R76 decision 22). + match crate::bluetooth::BtleplugCentral::start().await { + Ok(central) => Some(Arc::new(central) + as Arc), + Err(err) => { + tracing::warn!(%err, "the Bluetooth central did not start"); + None + } + }, ) } None => crate::bluetooth::unsupported_backends(), diff --git a/crates/connetto-peer-android/Cargo.toml b/crates/connetto-peer-android/Cargo.toml index 85c06315..2083ac78 100644 --- a/crates/connetto-peer-android/Cargo.toml +++ b/crates/connetto-peer-android/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "connetto-peer-android" -description = "The Android permissions the connetto peer link asks for, and the bundled Kotlin module that hosts and joins the local-only hotspot behind the peer link." +description = "The Android permissions the connetto peer link asks for, the bundled Kotlin module behind its hotspot and Bluetooth beacon, and the virtual machine and Java btleplug needs as its Bluetooth central." version.workspace = true edition.workspace = true rust-version.workspace = true @@ -10,8 +10,30 @@ repository.workspace = true homepage.workspace = true publish = false -[lints] -workspace = true +# The workspace's lints, with `unsafe_code` lowered from forbid to deny, so +# the one call building btleplug's virtual machine carries its own `expect` +# (R76 decisions 17 and 22). +[lints.rust] +missing_docs = "forbid" +unused_macro_rules = "forbid" +unconditional_recursion = "forbid" +unreachable_patterns = "forbid" +unused_import_braces = "forbid" +unused_must_use = "forbid" +unsafe_code = "deny" +unsafe_op_in_unsafe_fn = "forbid" +deprecated = "deny" +unknown_lints = "allow" + +[lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } +must_use_candidate = "allow" +module_name_repetitions = "allow" +allow_attributes = "deny" +allow_attributes_without_reason = "deny" +undocumented_unsafe_blocks = "deny" +multiple_unsafe_ops_per_block = "deny" [dependencies] thiserror.workspace = true @@ -21,3 +43,6 @@ thiserror.workspace = true # `android::with_activity` reaches the Activity, without code of our own # touching raw JNI pointers. manganis = "0.7.10" +# The jni btleplug's Android init takes (R76 decision 22), handed to the +# client as a safe virtual machine. +jni = "0.22" diff --git a/crates/connetto-peer-android/README.md b/crates/connetto-peer-android/README.md index b19e9727..872ff331 100644 --- a/crates/connetto-peer-android/README.md +++ b/crates/connetto-peer-android/README.md @@ -1,10 +1,12 @@ -# The peer link's Android permissions +# The peer link's Android permissions and Bluetooth glue [![Tests](https://github.com/LucaCappelletti94/connetto-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/LucaCappelletti94/connetto-rs/actions) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/LucaCappelletti94/connetto-rs/blob/main/LICENSE) [![Coverage](https://codecov.io/gh/LucaCappelletti94/connetto-rs/graph/badge.svg)](https://codecov.io/gh/LucaCappelletti94/connetto-rs) -The peer link needs two Android permissions: `CHANGE_WIFI_MULTICAST_STATE`, for the mDNS discovery that finds a peer's link, and `NEARBY_WIFI_DEVICES` (API 33 and later), for hosting and joining the local-only hotspot a peer dials through. This crate asks the operating system for whichever is missing and bundles the Kotlin module the rest of the peer link drives over JNI. +The peer link needs these Android permissions: `CHANGE_WIFI_MULTICAST_STATE`, for the mDNS discovery that finds a peer's link, `NEARBY_WIFI_DEVICES` (API 33 and later), for hosting and joining the local-only hotspot a peer dials through, and `BLUETOOTH_ADVERTISE`, `BLUETOOTH_CONNECT` and `BLUETOOTH_SCAN` (API 31 and later), for the beacon that hands the hotspot to a nearby device. This crate asks the operating system for whichever is missing and bundles the Kotlin module the rest of the peer link drives over JNI. + +It also bundles btleplug's Java, which the client's Bluetooth central needs in the application, under btleplug's licence in `android/btleplug-LICENSE.md`, with R8 rules keeping it in a shrinking build. `java_vm` hands btleplug the process's virtual machine under the jni btleplug links, and `use_application_class_loader` lets a thread attached from native code find btleplug's classes. `request_peer_permissions` opens the prompt on the Activity, so it runs on the UI thread only. Off Android it does nothing. diff --git a/crates/connetto-peer-android/android/btleplug-LICENSE.md b/crates/connetto-peer-android/android/btleplug-LICENSE.md new file mode 100644 index 00000000..be3eed59 --- /dev/null +++ b/crates/connetto-peer-android/android/btleplug-LICENSE.md @@ -0,0 +1,298 @@ +btleplug is covered under the following BSD-3-Clause license: + +Copyright (c) 2020-2021, Nonpolynomial +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +* Neither the name of btleplug nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +Some parts of btleplug were forked from Rumble, which is dual-covered +under the following MIT/Apache Licenses: + +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +--- + +Some parts of btleplug were forked from blurmac, which is covered +under the following BSD 3-Clause License: + +Copyright (c) 2017 Akos Kiss. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/crates/connetto-peer-android/android/build.gradle.kts b/crates/connetto-peer-android/android/build.gradle.kts index 4c15d641..63d76852 100644 --- a/crates/connetto-peer-android/android/build.gradle.kts +++ b/crates/connetto-peer-android/android/build.gradle.kts @@ -11,6 +11,9 @@ android { defaultConfig { minSdk = 24 + // btleplug's Java is reached from native code only, so a shrinking + // application build keeps it through these rules. + consumerProguardFiles("consumer-rules.pro") } compileOptions { diff --git a/crates/connetto-peer-android/android/consumer-rules.pro b/crates/connetto-peer-android/android/consumer-rules.pro new file mode 100644 index 00000000..b56980b7 --- /dev/null +++ b/crates/connetto-peer-android/android/consumer-rules.pro @@ -0,0 +1,4 @@ +# btleplug's Java and its vendored jni-utils are reached only through JNI, +# so R8 would strip them as unused. +-keep class com.nonpolynomial.** { *; } +-keep class io.github.gedgygedgy.** { *; } diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/Adapter.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/Adapter.java new file mode 100644 index 00000000..25807b3f --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/Adapter.java @@ -0,0 +1,78 @@ +package com.nonpolynomial.btleplug.android.impl; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothManager; +import android.bluetooth.le.BluetoothLeScanner; +import android.bluetooth.le.ScanCallback; +import android.bluetooth.le.ScanFilter.Builder; +import android.bluetooth.le.ScanResult; +import android.bluetooth.le.ScanSettings; +import android.os.Build; +import android.os.ParcelUuid; + +import java.util.ArrayList; +import java.util.List; + +@SuppressWarnings("unused") // Native code uses this class. +class Adapter { + private long handle; + private final Callback callback = new Callback(); + + public Adapter() {} + + @SuppressLint("MissingPermission") + public void startScan(ScanFilter filter) { + BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter == null) { + throw new NoBluetoothAdapterException(); + } + + ArrayList filters = null; + String[] uuids = filter.getUuids(); + if (uuids.length > 0) { + filters = new ArrayList<>(); + for (String uuid : uuids) { + filters.add(new Builder().setServiceUuid(ParcelUuid.fromString(uuid)).build()); + } + } + ScanSettings settings; + if (Build.VERSION.SDK_INT >= 26) { + settings = new ScanSettings.Builder() + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setLegacy(false) + .build(); + } else { + settings = new ScanSettings.Builder() + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .build(); + } + BluetoothLeScanner scanner = bluetoothAdapter.getBluetoothLeScanner(); + if (scanner == null) { + throw new NoBluetoothAdapterException(); + } + scanner.startScan(filters, settings, this.callback); + } + + @SuppressLint("MissingPermission") + public void stopScan() { + BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter != null) { + BluetoothLeScanner scanner = bluetoothAdapter.getBluetoothLeScanner(); + if (scanner != null) { + scanner.stopScan(this.callback); + } + } + } + + private native void reportScanResult(ScanResult result); + + public native void onConnectionStateChanged(String address, boolean connected); + + private class Callback extends ScanCallback { + @Override + public void onScanResult(int callbackType, ScanResult result) { + Adapter.this.reportScanResult(result); + } + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/BluetoothException.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/BluetoothException.java new file mode 100644 index 00000000..16d2e6d1 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/BluetoothException.java @@ -0,0 +1,11 @@ +package com.nonpolynomial.btleplug.android.impl; + +class BluetoothException extends RuntimeException { + public BluetoothException() { + super(); + } + + public BluetoothException(Throwable cause) { + super(cause); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NoBluetoothAdapterException.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NoBluetoothAdapterException.java new file mode 100644 index 00000000..6d2c34bf --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NoBluetoothAdapterException.java @@ -0,0 +1,4 @@ +package com.nonpolynomial.btleplug.android.impl; + +class NoBluetoothAdapterException extends BluetoothException { +} diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NoSuchCharacteristicException.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NoSuchCharacteristicException.java new file mode 100644 index 00000000..c99915bd --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NoSuchCharacteristicException.java @@ -0,0 +1,4 @@ +package com.nonpolynomial.btleplug.android.impl; + +class NoSuchCharacteristicException extends BluetoothException { +} diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NotConnectedException.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NotConnectedException.java new file mode 100644 index 00000000..1fd49a51 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/NotConnectedException.java @@ -0,0 +1,4 @@ +package com.nonpolynomial.btleplug.android.impl; + +class NotConnectedException extends BluetoothException { +} diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/Peripheral.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/Peripheral.java new file mode 100644 index 00000000..f502c3c8 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/Peripheral.java @@ -0,0 +1,830 @@ +package com.nonpolynomial.btleplug.android.impl; + +import android.annotation.SuppressLint; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothGattService; +import android.os.SystemClock; +import android.util.Log; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import io.github.gedgygedgy.rust.future.Future; +import io.github.gedgygedgy.rust.stream.QueueStream; +import io.github.gedgygedgy.rust.future.SimpleFuture; +import io.github.gedgygedgy.rust.stream.Stream; + +@SuppressWarnings("unused") // Native code uses this class. +class Peripheral { + private static final String TAG = "Peripheral"; + private static final UUID CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR = new UUID(0x00002902_0000_1000L, 0x8000_00805f9b34fbL); + + // 0x3E is the HCI reason for a connection that failed to be established; Android usually + // surfaces it as its generic GATT_ERROR (133), which is not itself a public API constant. + // Both can be reported for a connection attempt that never reached STATE_CONNECTED, typically + // because the peripheral missed the first connection events. connect() retries on these + // instead of surfacing a spurious failure, but only when the failed attempt was fast: on + // Android <= 14 a ~30s direct-connect timeout is also reported as 133, and retrying that would + // turn a connect to an absent device into a ~90s wait. + static final int GATT_ERROR = 133; + static final int HCI_ERR_CONNECTION_FAILED_ESTABLISHMENT = 0x3e; + static final int MAX_CONNECT_RETRIES = 2; // up to 3 total connect attempts + static final long CONNECT_RETRY_DELAY_MS = 200; + static final long CONNECT_RETRY_MAX_ELAPSED_MS = 10_000; + + // Library-owned scheduler for connect retries: deliberately not tied to any app's main + // looper, since an app that blocks its main thread while awaiting connect() would otherwise + // deadlock waiting for its own looper to run the retry. + private static final ScheduledExecutorService RETRY_EXECUTOR = + Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "btleplug-connect-retry"); + t.setDaemon(true); + return t; + }); + + private final BluetoothDevice device; + private final Adapter adapter; + private BluetoothGatt gatt; + private final Callback callback; + private boolean connected = false; + // Set for the duration of a single Callback.onConnectionStateChange dispatch to suppress the + // adapter's DeviceDisconnected notification when that DISCONNECTED is just a retryable failed + // connection attempt (see attemptConnect) rather than a real disconnect of a connected device. + private boolean suppressDisconnectNotification = false; + + // Cached connection parameters from onConnectionUpdated callback + private int connectionInterval = -1; // in 1.25ms units + private int connectionLatency = -1; + private int supervisionTimeout = -1; // in 10ms units + + private final Queue commandQueue = new LinkedList<>(); + private final LinkedList>> notificationStreams = new LinkedList<>(); + private boolean executingCommand = false; + private CommandCallback commandCallback; + + public Peripheral(Adapter adapter, String address) { + BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); + if (bluetoothAdapter == null) { + throw new NoBluetoothAdapterException(); + } + this.device = bluetoothAdapter.getRemoteDevice(address); + this.adapter = adapter; + this.callback = new Callback(); + } + + @SuppressLint("MissingPermission") + public Future connect() { + SimpleFuture future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> this.attemptConnect(future, 0)); + } + return future; + } + + /** + * Pure retry decision for a failed connect attempt, package-private for unit testing. + * + * @param status status reported by onConnectionStateChange + * @param attempt zero-based index of the attempt that just failed + * @param attemptElapsedMs wall-clock duration of the attempt that just failed + */ + static boolean shouldRetryConnect(int status, int attempt, long attemptElapsedMs) { + if (attempt >= MAX_CONNECT_RETRIES) { + return false; + } + if (status != GATT_ERROR && status != HCI_ERR_CONNECTION_FAILED_ESTABLISHMENT) { + return false; + } + return attemptElapsedMs < CONNECT_RETRY_MAX_ELAPSED_MS; + } + + @SuppressLint("MissingPermission") + private void attemptConnect(SimpleFuture future, int attempt) { + this.asyncWithFuture(future, () -> { + long attemptStartMs = SystemClock.elapsedRealtime(); + CommandCallback callback = new CommandCallback(future) { + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + long attemptElapsedMs = SystemClock.elapsedRealtime() - attemptStartMs; + if (newState == BluetoothGatt.STATE_DISCONNECTED + && Peripheral.shouldRetryConnect(status, attempt, attemptElapsedMs)) { + if (Peripheral.this.gatt != null) { + Peripheral.this.gatt.close(); + Peripheral.this.gatt = null; + } + Peripheral.this.commandCallback = null; + Peripheral.this.suppressDisconnectNotification = true; + // Not kept: nothing here needs to cancel a pending retry, and a + // pending retry already holds a reference to this Peripheral via + // the closure, keeping it alive until it runs. + RETRY_EXECUTOR.schedule(() -> { + Peripheral.this.dispatchToCommandCallback("connectRetry", () -> { + synchronized (Peripheral.this) { + Peripheral.this.attemptConnect(future, attempt + 1); + } + }); + }, CONNECT_RETRY_DELAY_MS, TimeUnit.MILLISECONDS); + return; + } + + if (Peripheral.this.gatt != null) { + Peripheral.this.gatt.close(); + Peripheral.this.gatt = null; + } + Peripheral.this.connected = false; + throw new NotConnectedException(); + } + + if (newState == BluetoothGatt.STATE_CONNECTED) { + Peripheral.this.wakeCommand(future, null); + } + }); + } + }; + + if (this.connected) { + Peripheral.this.wakeCommand(future, null); + } else if (this.gatt == null) { + try { + this.setCommandCallback(callback); + this.gatt = this.device.connectGatt(null, false, this.callback); + if (this.gatt == null) { + throw new NotConnectedException(); + } + } catch (SecurityException ex) { + throw new PermissionDeniedException(ex); + } + } else { + this.setCommandCallback(callback); + if (!this.gatt.connect()) { + throw new RuntimeException("Unable to reconnect to device"); + } + } + }); + } + + @SuppressLint("MissingPermission") + public Future disconnect() { + SimpleFuture future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> { + this.asyncWithFuture(future, () -> { + if (!this.connected) { + Peripheral.this.wakeCommand(future, null); + } else { + this.setCommandCallback(new CommandCallback(future) { + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + throw new RuntimeException("Unable to disconnect, status: " + status); + } + + if (newState == BluetoothGatt.STATE_DISCONNECTED) { + Peripheral.this.gatt.close(); + Peripheral.this.gatt = null; + Peripheral.this.wakeCommand(future, null); + } + }); + } + }); + this.gatt.disconnect(); + } + }); + }); + } + return future; + } + + public boolean isConnected() { + return this.connected; + } + + @SuppressLint("MissingPermission") + public String getDeviceName() { + return this.device.getName(); + } + + /** + * Returns cached connection parameters as [interval, latency, timeout], + * or null if not yet available. Interval is in 1.25ms units, timeout in 10ms units. + */ + public synchronized int[] getConnectionParameters() { + if (this.connectionInterval < 0) { + return null; + } + return new int[] { this.connectionInterval, this.connectionLatency, this.supervisionTimeout }; + } + + /** + * Request a connection priority change. + * @param priority 0=BALANCED, 1=HIGH, 2=LOW_POWER + */ + @SuppressLint("MissingPermission") + public synchronized boolean requestConnectionPriority(int priority) { + if (!this.connected || this.gatt == null) { + throw new NotConnectedException(); + } + return this.gatt.requestConnectionPriority(priority); + } + + @SuppressLint("MissingPermission") + public Future requestMtu(int mtu) { + SimpleFuture future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> { + this.asyncWithFuture(future, () -> { + if (!this.connected) { + throw new NotConnectedException(); + } + this.setCommandCallback(new CommandCallback(future) { + @Override + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + throw new RuntimeException("MTU negotiation failed, status: " + status); + } + Peripheral.this.wakeCommand(future, mtu); + }); + } + }); + if (!this.gatt.requestMtu(mtu)) { + throw new RuntimeException("Unable to request MTU"); + } + }); + }); + } + return future; + } + + @SuppressLint("MissingPermission") + public Future read(UUID uuid) { + SimpleFuture future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> { + this.asyncWithFuture(future, () -> { + if (!this.connected) { + throw new NotConnectedException(); + } + + BluetoothGattCharacteristic characteristic = this.getCharacteristicByUuid(uuid); + this.setCommandCallback(new CommandCallback(future) { + @Override + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + throw new RuntimeException("Unable to read characteristic, status: " + status); + } + + if (!characteristic.getUuid().equals(uuid)) { + throw new UnexpectedCharacteristicException(); + } + + Peripheral.this.wakeCommand(future, characteristic.getValue()); + }); + } + }); + if (!this.gatt.readCharacteristic(characteristic)) { + throw new RuntimeException("Unable to read characteristic"); + } + }); + }); + } + return future; + } + + @SuppressLint("MissingPermission") + public Future write(UUID uuid, byte[] data, int writeType) { + SimpleFuture future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> { + this.asyncWithFuture(future, () -> { + if (!this.connected) { + throw new NotConnectedException(); + } + + BluetoothGattCharacteristic characteristic = this.getCharacteristicByUuid(uuid); + characteristic.setValue(data); + characteristic.setWriteType(writeType); + this.setCommandCallback(new CommandCallback(future) { + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + throw new RuntimeException("Unable to write characteristic, status: " + status); + } + + if (!characteristic.getUuid().equals(uuid)) { + throw new UnexpectedCharacteristicException(); + } + + Peripheral.this.wakeCommand(future, null); + }); + } + }); + if (!this.gatt.writeCharacteristic(characteristic)) { + throw new RuntimeException("Unable to write characteristic"); + } + }); + }); + } + return future; + } + + @SuppressLint("MissingPermission") + public Future> discoverServices() { + SimpleFuture> future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> { + this.asyncWithFuture(future, () -> { + if (!this.connected) { + throw new NotConnectedException(); + } + + this.setCommandCallback(new CommandCallback(future) { + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + throw new RuntimeException("Unable to discover services, status: " + status); + } + + Peripheral.this.wakeCommand(future, gatt.getServices()); + }); + } + }); + if (!this.gatt.discoverServices()) { + throw new RuntimeException("Unable to discover services"); + } + }); + }); + } + return future; + } + + @SuppressLint("MissingPermission") + public Future setCharacteristicNotification(UUID uuid, boolean enable) { + SimpleFuture future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> { + this.asyncWithFuture(future, () -> { + if (!this.connected) { + throw new NotConnectedException(); + } + + BluetoothGattCharacteristic characteristic = this.getCharacteristicByUuid(uuid); + if (!this.gatt.setCharacteristicNotification(characteristic, enable)) { + throw new RuntimeException("Unable to set characteristic notification"); + } + + BluetoothGattDescriptor descriptor = characteristic.getDescriptor(CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR); + byte[] cccdValue; + if (!enable) { + cccdValue = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE; + } else if ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_INDICATE) != 0) { + cccdValue = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + cccdValue = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } + descriptor.setValue(cccdValue); + if (!this.gatt.writeDescriptor(descriptor)) { + throw new RuntimeException("Unable to write client characteristic configuration descriptor"); + } + + this.setCommandCallback(new CommandCallback(future) { + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + throw new RuntimeException("Unable to write client characteristic configuration descriptor, status: " + status); + } + + if (!descriptor.getUuid().equals(CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR) || !descriptor.getCharacteristic().getUuid().equals(uuid)) { + throw new UnexpectedCharacteristicException(); + } + + Peripheral.this.wakeCommand(future, null); + }); + } + }); + }); + }); + } + return future; + } + + public Stream getNotifications() { + QueueStream stream = new QueueStream<>(); + synchronized (this) { + this.notificationStreams.add(new WeakReference<>(stream)); + } + return stream; + } + + @SuppressLint("MissingPermission") + public Future readDescriptor(UUID characteristic, UUID uuid) { + SimpleFuture future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> { + this.asyncWithFuture(future, () -> { + if (!this.connected) { + throw new NotConnectedException(); + } + + BluetoothGattDescriptor descriptor = this.getDescriptorByUuid(characteristic, uuid); + this.setCommandCallback(new CommandCallback(future) { + @Override + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + throw new RuntimeException("Unable to read descriptor, status: " + status); + } + + if (!descriptor.getUuid().equals(uuid)) { + throw new UnexpectedCharacteristicException(); + } + + Peripheral.this.wakeCommand(future, descriptor.getValue()); + }); + } + }); + if (!this.gatt.readDescriptor(descriptor)) { + throw new RuntimeException("Unable to read descriptor"); + } + }); + }); + } + return future; + } + + @SuppressLint("MissingPermission") + public Future writeDescriptor(UUID characteristic, UUID uuid, byte[] data) { + SimpleFuture future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> { + this.asyncWithFuture(future, () -> { + if (!this.connected) { + throw new NotConnectedException(); + } + + BluetoothGattDescriptor descriptor = this.getDescriptorByUuid(characteristic, uuid); + descriptor.setValue(data); + this.setCommandCallback(new CommandCallback(future) { + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + throw new RuntimeException("Unable to write descriptor, status: " + status); + } + + if (!descriptor.getUuid().equals(uuid)) { + throw new UnexpectedCharacteristicException(); + } + + Peripheral.this.wakeCommand(future, null); + }); + } + }); + if (!this.gatt.writeDescriptor(descriptor)) { + throw new RuntimeException("Unable to write descriptor"); + } + }); + }); + } + return future; + } + + @SuppressLint("MissingPermission") + public Future readRemoteRssi() { + SimpleFuture future = new SimpleFuture<>(); + synchronized (this) { + this.queueCommand(() -> { + this.asyncWithFuture(future, () -> { + if (!this.connected) { + throw new NotConnectedException(); + } + this.setCommandCallback(new CommandCallback(future) { + @Override + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + Peripheral.this.asyncWithFuture(future, () -> { + if (status != BluetoothGatt.GATT_SUCCESS) { + throw new RuntimeException("RSSI read failed, status: " + status); + } + Peripheral.this.wakeCommand(future, rssi); + }); + } + }); + if (!this.gatt.readRemoteRssi()) { + throw new RuntimeException("Unable to read remote RSSI"); + } + }); + }); + } + return future; + } + + @SuppressLint("MissingPermission") + private List getCharacteristics() { + List result = new ArrayList<>(); + if (this.gatt != null) { + for (BluetoothGattService service : this.gatt.getServices()) { + result.addAll(service.getCharacteristics()); + } + } + return result; + } + + @SuppressLint("MissingPermission") + private BluetoothGattCharacteristic getCharacteristicByUuid(UUID uuid) { + for (BluetoothGattCharacteristic characteristic : this.getCharacteristics()) { + if (characteristic.getUuid().equals(uuid)) { + return characteristic; + } + } + + throw new NoSuchCharacteristicException(); + } + + @SuppressLint("MissingPermission") + private BluetoothGattDescriptor getDescriptorByUuid(UUID characteristicUuid, UUID uuid) { + BluetoothGattCharacteristic characteristic = getCharacteristicByUuid(characteristicUuid); + for (BluetoothGattDescriptor descriptor : characteristic.getDescriptors()) { + if (descriptor.getUuid().equals(uuid)) { + return descriptor; + } + } + + throw new NoSuchCharacteristicException(); + } + + private void queueCommand(Runnable callback) { + if (this.executingCommand) { + this.commandQueue.add(callback); + } else { + this.executingCommand = true; + callback.run(); + } + } + + private void setCommandCallback(CommandCallback callback) { + assert this.commandCallback == null; + this.commandCallback = callback; + } + + private void runNextCommand() { + assert this.executingCommand; + this.commandCallback = null; + if (this.commandQueue.isEmpty()) { + this.executingCommand = false; + } else { + Runnable callback = this.commandQueue.remove(); + callback.run(); + } + } + + private void wakeCommand(SimpleFuture future, T result) { + future.wake(result); + this.runNextCommand(); + } + + private void asyncWithFuture(SimpleFuture future, Runnable callback) { + try { + callback.run(); + } catch (Throwable ex) { + future.wakeWithThrowable(ex); + this.runNextCommand(); + } + } + + // Every BluetoothGattCallback method below runs on the Binder thread: none of them may let + // a Throwable escape, or the process crashes. dispatchToCommandCallback is the single + // choke point that guarantees that for calls forwarded into a CommandCallback. + private void dispatchToCommandCallback(String callbackName, Runnable dispatch) { + try { + dispatch.run(); + } catch (Throwable ex) { + Log.e(TAG, "Unexpected exception dispatching " + callbackName, ex); + } + } + + private class Callback extends BluetoothGattCallback { + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + boolean suppressDisconnectNotification; + boolean nowConnected; + synchronized (Peripheral.this) { + // connectGatt is always called (and its result assigned to Peripheral.this.gatt) + // while holding this same lock, so a callback for a superseded/stale gatt (e.g. + // a retry already moved on to a new connectGatt) can be safely ignored here. + if (gatt != Peripheral.this.gatt) { + Log.w(TAG, "Ignoring onConnectionStateChange for stale gatt"); + return; + } + switch (newState) { + case BluetoothGatt.STATE_CONNECTED: + Peripheral.this.connected = true; + break; + case BluetoothGatt.STATE_DISCONNECTED: + Peripheral.this.connected = false; + break; + } + // Reset before dispatch; the command callback below sets it back to true if needed. + Peripheral.this.suppressDisconnectNotification = false; + if (Peripheral.this.commandCallback != null) { + Peripheral.this.dispatchToCommandCallback("onConnectionStateChange", + () -> Peripheral.this.commandCallback.onConnectionStateChange(gatt, status, newState)); + } + suppressDisconnectNotification = Peripheral.this.suppressDisconnectNotification; + // A failed connect closes the gatt and clears `connected` even if the state was CONNECTED. + nowConnected = Peripheral.this.connected; + } + switch (newState) { + case BluetoothGatt.STATE_CONNECTED: + if (nowConnected) { + Peripheral.this.adapter.onConnectionStateChanged(Peripheral.this.device.getAddress(), true); + } + break; + case BluetoothGatt.STATE_DISCONNECTED: + if (!suppressDisconnectNotification) { + Peripheral.this.adapter.onConnectionStateChanged(Peripheral.this.device.getAddress(), false); + } + break; + } + } + + @Override + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + synchronized (Peripheral.this) { + if (Peripheral.this.commandCallback != null) { + Peripheral.this.dispatchToCommandCallback("onCharacteristicRead", + () -> Peripheral.this.commandCallback.onCharacteristicRead(gatt, characteristic, status)); + } + } + } + + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + synchronized (Peripheral.this) { + if (Peripheral.this.commandCallback != null) { + Peripheral.this.dispatchToCommandCallback("onCharacteristicWrite", + () -> Peripheral.this.commandCallback.onCharacteristicWrite(gatt, characteristic, status)); + } + } + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + synchronized (Peripheral.this) { + if (Peripheral.this.commandCallback != null) { + Peripheral.this.dispatchToCommandCallback("onServicesDiscovered", + () -> Peripheral.this.commandCallback.onServicesDiscovered(gatt, status)); + } + } + } + + @Override + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + BluetoothGattCharacteristic characteristic2 = new BluetoothGattCharacteristic(characteristic.getUuid(), characteristic.getProperties(), characteristic.getPermissions()); + characteristic2.setValue(characteristic.getValue()); + synchronized (Peripheral.this) { + for (WeakReference> ref : Peripheral.this.notificationStreams) { + QueueStream stream = ref.get(); + if (stream != null) { + stream.add(characteristic2); + } + } + } + } + + @Override + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + synchronized (Peripheral.this) { + if (Peripheral.this.commandCallback != null) { + Peripheral.this.dispatchToCommandCallback("onDescriptorRead", + () -> Peripheral.this.commandCallback.onDescriptorRead(gatt, descriptor, status)); + } + } + } + + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + synchronized (Peripheral.this) { + if (Peripheral.this.commandCallback != null) { + Peripheral.this.dispatchToCommandCallback("onDescriptorWrite", + () -> Peripheral.this.commandCallback.onDescriptorWrite(gatt, descriptor, status)); + } + } + } + + @Override + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + synchronized (Peripheral.this) { + if (Peripheral.this.commandCallback != null) { + Peripheral.this.dispatchToCommandCallback("onMtuChanged", + () -> Peripheral.this.commandCallback.onMtuChanged(gatt, mtu, status)); + } + } + } + + @Override + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + synchronized (Peripheral.this) { + if (Peripheral.this.commandCallback != null) { + Peripheral.this.dispatchToCommandCallback("onReadRemoteRssi", + () -> Peripheral.this.commandCallback.onReadRemoteRssi(gatt, rssi, status)); + } + } + } + + // Note: onConnectionUpdated is a hidden API in BluetoothGattCallback — no @Override. + public void onConnectionUpdated(BluetoothGatt gatt, int interval, int latency, int timeout, int status) { + if (status == BluetoothGatt.GATT_SUCCESS) { + synchronized (Peripheral.this) { + Peripheral.this.connectionInterval = interval; + Peripheral.this.connectionLatency = latency; + Peripheral.this.supervisionTimeout = timeout; + } + } + } + } + + private abstract class CommandCallback extends BluetoothGattCallback { + private final SimpleFuture future; + + CommandCallback(SimpleFuture future) { + this.future = future; + } + + // Default: a disconnect during any command this base isn't overridden for (i.e. every + // command except connect/disconnect themselves, which override this) fails that + // command's future with NotConnectedException and advances the queue, instead of relying + // on each subclass to remember to override this. connect/disconnect have their own + // onConnectionStateChange semantics and override this method entirely. + @Override + public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) { + if (newState == BluetoothGatt.STATE_DISCONNECTED) { + Peripheral.this.asyncWithFuture(this.future, () -> { + if (Peripheral.this.gatt != null) { + Peripheral.this.gatt.close(); + Peripheral.this.gatt = null; + } + throw new NotConnectedException(); + }); + } + } + + // The following are stray/unsolicited callbacks for a command that didn't ask for them + // (e.g. an onMtuChanged arriving while a read() is in flight). They're logged and + // ignored rather than failing the in-flight command or throwing, since they aren't + // evidence that the in-flight command itself failed. + @Override + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + Log.w(TAG, "Unexpected onCharacteristicRead callback"); + } + + @Override + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + Log.w(TAG, "Unexpected onCharacteristicWrite callback"); + } + + @Override + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, + int status) { + Log.w(TAG, "Unexpected onDescriptorRead callback"); + } + + @Override + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + Log.w(TAG, "Unexpected onServicesDiscovered callback"); + } + + @Override + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + Log.w(TAG, "Unexpected onDescriptorWrite callback"); + } + + @Override + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + Log.w(TAG, "Unexpected onMtuChanged callback"); + } + + @Override + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + Log.w(TAG, "Unexpected onReadRemoteRssi callback"); + } + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/PermissionDeniedException.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/PermissionDeniedException.java new file mode 100644 index 00000000..dfb62ce5 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/PermissionDeniedException.java @@ -0,0 +1,11 @@ +package com.nonpolynomial.btleplug.android.impl; + +class PermissionDeniedException extends BluetoothException { + public PermissionDeniedException() { + super(); + } + + public PermissionDeniedException(Throwable cause) { + super(cause); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/ScanFilter.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/ScanFilter.java new file mode 100644 index 00000000..22def4e9 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/ScanFilter.java @@ -0,0 +1,21 @@ +package com.nonpolynomial.btleplug.android.impl; + +import java.util.Arrays; + +public class ScanFilter { + private final String[] uuids; + + public ScanFilter(String uuids[]) { + if (uuids == null) { + this.uuids = new String[0]; + } else { + int len = uuids.length; + this.uuids = Arrays.copyOf(uuids, len); + } + } + + public String[] getUuids() { + int len = uuids.length; + return Arrays.copyOf(uuids, len); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/UnexpectedCallbackException.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/UnexpectedCallbackException.java new file mode 100644 index 00000000..63d28685 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/UnexpectedCallbackException.java @@ -0,0 +1,4 @@ +package com.nonpolynomial.btleplug.android.impl; + +class UnexpectedCallbackException extends BluetoothException { +} diff --git a/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/UnexpectedCharacteristicException.java b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/UnexpectedCharacteristicException.java new file mode 100644 index 00000000..fb698a34 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/com/nonpolynomial/btleplug/android/impl/UnexpectedCharacteristicException.java @@ -0,0 +1,4 @@ +package com.nonpolynomial.btleplug.android.impl; + +class UnexpectedCharacteristicException extends BluetoothException { +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/Future.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/Future.java new file mode 100644 index 00000000..32bf5aa3 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/Future.java @@ -0,0 +1,29 @@ +package io.github.gedgygedgy.rust.future; + +import io.github.gedgygedgy.rust.task.PollResult; +import io.github.gedgygedgy.rust.task.Waker; + +/** + * Interface for allowing Rust code to interact with Java code in an + * asynchronous manner. The intention of this interface is for asynchronous + * Rust code to directly call Java code that returns a {@link Future}, and + * then poll the {@link Future} using a {@code jni_utils::future::JFuture}. + * In this way, the complexities of interacting with asynchronous Java code can + * be abstracted into a simple {@code jni_utils::future::JFuture} that Rust + * code can {@code await} on. + *

+ * In general, you will probably want to use {@link SimpleFuture} for most use + * cases. + */ +public interface Future { + /** + * Polls the {@link Future} for a result. You generally won't need to call + * this directly, as {@code jni_utils::future::JFuture} takes care of this + * for you. + * + * @param waker Waker to wake with when the {@link Future} has a result. + * @return The future's result, or {@code null} if no result is available + * yet. + */ + PollResult poll(Waker waker); +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/FutureException.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/FutureException.java new file mode 100644 index 00000000..340d0a70 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/FutureException.java @@ -0,0 +1,13 @@ +package io.github.gedgygedgy.rust.future; + +/** + * Exception class for {@link Future} implementations to throw from + * {@link io.github.gedgygedgy.rust.task.PollResult#get} if the result of the + * future is an exception. Implementations should set the real exception as the + * cause of this exception. + */ +public class FutureException extends RuntimeException { + public FutureException(Throwable cause) { + super(cause); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/SimpleFuture.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/SimpleFuture.java new file mode 100644 index 00000000..68c66227 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/future/SimpleFuture.java @@ -0,0 +1,78 @@ +package io.github.gedgygedgy.rust.future; + +import io.github.gedgygedgy.rust.task.PollResult; +import io.github.gedgygedgy.rust.task.Waker; + +/** + * Simple implementation of {@link Future} which can be woken with a result. + * In general, methods which create a {@link SimpleFuture} should return it as + * a {@link Future} to keep calling code from waking it. + */ +public class SimpleFuture implements Future { + private Waker waker = null; + private PollResult result; + private final Object lock = new Object(); + + /** + * Creates a new {@link SimpleFuture} object. + */ + public SimpleFuture() {} + + @Override + public PollResult poll(Waker waker) { + PollResult result = null; + Waker oldWaker = null; + synchronized (this.lock) { + if (this.result != null) { + result = this.result; + } else { + oldWaker = this.waker; + this.waker = waker; + } + } + if (oldWaker != null) { + oldWaker.close(); + } + if (result != null) { + waker.close(); + } + return result; + } + + private void wakeInternal(PollResult result) { + Waker waker = null; + synchronized (this.lock) { + assert this.result == null; + this.result = result; + waker = this.waker; + } + if (waker != null) { + waker.wake(); + } + } + + /** + * Wakes the {@link SimpleFuture} with a result. + * + * @param result Result to wake with. This can be {@code null}. + */ + public void wake(T result) { + this.wakeInternal(() -> { + return result; + }); + } + + /** + * Wakes the {@link SimpleFuture} with an exception. When code calls + * {@link PollResult#get} on the resulting {@link PollResult}, a + * {@link FutureException} will be thrown with the given exception as the + * cause. + * + * @param result Exception to wake with. + */ + public void wakeWithThrowable(Throwable result) { + this.wakeInternal(() -> { + throw new FutureException(result); + }); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnAdapter.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnAdapter.java new file mode 100644 index 00000000..7e8dc8c4 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnAdapter.java @@ -0,0 +1,29 @@ +package io.github.gedgygedgy.rust.ops; + +import io.github.gedgygedgy.rust.thread.LocalThreadChecker; + +import java.io.Closeable; + +final class FnAdapter implements Closeable { + private final LocalThreadChecker threadChecker; + private long data; + + private FnAdapter(boolean local) { + this.threadChecker = new LocalThreadChecker(local); + } + + public R call(O self, T arg1, U arg2) { + this.threadChecker.check(); + return this.callInternal(self, arg1, arg2); + } + + private native R callInternal(O self, T arg1, U arg2); + + @Override + public void close() { + this.threadChecker.check(); + this.closeInternal(); + } + + private native void closeInternal(); +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnBiFunction.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnBiFunction.java new file mode 100644 index 00000000..f8a26821 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnBiFunction.java @@ -0,0 +1,36 @@ +package io.github.gedgygedgy.rust.ops; + +import java.io.Closeable; +import java.util.function.BiFunction; + +/** + * Wraps a closure in a Java object. + *

+ * Instances of this class cannot be obtained directly from Java. Instead, call + * {@code jni_utils::ops::fn_bi_function()}, + * {@code jni_utils::ops::fn_mut_bi_function()}, or + * {@code jni_utils::ops::fn_once_bi_function()} from Rust code to obtain an + * instance of this class. + */ +public interface FnBiFunction extends BiFunction, Closeable { + /** + * Runs the closure associated with this object. + *

+ * If the closure is a {@code std::ops::Fn} or {@code std::ops::FnMut}, + * calling this method twice will call the associated closure twice. If + * the closure is a {@code std::ops::FnOnce}, the second call will return + * {@code null}. If {@link close} has already been called, this method + * returns {@code null}. + */ + @Override + public R apply(T t, U u); + + /** + * Disposes of the closure associated with this object. + *

+ * This method is idempotent - if it's called twice, the second call is a + * no-op. + */ + @Override + public void close(); +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnBiFunctionImpl.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnBiFunctionImpl.java new file mode 100644 index 00000000..fb4d63db --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnBiFunctionImpl.java @@ -0,0 +1,21 @@ +package io.github.gedgygedgy.rust.ops; + +import java.io.Closeable; + +final class FnBiFunctionImpl implements FnBiFunction { + private final FnAdapter, T, U, R> adapter; + + private FnBiFunctionImpl(FnAdapter, T, U, R> adapter) { + this.adapter = adapter; + } + + @Override + public R apply(T t, U u) { + return this.adapter.call(this, t, u); + } + + @Override + public void close() { + this.adapter.close(); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnFunction.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnFunction.java new file mode 100644 index 00000000..d14805d7 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnFunction.java @@ -0,0 +1,36 @@ +package io.github.gedgygedgy.rust.ops; + +import java.io.Closeable; +import java.util.function.Function; + +/** + * Wraps a closure in a Java object. + *

+ * Instances of this class cannot be obtained directly from Java. Instead, call + * {@code jni_utils::ops::fn_function()}, + * {@code jni_utils::ops::fn_mut_function()}, or + * {@code jni_utils::ops::fn_once_function()} from Rust code to obtain an + * instance of this class. + */ +public interface FnFunction extends Function, Closeable { + /** + * Runs the closure associated with this object. + *

+ * If the closure is a {@code std::ops::Fn} or {@code std::ops::FnMut}, + * calling this method twice will call the associated closure twice. If + * the closure is a {@code std::ops::FnOnce}, the second call will return + * {@code null}. If {@link close} has already been called, this method + * returns {@code null}. + */ + @Override + public R apply(T t); + + /** + * Disposes of the closure associated with this object. + *

+ * This method is idempotent - if it's called twice, the second call is a + * no-op. + */ + @Override + public void close(); +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnFunctionImpl.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnFunctionImpl.java new file mode 100644 index 00000000..f394d651 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnFunctionImpl.java @@ -0,0 +1,21 @@ +package io.github.gedgygedgy.rust.ops; + +import java.io.Closeable; + +final class FnFunctionImpl implements FnFunction { + private final FnAdapter, T, Void, R> adapter; + + private FnFunctionImpl(FnAdapter, T, Void, R> adapter) { + this.adapter = adapter; + } + + @Override + public R apply(T t) { + return this.adapter.call(this, t, null); + } + + @Override + public void close() { + this.adapter.close(); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnRunnable.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnRunnable.java new file mode 100644 index 00000000..3e327758 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnRunnable.java @@ -0,0 +1,35 @@ +package io.github.gedgygedgy.rust.ops; + +import java.io.Closeable; + +/** + * Wraps a closure in a Java object. + *

+ * Instances of this class cannot be obtained directly from Java. Instead, call + * {@code jni_utils::ops::fn_runnable()}, + * {@code jni_utils::ops::fn_mut_runnable()}, or + * {@code jni_utils::ops::fn_once_runnable()} from Rust code to obtain an + * instance of this class. + */ +public interface FnRunnable extends Runnable, Closeable { + /** + * Runs the closure associated with this object. + *

+ * If the closure is a {@code std::ops::Fn} or {@code std::ops::FnMut}, + * calling this method twice will call the associated closure twice. If + * the closure is a {@code std::ops::FnOnce}, this method is idempotent - + * calling it a second time will have no effect. If {@link close} has + * already been called, this method is a no-op. + */ + @Override + public void run(); + + /** + * Disposes of the closure associated with this object. + *

+ * This method is idempotent - if it's called twice, the second call is a + * no-op. + */ + @Override + public void close(); +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnRunnableImpl.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnRunnableImpl.java new file mode 100644 index 00000000..78a7ec8d --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/ops/FnRunnableImpl.java @@ -0,0 +1,21 @@ +package io.github.gedgygedgy.rust.ops; + +import java.io.Closeable; + +final class FnRunnableImpl implements FnRunnable { + private final FnAdapter adapter; + + private FnRunnableImpl(FnAdapter adapter) { + this.adapter = adapter; + } + + @Override + public void run() { + this.adapter.call(this, null, null); + } + + @Override + public void close() { + this.adapter.close(); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/panic/PanicException.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/panic/PanicException.java new file mode 100644 index 00000000..73eb2dcb --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/panic/PanicException.java @@ -0,0 +1,18 @@ +package io.github.gedgygedgy.rust.panic; + +/** + * Exception that represents a Rust panic. This exception can be used to carry + * Rust panics across the JNI boundary. + *

+ * Instances of this class cannot be obtained directly from Java. Instead, call + * {@code jni_utils::exceptions::JPanicException::new())} to create a new + * instance of this class. + */ +public final class PanicException extends RuntimeException { + @SuppressWarnings("unused") // Native code uses this field. + private long any; + + private PanicException(String message) { + super(message); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/QueueStream.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/QueueStream.java new file mode 100644 index 00000000..44526ccb --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/QueueStream.java @@ -0,0 +1,83 @@ +package io.github.gedgygedgy.rust.stream; + +import io.github.gedgygedgy.rust.task.PollResult; +import io.github.gedgygedgy.rust.task.Waker; + +import java.util.LinkedList; +import java.util.Queue; + +/** + * Simple implementation of {@link Stream} which can be woken with items. + * In general, methods which create a {@link QueueStream} should return it as + * a {@link Stream} to keep calling code from waking it. + */ +public class QueueStream implements Stream { + private Waker waker = null; + private final Queue result = new LinkedList<>(); + private boolean finished = false; + private final Object lock = new Object(); + + /** + * Creates a new {@link QueueStream} object. + */ + public QueueStream() {} + + @Override + public PollResult> pollNext(Waker waker) { + PollResult> result = null; + Waker oldWaker = null; + synchronized (this.lock) { + if (!this.result.isEmpty()) { + result = () -> () -> this.result.remove(); + } else if (this.finished) { + result = () -> null; + } else { + oldWaker = this.waker; + this.waker = waker; + } + } + if (oldWaker != null) { + oldWaker.close(); + } + if (result != null) { + waker.close(); + } + return result; + } + + private void doEvent(Runnable r) { + Waker waker = null; + synchronized (this.lock) { + assert !this.finished; + r.run(); + waker = this.waker; + } + if (waker != null) { + waker.wake(); + } + } + + /** + * Adds a new item to the queue of items to be returned by + * {@link pollNext}. This can be anything, including {@code null}. + * + * @param item Item to add to the queue. + */ + public void add(T item) { + this.doEvent(() -> { + synchronized (this.lock) { + this.result.add(item); + } + }); + } + + /** + * Marks the queue as finished. After the queue is finished, no new items + * can be added. Once all existing items have been drained from the queue, + * the {@link PollResult} returned from {@link pollNext} will return + * {@code null} from its own {@link PollResult#get}. + */ + public void finish() { + this.doEvent(() -> this.finished = true); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/Stream.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/Stream.java new file mode 100644 index 00000000..940cfeb7 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/Stream.java @@ -0,0 +1,36 @@ +package io.github.gedgygedgy.rust.stream; + +import io.github.gedgygedgy.rust.task.PollResult; +import io.github.gedgygedgy.rust.task.Waker; + +/** + * Interface for allowing Rust code to interact with Java code in an + * asynchronous manner. The intention of this interface is for asynchronous + * Rust code to directly call Java code that returns a {@link Stream}, and + * then poll the {@link Stream} using a {@code jni_utils::stream::JStream}. + * In this way, the complexities of interacting with asynchronous Java code can + * be abstracted into a simple {@code jni_utils::stream::JStream} that Rust + * code can {@code await} on. + *

+ * In general, you will probably want to use {@link QueueStream} for most use + * cases. + */ +public interface Stream { + /** + * Polls the {@link Stream} for a new item. You generally won't need to + * call this directly, as {@code jni_utils::future::JFuture} takes care of + * this for you. + *

+ * If no item is available, and it is not known whether or not more items + * will be available in the future, this method should return {@code null}. + * If an item is available, this method should return a {@link PollResult} + * whose {@link PollResult#get} returns a non-{@code null} + * {@link StreamPoll}. If no item is available, and no items will ever be + * available, this method should return a {@link PollResult} whose + * {@link PollResult#get} returns {@code null}. + * + * @param waker Waker to wake with when the {@link Stream} has a result. + * @return The next item. + */ + PollResult> pollNext(Waker waker); +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/StreamPoll.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/StreamPoll.java new file mode 100644 index 00000000..b44dad9a --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/StreamPoll.java @@ -0,0 +1,15 @@ +package io.github.gedgygedgy.rust.stream; + +/** + * Represents the result of polling an async stream. + *

+ * See {@link Stream#pollNext} for a description of how to use this interface. + */ +public interface StreamPoll { + /** + * Gets the stream item. This can be anything, including {@code null}. + * + * @return The stream item. + */ + T get(); +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/task/PollResult.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/task/PollResult.java new file mode 100644 index 00000000..65b236db --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/task/PollResult.java @@ -0,0 +1,19 @@ +package io.github.gedgygedgy.rust.task; + +/** + * Represents the result of polling an async future. + *

+ * If a future result is available, the future should return an instance of + * this interface which returns the result from the {@link get} method. + * If no result is available, the future should return {@code null} (NOT a + * {@link PollResult} whose {@link get} returns {@code null}.) A + * non-{@code null} {@link PollResult} indicates that the future has completed. + */ +public interface PollResult { + /** + * Gets the future result. This can be anything, including {@code null}. + * + * @return The future result. + */ + public T get(); +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/task/Waker.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/task/Waker.java new file mode 100644 index 00000000..4279e896 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/task/Waker.java @@ -0,0 +1,40 @@ +package io.github.gedgygedgy.rust.task; + +import io.github.gedgygedgy.rust.ops.FnRunnable; + +import java.io.Closeable; + +/** + * Wraps a {@code std::task::Waker} in a Java object. + *

+ * Instances of this class cannot be obtained directly from Java. Instead, call + * {@code jni_utils::task::waker()} from Rust code to obtain an instance of + * this class. (This generally shouldn't be necessary, since + * {@code jni_utils::future::JFuture} and {@code jni_utils::stream::JStream} + * take care of this for you.) + */ +public final class Waker implements Closeable { + private final FnRunnable wakeRunnable; + + private Waker(FnRunnable wakeRunnable) { + this.wakeRunnable = wakeRunnable; + } + + /** + * Wakes the {@code std::task::Waker} associated with this object. + *

+ * If the {@code std::task::Waker} has already been woken, this method + * does nothing. + */ + public void wake() { + this.wakeRunnable.run(); + } + + /** + * Frees the {@code std::task::Waker} associated with this object. + */ + @Override + public void close() { + this.wakeRunnable.close(); + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/thread/LocalThreadChecker.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/thread/LocalThreadChecker.java new file mode 100644 index 00000000..a8fff73f --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/thread/LocalThreadChecker.java @@ -0,0 +1,15 @@ +package io.github.gedgygedgy.rust.thread; + +public class LocalThreadChecker { + private final Thread origin; + + public LocalThreadChecker(boolean local) { + this.origin = local ? Thread.currentThread() : null; + } + + public void check() { + if (this.origin != null && this.origin != Thread.currentThread()) { + throw new LocalThreadException(); + } + } +} diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/thread/LocalThreadException.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/thread/LocalThreadException.java new file mode 100644 index 00000000..8885f7a0 --- /dev/null +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/thread/LocalThreadException.java @@ -0,0 +1,12 @@ +package io.github.gedgygedgy.rust.thread; + +/** + * Thrown when a non-{@code Send} Rust object is accessed from outside its + * origin thread. + */ +public class LocalThreadException extends IllegalStateException { + /** + * Creates a new exception. + */ + public LocalThreadException() {} +} diff --git a/crates/connetto-peer-android/src/android.rs b/crates/connetto-peer-android/src/android.rs index b9abaa9a..a099c0d0 100644 --- a/crates/connetto-peer-android/src/android.rs +++ b/crates/connetto-peer-android/src/android.rs @@ -1,4 +1,5 @@ -//! The bundled hotspot Kotlin module, and the prompt for its permissions. +//! The bundled Kotlin module and btleplug's Java, the prompt for their +//! permissions, and the virtual machine btleplug's jni takes. use manganis::android::with_activity; use manganis::jni::JNIEnv; @@ -62,3 +63,79 @@ fn plugin<'local>(env: &mut JNIEnv<'local>) -> Result, JniError> .l()?; Ok(JClass::from(class)) } + +/// Why btleplug's virtual machine is not at hand. +#[derive(Debug, thiserror::Error)] +pub enum VmError { + /// The process shows no Activity, so its virtual machine is unknown yet. + #[error("no Activity stands, so the virtual machine is unknown")] + NoActivity, + /// The virtual machine refused a call. + #[error("the virtual machine refused: {0}")] + Jni(#[from] JniError), +} + +/// The process's virtual machine under the jni btleplug links (R76 decision +/// 22), taken from the one manganis hands out. +/// +/// # Errors +/// +/// [`VmError::NoActivity`] before the application's Activity stands, and +/// [`VmError::Jni`] when the virtual machine refuses the lookup. +pub fn java_vm() -> Result { + let raw = + with_activity(|env, _activity| Some(env.get_java_vm().map(|vm| vm.get_java_vm_pointer()))) + .ok_or(VmError::NoActivity)??; + #[expect( + unsafe_code, + reason = "jni 0.22 learns the running virtual machine only through from_raw" + )] + // SAFETY: `raw` is the process's one virtual machine, which manganis's jni + // read from the live environment it handed out, so it is non-null and + // stays valid for the life of the process. Both jni versions point at the + // same `JNIInvokeInterface_` table. + let vm = unsafe { jni::JavaVM::from_raw(raw.cast()) }; + Ok(vm) +} + +/// Point the calling thread's context class loader at the application's, so +/// btleplug's class lookups on a thread attached from native code find the +/// bundled Java (R76 decision 22). +/// +/// # Errors +/// +/// The virtual machine's refusal of any of the framework calls. +pub fn use_application_class_loader(env: &mut jni::Env<'_>) -> jni::errors::Result<()> { + use jni::{jni_sig, jni_str}; + let application = env + .call_static_method( + jni_str!("android/app/ActivityThread"), + jni_str!("currentApplication"), + jni_sig!("()Landroid/app/Application;"), + &[], + )? + .l()?; + let loader = env + .call_method( + &application, + jni_str!("getClassLoader"), + jni_sig!("()Ljava/lang/ClassLoader;"), + &[], + )? + .l()?; + let thread = env + .call_static_method( + jni_str!("java/lang/Thread"), + jni_str!("currentThread"), + jni_sig!("()Ljava/lang/Thread;"), + &[], + )? + .l()?; + env.call_method( + &thread, + jni_str!("setContextClassLoader"), + jni_sig!("(Ljava/lang/ClassLoader;)V"), + &[jni::objects::JValue::Object(&loader)], + )?; + Ok(()) +} diff --git a/crates/connetto-peer-android/src/lib.rs b/crates/connetto-peer-android/src/lib.rs index db3747bf..742f01ea 100644 --- a/crates/connetto-peer-android/src/lib.rs +++ b/crates/connetto-peer-android/src/lib.rs @@ -1,5 +1,6 @@ -//! The Android permissions the connetto peer link asks for, and the bundled -//! Kotlin module behind the local-only hotspot the peer link dials through. +//! The Android permissions the connetto peer link asks for, the bundled +//! Kotlin module behind its hotspot and beacon, and btleplug's virtual machine +//! and Java for the Bluetooth central. //! //! See the crate's README for the two permissions the peer link needs and //! where each of them is used. @@ -10,7 +11,10 @@ mod android; #[cfg(target_os = "android")] -pub use android::request_peer_permissions; +pub use android::{VmError, java_vm, request_peer_permissions, use_application_class_loader}; +/// The jni btleplug links, whose virtual machine [`java_vm`] answers. +#[cfg(target_os = "android")] +pub use jni; /// Ask the operating system for the permissions the peer link's discovery and /// the hotspot need (R76), opening the prompt on the Activity when any is diff --git a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs index 5c8ca9e3..16d82071 100644 --- a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs +++ b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs @@ -42,7 +42,10 @@ //! With `--bluetooth` the main phone hosts a hotspot after its own proof, and //! the run requires the demo's beacon on the panel and its legacy advertising //! set and GATT service in the phone's Bluetooth dump (R76 slice 5a). The -//! proof ends with the phone's Bluetooth off and its Wi-Fi on. +//! proof ends with the phone's Bluetooth off and its Wi-Fi on. With +//! `--peer-serial` as well, the second phone finds the first one's beacon and +//! joins through the exchange behind it in place of the join form (R76 slice +//! 6), and both phones end with their Bluetooth off. use std::path::{Path, PathBuf}; use std::time::Duration; @@ -146,8 +149,13 @@ async fn main() -> Result<()> { // The peer proof runs only on a proved phone, and a failed // proof keeps its own error. match (bluetooth, peer_serial, outcome) { - (true, _, Ok(())) => bluetooth_proof(&device, &evidence).await, - (false, Some(serial), Ok(())) => { + (true, None, Ok(())) => bluetooth_proof(&device, &evidence).await, + (bluetooth, Some(serial), Ok(())) => { + let join_by = if bluetooth { + JoinBy::Beacon + } else { + JoinBy::Form + }; peer_proof( &device, &Device::new(serial), @@ -155,6 +163,7 @@ async fn main() -> Result<()> { &stack, &evidence, unlock, + join_by, ) .await } @@ -449,6 +458,15 @@ async fn read_hotspot_offer(app: &mut PageSession) -> Result } } +/// How the second phone learns the hotspot the first one hosts. +#[derive(Clone, Copy, PartialEq, Eq)] +enum JoinBy { + /// The first phone's offer lines typed into the second's join form. + Form, + /// The first phone's Bluetooth beacon and the exchange behind it (R76). + Beacon, +} + /// The two-phone peer proof (R76 slice 4): `host` hosts a hotspot from the /// demo and `joiner` signs in as its own account and joins it, the driver /// tapping the system's approval in place of the user (decision 16), until @@ -461,6 +479,7 @@ async fn peer_proof( stack: &Stack, evidence: &Path, unlock: Unlock<'_>, + join_by: JoinBy, ) -> Result<()> { if !demo_has_device_identity() { bail!("the peer proof needs a demo build with its device identity"); @@ -478,8 +497,16 @@ async fn peer_proof( evidence, &unlock, usual_ssid.as_ref(), + join_by, ) .await; + if join_by == JoinBy::Beacon { + for phone in [host, joiner] { + let _ = phone + .adb(&["shell", "cmd", "bluetooth_manager", "disable"]) + .await; + } + } // The joiner's log for the record, and its state, which the run leaves // as it found it. @@ -546,6 +573,10 @@ async fn prepare_joiner( /// The walk itself: install and sign in the joiner, host on the first phone, /// join from the second, link, end the link, and bring the joiner back to /// its usual network. +#[expect( + clippy::too_many_arguments, + reason = "the two phones, the build, the stack, the evidence, the unlock, the joiner's network and the join's route are each the walk's own" +)] async fn run_peer_proof( host: &Device, joiner: &Device, @@ -554,6 +585,7 @@ async fn run_peer_proof( evidence: &Path, unlock: &Unlock<'_>, usual_ssid: Option<&String>, + join_by: JoinBy, ) -> Result<()> { joiner .adb(&["install", "-r", "-t", &apk.display().to_string()]) @@ -574,6 +606,17 @@ async fn run_peer_proof( ]) .await?; } + if join_by == JoinBy::Beacon { + step("turn Bluetooth on and grant its permissions on both phones"); + for phone in [host, joiner] { + bluetooth_on(phone).await?; + for permission in BT_PERMISSIONS { + phone + .adb(&["shell", "pm", "grant", PACKAGE, permission]) + .await?; + } + } + } let mut peer_app = sign_in_joiner(joiner, stack, evidence, unlock).await?; @@ -598,6 +641,7 @@ async fn run_peer_proof( evidence, &host_identity, &joiner_identity, + join_by, ) .await; let restored = host @@ -614,6 +658,10 @@ async fn run_peer_proof( /// Host on the first phone, join from the second, link, and end the link, /// answering the hotspot's ssid for the cleanup. +#[expect( + clippy::too_many_arguments, + reason = "the two phones, their pages, the evidence, both identities and the join's route are each the walk's own" +)] async fn host_and_link( host: &Device, joiner: &Device, @@ -622,16 +670,35 @@ async fn host_and_link( evidence: &Path, host_identity: &str, joiner_identity: &str, + join_by: JoinBy, ) -> Result { app.click("Host a hotspot").await?; let offer = read_hotspot_offer(app).await?; host.screenshot(evidence, "host-offer").await?; - step("fill the join form on the second phone"); - peer_app.set_input("ssid", &offer.ssid).await?; - peer_app.set_input("passphrase", &offer.passphrase).await?; - peer_app.set_input("port", &offer.port.to_string()).await?; - peer_app.click("Join the hotspot").await?; + match join_by { + JoinBy::Form => { + step("fill the join form on the second phone"); + peer_app.set_input("ssid", &offer.ssid).await?; + peer_app.set_input("passphrase", &offer.passphrase).await?; + peer_app.set_input("port", &offer.port.to_string()).await?; + peer_app.click("Join the hotspot").await?; + } + JoinBy::Beacon => { + let beacon = read_beacon(app).await?; + let prefix = beacon + .strip_prefix("beacon: advertising ") + .context("the beacon line carries no prefix")? + .to_owned(); + step("find the beacon on the second phone"); + peer_app + .wait_for_text(&format!("nearby: {prefix}"), Duration::from_secs(60)) + .await + .context("the second phone never saw the first phone's beacon")?; + joiner.screenshot(evidence, "peer-nearby").await?; + peer_app.click("Join the nearby host").await?; + } + } step("approve the network on the second phone"); joiner.tap_connect_button().await?; @@ -1057,7 +1124,8 @@ struct Arguments { apk: Option, unlock_pin: Option, peer_serial: Option, - /// The host-side Bluetooth beacon proof. + /// The Bluetooth beacon proof, host-side alone, and the beacon join with + /// `--peer-serial`. bluetooth: bool, } diff --git a/examples/dioxus-desktop-demo/Cargo.lock b/examples/dioxus-desktop-demo/Cargo.lock index f297eace..f7d78623 100644 --- a/examples/dioxus-desktop-demo/Cargo.lock +++ b/examples/dioxus-desktop-demo/Cargo.lock @@ -719,6 +719,35 @@ dependencies = [ "objc2", ] +[[package]] +name = "bluez-async" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" +dependencies = [ + "bitflags 2.13.2", + "bluez-generated", + "dbus", + "dbus-tokio", + "futures", + "itertools 0.14.0", + "log", + "serde", + "serde-xml-rs", + "thiserror 2.0.20", + "tokio", + "uuid", +] + +[[package]] +name = "bluez-generated" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9676783265eadd6f11829982792c6f303f3854d014edfba384685dcf237dd062" +dependencies = [ + "dbus", +] + [[package]] name = "bollard" version = "0.21.1" @@ -801,6 +830,32 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "btleplug" +version = "0.13.4" +source = "git+https://github.com/LucaCappelletti94/btleplug?rev=5f6b3686849ed5617bebba136dc29ac253e71937#5f6b3686849ed5617bebba136dc29ac253e71937" +dependencies = [ + "async-trait", + "bitflags 2.13.2", + "bluez-async", + "dashmap", + "dbus", + "futures", + "jni 0.22.4", + "log", + "objc2", + "objc2-core-bluetooth", + "objc2-foundation", + "once_cell", + "static_assertions", + "thiserror 2.0.20", + "tokio", + "tokio-stream", + "uuid", + "windows 0.62.2", + "windows-future 0.3.2", +] + [[package]] name = "built" version = "0.8.1" @@ -1157,10 +1212,12 @@ dependencies = [ "anyhow", "apple-native-keyring-store", "base64 0.22.1", + "btleplug", "chacha20poly1305", "connetto-app-attest", "connetto-core", "connetto-peer", + "connetto-peer-android", "connetto-schema", "core-foundation", "diesel", @@ -1356,6 +1413,7 @@ dependencies = [ name = "connetto-peer-android" version = "0.0.0" dependencies = [ + "jni 0.22.4", "manganis", "thiserror 2.0.20", ] @@ -1883,12 +1941,50 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "futures-channel", + "futures-util", + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-tokio" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007688d459bc677131c063a3a77fb899526e17b7980f390b69644bdbc41fad13" +dependencies = [ + "dbus", + "libc", + "tokio", +] + [[package]] name = "defmt" version = "1.1.1" @@ -3619,6 +3715,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -3895,7 +3997,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -4519,6 +4621,15 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libfuzzer-sys" version = "0.4.13" @@ -5425,6 +5536,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-bluetooth" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b30b9eacc37434a61377866f68b27acef9fa5496f25cc9ca8b2549032e0394" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -7665,6 +7787,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "serde-xml-rs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2215ce3e6a77550b80a1c37251b7d294febaf42e36e21b7b411e0bf54d540d" +dependencies = [ + "log", + "serde", + "thiserror 2.0.20", + "xml", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -8234,6 +8368,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -8476,8 +8616,8 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", "windows-version", "x11-dl", ] @@ -8755,6 +8895,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -9625,8 +9766,8 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", "windows-implement", "windows-interface", ] @@ -9649,8 +9790,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] @@ -9709,11 +9850,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", - "windows-core", - "windows-future", + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -9722,7 +9875,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core", + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", ] [[package]] @@ -9734,8 +9896,21 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -9744,9 +9919,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -9807,10 +9993,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core", + "windows-core 0.61.2", "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -9820,6 +10016,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -9829,6 +10034,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -9931,6 +10145,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -10155,8 +10378,8 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", "windows-version", ] @@ -10241,6 +10464,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "y4m" version = "0.8.0" diff --git a/examples/dioxus-desktop-demo/src/main.rs b/examples/dioxus-desktop-demo/src/main.rs index beb89646..33f648e7 100644 --- a/examples/dioxus-desktop-demo/src/main.rs +++ b/examples/dioxus-desktop-demo/src/main.rs @@ -735,6 +735,9 @@ fn PeerPanel(parts: SessionParts) -> Element { // A permission a host or join named as missing, which the panel asks for // on the UI thread, where the platform's prompt wants to be asked from. let missing: Signal> = use_signal(|| None); + // The hosts whose beacons this device sees, with their prefixes and + // signals, as the scan reports them. + let nearby: Signal> = use_signal(Vec::new); // The join form, the host's offer read off its lines or typed over. let mut join_ssid: Signal = use_signal(String::new); let mut join_passphrase: Signal = use_signal(String::new); @@ -747,6 +750,7 @@ fn PeerPanel(parts: SessionParts) -> Element { let mut peers = peers; let mut offer = offer; let mut outcome = outcome; + let mut nearby = nearby; use_hook(move || { spawn(async move { let mut rx = event_rx; @@ -776,6 +780,17 @@ fn PeerPanel(parts: SessionParts) -> Element { ClientEvent::HotspotLeft => { outcome.set("the joined network was lost".to_owned()); } + ClientEvent::HostNearby { host, prefix, rssi } => { + let mut held = nearby.peek().clone(); + held.retain(|known| known.host != host); + held.push(NearbyHost { host, prefix, rssi }); + nearby.set(held); + } + ClientEvent::HostGone { host } => { + let mut held = nearby.peek().clone(); + held.retain(|known| known.host != host); + nearby.set(held); + } _ => {} } } @@ -808,6 +823,9 @@ fn PeerPanel(parts: SessionParts) -> Element { let leave_outcome = outcome; let bt_parts = parts.clone(); let bt_outcome = outcome; + let nearby_parts = parts.clone(); + let nearby_outcome = outcome; + let nearby_hosts = nearby; let me = parts .0 @@ -819,6 +837,7 @@ fn PeerPanel(parts: SessionParts) -> Element { _ => "peer: not serving".to_owned(), }; let peers = peers(); + let nearby_lines: Vec = nearby().iter().map(NearbyHost::line).collect(); let offer = offer(); let outcome = outcome(); @@ -831,6 +850,9 @@ fn PeerPanel(parts: SessionParts) -> Element { for line in peers.iter().cloned() { p { {line} } } + for line in nearby_lines { + p { {line} } + } if let Some(offer) = offer { p { "hotspot ssid: " {offer.ssid.clone()} } p { "hotspot passphrase: " {offer.passphrase.clone()} } @@ -977,7 +999,57 @@ fn PeerPanel(parts: SessionParts) -> Element { }, "Leave the hotspot" } + button { + onclick: move |_| { + let strongest = nearby_hosts + .peek() + .iter() + .max_by_key(|known| known.rssi.unwrap_or(i16::MIN)) + .map(|known| known.host); + let mut outcome = nearby_outcome; + let Some(host) = strongest else { + outcome.set("no host is nearby".to_owned()); + return; + }; + let parts = nearby_parts.clone(); + spawn(async move { + outcome.set("joining the nearby host".to_owned()); + match parts.0.native.join_nearby(&host).await { + Ok(gateway) => { + outcome.set(format!("joined through the gateway {gateway}")); + } + Err(err) => outcome.set(err.to_string()), + } + }); + }, + "Join the nearby host" + } + } + } +} + +/// A host whose beacon this device sees (R76). +#[cfg(feature = "peer")] +#[derive(Clone, PartialEq)] +struct NearbyHost { + host: connetto_client::HostId, + prefix: [u8; 8], + rssi: Option, +} + +#[cfg(feature = "peer")] +impl NearbyHost { + /// The panel's line, `nearby: ` with the signal where known. + fn line(&self) -> String { + use std::fmt::Write as _; + let mut line = String::from("nearby: "); + for byte in self.prefix { + let _ = write!(line, "{byte:02x}"); + } + if let Some(rssi) = self.rssi { + let _ = write!(line, " at {rssi} dBm"); } + line } } diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index 5b7dffe5..5c9d9326 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -5313,6 +5313,7 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 19. **A joiner scans in the background by default and joins on its own only when the application asks** (2026-10-08). `set_hotspot_scan(bool)`, on by default, scans whenever the device serves and has joined no hotspot and reports `HostNearby` and `HostGone`. `set_hotspot_autojoin(bool)`, off by default, also joins the strongest host it sees through the exchange, one attempt per host until its beacon changes. Both are settable on the builder and at run time, since whether unattended joining fits depends on where and how the application is used. `join_nearby` and `fetch_offer` stay for a manual join. An application-driven scan alone was rejected, since hosts should appear without the user asking, and so was joining on by default, since on Android each join shows the system's approval dialog. 20. **Every joiner's central is btleplug, Android's included, once btleplug's Android `init` resolves its classes through the context class loader** (2026-10-08). Measured between the two A35s inside the demo with btleplug 0.13.4: scanning saw the advertised UUID and service data within a second, and connecting, service discovery, an MTU of 517, writes with response and notifications all worked, once one call in btleplug's `init` looked up `Adapter` through `LoaderContext` instead of raw `FindClass`, which on a thread manganis attached never sees application classes. That fix is upstream as deviceplug/btleplug#495, and until a release carries it the workspace pins `LucaCappelletti94/btleplug` at its commit `5f6b368`, as it pins the keyring forks, and moves to the release once one carries it. `connetto-peer-android` bundles btleplug's Java in its Gradle module with R8 keep rules for `com.nonpolynomial.**` and `io.github.gedgygedgy.**`, sets each calling thread's context class loader to the application's, and hands btleplug the virtual machine under its jni 0.22. A Kotlin GATT client in `connetto-peer-android` was rejected, since it serves Android alone and Apple, Windows and Linux joiners would need a second central, and so was waiting for a btleplug release before building the Android central. deviceplug/btleplug#480, a use-after-free in `FnAdapter` on notifications reported at about 1.1% of sessions, stays open, and the emi runs watch for it. 21. **Every backend reports one Bluetooth readiness and prompts only inside a call the user triggered** (2026-10-08). `BluetoothState::{Ready, Off, NotPermitted, Unsupported}` is read with `bluetooth_state()` and reported as `ClientEvent::BluetoothChanged`. The background scan and the host's beacon never prompt, wait while the state is not `Ready` and resume when it is, and the hotspot runs throughout. `host_hotspot`, `join_nearby`, `fetch_offer` and `enable_bluetooth()` run the platform's strongest action once inside the call, the system dialog on Android, switching the radio on through `Windows.Devices.Radios` or BlueZ's `Powered`, and Core Bluetooth's power alert or the Bluetooth settings pane on Apple, then go on or answer `BluetoothOff` or `BluetoothNotPermitted` saying whether the user declined, was sent to settings or met a block. `set_bluetooth_prompt(false)` leaves that action to the application's own `enable_bluetooth()` call, for an application that explains first. `host_hotspot` answers `Hosted { offer, beacon: Result<(), BluetoothError> }`, so the offer reaches the application for the text and QR fallback while the beacon is down, and an error carrying the offer was rejected, since a started hotspot would read as a failure. No system switches Bluetooth on silently everywhere, and every system can report the state and offer one action inside a user gesture, so this is the shape every backend shares. Prompting from the background scan was rejected, and so was answering a typed error alone, which leaves every application to write its own prompt per platform. +22. **`connetto-client` implements the btleplug central for every system, and `connetto-peer-android` only bridges Android's virtual machine to it** (2026-10-09). btleplug's Android `init` takes a jni 0.22 environment, connetto and manganis stand on jni 0.21, and jni 0.22 learns the running virtual machine only through the `unsafe` `JavaVM::from_raw`. So `connetto-peer-android` offers a safe `java_vm()` building it from the virtual machine manganis hands out, its one `unsafe` call documented under the crate's own lints as decision 17 places it, and ships btleplug's Java in its Gradle module. `connetto-client` depends on it on Android only and runs btleplug on one dedicated thread, attached, its class loader set to the application's, `init` called once, and its own runtime, and the polled `CentralBackend` reaches that thread over channels. Slice 8's other systems run the same central without the bridge. The central inside `connetto-peer-android`, handed to the builder by the application, was rejected, since it would keep Android's copy apart from every other system's and cost every application a line. ### Design @@ -5462,7 +5463,7 @@ One pull request each, in this order, each green and useful on its own (2026-10- 3. **Discovery** (step 1, decisions 12 and 13). **Built (R76, 2026-10-07)** in `connetto-peer`'s `discovery` feature, which `connetto-client`'s `peer` feature turns on. `mdns-sd` under `_connetto-peer._tcp.local`, the TXT record carrying the frame version and the leaf fingerprint only, `PeerFound` and `PeerGone`, autolink under the discovery table, and Android's `MulticastLock`. Proven on loopback in CI. The gateway probe of decision 11 arrives with slice 4's join API, which knows the gateway and the port, and the A35 leg of discovery runs over slice 4's hotspot, since emi's eduroam separates the phones. 4. **Android hosting and joining** (step 3's Android part and step 6's Wi-Fi permissions, decisions 11 and 14 to 17). `startLocalOnlyHotspot` and the joining network request through `connetto-peer-android`'s Kotlin plugin, the hotspot tables, the per-socket binding, the gateway probe, the typed refusals naming a missing permission, and the demo hosting, joining and showing its peers. Proven on emi, one A35 hosting and the other joining through its application, the runner tapping the approval (decision 16). **Built (R76, 2026-10-08)**, the two-phone run green on emi, the Android 15 A35 hosting and the Android 14 one joining, both `chip-proven`, the joiner reaching the host at the gateway. Measured on the way: an A35 refuses to start an access point while its own Wi-Fi sits on eduroam's 5 GHz channel 56, hostapd failing on its beacon on both phones, and starts it once its Wi-Fi is off, so the run turns the host's Wi-Fi off first, as a host in the field has no network. Whether mDNS works on a joined network without binding its sockets, which `mdns-sd` offers no way to do, is not measured yet, since the gateway dial links the two first. 5. **The Bluetooth beacon and exchange on Android, without the central** (step 4's Android host part and step 6's Bluetooth permissions, decisions 18, 19 and 21). `BluetoothGattServer` and the legacy advertisement as host, decision 21's readiness on Android, the beacon codec, the framed exchange with rustls over a transport trait, `HotspotOffer` only after both sides proved their identity, and both tables, the joiner's driven through a scanning and connecting backend trait. Proven by the exchange and both machines over in-memory transports, and on emi by one A35 hosting with its beacon in its own Bluetooth dump. **Built (R76, 2026-10-09)** as `connetto-peer`'s `bluetooth` feature (`beacon.rs`, `exchange.rs` with `Node::offer_over` and `Node::fetch_over` over `ChunkStream`) and `connetto-client`'s `bluetooth` module, whose machine runs beside the hotspot machine and takes the central as an option, so a system without one neither scans nor joins and `join_nearby` answers `Unsupported`, which is Android until slice 6. On emi the Android 15 A35 hosted from the demo, its panel showed `beacon: advertising` with the prefix, and `dumpsys bluetooth_manager` listed the demo's set as legacy and connectable with 9 bytes of service data under the beacon's UUID beside the started GATT service with both characteristics. Measured on the way: Android's `adb` offers no LE scan without an application, so the second phone's sighting of the beacon moves to slice 6 with the central, and the platform's dump masks the service data UUID past its first group and never shows the data. -6. **The Android central** (step 4's Android joiner part, decision 20). The backend trait over btleplug, pinned at the fork's fix commit until a release carries it, with btleplug's Java in `connetto-peer-android`. Proven on emi between the two A35s, one hosting and the other joining through the beacon and exchange. +6. **The Android central** (step 4's Android joiner part, decision 20). The backend trait over btleplug, pinned at the fork's fix commit until a release carries it, with btleplug's Java in `connetto-peer-android`. Proven on emi between the two A35s, one hosting and the other joining through the beacon and exchange. **Built (R76, 2026-10-09)** as `connetto-client`'s `BtleplugCentral` over btleplug pinned at `LucaCappelletti94/btleplug` `5f6b368`, with `connetto-peer-android`'s `java_vm` and `use_application_class_loader` and btleplug's Java in its Gradle module (decision 22). On emi the Android 14 A35 saw the Android 15 one's beacon on its panel, joined through `Join the nearby host`, the exchange handing over the offer, the runner tapping the approval, and both phones named each other linked. Measured on the way: the joiner's chunk is the smaller of the packet less its 3-byte header and the 512 bytes GATT allows an attribute value, since Android refuses a longer notification even at an MTU of 517, and a joiner holds no Bluetooth central where btleplug's thread does not start, so it neither scans nor joins there. 7. **The text and QR fallback** (step 5). The `WIFI:` payload and its text form, the text path proven on emi and the camera scan left to the home run. 8. **The unattended emi run** (step 7's first half). A schedule on emi running slices 4 to 7 from `main`, never from a pull request, with its evidence kept on emi. 9. **Every other machine** (step 3, 4 and 6 for Windows, Linux, macOS, iPhone and iPad, and step 7's home run). Split further when it starts. From d7ed6a77eeac893e6a02357591e3149564cf778e Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 9 Oct 2026 05:41:23 +0200 Subject: [PATCH 2/6] Open the Bluetooth prompt from the application's Activity --- .../connetto-client/src/bluetooth/android.rs | 16 +-- .../android/build.gradle.kts | 6 + .../dev/connetto/peer/BluetoothPlugin.kt | 116 +++++++++--------- crates/connetto-peer-android/src/android.rs | 42 +++++-- crates/connetto-peer-android/src/lib.rs | 5 +- 5 files changed, 109 insertions(+), 76 deletions(-) diff --git a/crates/connetto-client/src/bluetooth/android.rs b/crates/connetto-client/src/bluetooth/android.rs index a3f89ac1..b35d70b9 100644 --- a/crates/connetto-client/src/bluetooth/android.rs +++ b/crates/connetto-client/src/bluetooth/android.rs @@ -64,18 +64,14 @@ impl ReadinessBackend for AndroidBluetoothBackend { } fn prompt(&self) -> Result<(), BluetoothError> { - let started = call(&*self.java, |env| { - let class = plugin(env, PLUGIN_CLASS)?; - int(env, &class, "prompt") - }) - .map_err(|failure| BluetoothError::Failed(failure.to_string()))?; - match started { - 0 => Ok(()), - // The action is blocked, so the call's rows stand on the - // standing's own reason. - _ => Err(BluetoothError::Failed( + // The dialogs open from the application's Activity, which only the + // Android glue can reach. + match connetto_peer_android::start_bluetooth_prompt() { + Ok(true) => Ok(()), + Ok(false) => Err(BluetoothError::Failed( "the prompt action is blocked".into(), )), + Err(err) => Err(BluetoothError::Failed(err.to_string())), } } diff --git a/crates/connetto-peer-android/android/build.gradle.kts b/crates/connetto-peer-android/android/build.gradle.kts index 63d76852..0085f516 100644 --- a/crates/connetto-peer-android/android/build.gradle.kts +++ b/crates/connetto-peer-android/android/build.gradle.kts @@ -29,3 +29,9 @@ android { tasks.withType().configureEach { archiveBaseName.set("dx-native-connetto-peer") } + +dependencies { + // The Activity result registry the Bluetooth prompt answers through, + // which the application's AppCompat already brings. + compileOnly("androidx.activity:activity:1.8.0") +} diff --git a/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/BluetoothPlugin.kt b/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/BluetoothPlugin.kt index 586b7193..81be506a 100644 --- a/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/BluetoothPlugin.kt +++ b/crates/connetto-peer-android/android/src/main/kotlin/dev/connetto/peer/BluetoothPlugin.kt @@ -2,7 +2,6 @@ package dev.connetto.peer import android.Manifest import android.app.Activity -import android.app.Application import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt @@ -21,7 +20,12 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build +import android.os.Handler +import android.os.Looper import android.os.ParcelUuid +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import java.util.Base64 import java.util.UUID @@ -48,6 +52,9 @@ class BluetoothPlugin { // turn its notifications on. private val CCC_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + // The main thread, where the prompt's dialogs open. + private val handler = Handler(Looper.getMainLooper()) + // The ATT packet size a connection has before any exchange. private const val DEFAULT_MTU = 23 @@ -71,10 +78,9 @@ class BluetoothPlugin { private const val OK = 0 private const val FAILED = 1 - private const val PERMISSION_REQUEST = 2001 - private const val PENDING_NONE = 0 - private const val PENDING_PERMISSIONS = 1 - private const val PENDING_ENABLE = 2 + // The keys the prompt's dialogs register their answers under. + private const val PERMISSIONS_KEY = "dev.connetto.peer.bluetooth-permissions" + private const val ENABLE_KEY = "dev.connetto.peer.bluetooth-enable" // The event lines' codes. private const val EVENT_CONNECTED = 1 @@ -85,9 +91,6 @@ class BluetoothPlugin { private val lock = Any() private var cachedContext: Context? = null - private var callbacksRegistered = false - private var resumedActivity: Activity? = null - private var pendingPrompt = PENDING_NONE private var promptOutcome = OUTCOME_IN_FLIGHT private var advertising = false @@ -116,35 +119,65 @@ class BluetoothPlugin { synchronized(lock) { missingPermissionsLocked().joinToString("\n") } @JvmStatic - fun prompt(): Int = synchronized(lock) { + fun prompt(activity: Activity): Int = synchronized(lock) { promptOutcome = OUTCOME_IN_FLIGHT val adapter = adapter() ?: return PROMPT_BLOCKED - registerLifecycleLocked() val missing = missingPermissionsLocked() if (missing.isEmpty() && adapter.isEnabled) { promptOutcome = OUTCOME_NOT_ASKED return PROMPT_STARTED } - val activity = resumedActivity ?: run { + // The dialogs answer through the Activity's result registry, + // which the application's AppCompat Activity carries. + val component = activity as? ComponentActivity ?: run { promptOutcome = OUTCOME_BLOCKED return PROMPT_BLOCKED } - if (missing.isNotEmpty()) { - activity.requestPermissions(missing.toTypedArray(), PERMISSION_REQUEST) - pendingPrompt = PENDING_PERMISSIONS - return PROMPT_STARTED + // The dialogs open from the main thread, which the caller may + // not be on. + handler.post { + try { + if (missing.isNotEmpty()) { + askPermissions(component, missing) + } else { + askEnable(component) + } + } catch (e: Exception) { + synchronized(lock) { promptOutcome = OUTCOME_BLOCKED } + } } - return try { - activity.startActivityForResult( - Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), - PERMISSION_REQUEST - ) - pendingPrompt = PENDING_ENABLE - PROMPT_STARTED - } catch (e: Exception) { - promptOutcome = OUTCOME_BLOCKED - PROMPT_BLOCKED + PROMPT_STARTED + } + + private fun askPermissions(activity: ComponentActivity, missing: List) { + var launcher: ActivityResultLauncher>? = null + launcher = activity.activityResultRegistry.register( + PERMISSIONS_KEY, + ActivityResultContracts.RequestMultiplePermissions() + ) { granted -> + launcher?.unregister() + synchronized(lock) { + promptOutcome = + if (granted.values.all { it }) OUTCOME_NOT_ASKED else OUTCOME_DECLINED + } } + launcher.launch(missing.toTypedArray()) + } + + private fun askEnable(activity: ComponentActivity) { + var launcher: ActivityResultLauncher? = null + launcher = activity.activityResultRegistry.register( + ENABLE_KEY, + ActivityResultContracts.StartActivityForResult() + ) { result -> + launcher?.unregister() + synchronized(lock) { + promptOutcome = + if (result.resultCode == Activity.RESULT_OK) OUTCOME_NOT_ASKED + else OUTCOME_DECLINED + } + } + launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) } @JvmStatic @@ -265,39 +298,6 @@ class BluetoothPlugin { .map { it.substringAfterLast('.') } } - private fun registerLifecycleLocked() { - if (callbacksRegistered) return - val application = context()?.applicationContext as? Application ?: return - application.registerActivityLifecycleCallbacks(lifecycleCallbacks) - callbacksRegistered = true - } - - private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks { - override fun onActivityResumed(activity: Activity) { - synchronized(lock) { - resumedActivity = activity - if (pendingPrompt == PENDING_NONE) return - pendingPrompt = PENDING_NONE - val ready = missingPermissionsLocked().isEmpty() && - adapter()?.isEnabled == true - promptOutcome = - if (ready) OUTCOME_NOT_ASKED else OUTCOME_DECLINED - } - } - - override fun onActivityDestroyed(activity: Activity) { - synchronized(lock) { - if (resumedActivity === activity) resumedActivity = null - } - } - - override fun onActivityCreated(activity: Activity, savedInstanceState: android.os.Bundle?) {} - override fun onActivityStarted(activity: Activity) {} - override fun onActivityPaused(activity: Activity) {} - override fun onActivityStopped(activity: Activity) {} - override fun onActivitySaveInstanceState(activity: Activity, outState: android.os.Bundle) {} - } - private val advertiseCallback = object : AdvertiseCallback() { override fun onStartSuccess(settings: AdvertiseSettings) { synchronized(lock) { advertising = true } diff --git a/crates/connetto-peer-android/src/android.rs b/crates/connetto-peer-android/src/android.rs index a099c0d0..b4d595ef 100644 --- a/crates/connetto-peer-android/src/android.rs +++ b/crates/connetto-peer-android/src/android.rs @@ -13,16 +13,17 @@ extern "Kotlin" { pub type BluetoothPlugin; } -/// The plugin's class, reached through the application's class loader, since -/// a thread Dioxus attached to the VM cannot find app classes with +/// The plugins' classes, reached through the application's class loader, +/// since a thread Dioxus attached to the VM cannot find app classes with /// `FindClass`. -const PLUGIN_CLASS: &str = "dev.connetto.peer.HotspotPlugin"; +const HOTSPOT_PLUGIN: &str = "dev.connetto.peer.HotspotPlugin"; +const BLUETOOTH_PLUGIN: &str = "dev.connetto.peer.BluetoothPlugin"; /// Ask the operating system for the peer link's permissions, from the plugin /// standing behind the Activity (R76). pub fn request_peer_permissions() { let _ = with_activity(|env, activity| { - let class = plugin(env).ok()?; + let class = plugin(env, HOTSPOT_PLUGIN).ok()?; env.call_static_method( class, "requestPermissions", @@ -34,8 +35,8 @@ pub fn request_peer_permissions() { }); } -/// The bundled plugin's class, loaded on the Activity's class loader. -fn plugin<'local>(env: &mut JNIEnv<'local>) -> Result, JniError> { +/// A bundled plugin's class, loaded on the Activity's class loader. +fn plugin<'local>(env: &mut JNIEnv<'local>, name: &str) -> Result, JniError> { let application = env .call_static_method( "android/app/ActivityThread", @@ -52,7 +53,7 @@ fn plugin<'local>(env: &mut JNIEnv<'local>) -> Result, JniError> &[], )? .l()?; - let name = env.new_string(PLUGIN_CLASS)?; + let name = env.new_string(name)?; let class = env .call_method( &class_loader, @@ -64,6 +65,33 @@ fn plugin<'local>(env: &mut JNIEnv<'local>) -> Result, JniError> Ok(JClass::from(class)) } +/// Start the platform's action that brings Bluetooth to `Ready`, the +/// permission prompt or the enable dialog, from the application's Activity +/// (R76 decision 21), answering whether it started. +/// +/// # Errors +/// +/// [`VmError::NoActivity`] before the application's Activity stands, and +/// [`VmError::Jni`] when the plugin's call fails. +pub fn start_bluetooth_prompt() -> Result { + with_activity(|env, activity| { + Some((|| { + let class = plugin(env, BLUETOOTH_PLUGIN)?; + let code = env + .call_static_method( + class, + "prompt", + "(Landroid/app/Activity;)I", + &[JValue::Object(activity)], + )? + .i()?; + Ok::<_, JniError>(code == 0) + })()) + }) + .ok_or(VmError::NoActivity)? + .map_err(VmError::from) +} + /// Why btleplug's virtual machine is not at hand. #[derive(Debug, thiserror::Error)] pub enum VmError { diff --git a/crates/connetto-peer-android/src/lib.rs b/crates/connetto-peer-android/src/lib.rs index 742f01ea..19b08b08 100644 --- a/crates/connetto-peer-android/src/lib.rs +++ b/crates/connetto-peer-android/src/lib.rs @@ -11,7 +11,10 @@ mod android; #[cfg(target_os = "android")] -pub use android::{VmError, java_vm, request_peer_permissions, use_application_class_loader}; +pub use android::{ + VmError, java_vm, request_peer_permissions, start_bluetooth_prompt, + use_application_class_loader, +}; /// The jni btleplug links, whose virtual machine [`java_vm`] answers. #[cfg(target_os = "android")] pub use jni; From 8400dd57763e13bbfbf439ed117e61c2c219e0f5 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 9 Oct 2026 09:19:51 +0200 Subject: [PATCH 3/6] Join one nearby host at a time, pick ties by host, and check the beacon proof ends with Bluetooth off --- .../src/bin/connetto-android-proof.rs | 17 +++++++++++++---- examples/dioxus-desktop-demo/src/main.rs | 6 +++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs index 16d82071..a15ff812 100644 --- a/crates/connetto-test-harness/src/bin/connetto-android-proof.rs +++ b/crates/connetto-test-harness/src/bin/connetto-android-proof.rs @@ -500,11 +500,10 @@ async fn peer_proof( join_by, ) .await; + let mut bluetooth = Ok(()); if join_by == JoinBy::Beacon { for phone in [host, joiner] { - let _ = phone - .adb(&["shell", "cmd", "bluetooth_manager", "disable"]) - .await; + bluetooth = bluetooth.and(bluetooth_off(phone).await); } } @@ -534,7 +533,7 @@ async fn peer_proof( let _ = joiner.adb(&["forward", "--remove-all"]).await; let _ = joiner.adb(&["reverse", "--remove-all"]).await; let _ = joiner.adb(&["shell", "am", "force-stop", PACKAGE]).await; - result.and(restore_role).and(restore_stay) + result.and(restore_role).and(restore_stay).and(bluetooth) } /// Reconnect a wireless joiner over `adb connect`, hold it awake and @@ -695,6 +694,16 @@ async fn host_and_link( .wait_for_text(&format!("nearby: {prefix}"), Duration::from_secs(60)) .await .context("the second phone never saw the first phone's beacon")?; + // The join button takes the strongest host, so the first phone's + // must be the only one in sight. + let page = peer_app.page_text().await?; + let nearby: Vec<&str> = page + .lines() + .filter(|line| line.starts_with("nearby: ")) + .collect(); + if nearby.len() != 1 { + bail!("the second phone sees other hosts than the first: {nearby:?}"); + } joiner.screenshot(evidence, "peer-nearby").await?; peer_app.click("Join the nearby host").await?; } diff --git a/examples/dioxus-desktop-demo/src/main.rs b/examples/dioxus-desktop-demo/src/main.rs index 33f648e7..e54aea74 100644 --- a/examples/dioxus-desktop-demo/src/main.rs +++ b/examples/dioxus-desktop-demo/src/main.rs @@ -1004,9 +1004,13 @@ fn PeerPanel(parts: SessionParts) -> Element { let strongest = nearby_hosts .peek() .iter() - .max_by_key(|known| known.rssi.unwrap_or(i16::MIN)) + .max_by_key(|known| (known.rssi.unwrap_or(i16::MIN), known.host)) .map(|known| known.host); let mut outcome = nearby_outcome; + // One nearby join at a time, so its outcome is the one shown. + if *outcome.peek() == "joining the nearby host" { + return; + } let Some(host) = strongest else { outcome.set("no host is nearby".to_owned()); return; From 0c111f759bdac809d6509fbfe25d3cbabf2eed72 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sun, 11 Oct 2026 07:13:10 +0200 Subject: [PATCH 4/6] Depend on btleplug 0.13.5 --- Cargo.lock | 22 ++++++++++++++----- crates/connetto-client/Cargo.toml | 6 ++--- .../gedgygedgy/rust/stream/QueueStream.java | 3 ++- examples/dioxus-desktop-demo/Cargo.lock | 20 ++++++++++++----- plans/master-implementation-plan.md | 4 ++-- 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2121314c..6586c565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -736,16 +736,16 @@ dependencies = [ [[package]] name = "bluez-async" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" +checksum = "23a32d8f6aedb42c4ec4ed4e39720ee0e807e72c93feb7cd6483f51d08ca1f65" dependencies = [ "bitflags 2.13.2", "bluez-generated", "dbus", "dbus-tokio", "futures", - "itertools 0.14.0", + "itertools 0.15.0", "log", "serde", "serde-xml-rs", @@ -847,8 +847,9 @@ dependencies = [ [[package]] name = "btleplug" -version = "0.13.4" -source = "git+https://github.com/LucaCappelletti94/btleplug?rev=5f6b3686849ed5617bebba136dc29ac253e71937#5f6b3686849ed5617bebba136dc29ac253e71937" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d836502de731602ea87cbb307065e8feaed4fd10f0855a75b0b87944ee011dc" dependencies = [ "async-trait", "bitflags 2.13.2", @@ -4242,7 +4243,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4557,6 +4558,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/crates/connetto-client/Cargo.toml b/crates/connetto-client/Cargo.toml index 9e9e310f..e9793371 100644 --- a/crates/connetto-client/Cargo.toml +++ b/crates/connetto-client/Cargo.toml @@ -256,9 +256,9 @@ android_system_properties = { version = "0.1.6", optional = true } socket2 = "0.6" # The joined network's subnet, the targets its bind admits (R76). ipnet = "2.12" -# The joiner's Bluetooth central (R76 decision 22), pinned at the fork's fix -# of its Android init until a release carries deviceplug/btleplug#495. -btleplug = { git = "https://github.com/LucaCappelletti94/btleplug", rev = "5f6b3686849ed5617bebba136dc29ac253e71937", optional = true } +# The joiner's Bluetooth central (R76 decision 22), from the release whose +# Android init resolves its classes through the context class loader. +btleplug = { version = "0.13.5", optional = true } # The jni 0.22 virtual machine btleplug's init takes, and btleplug's Java # bundled in its Gradle module (R76 decision 22). connetto-peer-android = { path = "../connetto-peer-android", optional = true } diff --git a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/QueueStream.java b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/QueueStream.java index 44526ccb..d8952d64 100644 --- a/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/QueueStream.java +++ b/crates/connetto-peer-android/android/src/main/java/io/github/gedgygedgy/rust/stream/QueueStream.java @@ -28,7 +28,8 @@ public PollResult> pollNext(Waker waker) { Waker oldWaker = null; synchronized (this.lock) { if (!this.result.isEmpty()) { - result = () -> () -> this.result.remove(); + T item = this.result.remove(); + result = () -> () -> item; } else if (this.finished) { result = () -> null; } else { diff --git a/examples/dioxus-desktop-demo/Cargo.lock b/examples/dioxus-desktop-demo/Cargo.lock index f7d78623..75d8f844 100644 --- a/examples/dioxus-desktop-demo/Cargo.lock +++ b/examples/dioxus-desktop-demo/Cargo.lock @@ -721,16 +721,16 @@ dependencies = [ [[package]] name = "bluez-async" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ae4213cc2a8dc663acecac67bbdad05142be4d8ef372b6903abf878b0c690a" +checksum = "23a32d8f6aedb42c4ec4ed4e39720ee0e807e72c93feb7cd6483f51d08ca1f65" dependencies = [ "bitflags 2.13.2", "bluez-generated", "dbus", "dbus-tokio", "futures", - "itertools 0.14.0", + "itertools 0.15.0", "log", "serde", "serde-xml-rs", @@ -832,8 +832,9 @@ dependencies = [ [[package]] name = "btleplug" -version = "0.13.4" -source = "git+https://github.com/LucaCappelletti94/btleplug?rev=5f6b3686849ed5617bebba136dc29ac253e71937#5f6b3686849ed5617bebba136dc29ac253e71937" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d836502de731602ea87cbb307065e8feaed4fd10f0855a75b0b87944ee011dc" dependencies = [ "async-trait", "bitflags 2.13.2", @@ -4292,6 +4293,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index 5c9d9326..fa5fd5ed 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -5311,7 +5311,7 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 17. **Each system needing foreign code or `unsafe` gets its own small crate, and `connetto-client` owns the rules** (2026-10-08). `connetto-client` holds the hotspot API, the backend trait each system implements, and the tables. Android's backend needs Kotlin compiled into the application, since `LocalOnlyHotspotCallback` and `NetworkCallback` are abstract classes, so `connetto-peer-android` ships the plugin through manganis, and the client drives it by class name over `JavaAccess` (R74 decision 20). Apple's and Windows's backends need `unsafe` and arrive in slice 8 as `connetto-peer-apple` and `connetto-peer-windows`, as App Attest lives in `connetto-app-attest`. Linux's NetworkManager backend is safe Rust over zbus and stays in `connetto-peer`. This narrows decision 5, which placed every platform dependency in `connetto-peer`. One crate for every platform's glue was rejected, since it would allow `unsafe` for every platform at once, and so was the Android plugin inside `connetto-auth-session`, which would fit Android alone. 18. **The beacon is a legacy advertisement carrying the first 8 bytes of the leaf's fingerprint** (2026-10-08). A legacy advertisement is the format every joiner receives, iPhone, iPad and the Mac's CoreBluetooth included, and its 31 bytes hold connetto's 128-bit service UUID, the frame version and an 8-byte prefix of the SHA-256 of the presented leaf beside the flags. The prefix lets a joiner tell nearby hosts apart, and the exchange's TLS proves the full identity. This refines decision 3, which named the whole fingerprint. An extended advertisement with the whole fingerprint was rejected, since Apple's devices and some desktop adapters do not reliably see one. 19. **A joiner scans in the background by default and joins on its own only when the application asks** (2026-10-08). `set_hotspot_scan(bool)`, on by default, scans whenever the device serves and has joined no hotspot and reports `HostNearby` and `HostGone`. `set_hotspot_autojoin(bool)`, off by default, also joins the strongest host it sees through the exchange, one attempt per host until its beacon changes. Both are settable on the builder and at run time, since whether unattended joining fits depends on where and how the application is used. `join_nearby` and `fetch_offer` stay for a manual join. An application-driven scan alone was rejected, since hosts should appear without the user asking, and so was joining on by default, since on Android each join shows the system's approval dialog. -20. **Every joiner's central is btleplug, Android's included, once btleplug's Android `init` resolves its classes through the context class loader** (2026-10-08). Measured between the two A35s inside the demo with btleplug 0.13.4: scanning saw the advertised UUID and service data within a second, and connecting, service discovery, an MTU of 517, writes with response and notifications all worked, once one call in btleplug's `init` looked up `Adapter` through `LoaderContext` instead of raw `FindClass`, which on a thread manganis attached never sees application classes. That fix is upstream as deviceplug/btleplug#495, and until a release carries it the workspace pins `LucaCappelletti94/btleplug` at its commit `5f6b368`, as it pins the keyring forks, and moves to the release once one carries it. `connetto-peer-android` bundles btleplug's Java in its Gradle module with R8 keep rules for `com.nonpolynomial.**` and `io.github.gedgygedgy.**`, sets each calling thread's context class loader to the application's, and hands btleplug the virtual machine under its jni 0.22. A Kotlin GATT client in `connetto-peer-android` was rejected, since it serves Android alone and Apple, Windows and Linux joiners would need a second central, and so was waiting for a btleplug release before building the Android central. deviceplug/btleplug#480, a use-after-free in `FnAdapter` on notifications reported at about 1.1% of sessions, stays open, and the emi runs watch for it. +20. **Every joiner's central is btleplug, Android's included, once btleplug's Android `init` resolves its classes through the context class loader** (2026-10-08). Measured between the two A35s inside the demo with btleplug 0.13.4: scanning saw the advertised UUID and service data within a second, and connecting, service discovery, an MTU of 517, writes with response and notifications all worked, once one call in btleplug's `init` looked up `Adapter` through `LoaderContext` instead of raw `FindClass`, which on a thread manganis attached never sees application classes. That fix is deviceplug/btleplug#495, released in btleplug 0.13.5 with #497's fix of the Java notification queue, and the workspace depends on that release. `connetto-peer-android` bundles btleplug's Java in its Gradle module with R8 keep rules for `com.nonpolynomial.**` and `io.github.gedgygedgy.**`, sets each calling thread's context class loader to the application's, and hands btleplug the virtual machine under its jni 0.22. A Kotlin GATT client in `connetto-peer-android` was rejected, since it serves Android alone and Apple, Windows and Linux joiners would need a second central, and so was waiting for a btleplug release before building the Android central. deviceplug/btleplug#480, a use-after-free in `FnAdapter` on notifications reported at about 1.1% of sessions, stays open, and the emi runs watch for it. 21. **Every backend reports one Bluetooth readiness and prompts only inside a call the user triggered** (2026-10-08). `BluetoothState::{Ready, Off, NotPermitted, Unsupported}` is read with `bluetooth_state()` and reported as `ClientEvent::BluetoothChanged`. The background scan and the host's beacon never prompt, wait while the state is not `Ready` and resume when it is, and the hotspot runs throughout. `host_hotspot`, `join_nearby`, `fetch_offer` and `enable_bluetooth()` run the platform's strongest action once inside the call, the system dialog on Android, switching the radio on through `Windows.Devices.Radios` or BlueZ's `Powered`, and Core Bluetooth's power alert or the Bluetooth settings pane on Apple, then go on or answer `BluetoothOff` or `BluetoothNotPermitted` saying whether the user declined, was sent to settings or met a block. `set_bluetooth_prompt(false)` leaves that action to the application's own `enable_bluetooth()` call, for an application that explains first. `host_hotspot` answers `Hosted { offer, beacon: Result<(), BluetoothError> }`, so the offer reaches the application for the text and QR fallback while the beacon is down, and an error carrying the offer was rejected, since a started hotspot would read as a failure. No system switches Bluetooth on silently everywhere, and every system can report the state and offer one action inside a user gesture, so this is the shape every backend shares. Prompting from the background scan was rejected, and so was answering a typed error alone, which leaves every application to write its own prompt per platform. 22. **`connetto-client` implements the btleplug central for every system, and `connetto-peer-android` only bridges Android's virtual machine to it** (2026-10-09). btleplug's Android `init` takes a jni 0.22 environment, connetto and manganis stand on jni 0.21, and jni 0.22 learns the running virtual machine only through the `unsafe` `JavaVM::from_raw`. So `connetto-peer-android` offers a safe `java_vm()` building it from the virtual machine manganis hands out, its one `unsafe` call documented under the crate's own lints as decision 17 places it, and ships btleplug's Java in its Gradle module. `connetto-client` depends on it on Android only and runs btleplug on one dedicated thread, attached, its class loader set to the application's, `init` called once, and its own runtime, and the polled `CentralBackend` reaches that thread over channels. Slice 8's other systems run the same central without the bridge. The central inside `connetto-peer-android`, handed to the builder by the application, was rejected, since it would keep Android's copy apart from every other system's and cost every application a line. @@ -5463,7 +5463,7 @@ One pull request each, in this order, each green and useful on its own (2026-10- 3. **Discovery** (step 1, decisions 12 and 13). **Built (R76, 2026-10-07)** in `connetto-peer`'s `discovery` feature, which `connetto-client`'s `peer` feature turns on. `mdns-sd` under `_connetto-peer._tcp.local`, the TXT record carrying the frame version and the leaf fingerprint only, `PeerFound` and `PeerGone`, autolink under the discovery table, and Android's `MulticastLock`. Proven on loopback in CI. The gateway probe of decision 11 arrives with slice 4's join API, which knows the gateway and the port, and the A35 leg of discovery runs over slice 4's hotspot, since emi's eduroam separates the phones. 4. **Android hosting and joining** (step 3's Android part and step 6's Wi-Fi permissions, decisions 11 and 14 to 17). `startLocalOnlyHotspot` and the joining network request through `connetto-peer-android`'s Kotlin plugin, the hotspot tables, the per-socket binding, the gateway probe, the typed refusals naming a missing permission, and the demo hosting, joining and showing its peers. Proven on emi, one A35 hosting and the other joining through its application, the runner tapping the approval (decision 16). **Built (R76, 2026-10-08)**, the two-phone run green on emi, the Android 15 A35 hosting and the Android 14 one joining, both `chip-proven`, the joiner reaching the host at the gateway. Measured on the way: an A35 refuses to start an access point while its own Wi-Fi sits on eduroam's 5 GHz channel 56, hostapd failing on its beacon on both phones, and starts it once its Wi-Fi is off, so the run turns the host's Wi-Fi off first, as a host in the field has no network. Whether mDNS works on a joined network without binding its sockets, which `mdns-sd` offers no way to do, is not measured yet, since the gateway dial links the two first. 5. **The Bluetooth beacon and exchange on Android, without the central** (step 4's Android host part and step 6's Bluetooth permissions, decisions 18, 19 and 21). `BluetoothGattServer` and the legacy advertisement as host, decision 21's readiness on Android, the beacon codec, the framed exchange with rustls over a transport trait, `HotspotOffer` only after both sides proved their identity, and both tables, the joiner's driven through a scanning and connecting backend trait. Proven by the exchange and both machines over in-memory transports, and on emi by one A35 hosting with its beacon in its own Bluetooth dump. **Built (R76, 2026-10-09)** as `connetto-peer`'s `bluetooth` feature (`beacon.rs`, `exchange.rs` with `Node::offer_over` and `Node::fetch_over` over `ChunkStream`) and `connetto-client`'s `bluetooth` module, whose machine runs beside the hotspot machine and takes the central as an option, so a system without one neither scans nor joins and `join_nearby` answers `Unsupported`, which is Android until slice 6. On emi the Android 15 A35 hosted from the demo, its panel showed `beacon: advertising` with the prefix, and `dumpsys bluetooth_manager` listed the demo's set as legacy and connectable with 9 bytes of service data under the beacon's UUID beside the started GATT service with both characteristics. Measured on the way: Android's `adb` offers no LE scan without an application, so the second phone's sighting of the beacon moves to slice 6 with the central, and the platform's dump masks the service data UUID past its first group and never shows the data. -6. **The Android central** (step 4's Android joiner part, decision 20). The backend trait over btleplug, pinned at the fork's fix commit until a release carries it, with btleplug's Java in `connetto-peer-android`. Proven on emi between the two A35s, one hosting and the other joining through the beacon and exchange. **Built (R76, 2026-10-09)** as `connetto-client`'s `BtleplugCentral` over btleplug pinned at `LucaCappelletti94/btleplug` `5f6b368`, with `connetto-peer-android`'s `java_vm` and `use_application_class_loader` and btleplug's Java in its Gradle module (decision 22). On emi the Android 14 A35 saw the Android 15 one's beacon on its panel, joined through `Join the nearby host`, the exchange handing over the offer, the runner tapping the approval, and both phones named each other linked. Measured on the way: the joiner's chunk is the smaller of the packet less its 3-byte header and the 512 bytes GATT allows an attribute value, since Android refuses a longer notification even at an MTU of 517, and a joiner holds no Bluetooth central where btleplug's thread does not start, so it neither scans nor joins there. +6. **The Android central** (step 4's Android joiner part, decision 20). The backend trait over btleplug 0.13.5, with btleplug's Java in `connetto-peer-android`. Proven on emi between the two A35s, one hosting and the other joining through the beacon and exchange. **Built (R76, 2026-10-09)** as `connetto-client`'s `BtleplugCentral` over btleplug 0.13.5, with `connetto-peer-android`'s `java_vm` and `use_application_class_loader` and btleplug's Java in its Gradle module (decision 22). On emi the Android 14 A35 saw the Android 15 one's beacon on its panel, joined through `Join the nearby host`, the exchange handing over the offer, the runner tapping the approval, and both phones named each other linked. Measured on the way: the joiner's chunk is the smaller of the packet less its 3-byte header and the 512 bytes GATT allows an attribute value, since Android refuses a longer notification even at an MTU of 517, and a joiner holds no Bluetooth central where btleplug's thread does not start, so it neither scans nor joins there. 7. **The text and QR fallback** (step 5). The `WIFI:` payload and its text form, the text path proven on emi and the camera scan left to the home run. 8. **The unattended emi run** (step 7's first half). A schedule on emi running slices 4 to 7 from `main`, never from a pull request, with its evidence kept on emi. 9. **Every other machine** (step 3, 4 and 6 for Windows, Linux, macOS, iPhone and iPad, and step 7's home run). Split further when it starts. From 9e8051a8aa16cb14b392dd79e21a59825f8fdcb5 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sun, 11 Oct 2026 19:10:56 +0200 Subject: [PATCH 5/6] Start the Bluetooth central once Bluetooth reads Ready --- crates/connetto-client/src/bluetooth.rs | 462 ++++++++++++++++--- crates/connetto-client/src/builder/native.rs | 32 +- plans/master-implementation-plan.md | 13 +- 3 files changed, 430 insertions(+), 77 deletions(-) diff --git a/crates/connetto-client/src/bluetooth.rs b/crates/connetto-client/src/bluetooth.rs index 65b1b4f2..a42077ed 100644 --- a/crates/connetto-client/src/bluetooth.rs +++ b/crates/connetto-client/src/bluetooth.rs @@ -19,6 +19,7 @@ use connetto_core::device_cert::DeviceIdentity; use connetto_peer::{ Beacon, ChunkStream, EXCHANGE_BOUND, ExchangeError, Fingerprint, Node, OfferFrame, }; +use parking_lot::Mutex; use tokio::sync::{mpsc, oneshot, watch}; use crate::ClientEvent; @@ -49,6 +50,9 @@ const CHUNK_OVERHEAD: usize = 3; /// The longest attribute value GATT allows, which a notification or a write /// cannot pass however large the packet. const MAX_ATTRIBUTE_VALUE: usize = 512; +/// The time a failed central start waits before it starts again (R76 central +/// table). +const CENTRAL_RETRY: Duration = Duration::from_secs(30); /// The device of a nearby host, the key the platform's Bluetooth stands on. pub type HostId = u64; @@ -111,6 +115,9 @@ pub enum BluetoothError { /// An exchange is already running. #[error("an exchange is already running")] Busy, + /// The central is starting, so the call goes once it runs. + #[error("the Bluetooth central is starting")] + CentralStarting, /// The exchange failed, with the link's reason. #[error(transparent)] Exchange(#[from] ExchangeError), @@ -323,11 +330,12 @@ impl PeripheralBackend for UnsupportedBackend { fn disconnect(&self, _device: u64) {} } -/// A system's readiness, its peripheral, and its central where it runs one. +/// A system's readiness, its peripheral, and its central's starter where it +/// runs one. pub(crate) type Backends = ( Arc, Arc, - Option>, + Option, ); /// The backends on a system with no Bluetooth, which runs no central. @@ -541,12 +549,37 @@ enum BeaconDecision { /// The seam handing an exchange's offer to the hotspot's join. pub(crate) type JoinSeam = Arc>) + Send + Sync>; +/// The starter's start, resolving to the central or the platform's +/// reason (R76 decision 22). +pub(crate) type CentralStart = + Pin, String>> + Send>>; +/// The seam that starts the joiner's central once, answering with it or +/// the platform's reason (R76 decision 22). +pub(crate) type CentralStarter = Arc CentralStart + Send + Sync>; + +/// The joiner's central's standing, and its start's answer (R76 central +/// table). +enum CentralState { + /// The central does not run, with the time its retry is due. + Absent { retry: Option }, + /// The start is in flight, with its answer. + Starting(oneshot::Receiver, String>>), + /// The central runs, in the state the runner's drop reads. + Running, +} /// The machine, one per client (R76). pub(crate) struct Machine { readiness: Arc, peripheral: Arc, - central: Option>, + /// The seam that starts the central, `None` on a system that runs no + /// central. + starter: Option, + /// The central's standing (R76 central table). + central_state: CentralState, + /// The running central, shared with the runner's drop so a central + /// started after the loop began is still stopped with it. + central: Arc>>>, node: Node, /// The fingerprint the device serves, `None` where it does not. serving: Arc Option + Send + Sync>, @@ -575,9 +608,10 @@ pub(crate) struct Machine { } impl Machine { - /// The machine over the backends, the peer node, the scan and autojoin - /// standings, the prompt standing, and the serving, join and event - /// seams. The receiver stands the host's beacon for the panel. + /// The machine over the backends, the central's starter where the system + /// runs one, the peer node, the scan and autojoin standings, the prompt + /// standing, and the serving, join and event seams. The receiver stands + /// the host's beacon for the panel. #[expect( clippy::too_many_arguments, reason = "the three backends and the three seams join the node and its three standings, and a config struct would hide the same arity behind another type" @@ -585,7 +619,7 @@ impl Machine { pub(crate) fn new( readiness: Arc, peripheral: Arc, - central: Option>, + starter: Option, node: Node, scan: bool, autojoin: bool, @@ -602,7 +636,9 @@ impl Machine { Self { readiness, peripheral, - central, + starter, + central_state: CentralState::Absent { retry: None }, + central: Arc::new(Mutex::new(None)), node, serving, join, @@ -691,7 +727,7 @@ impl Machine { /// A manual exchange from the call's rows (R76 joiner table). fn begin_manual(&mut self, host: HostId, kind: Kind, reply: ManualReply) { - if self.central.is_none() { + if self.starter.is_none() { // The system runs no central, so the call has nothing to join // with. Self::answer_manual(reply, BluetoothError::Unsupported); @@ -703,7 +739,9 @@ impl Machine { return; } match self.readiness.state() { - BluetoothState::Ready => self.begin_join_exchange(host, kind, Some(reply)), + // While Bluetooth reads Ready, the central's rows apply (R76 + // central table). + BluetoothState::Ready => self.manual_central(host, kind, reply), BluetoothState::Unsupported => { Self::answer_manual(reply, BluetoothError::Unsupported); } @@ -720,6 +758,27 @@ impl Machine { } } + /// The central's rows against a manual call, while Bluetooth reads + /// Ready (R76 central table). + fn manual_central(&mut self, host: HostId, kind: Kind, reply: ManualReply) { + match self.central_state { + // The central is absent, so the start goes and the call waits + // for it. + CentralState::Absent { .. } => { + self.start_central(); + Self::answer_manual(reply, BluetoothError::CentralStarting); + } + // The start is in flight, so the call waits for it. + CentralState::Starting(_) => { + Self::answer_manual(reply, BluetoothError::CentralStarting); + } + // The central runs, so the joiner's rows apply. + CentralState::Running => { + self.begin_join_exchange(host, kind, Some(reply)); + } + } + } + /// The manual exchange's answer, on its error rows. fn answer_manual(reply: ManualReply, err: BluetoothError) { match reply { @@ -830,14 +889,12 @@ impl Machine { let _ = reply.send(Ok(())); } PromptPending::Fetch { host, reply } => { - self.begin_join_exchange( - host, - Kind::Fetch, - Some(ManualReply::Fetch(reply)), - ); + // The prompt settled Ready, so the central's rows + // apply. + self.manual_central(host, Kind::Fetch, ManualReply::Fetch(reply)); } PromptPending::Join { host, reply } => { - self.begin_join_exchange(host, Kind::Join, Some(ManualReply::Join(reply))); + self.manual_central(host, Kind::Join, ManualReply::Join(reply)); } }, (BluetoothState::Unsupported, Some(_)) => { @@ -884,6 +941,7 @@ impl Machine { } } + self.tick_central(state, now); self.tick_host(state, hosting, serving.as_ref()); self.tick_joiner(state, joined, serving.is_some(), now); self.answer_hosted(state); @@ -891,6 +949,58 @@ impl Machine { self.update_beacon(state, hosting.is_some(), serving.is_some()); } + /// The central's rows (R76 central table). + fn tick_central(&mut self, state: BluetoothState, now: tokio::time::Instant) { + // Bluetooth reads Ready and the central is absent, so the start + // goes, retrying from its bound where the last start failed. + if state == BluetoothState::Ready + && let CentralState::Absent { retry } = &self.central_state + && retry.is_none_or(|until| now >= until) + { + self.start_central(); + } + // The start's answer, once it stands or the task ends without it. + if let CentralState::Starting(answer) = &mut self.central_state { + match answer.try_recv() { + Ok(Ok(central)) => { + // The central runs, so the joiner's rows apply on this + // look. + *self.central.lock() = Some(central); + self.central_state = CentralState::Running; + } + Ok(Err(reason)) => { + tracing::warn!(%reason, "the Bluetooth central did not start"); + self.central_state = CentralState::Absent { + retry: Some(now + CENTRAL_RETRY), + }; + } + Err(oneshot::error::TryRecvError::Closed) => { + tracing::warn!("the Bluetooth central's start ended without its answer"); + self.central_state = CentralState::Absent { + retry: Some(now + CENTRAL_RETRY), + }; + } + Err(oneshot::error::TryRecvError::Empty) => {} + } + } + } + + /// The central's start, spawned and its answer watched on the later + /// looks (R76 central table). + fn start_central(&mut self) { + let Some(starter) = self.starter.clone() else { + // The system runs no central, so it stays absent. + return; + }; + let (answer_tx, answer_rx) = oneshot::channel(); + self.central_state = CentralState::Starting(answer_rx); + let pending = (starter)(); + tokio::spawn(async move { + let outcome = pending.await; + let _ = answer_tx.send(outcome); + }); + } + /// The host's beacon rows (R76 host table). fn tick_host( &mut self, @@ -1089,19 +1199,19 @@ impl Machine { serving: bool, now: tokio::time::Instant, ) { - // The scan's condition, decision 19, on a system whose central runs - // and whose Bluetooth stands Ready. + // The scan's condition, decision 19, on a system whose central + // runs and whose Bluetooth stands Ready. let should_scan = self.scan && serving && !joined && state == BluetoothState::Ready - && self.central.is_some(); + && matches!(self.central_state, CentralState::Running); match self.joiner { // Scan or autojoin turned on while the device serves with no // joined hotspot. JoinerState::Idle if should_scan => { - if let Some(central) = &self.central { + if let Some(central) = &*self.central.lock() { central.start_scan(); } self.joiner = JoinerState::Scanning; @@ -1109,7 +1219,7 @@ impl Machine { // Scan turned off, the device stops serving, a hotspot is // joined, or Bluetooth leaves Ready. JoinerState::Scanning if !should_scan => { - if let Some(central) = &self.central { + if let Some(central) = &*self.central.lock() { central.stop_scan(); } self.report_hosts_gone(); @@ -1141,9 +1251,10 @@ impl Machine { (self.emit)(ClientEvent::HostGone { host }); } - // The central's events. + // The central's events, where it runs. let events = self .central + .lock() .as_ref() .map(|central| central.poll()) .unwrap_or_default(); @@ -1199,7 +1310,7 @@ impl Machine { if matches!(&self.joiner, JoinerState::Exchanging { host: h, .. } if *h == host) { self.begin_join_task(mtu); - } else if let Some(central) = &self.central { + } else if let Some(central) = &*self.central.lock() { central.disconnect(host); } } @@ -1254,11 +1365,10 @@ impl Machine { if let JoinerState::Exchanging { host, outbound, .. } = &mut self.joiner && let Some(outbound) = outbound.as_mut() { + // The central's handle, for the chunks' writes. + let central = self.central.lock().clone(); while let Ok(chunk) = outbound.try_recv() { - let written = self - .central - .as_ref() - .map(|central| central.write(*host, &chunk)); + let written = central.as_ref().map(|central| central.write(*host, &chunk)); if !matches!(written, Some(Ok(()))) { failed = true; break; @@ -1319,7 +1429,7 @@ impl Machine { /// The join's exchange, from the scan or the call (R76). fn begin_join_exchange(&mut self, host: HostId, kind: Kind, reply: Option) { if matches!(self.joiner, JoinerState::Scanning) - && let Some(central) = &self.central + && let Some(central) = &*self.central.lock() { central.stop_scan(); } @@ -1332,7 +1442,7 @@ impl Machine { outbound: None, task_reply: None, }; - if let Some(central) = &self.central { + if let Some(central) = &*self.central.lock() { central.connect(host); } } @@ -1400,7 +1510,7 @@ impl Machine { else { return; }; - if let Some(central) = &self.central { + if let Some(central) = &*self.central.lock() { central.disconnect(host); } match outcome { @@ -1634,7 +1744,7 @@ impl Machine { // The host's service stops, the joiner's scan and exchange cancel. self.peripheral.stop(); if matches!(self.joiner, JoinerState::Scanning) - && let Some(central) = &self.central + && let Some(central) = &*self.central.lock() { central.stop_scan(); } @@ -1645,6 +1755,10 @@ impl Machine { self.pending_join.clear(); self.prompting = None; self.action_waiters.clear(); + // The central's rows, the start's answer dropped and the running + // central stopped. + self.central_state = CentralState::Absent { retry: None }; + self.central.lock().take(); // The beacon's standing settles, for the panel. let state = self.readiness.state(); self.update_beacon(state, false, (self.serving)().is_some()); @@ -1705,7 +1819,7 @@ pub(crate) struct Runner { future: Pin + Send>>, hotspot: Arc, peripheral: Arc, - central: Option>, + central: Arc>>>, } impl Future for Runner { @@ -1724,9 +1838,9 @@ impl Drop for Runner { self.hotspot.stop_host(); self.hotspot.leave_join(); self.peripheral.stop(); - if let Some(central) = &self.central { - central.stop_scan(); - } + // The running central stops with the client, its thread ending with + // its last command sender. + self.central.lock().take(); } } @@ -1740,7 +1854,7 @@ pub(crate) fn run( ) -> Runner { let hotspot_backend = Arc::clone(&hotspot.backend); let peripheral = Arc::clone(&bluetooth.peripheral); - let central = bluetooth.central.clone(); + let central = Arc::clone(&bluetooth.central); Runner { future: Box::pin(async move { let mut hotspot_closed = false; @@ -2138,12 +2252,56 @@ mod tests { } } + /// The answer a start's starter waits on, over the fakes. + type StarterAnswer = oneshot::Sender, String>>; + + /// A starter the test steers, each start answering when the test sends. + struct FakeStarter { + /// The starts the machine has asked, oldest first. + starts: Mutex>, + /// Every start the machine has asked, answered or not. + total: Mutex, + } + + impl FakeStarter { + fn new() -> Self { + Self { + starts: Mutex::new(Vec::new()), + total: Mutex::new(0), + } + } + + fn asked(&self) -> usize { + *self.total.lock() + } + + fn answer(&self, outcome: Result, String>) { + let sender = self.starts.lock().remove(0); + let _ = sender.send(outcome); + } + } + + /// The fake central, as the machine's central. + fn central_handle(central: Arc) -> Arc { + central + } + + /// A starter that stands the central at once, for the machines whose + /// tests begin with it running. + fn instant_starter(central: Arc) -> CentralStarter { + Arc::new(move || -> CentralStart { + let central = central.clone(); + Box::pin(async move { Ok(central as Arc) }) + }) + } + /// The machine over the fakes, with its seams' records. struct Harness { machine: Machine, readiness: Arc, peripheral: Arc, central: Arc, + starter: Arc, serving: Arc>>, events: Arc>>, beacon: watch::Receiver, @@ -2174,6 +2332,7 @@ mod tests { let readiness = Arc::new(FakeReadiness::new(state)); let peripheral = Arc::new(FakePeripheral::new(wire.clone())); let central = Arc::new(FakeCentral::new(wire)); + let starter = Arc::new(FakeStarter::new()); let serving = Arc::new(Mutex::new(serving)); let scan = Arc::new(Mutex::new(scan)); let autojoin = Arc::new(Mutex::new(autojoin)); @@ -2183,7 +2342,21 @@ mod tests { let (machine, beacon) = Machine::new( Arc::clone(&readiness) as Arc, Arc::clone(&peripheral) as Arc, - Some(Arc::clone(¢ral) as Arc), + Some({ + let starter = Arc::clone(&starter); + let seam: CentralStarter = Arc::new(move || -> CentralStart { + let (tx, rx) = oneshot::channel(); + *starter.total.lock() += 1; + starter.starts.lock().push(tx); + Box::pin(async move { + match rx.await { + Ok(central) => central.map(central_handle), + Err(_) => Err("the start's answer was dropped".into()), + } + }) + }); + seam + }), node, *scan.lock(), *autojoin.lock(), @@ -2206,6 +2379,7 @@ mod tests { readiness, peripheral, central, + starter, serving, events, beacon, @@ -2220,6 +2394,16 @@ mod tests { self.machine.tick(hosting, false); } + /// Stand the central over the fakes, for a fresh machine whose radio + /// stands Ready, so the joiner's rows run against it. + async fn settle(&mut self) { + self.machine.tick(None, false); + self.starter.answer(Ok(Arc::clone(&self.central))); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + self.machine.tick(None, false); + } + fn host_seen(&self, host: HostId, host_fp: &Fingerprint, rssi: i16) { self.central.push(CentralEvent::Seen { host, @@ -2270,7 +2454,7 @@ mod tests { } impl ExchangePair { - fn new() -> Self { + async fn new() -> Self { let d = Deployment::new(); let wire = Arc::new(Wire::new()); // The host plays no joiner and the joiner plays no host, so their @@ -2281,13 +2465,12 @@ mod tests { let joiner_node = serving_node(d.root.clone(), d.joiner_identity); let h_readiness = Arc::new(FakeReadiness::new(BluetoothState::Ready)); let h_peripheral = Arc::new(FakePeripheral::new(wire.clone())); - let h_central = Arc::new(FakeCentral::new(dead_wire.clone())); let h_serving = Arc::new(Mutex::new(Some(host_fp))); let h_events: Arc>> = Arc::new(Mutex::new(Vec::new())); let (host, _h_beacon) = Machine::new( h_readiness, h_peripheral.clone(), - Some(h_central as Arc), + None, host_node, false, false, @@ -2309,10 +2492,10 @@ mod tests { let joins: Arc>> = Arc::new(Mutex::new(Vec::new())); let j_events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (joiner, _j_beacon) = Machine::new( + let (mut joiner, _j_beacon) = Machine::new( j_readiness, j_peripheral, - Some(j_central.clone() as Arc), + Some(instant_starter(Arc::clone(&j_central))), joiner_node, true, false, @@ -2330,6 +2513,11 @@ mod tests { move |event| events.lock().push(event) }), ); + // The central stands, so the joiner's rows run against it. + joiner.tick(None, false); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + joiner.tick(None, false); Self { host, host_peripheral: h_peripheral, @@ -2545,12 +2733,146 @@ mod tests { assert!(matches!(h.beacon_state(), BeaconState::Off { .. })); } + // --- The joiner's central table --- + + #[tokio::test] + async fn the_central_does_not_start_while_bluetooth_is_not_ready() { + let mut h = Harness::new(BluetoothState::Off); + h.tick(None); + h.tick(None); + assert_eq!(h.starter.asked(), 0); + assert!(h.machine.central.lock().is_none()); + } + + #[tokio::test] + async fn the_central_starts_on_first_ready_and_the_scan_begins_once_it_runs() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(None); + // The start goes, but the scan waits for the central to run. + assert_eq!(h.starter.asked(), 1); + assert!(!h.central.started()); + h.starter.answer(Ok(Arc::clone(&h.central))); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + h.tick(None); + assert!(matches!(h.machine.central_state, CentralState::Running)); + assert!(h.central.started()); + } + + #[tokio::test(start_paused = true)] + async fn a_failed_start_retries_after_its_bound_while_ready_and_not_before() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(None); + assert_eq!(h.starter.asked(), 1); + h.starter.answer(Err("the adapter is missing".into())); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + h.tick(None); + // The failure stands, and the retry waits its 30 seconds. + assert!(matches!( + h.machine.central_state, + CentralState::Absent { .. } + )); + assert_eq!(h.starter.asked(), 1); + tokio::time::sleep(Duration::from_secs(29)).await; + h.tick(None); + assert!(matches!( + h.machine.central_state, + CentralState::Absent { .. } + )); + assert_eq!(h.starter.asked(), 1); + tokio::time::sleep(Duration::from_secs(1)).await; + h.tick(None); + assert_eq!(h.starter.asked(), 2); + h.starter.answer(Ok(Arc::clone(&h.central))); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + h.tick(None); + assert!(matches!(h.machine.central_state, CentralState::Running)); + } + + #[tokio::test] + async fn a_manual_join_while_the_central_is_starting_answers_central_starting() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(None); + assert_eq!(h.starter.asked(), 1); + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.handle(Command::JoinNearby { + host: 1, + reply: reply_tx, + }); + let answer = reply_rx.try_recv().expect("the caller is answered"); + assert!(matches!( + answer, + Err(JoinNearbyError::Bluetooth(BluetoothError::CentralStarting)) + )); + // The call did not start a second central. + assert_eq!(h.starter.asked(), 1); + assert!(h.machine.central.lock().is_none()); + } + + #[tokio::test] + async fn a_manual_fetch_while_the_central_is_starting_answers_central_starting() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(None); + assert_eq!(h.starter.asked(), 1); + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.handle(Command::Fetch { + host: 1, + reply: reply_tx, + }); + let answer = reply_rx.try_recv().expect("the caller is answered"); + assert!(matches!(answer, Err(BluetoothError::CentralStarting))); + assert_eq!(h.starter.asked(), 1); + } + + #[tokio::test] + async fn a_manual_join_while_absent_and_ready_starts_the_central() { + let mut h = Harness::new(BluetoothState::Ready); + let (reply_tx, mut reply_rx) = oneshot::channel(); + h.machine.handle(Command::JoinNearby { + host: 1, + reply: reply_tx, + }); + let answer = reply_rx.try_recv().expect("the caller is answered"); + assert!(matches!( + answer, + Err(JoinNearbyError::Bluetooth(BluetoothError::CentralStarting)) + )); + // The call started the central, and its tick does not start a second. + assert_eq!(h.starter.asked(), 1); + h.tick(None); + assert_eq!(h.starter.asked(), 1); + assert!(matches!(h.machine.central_state, CentralState::Starting(_))); + assert!(!h.central.started()); + } + + #[tokio::test] + async fn a_closed_client_drops_a_starting_central() { + let mut h = Harness::new(BluetoothState::Ready); + h.tick(None); + assert_eq!(h.starter.asked(), 1); + h.machine.close(); + // The start's answer is dropped, so the central never stands. + assert!(matches!( + h.machine.central_state, + CentralState::Absent { .. } + )); + assert!(h.machine.central.lock().is_none()); + h.starter.answer(Ok(Arc::clone(&h.central))); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + // The dropped answer did not stand a central. + assert!(h.machine.central.lock().is_none()); + assert!(!h.central.started()); + } + // --- The joiner's scan and exchange table --- #[tokio::test] async fn scan_on_while_serving_starts_scanning() { let mut h = Harness::new(BluetoothState::Ready); - h.tick(None); + h.settle().await; assert!(h.central.started()); } @@ -2558,7 +2880,7 @@ mod tests { async fn scan_off_stops_scanning_and_reports_its_hosts_gone() { let mut h = Harness::new(BluetoothState::Ready); let host_fp = fp(9); - h.tick(None); + h.settle().await; h.host_seen(1, &host_fp, -50); h.tick(None); assert_eq!(h.host_nearby(), vec![(1, host_fp.prefix())]); @@ -2572,7 +2894,7 @@ mod tests { async fn a_new_beacon_reports_host_nearby_and_a_known_one_refreshes() { let mut h = Harness::new(BluetoothState::Ready); let host_fp = fp(9); - h.tick(None); + h.settle().await; h.host_seen(1, &host_fp, -50); h.tick(None); assert_eq!(h.host_nearby(), vec![(1, host_fp.prefix())]); @@ -2586,7 +2908,7 @@ mod tests { async fn an_unseen_beacon_is_reported_gone_after_its_bound() { let mut h = Harness::new(BluetoothState::Ready); let host_fp = fp(9); - h.tick(None); + h.settle().await; h.host_seen(1, &host_fp, -50); h.tick(None); assert_eq!(h.host_gone(), Vec::::new()); @@ -2607,7 +2929,7 @@ mod tests { ); let weak = fp(10); let strong = fp(11); - h.tick(None); + h.settle().await; h.host_seen(1, &weak, -80); h.host_seen(2, &strong, -40); h.tick(None); @@ -2617,28 +2939,36 @@ mod tests { #[tokio::test] async fn a_join_without_ready_bluetooth_runs_its_action_once() { let mut h = Harness::new(BluetoothState::Off); - let (reply_tx, _reply_rx) = oneshot::channel(); + let (reply_tx, mut reply_rx) = oneshot::channel(); h.machine.handle(Command::Fetch { host: 1, reply: reply_tx, }); h.tick(None); assert_eq!(*h.readiness.prompt_calls.lock(), 1); - // The user approves, so the radio stands Ready and the exchange goes. + // The user approves, so the radio stands Ready and the central's + // rows apply. *h.readiness.state.lock() = BluetoothState::Ready; *h.readiness.outcome.lock() = Some(PromptOutcome::NotAsked); h.tick(None); assert_eq!(*h.readiness.prompt_calls.lock(), 1); - assert_eq!(*h.central.connects.lock(), vec![1]); + // The central is absent, so the start goes and the call waits for + // it. + assert_eq!(h.starter.asked(), 1); + assert!(matches!( + reply_rx.try_recv(), + Ok(Err(BluetoothError::CentralStarting)) + )); } #[tokio::test] async fn a_system_without_a_central_neither_scans_nor_joins() { let mut h = Harness::new(BluetoothState::Ready); - h.machine.central = None; + h.machine.starter = None; h.tick(None); h.tick(None); - assert_eq!(*h.central.starts.lock(), 0); + assert_eq!(h.starter.asked(), 0); + assert!(h.machine.central.lock().is_none()); let (reply_tx, mut reply_rx) = oneshot::channel(); h.machine.handle(Command::Fetch { host: 1, @@ -2654,6 +2984,7 @@ mod tests { #[tokio::test] async fn a_manual_join_runs_its_exchange_from_its_state() { let mut h = Harness::new(BluetoothState::Ready); + h.settle().await; let (first_tx, _first_rx) = oneshot::channel(); h.machine.handle(Command::Fetch { host: 1, @@ -2676,7 +3007,7 @@ mod tests { async fn a_refused_or_failed_exchange_marks_its_host_tried() { let mut h = Harness::new(BluetoothState::Ready); let host_fp = fp(9); - h.tick(None); + h.settle().await; h.host_seen(1, &host_fp, -50); h.tick(None); // A manual fetch, whose NotServing exchange ends refused. @@ -2699,7 +3030,7 @@ mod tests { #[tokio::test] async fn a_host_that_disconnects_ends_its_exchange_at_once() { let mut h = Harness::new(BluetoothState::Ready); - h.tick(None); + h.settle().await; h.host_seen(1, &fp(9), -50); h.tick(None); let (reply_tx, mut reply_rx) = oneshot::channel(); @@ -2720,7 +3051,7 @@ mod tests { #[tokio::test] async fn a_declined_join_marks_its_host_tried() { - let mut pair = ExchangePair::new(); + let mut pair = ExchangePair::new().await; let device: HostId = 1; // The host's beacon is seen, so it is tracked and untried. pair.joiner_central.push(CentralEvent::Seen { @@ -2757,6 +3088,7 @@ mod tests { #[tokio::test] async fn bluetooth_leaving_ready_fails_its_exchange() { let mut h = Harness::new(BluetoothState::Ready); + h.settle().await; let (reply_tx, mut reply_rx) = oneshot::channel(); h.machine.handle(Command::Fetch { host: 1, @@ -2778,21 +3110,26 @@ mod tests { } #[tokio::test] - async fn bluetooth_becoming_ready_starts_its_scan() { + async fn bluetooth_becoming_ready_starts_its_central_and_scan() { let mut h = Harness::new(BluetoothState::Off); h.tick(None); h.tick(None); - // The scan does not start while the radio stands Off. - assert_eq!(*h.central.starts.lock(), 0); + // The central does not start while the radio stands Off. + assert_eq!(h.starter.asked(), 0); *h.readiness.state.lock() = BluetoothState::Ready; h.tick(None); + assert_eq!(h.starter.asked(), 1); + h.starter.answer(Ok(Arc::clone(&h.central))); + tokio::task::yield_now().await; + tokio::task::yield_now().await; + h.tick(None); assert!(h.central.started()); } #[tokio::test] async fn a_closed_client_stops_its_scan_and_exchange() { let mut h = Harness::new(BluetoothState::Ready); - h.tick(None); + h.settle().await; assert!(h.central.started()); // An exchange runs beneath the scan. let (reply_tx, mut reply_rx) = oneshot::channel(); @@ -2813,7 +3150,7 @@ mod tests { #[tokio::test] async fn a_completed_exchange_sends_its_offer_and_ends() { - let mut pair = ExchangePair::new(); + let mut pair = ExchangePair::new().await; let device: HostId = 1; let (fetch_tx, _fetch_rx) = oneshot::channel(); pair.joiner.handle(Command::Fetch { @@ -2836,7 +3173,7 @@ mod tests { #[tokio::test] async fn an_exchanged_offer_reaches_its_caller_and_the_join() { - let mut pair = ExchangePair::new(); + let mut pair = ExchangePair::new().await; let device: HostId = 1; let (join_tx, mut join_rx) = oneshot::channel(); pair.joiner.handle(Command::JoinNearby { @@ -2913,13 +3250,12 @@ mod tests { let wire = Arc::new(Wire::new()); let h_readiness = Arc::new(FakeReadiness::new(BluetoothState::Ready)); let h_peripheral = Arc::new(FakePeripheral::new(wire.clone())); - let h_central = Arc::new(FakeCentral::new(wire)); let h_serving = Arc::new(Mutex::new(Some(fp(1)))); let h_events: Arc>> = Arc::new(Mutex::new(Vec::new())); let (bt_machine, _bt_beacon) = Machine::new( h_readiness, h_peripheral, - Some(h_central as Arc), + None, quiet_node(), false, false, diff --git a/crates/connetto-client/src/builder/native.rs b/crates/connetto-client/src/builder/native.rs index 539374c3..2b491fd8 100644 --- a/crates/connetto-client/src/builder/native.rs +++ b/crates/connetto-client/src/builder/native.rs @@ -923,30 +923,36 @@ where }), ); #[cfg(target_os = "android")] - let (bt_readiness, bt_peripheral, bt_central) = match device.java.clone() { + let (bt_readiness, bt_peripheral, bt_starter) = match device.java.clone() { Some(java) => { let backend = Arc::new(crate::bluetooth::AndroidBluetoothBackend::new(java)); + // btleplug's central, started once + // Bluetooth reads Ready and retried where + // its start fails (R76 decision 22). + let bt_starter: crate::bluetooth::CentralStarter = + Arc::new(move || -> crate::bluetooth::CentralStart { + Box::pin(async move { + crate::bluetooth::BtleplugCentral::start() + .await + .map(|central| { + Arc::new(central) + as Arc + }) + .map_err(|err| err.to_string()) + }) + }); ( Arc::clone(&backend) as Arc, Arc::clone(&backend) as Arc, - // btleplug's central, absent where its thread - // does not start (R76 decision 22). - match crate::bluetooth::BtleplugCentral::start().await { - Ok(central) => Some(Arc::new(central) - as Arc), - Err(err) => { - tracing::warn!(%err, "the Bluetooth central did not start"); - None - } - }, + Some(bt_starter), ) } None => crate::bluetooth::unsupported_backends(), }; #[cfg(not(target_os = "android"))] - let (bt_readiness, bt_peripheral, bt_central) = + let (bt_readiness, bt_peripheral, bt_starter) = crate::bluetooth::unsupported_backends(); let bt_state = Arc::clone(&bt_readiness); let serving_handle = handle.clone(); @@ -955,7 +961,7 @@ where let bt_machine = crate::bluetooth::Machine::new( bt_readiness, bt_peripheral, - bt_central, + bt_starter, beacon_node, device.bt_scan, device.bt_autojoin, diff --git a/plans/master-implementation-plan.md b/plans/master-implementation-plan.md index fa5fd5ed..cc383a9d 100644 --- a/plans/master-implementation-plan.md +++ b/plans/master-implementation-plan.md @@ -5313,7 +5313,7 @@ The local data plane R25 decided, discovery by gateway-probe first (a hotspot ho 19. **A joiner scans in the background by default and joins on its own only when the application asks** (2026-10-08). `set_hotspot_scan(bool)`, on by default, scans whenever the device serves and has joined no hotspot and reports `HostNearby` and `HostGone`. `set_hotspot_autojoin(bool)`, off by default, also joins the strongest host it sees through the exchange, one attempt per host until its beacon changes. Both are settable on the builder and at run time, since whether unattended joining fits depends on where and how the application is used. `join_nearby` and `fetch_offer` stay for a manual join. An application-driven scan alone was rejected, since hosts should appear without the user asking, and so was joining on by default, since on Android each join shows the system's approval dialog. 20. **Every joiner's central is btleplug, Android's included, once btleplug's Android `init` resolves its classes through the context class loader** (2026-10-08). Measured between the two A35s inside the demo with btleplug 0.13.4: scanning saw the advertised UUID and service data within a second, and connecting, service discovery, an MTU of 517, writes with response and notifications all worked, once one call in btleplug's `init` looked up `Adapter` through `LoaderContext` instead of raw `FindClass`, which on a thread manganis attached never sees application classes. That fix is deviceplug/btleplug#495, released in btleplug 0.13.5 with #497's fix of the Java notification queue, and the workspace depends on that release. `connetto-peer-android` bundles btleplug's Java in its Gradle module with R8 keep rules for `com.nonpolynomial.**` and `io.github.gedgygedgy.**`, sets each calling thread's context class loader to the application's, and hands btleplug the virtual machine under its jni 0.22. A Kotlin GATT client in `connetto-peer-android` was rejected, since it serves Android alone and Apple, Windows and Linux joiners would need a second central, and so was waiting for a btleplug release before building the Android central. deviceplug/btleplug#480, a use-after-free in `FnAdapter` on notifications reported at about 1.1% of sessions, stays open, and the emi runs watch for it. 21. **Every backend reports one Bluetooth readiness and prompts only inside a call the user triggered** (2026-10-08). `BluetoothState::{Ready, Off, NotPermitted, Unsupported}` is read with `bluetooth_state()` and reported as `ClientEvent::BluetoothChanged`. The background scan and the host's beacon never prompt, wait while the state is not `Ready` and resume when it is, and the hotspot runs throughout. `host_hotspot`, `join_nearby`, `fetch_offer` and `enable_bluetooth()` run the platform's strongest action once inside the call, the system dialog on Android, switching the radio on through `Windows.Devices.Radios` or BlueZ's `Powered`, and Core Bluetooth's power alert or the Bluetooth settings pane on Apple, then go on or answer `BluetoothOff` or `BluetoothNotPermitted` saying whether the user declined, was sent to settings or met a block. `set_bluetooth_prompt(false)` leaves that action to the application's own `enable_bluetooth()` call, for an application that explains first. `host_hotspot` answers `Hosted { offer, beacon: Result<(), BluetoothError> }`, so the offer reaches the application for the text and QR fallback while the beacon is down, and an error carrying the offer was rejected, since a started hotspot would read as a failure. No system switches Bluetooth on silently everywhere, and every system can report the state and offer one action inside a user gesture, so this is the shape every backend shares. Prompting from the background scan was rejected, and so was answering a typed error alone, which leaves every application to write its own prompt per platform. -22. **`connetto-client` implements the btleplug central for every system, and `connetto-peer-android` only bridges Android's virtual machine to it** (2026-10-09). btleplug's Android `init` takes a jni 0.22 environment, connetto and manganis stand on jni 0.21, and jni 0.22 learns the running virtual machine only through the `unsafe` `JavaVM::from_raw`. So `connetto-peer-android` offers a safe `java_vm()` building it from the virtual machine manganis hands out, its one `unsafe` call documented under the crate's own lints as decision 17 places it, and ships btleplug's Java in its Gradle module. `connetto-client` depends on it on Android only and runs btleplug on one dedicated thread, attached, its class loader set to the application's, `init` called once, and its own runtime, and the polled `CentralBackend` reaches that thread over channels. Slice 8's other systems run the same central without the bridge. The central inside `connetto-peer-android`, handed to the builder by the application, was rejected, since it would keep Android's copy apart from every other system's and cost every application a line. +22. **`connetto-client` implements the btleplug central for every system, and `connetto-peer-android` only bridges Android's virtual machine to it** (2026-10-09). btleplug's Android `init` takes a jni 0.22 environment, connetto and manganis stand on jni 0.21, and jni 0.22 learns the running virtual machine only through the `unsafe` `JavaVM::from_raw`. So `connetto-peer-android` offers a safe `java_vm()` building it from the virtual machine manganis hands out, its one `unsafe` call documented under the crate's own lints as decision 17 places it, and ships btleplug's Java in its Gradle module. `connetto-client` depends on it on Android only and runs btleplug on one dedicated thread, attached, its class loader set to the application's, `init` called once, and its own runtime, and the polled `CentralBackend` reaches that thread over channels. Slice 8's other systems run the same central without the bridge. The central inside `connetto-peer-android`, handed to the builder by the application, was rejected, since it would keep Android's copy apart from every other system's and cost every application a line. The central starts when Bluetooth first reads `Ready`, never while the client is built (decided with the maintainer 2026-10-11). On iPhone and iPad btleplug's start waits for the system's Bluetooth state, which arrives only once the user answers the permission alert, so a start while the client was built passed its 10-second bound whenever the alert waited longer and left the joiner without a central until the application restarted. A longer bound tried once was rejected, since an alert answered after it still left no central. ### Design @@ -5360,6 +5360,17 @@ The joiner's scan and exchange against its state. Scanning runs while scan is on | Bluetooth becomes `Ready` while scan is on, the device serves and no hotspot is joined | start scanning, become Scanning | not applicable | not applicable | | Client closed | nothing | stop scanning | cancel the exchange | +The joiner's central against its state, on a system that runs one (decision 22). A system without a central answers `join_nearby` and `fetch_offer` with `Unsupported` and never leaves Absent. + +| Event | Absent | Starting | Running | +| --- | --- | --- | --- | +| Bluetooth reads `Ready` | start btleplug's thread, become Starting | nothing | nothing | +| The start answers the central | not applicable | become Running, then the joiner table's "Bluetooth becomes `Ready`" row applies | not applicable | +| The start fails or passes its 10-second bound | not applicable | log the failure, become Absent, and start again 30 seconds later while Bluetooth still reads `Ready` | not applicable | +| Bluetooth leaves `Ready` | nothing | let the start finish | the joiner table's rows apply, the central stays | +| `join_nearby(host)` or `fetch_offer(host)` while Bluetooth reads `Ready` | start btleplug's thread, become Starting, answer `CentralStarting` | answer `CentralStarting` | the joiner table's rows apply | +| Client closed | nothing | drop the start's answer, which stops its thread | stop the central | + - **Hotspot.** The application calls `NativeClient::host_hotspot()`, answered by a `HotspotOffer { ssid, passphrase, security, port }` or a `HotspotError` (`Unsupported`, `MissingPermission(name)`, `Incompatible`, `NoChannel`, `Disallowed`, `TimedOut`, `Failed`), and `stop_hotspot()`. A joiner calls `join_hotspot(offer)`, answered by the joined network's gateway or a `JoinError` (`Unsupported`, `MissingPermission(name)`, `Declined`, `TimedOut`, `Failed`), and `leave_hotspot()`. `ClientEvent::HotspotStopped` and `HotspotLeft` report what the system ended. Android needs `CHANGE_WIFI_STATE`, `NEARBY_WIFI_DEVICES` (with `neverForLocation`), `ACCESS_WIFI_STATE`, `CHANGE_NETWORK_STATE` and `ACCESS_NETWORK_STATE`, which `connetto-peer-android`'s manifest declares, and the application asks the user for `NEARBY_WIFI_DEVICES` at run time, which the plugin offers to do. A system whose backend has not arrived answers `Unsupported` to every call, Linux, Windows and the Apple systems until slice 8. Every wait is bounded, 30 seconds for a hotspot to start and 60 seconds for a join including the user's approval. The hosting side against its state, beside the link's and discovery's tables. Nothing in it depends on the device's standing (decision 14). From cc592d8627412f6224229e9fb87ce6d34e0dab9c Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sun, 11 Oct 2026 21:32:42 +0200 Subject: [PATCH 6/6] Bound each Bluetooth write to the host --- .../connetto-client/src/bluetooth/central.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/connetto-client/src/bluetooth/central.rs b/crates/connetto-client/src/bluetooth/central.rs index 1d9a4421..1733ffbd 100644 --- a/crates/connetto-client/src/bluetooth/central.rs +++ b/crates/connetto-client/src/bluetooth/central.rs @@ -314,14 +314,27 @@ async fn link( tokio::select! { outgoing = writes.recv() => { let Some(bytes) = outgoing else { break }; - if let Err(err) = peripheral.write(&inbox, &bytes, WriteType::WithResponse).await { - tracing::warn!(%err, "a chunk to the host did not go"); - break; + let written = tokio::time::timeout( + LINK_STEP_BOUND, + peripheral.write(&inbox, &bytes, WriteType::WithResponse), + ) + .await; + match written { + Ok(Ok(())) => tracing::debug!(len = bytes.len(), "a chunk went to the host"), + Ok(Err(err)) => { + tracing::warn!(%err, "a chunk to the host did not go"); + break; + } + Err(_) => { + tracing::warn!(len = bytes.len(), "a chunk to the host got no answer within its bound"); + break; + } } } notification = notifications.next() => { let Some(notification) = notification else { break }; if notification.uuid == outbox.uuid { + tracing::debug!(len = notification.value.len(), "the host's chunk arrived"); push(&queue, CentralEvent::Chunk { host, bytes: notification.value }); } }