bluest icon indicating copy to clipboard operation
bluest copied to clipboard

Android support

Open Dirbaio opened this issue 1 year ago • 13 comments

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.

Dirbaio avatar Feb 28 '24 20:02 Dirbaio

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?

alexmoon avatar Feb 28 '24 21:02 alexmoon

it seems it's not supported on windows, no :(

Dirbaio avatar Feb 29 '24 13:02 Dirbaio

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:

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 javadoc in 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"?

wuwbobo2021 avatar Jan 05 '25 09:01 wuwbobo2021

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 via adb logcat RustStdoutStderr:D '*:S'.
  • TODO: request runtime permission for Android 12 and higher versions.
  • scan_result.isConnectable() found in bluest/src/android/adapter.rs crashes on Android 7.x and lower versions.
  • futures_lite::future::block_on asynchronous runner must not be called in android_main, which is the application entry point required by android-activity.
  • I made the program more complicated than making use of jni-min-helper for the runtime dex class loader, it's just seeking for possibility of avoiding jni-rs dependency, or even introducing the dex loader to java-spaghetti (this is merely a draft, though). Actually, jni-rs is a dependency of android-activity, so I'm not reducing the overall application size.
  • JNI DetachCurrentThread is required for a native background thread to end the execution, otherwise it will panic. Looking into the comments in java-spaghetti/src/refs/local.rs, I have an idea: java-spaghetti can have a Option<Env> wrapper (which implements a custom Drop) in thread-local storage; VM::with_env tries GetEnv at first, but calls AttachCurrentThread and stores the Env into the wrapper's Option if it's not already attached by other means; on thread termination, the wrapper's Drop should call DetachCurrentThread if the Option is Some (which indicates that it has been attached by java-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_main is possible to back another future to be ORed with async_main for block_on to 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}"

wuwbobo2021 avatar Jan 20 '25 09:01 wuwbobo2021

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.

wuwbobo2021 avatar Mar 09 '25 21:03 wuwbobo2021