Android support
I've started to work on Android support at https://github.com/akiles/bluest/tree/android .
Are you OK with PRs implementing only part, leaving some stuff as "not implemented yet"? My current goal is implementing scanning, connecting, L2CAP channels and perhaps basic GATT (we don't use GATT in our product), so I probably won't have time available to implement the full API in the short term.
Yes, I think partial support is better than no support. We'd just need to document it clearly. (We may want to put it behind an unstable feature flag, though)
Did you ever find a way to support L2CAP on Windows?
it seems it's not supported on windows, no :(
I have read the first comment in https://github.com/alexmoon/bluest/pull/10. I realized that this library cannot be used directly in a Rust native application based on android-activity crate (e.g. slint, egui), because of the BluestScanCallback class defined here.
Please check this issue: https://github.com/wuwbobo2021/jni-min-helper/issues/1. I have built this library in order to support android-usbser-rs which needs a BroadcastReceiver for handling USB permission requests and hotplug events.
I just created android-bluetooth-serial-rs for Bluetooth SPP (not BLE) connection, it is currently unfinished. I realized that it's good to have a dex class loader in java-spaghetti to get rid of my compromise of having jni-min-helper.
More words
I had wanted to create any callback class implementation in Rust via Java dynamic proxy. It's insipired by droid-wrap-utils, but it has been proven to be a wrong idea: callbacks required in the Android API must be inherited from abstract classes, but not Java interfaces.
Eventually I added a broatcast receiver module in jni-min-helper (I thought it's important enough for Android support; still, there is https://github.com/rust-mobile/android-activity/issues/174). Considering existences of different abstract callbacks in the Android API, I do know its impossible for jni-min-helper to cover all of them. So I exposed a few functions handling the Android dex class loader. It was inspired by:
- https://docs.rs/crate/robius-authentication/latest/source/build.rs
- https://docs.rs/crate/droid-wrap-utils/latest/source/build.rs
- https://github.com/wuwbobo2021/android-rust-java-test
- https://github.com/slint-ui/slint/blob/master/internal/backends/android-activity/build.rs
In https://github.com/wuwbobo2021/jni-min-helper/issues/1, the author of droid-wrap told me that java-spaghetti looks well-designed, but he will build another framework (based on jni-sys) by himself, mainly for reaching two goals:
- Generate bindings for any Java class by macros in the Rust code, including abstract class implementations;
- Support
javadocin a better way, to include bodies of Java documentation.
Are you planning to do these things as well? In other words, do you think the droid-wrap author's plan is "redundant"?
bluest can work with applications based on android-activity crate. However, the device portion of the Android implementation is unimplemented! The test program for device discovery is provided here.
Notes:
- Build the application with
cargo-apk; view output viaadb logcat RustStdoutStderr:D '*:S'. - TODO: request runtime permission for Android 12 and higher versions.
scan_result.isConnectable()found inbluest/src/android/adapter.rscrashes on Android 7.x and lower versions.futures_lite::future::block_onasynchronous runner must not be called inandroid_main, which is the application entry point required byandroid-activity.- I made the program more complicated than making use of
jni-min-helperfor the runtime dex class loader, it's just seeking for possibility of avoidingjni-rsdependency, or even introducing the dex loader tojava-spaghetti(this is merely a draft, though). Actually,jni-rsis a dependency ofandroid-activity, so I'm not reducing the overall application size. - JNI
DetachCurrentThreadis required for a native background thread to end the execution, otherwise it will panic. Looking into the comments injava-spaghetti/src/refs/local.rs, I have an idea:java-spaghettican have aOption<Env>wrapper (which implements a customDrop) in thread-local storage;VM::with_envtriesGetEnvat first, but callsAttachCurrentThreadand stores theEnvinto the wrapper'sOptionif it's not already attached by other means; on thread termination, the wrapper'sDropshould callDetachCurrentThreadif theOptionisSome(which indicates that it has been attached byjava-spaghetti, not by other means). Inspired by https://github.com/jni-rs/jni-rs/issues/548. - I forgot to set an exit flag for the async thread on destroy. the Destroy (or Stop) event from
android_mainis possible to back another future to be ORed withasync_mainforblock_onto exit, but I'm not sure whether it is a good idea.
Test code
I'm trying to be non-intrusive; however, to register native callbacks at runtime, its required to add this snippet in lib.rs of bluest:
#[cfg(target_os = "android")]
#[doc(hidden)]
#[allow(missing_docs)]
pub use sys::adapter::{
Java_com_github_alexmoon_bluest_android_BluestScanCallback_nativeOnScanResult,
Java_com_github_alexmoon_bluest_android_BluestScanCallback_nativeOnScanFailed
};
To build the classes.dex file for the callback class, a build script is taken from my https://github.com/wuwbobo2021/jni-min-helper/blob/main/build.rs: just remove the non-Android and "prebuilt fallback" portions in the main function.
Cargo.toml:
[package]
name = "bluest-test"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
# bluest = { version = "0.6.7", features = ["unstable"] }
bluest = { path = "../bluest", features = ["unstable"] }
tracing = "0.1.36"
tracing-subscriber = "0.3.15"
# android-activity uses `log`
log = "0.4"
tracing-log = "0.2.0"
ndk-context = "0.1.1"
android-activity = { version = "0.6", features = ["native-activity"] }
java-spaghetti = "0.2.0"
futures-lite = "1.13.0"
[build-dependencies]
java-locator = "0.1.8"
[lib]
crate-type = ["cdylib"]
[package.metadata.android]
package = "com.example.bluest_test"
build_targets = [ "aarch64-linux-android" ]
[package.metadata.android.sdk]
min_sdk_version = 16
target_sdk_version = 30
[[package.metadata.android.uses_feature]]
name = "android.hardware.bluetooth"
required = true
# TODO: support Android 12 and above, which require runtime permissions.
# <https://developer.android.google.cn/develop/connectivity/bluetooth/bt-permissions>
[[package.metadata.android.uses_permission]]
name = "android.permission.ACCESS_FINE_LOCATION"
max_sdk_version = 30
[[package.metadata.android.uses_permission]]
name = "android.permission.ACCESS_COARSE_LOCATION"
max_sdk_version = 30
[[package.metadata.android.uses_permission]]
name = "android.permission.BLUETOOTH"
max_sdk_version = 30
[[package.metadata.android.uses_permission]]
name = "android.permission.BLUETOOTH_ADMIN"
max_sdk_version = 30
lib.rs:
mod bluest_adapter;
use android_activity::{AndroidApp, MainEvent, PollEvent};
use bluest_adapter::bluetooth_adapter;
use futures_lite::StreamExt;
#[no_mangle]
fn android_main(app: AndroidApp) {
// android_logger::init_once(
// android_logger::Config::default()
// .with_max_level(log::LevelFilter::Info)
// .with_tag("bluest_test".as_bytes()),
// );
let subscriber = tracing_subscriber::FmtSubscriber::builder()
.without_time()
.finish();
tracing::subscriber::set_global_default(subscriber).expect("setting tracing default failed");
tracing_log::LogTracer::init().expect("setting log tracer failed");
std::thread::spawn(|| {
let _ = futures_lite::future::block_on(async_main());
// Safety: any usage of `java_spaghetti::VM::with_env` a thread attaches it
// to the JVM; this is probably acceptable even if it is not attached...
unsafe {
detach_current_thread();
}
});
let mut on_destroy = false;
loop {
app.poll_events(None, |event| match event {
PollEvent::Main(MainEvent::Destroy) => {
on_destroy = true;
}
_ => (),
});
if on_destroy {
return;
}
}
}
async fn async_main() -> Result<(), Box<dyn std::error::Error>> {
let adapter = bluetooth_adapter()?;
adapter.wait_available().await?;
tracing::info!("starting scan");
let mut scan = adapter.scan(&[]).await?;
tracing::info!("scan started");
while let Some(discovered_device) = scan.next().await {
tracing::info!("found a device...");
tracing::info!("{:#?}", discovered_device);
}
Ok(())
}
unsafe fn detach_current_thread() {
let vm = bluest_adapter::get_vm();
((**vm.as_raw()).v1_2.DetachCurrentThread)(vm.as_raw());
}
bluest_adapter/mod.rs:
use java_spaghetti::{AsArg, Global};
mod bindings;
use bindings::{
dalvik::system::{DexClassLoader, InMemoryDexClassLoader},
java::lang::{ClassLoader, Object, String as JString, Throwable},
};
const DEX_DATA: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/classes.dex"));
pub fn get_vm() -> java_spaghetti::VM {
let vm = ndk_context::android_context().vm();
if vm.is_null() {
panic!("ndk-context is unconfigured: null JVM pointer, check the glue crate.");
}
unsafe { java_spaghetti::VM::from_raw(vm.cast()) }
}
// Bindings for `android.content.Context` isn't generated, reducing code size.
pub fn get_android_context() -> Global<Object> {
let ctx = ndk_context::android_context().context();
if ctx.is_null() {
// TODO: use `android.app.ActivityThread.getApplication()` and print a warning instead
panic!("ndk-context is unconfigured: null Android context pointer, check the glue crate.");
}
let context = unsafe { java_spaghetti::Global::<Object>::from_raw(get_vm(), ctx.cast()) };
let context_clone = context.clone(); // this is the real owned global reference
let _ = context.into_raw();
context_clone
}
fn get_context_class_loader() -> Global<ClassLoader> {
let vm = get_vm();
let context = get_android_context();
vm.with_env(|env| {
// Safety: `ndk_context` is configured correctly.
// Doing this to exclude `android.content.Context` from `java-spaghetti.toml`, reducing code size.
unsafe {
let (_, method_get_loader) = env.require_class_method(
"android/content/Context\0",
"getClassLoader\0",
"()Ljava/lang/ClassLoader;\0",
);
env.call_object_method_a::<ClassLoader, Throwable>(
context.as_raw(),
method_get_loader,
[].as_ptr(),
)
}
.unwrap()
.unwrap()
.as_global()
})
}
/// Call this function for once, to load the dex data for `java-spaghetti`.
fn init_loader() {
let context_loader = get_context_class_loader();
let vm = get_vm();
vm.with_env(|env| {
let class_loader = unsafe {
let sdk_ver = {
let os_build_class = env.require_class("android/os/Build$VERSION\0");
let sdk_int_field = env.require_static_field(os_build_class, "SDK_INT\0", "I\0");
env.get_static_int_field(os_build_class, sdk_int_field)
};
if sdk_ver >= 26 {
use java_spaghetti::PrimitiveArray;
// Safety: casts `&[u8]` to `&[i8]`.
let data =
std::slice::from_raw_parts(DEX_DATA.as_ptr() as *const i8, DEX_DATA.len());
let byte_array = java_spaghetti::ByteArray::new_from(env, data);
let dex_buffer =
bindings::java::nio::ByteBuffer::wrap_byte_array(env, byte_array).unwrap();
let dex_loader = InMemoryDexClassLoader::new_ByteBuffer_ClassLoader(
env,
dex_buffer,
context_loader,
)
.unwrap();
dex_loader.as_global().into_raw()
} else {
let context = get_android_context();
let code_cache_path = {
let (_, method_get_cache_dir) = env.require_class_method(
"android/content/Context\0",
"getCodeCacheDir\0",
"()Ljava/io/File;\0",
);
let jfile = env
.call_object_method_a::<Object, Throwable>(
context.as_raw(),
method_get_cache_dir,
[].as_ptr(),
)
.unwrap()
.unwrap();
let (_, method_get_abs_path) = env.require_class_method(
"java/io/File\0",
"getAbsolutePath\0",
"()Ljava/lang/String;\0",
);
let jstring = env
.call_object_method_a::<JString, Throwable>(
jfile.as_raw(),
method_get_abs_path,
[].as_ptr(),
)
.unwrap()
.unwrap();
let convert =
java_spaghetti::StringChars::from_env_jstring(env, jstring.as_raw());
let path_string = convert.to_string_lossy();
std::path::PathBuf::from(path_string)
};
let dex_file_path =
code_cache_path.join(env!("CARGO_CRATE_NAME").to_string() + ".dex");
std::fs::write(&dex_file_path, DEX_DATA).unwrap();
let dex_file_path = JString::from_env_str(env, dex_file_path.to_str().unwrap());
let oats_dir_path = code_cache_path.join("oats");
let _ = std::fs::create_dir(&oats_dir_path);
let oats_dir_path = JString::from_env_str(env, oats_dir_path.to_str().unwrap());
let dex_loader = DexClassLoader::new(
env,
&dex_file_path,
&oats_dir_path,
java_spaghetti::Null,
&context_loader,
);
dex_loader.unwrap().as_global().into_raw()
}
};
// Safety: `into_raw()` called above leaks the global reference.
unsafe {
java_spaghetti::Env::set_class_loader(class_loader);
}
});
}
#[inline(always)]
pub fn bluetooth_adapter() -> Result<&'static bluest::Adapter, bluest::Error> {
use std::sync::OnceLock;
static BT_ADAPTER: OnceLock<bluest::Adapter> = OnceLock::new();
if let Some(ref_adapter) = BT_ADAPTER.get() {
Ok(ref_adapter)
} else {
init_loader();
let adapter = get_bluetooth_adapter()?;
unsafe {
register_bluetooth_scan_callback();
}
let _ = BT_ADAPTER.set(adapter.clone());
Ok(BT_ADAPTER.get().unwrap())
}
}
/// Safety: call it after `init_loader()`.
unsafe fn register_bluetooth_scan_callback() {
let vm = get_vm();
vm.with_env(|env| {
unsafe {
let callback_class = env.require_class("com/github/alexmoon/bluest/android/BluestScanCallback\0");
let (mut name_result, mut sig_result) = (*b"nativeOnScanResult\0", *b"(IILandroid/bluetooth/le/ScanResult;)V\0");
let (mut name_failed, mut sig_failed) = (*b"nativeOnScanFailed\0", *b"(II)V\0");
let mut native_methods = [
java_spaghetti::sys::JNINativeMethod {
name: name_result.as_mut_ptr(),
signature: sig_result.as_mut_ptr(),
fnPtr: bluest::Java_com_github_alexmoon_bluest_android_BluestScanCallback_nativeOnScanResult as *mut _,
},
java_spaghetti::sys::JNINativeMethod {
name: name_failed.as_mut_ptr(),
signature: sig_failed.as_mut_ptr(),
fnPtr: bluest::Java_com_github_alexmoon_bluest_android_BluestScanCallback_nativeOnScanFailed as *mut _,
},
];
((**env.as_raw()).v1_2.RegisterNatives)(env.as_raw(), callback_class, native_methods.as_mut_ptr(), 2);
}
});
}
fn get_bluetooth_adapter() -> Result<bluest::Adapter, bluest::Error> {
let context = get_android_context();
const BLUETOOTH_SERVICE: &str = "bluetooth";
unsafe {
let bluetooth_manager = get_vm().with_env(|env| {
let service_name = JString::from_env_str(env, BLUETOOTH_SERVICE);
let (_, method_get_service) = env.require_class_method(
"android/content/Context\0",
"getSystemService\0",
"(Ljava/lang/String;)Ljava/lang/Object;\0",
);
let manager = env
.call_object_method_a::<Object, Throwable>(
context.as_raw(),
method_get_service,
[AsArg::<JString>::as_arg_jvalue(&service_name)].as_ptr(),
)
.unwrap()
.unwrap();
if manager.as_raw().is_null() {
return Err(bluest::Error::from(
bluest::error::ErrorKind::AdapterUnavailable,
));
}
// `bluest::Adapter::new` takes the ownership of `manager`.
Ok(manager.as_global().into_raw())
})?;
bluest::Adapter::new(get_vm().as_raw(), bluetooth_manager)
}
}
java-spaghetti.toml for generating bluest_adapter/bindings.rs:
include = [
"java/lang/Object",
"java/lang/Throwable",
"java/lang/StackTraceElement",
"java/lang/String",
"java/lang/ClassLoader",
"java/nio/Buffer",
"java/nio/ByteBuffer",
"dalvik/system/InMemoryDexClassLoader",
"dalvik/system/DexClassLoader",
"dalvik/system/BaseDexClassLoader"
]
[logging]
verbose = true
[input]
files = [
"E:\\android\\platforms\\android-30\\android.jar"
]
[output]
path = "bindings.rs"
[codegen]
method_naming_style = "java"
method_naming_style_collision = "java_short_signature"
keep_rejected_emits = false
[codegen.field_naming_style]
const_finals = true
rustify_names = false
getter_pattern = "{NAME}"
setter_pattern = "set_{NAME}"
I would like to finish the Android implementation for the bluest API. Here are a few questions:
From a native activity's point of view, a temporary Java Activity overriding onRequestPermissionResult is required to receive the callback of a runtime permission request for Bluetooth operations. It may be added in a future version of jni-min-helper, but it will introduce the jni-rs dependency. Is is acceptable? If not, I may abandon my jni-min-helper and implement the permission request wrapper based on java-spaghetti.
android.bluetooth.le.ScanCallback and android.bluetooth.BluetoothGattCallback are Java abstract classes instead of Java interfaces. In jni-min-helper, it's possible to create dynamic proxy objects for any Java interface with a Rust invocation handler closure, and the BroadcastReceiver (abstract class) wrapper is implemented on top of the dynamic proxy feature: https://docs.rs/crate/jni-min-helper/0.3.0/source/java/BroadcastRec.java, https://docs.rs/crate/jni-min-helper/0.3.0/source/receiver.rs.
-
I'm afraid that it is slower (and safer?) than registering native callbacks directly, is it acceptable?
-
Is it better to have some Rust macros that genrate proxy-backed implementations for Java abstract classes and corresponding Android dex bytecode to be loaded by the dex class loader?
Note: I think the runtime DexClassLoader is necessary for Android native activity applications. The xbuild project wants to unify dex bytecode management by introducing a special build process which is supposed to succeed cargo-apk, but their progress is slow, and xbuild hasn't become popular. Currently I just want a dedicated dex builder crate that invokes javac and Android D8: https://github.com/slint-ui/slint/issues/7829.