deviceplug / btleplug

Rust Cross-Platform Host-Side Bluetooth LE Access Library

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Add Android Support

qdot opened this issue · comments

commented

Same idea as blurmac. Fork bluedroid, bring in code, fit to whatever our API surface is.

Hi - I see this sits on Jan 11. How can I help?

may be worth looking into using https://github.com/jni-rs/jni-rs when pulling in blurdroid, it currently appears to use rust -(ffi)-> c -(jni)-> java wrapper -> android bluetooth. I'd think we'd be able to strip out the C layer, at least.

I intend to work on this, because I want it for one of my own projects.

I looked into blurdroid, and I see three problems with it:

  1. As mentioned above, it has a C middleware layer which can and should be stripped out and replaced with pure Rust.
  2. It is asynchronous in nature, due to Android's asynchronous Bluetooth API. This needs to be made synchronous.
  3. And of course, it does not match the btleplug API whatsoever.

Changing these three things effectively amounts to a complete rewrite. Blurdroid will certainly serve as a useful reference, but I believe it will be easier to start from scratch.

commented

@gedgygedgy You'll definitely want to work off the dev branch too, which has an API completely reworked for rust async. It's 100+ commits ahead of master, and we'll hopefully be merging it with master soon, so consider it the place to start.

The new async API definitely changes things, and will probably make it easier to port to Android.

One problem I see is that doing anything Java-related in Rust requires a JavaVM, which unfortunately can't be obtained from JNI_CreateJavaVM() or JNI_GetCreatedJavaVMs(). It can only be obtained by implementing JNI_OnLoad() in your library or as a parameter to a JNI function. This makes it impossible to implement Java support without either:

  1. Adding parameters to Manager::new() or
  2. Creating a global variable (ick!) which is initialized by JNI_OnLoad() (or some init function which gets called by another library), similar to how blurdroid does it.

Manager::new() is not currently bound by a trait, which makes it tempting to simply add a JavaVM argument to Manager::new(), but this would make things slightly more difficult for Android clients, which now have to conditionally (by #[cfg]) pass a JavaVM around in their Bluetooth code.

Using a global init approach limits the library to a single Java VM (which isn't a problem in Android, since Android only allows one VM per process anyway), requires the client to call the init function if it already implements JNI_OnLoad(), and makes Rust angry by creating global variables.

Neither option is very appealing. What are your thoughts? Any other ideas on how to make this work?

Personally, I would prefer to add a parameter to Manager::new(). The Manager will (hopefully) be created early on, at a high level in the program, where it will be easier to conditionally pass a JavaVM around, and there will likely only be one Manager.

personally, I'm more inclined to go the route of the global variable than I am of adding an argument to the manager, just to reduce the amount of #[cfg]s necessary. Expose a function to set the JavaVM for the library, use a OnceCell or similar to store it, call it a day. I will note that said function does not need to be called in JNI_OnLoad (though it is convienent), just sometime before someone calls Manager::new().

it also means that you can have a library A, which uses btleplug and doesn't care about android at all, and application B that runs on android, which uses library A, and application B can just pass the JavaVM to btleplug, still without needing library A to care at all.

After seeing how easy OnceCell is to use (doesn't even require unsafe), I am now thoroughly convinced that a blteplug::platform::init() approach is the right way to go.

I've started working on the Android port. You can check out my progress here.

I've created a small crate, jni-utils-rs, to do some extra stuff that jni-rs doesn't do, specifically async and futures. I'm using this as the foundation of my Android port.

What is btleplug's expectation for attempting to run two async commands at the same time? For example, one thread calls Peripheral::connect(), and then another thread calls Peripheral::disconnect() before Peripheral::connect() is finished. Is btleplug supposed to handle this, or is the user just supposed to not do this? Android's Bluetooth system doesn't seem to handle simultaneous commands very well, so I'm wondering if I need to set up some sort of command queue.

Upon thinking about this some more, it doesn't seem very Rustacean to provide an API that can fail so easily. I will go forward with a command queue to make sure only one command gets executed at a time.

commented

Upon thinking about this some more, it doesn't seem very Rustacean to provide an API that can fail so easily. I will go forward with a command queue to make sure only one command gets executed at a time.

Well if it makes you feel any better, I was gonna say command queue too. It's what I usually do in this case. :)

I have a working Peripheral::connect() and Peripheral::disconnect() implementation. It's a bit of a proof-of-concept and needs to be cleaned up with a proper command queue, but it works.

commented

I have a working Peripheral::connect() and Peripheral::disconnect() implementation. It's a bit of a proof-of-concept and needs to be cleaned up with a proper command queue, but it works.

WOOOOOOOOOOOOOOOOOOOO 👍🏻 💯 🥇 🚀

I've added a command queue to ensure commands execute one at a time. Peripheral::discover_characteristics(), Peripheral::read(), and Peripheral::write() are all working. The rest of the functionality is on its way.

At this point I think I've implemented all of the functionality for Android. Scanning, CentralEvents, reading/writing characteristics, peripheral properties, and characteristic notifications are all working.

A few caveats:

  • This is somewhat dependent on my API changes in #166.
  • This is also dependent on jni-utils-rs, which has not yet stabilized, and which I have not yet published to crates.io.
  • Android apps that use this functionality must declare a runtime-only dependency on the .aar file generated by src/droidplug/java (which itself also depends on the Java code from jni-utils-rs.)
  • This code interacts heavily with the JVM. As such, any thread that interacts with btleplug must be attached to the JVM. If the thread is created natively (rather than by a java.lang.Thread), it must call JavaVM::attach_current_thread(), and it must also set the thread's classloader. While this isn't a dealbreaker for systems like tokio, it does make them more difficult to use.
  • For some reason, not all events that get sent by AdapterManager::emit() seem to go through, at least on my setup. (Strangely, adding print statements between the emit() calls seems to help somewhat. Perhaps there's a race condition here?)
  • Some apps won't want to use Adapter::start_scan() and Adapter::stop_scan(), because this requires location permissions on Android. To allow such apps to use the Companion Device Manager instead, I've provided an Adapter::report_scan_result() function, which takes an android.bluetooth.le.ScanResult and applies it as if it had been discovered by its own scan. In addition, clients can use Adapter::add_peripheral() to skip the scanning step if they already know the MAC address (probably retrieved from the Companion Device Manager.)
  • I have not written any documentation yet. That is going to be my next step.

This code interacts heavily with the JVM. As such, any thread that interacts with btleplug must be attached to the JVM. If the thread is created natively (rather than by a java.lang.Thread), it must call JavaVM::attach_current_thread(), and it must also set the thread's classloader. While this isn't a dealbreaker for systems like tokio, it does make them more difficult to use.

it seems like tokio has a couple of things that might help with this and not be too much of a hassle (in the case where you attach every thread to the jvm at least): https://docs.rs/tokio/1.7.1/tokio/runtime/struct.Builder.html#method.on_thread_start and https://docs.rs/tokio/1.7.1/tokio/runtime/struct.Builder.html#method.on_thread_stop

What's the status on this? Did any of these changes make it into 0.9?

commented

This is currently on hold. The original author went quite in mid-August 2021, and I haven't been able to get ahold of them since.

@qdot hi! Is there any chance we might take over the work from the original author? It has been quite a bit since they last showed interest, right?

commented

@stevekuznetsov I actually just started working on this exact thing! I've at least gotten their branch to build, but we'll need to bring it up to the 0.8 API, which I don't think should be too much work.

They were doing this work in order to work with my other library, so my goal right now is to get that full chain built and working on a phone from the state it was in last year, then start updating the pieces from btleplug up. I'll let you know how this turns out.

commented

Good news! I've managed to get @gedgygedgy's full chain from btleplug up through buttplug running on my Pixel 3. This is going to require a TON of documentation, as said earlier, btleplug android (aka droidplug) expects to use its own async executor or thread registration, in order to keep the environment/thread contexts straight. Buttplug has an executor abstraction mechanism to handle this currently, but I'm... not real sure how to convey this requirement in relation to btleplug for normal users.

The focus right now will be getting the old repo from btleplug 0.7 to btleplug 0.9, then I'll take a look at the executor context stuff for tokio.

@stevekuznetsov @schell

Wow that's super exciting! Please let me know when you get to a good spot if there's something I could help out with :)

@qdot that's great news! What branch is this work happening on? I'd like to follow along.

commented

The work on bringing up android support to the 0.9.2 API is done and working, now I'm on to tokio runtime support, which is mostly there but hitting a some JNI bugs. Hopefully will get that ironed out in the next few days. I need to do some cleanup because I've been moving kinda fast and loose on this, but should have a branch up tomorrow.

@schell @stevekuznetsov

commented

Ok, the android branch is now up as android-update on this repo. I'll open a PR on it also, just so others will know, but I have a feeling it may be a while before it comes in.

Unless you are well versed in JNI and poking at gradle by hand, I wouldn't recommend playing with this quite yet.

In terms of how to build it:

  • You'll need to clone gedgygedgy/jni-rs, gedgegedgy/jni-utils-rs, and gedgygedgy/android-utils-rs and have them next to this repo in the file system. I currently just set up local path dependencies in cargo between all of them.
  • You'll write/build your rust code as normal, I recommend using cargo ndk.
  • You'll need to local path links from your app gradle to this library as well as jni-rs probably. You'll also need to call init on all the libraries on JNI_OnLoad, it looks something like
#[no_mangle]
pub extern "C" fn JNI_OnLoad(vm: JavaVM, _res: *const c_void) -> jint {
    let env = vm.get_env().unwrap();
    android_utils::init(&env).unwrap();
    jni_utils::init(&env).unwrap();
    btleplug::platform::init(&env).unwrap();
    jni::JNIVersion::V6.into()
}

So yeah, using this at the moment is a significant amount of work.

I'm still stuck on tokio support right now. The find_class() calls to get the btleplug Peripheral java class fail when called on non-main threads, even if the thread is attached to the VM and has its classloader set to the main thread classloader. No idea what's up. I've got some vague solutions around caching ClassLoader calls during initialization that might do the trick but it's not a great solution.

commented

Ok, well, went ahead and implemented the ClassLoader cache and it makes the tokio multithreaded runtime work. That said, it's ugly and requires changes back through jni_utils. Would really like to figure out why env.find_class() isn't working but we have a backup solution if we absolutely need it.

commented

Finally did more cleanup and got the tokio code up. This requires:

THE android-utils-rs REPO IS NO LONGER NEEDED WHEN USING TOKIO AND THESE NEW BRANCHES

So you should be able to:

  • Either build java directories or add them to your app gradle as local path dependencies
  • Set the btleplug jni-utils-rs dep to your local checkout on the classcache branch (I think I have the cargo file already doing this and assuming the repo directories are next to each other)
  • Build btleplug using cargo ndk
  • Copy those output files to the proper native lib architecture directory in your app.

Lemme know if you actually start trying this and either automate those steps in gradle (which I barely understand how to use) or need me to list out how I'm running things.

(Next goal, which will happen fairly soon, is hopefully just kicking out an AAR)

This is great progress @qdot ! Thank you so much!

Hi, I am playing with this and actually managed to build a flutter app that starts without error. I'm trying the event_driven_discovery example inside a

tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .unwrap()
        .block_on(async {

block. Unfortunately, line central.start_scan(ScanFilter::default()).await.unwrap(); fails with JniCall(ThreadDetached). Do you have any ideas what's happening there?

commented

@trobanga

Oh wow, that's awesome that you've got it working through flutter! Curious about your FFI setup for that. :)

If you're seeing ThreadDetached, that most likely means the required setup methods haven't been run. Unfortunately I don't have great (or really any) documentation for this yet, but I'll point you to where I implemented this and gesture wildly in a hopefully helpful way.

First off, you'll need to have a JNI_OnLoad method that calls all of the proper init methods across the required libraries:

https://github.com/buttplugio/buttplug-rs-ffi/blob/52b8ff0e49dc4b0ce34f254a790ae4209ff0c422/ffi/src/export.rs#L29

JNI_OnLoad is called by JNI when loading the library, so it should just be called automatically when library is loaded into the JVM.

Then, you'll need to set up your Tokio runtime to attach threads to the JNI as they're created:

https://github.com/buttplugio/buttplug-rs-ffi/blob/52b8ff0e49dc4b0ce34f254a790ae4209ff0c422/ffi/src/export.rs#L56

After that, things should work. Or hopefully you'll at least get a different error.

No, it's still the same error. I'll take a closer look tomorrow.

commented

Well, ThreadDetached means that whatever thread the btleplug command is running from isn't attached to the JVM. That needs to happen on thread bringup, otherwise there's no way for the JNI portions of the library to reach the Android stuff. Not real sure how this would work in flutter's library loading and execution context tho.

It works! In the end I just had to run vm.attach_current_thread() in the block_on closure too.
I've created an example flutter_btleplug. If you go to example/ and type flutter run it should immediately work an throw the scanned devices on the console.

Depending on your Android device the app might need permission to location.

commented

Back at this, now trying build my library chain into my own flutter app. Assuming all of that doesn't end in a giant ball of fire, I'll probably just end up maintaining jni-utils-rs so we can bring the android branches into btleplug mainline finally.

commented

Ok! Got blteplug android up and working in my own flutter project. Testing iOS next.

@trobanga Not sure if you're still working on your app with btleplug, but if you want to avoid having to sprinkle attach_current_thread() everywhere, you can put your AttachGuard in thread local storage, and just free it when the thread terminates. Probably faster to do that too. I've got a gist with my modified version of your android setup code here:

https://gist.github.com/qdot/210f31439888927fc23b14d2e3d40503

Now we're just down to figuring out what and how to get everything landed.

commented

Ok, Android is now a supported platform in v0.10. Thanks to everyone who helped out on this!

commented

@trobanga Just curious, have you faced any issues with dead code removal when compiling flutter apps for release with btleplug? My flutter app works fine in debug, but crashes in release on android, in a way similar to what I was seeing when I'd had the wrong dependency for the droidplug.aar file that means it wasn't getting included. I'm wondering if I need to add some sort of explicit call into droidplug to keep it live or something, but that's just a guess at the moment.

commented

I just checked and in release mode my btleplugtest crashes immediately. Unfortunately I'm no Android expert and I don't have time to dig in at the moment.

commented

@trobanga Ok cool, thanks for checking that! I just filed #272, and will be working on this myself. Pretty sure I just need to add some sort of explicit access to the aar to keep the file alive.

commented

@trobanga I have a stopgap solution at #272 (comment) if you end up needing it.

Hey! Great work on adding support for Android!

I think I already know the answer but just confirming: is it possible to build a working CLI for Android?

No UI, no flutter, no maven. Just a single binary (built with cross, for example) that I can call from Termux.

Thanks!

commented

@denisidoro I... honestly have no idea. I'm not sure what kinda environment termux executes in, so I'm not sure if the JNI the process normally requires work would?

I have followed all the steps above and everything else including the gist by @qdot and the steps in the main README.md file, but on Android I did not manage to get or discover any devices.

I am able however to get the adapter which returns "Android" as the ID. I've noticed the following loop never works:

    let _ = adapter.start_scan(ScanFilter::default()).await;

    while let Some(event) = events.next().await {
    }

And, getting the device lists this way returns an empty Vec:

    let peripherals = match adapter.peripherals().await {
    }

So, neither even-driven discovery nor getting the devices works for me.

I'd appreciate any suggestion on what might be going wrong.

Here is the relevant code:

#[cfg(target_os = "android")]
#[allow(dead_code)]
pub static ANDROID_RUNTIME: once_cell::sync::OnceCell<tokio::runtime::Runtime> = once_cell::sync::OnceCell::new();

fn get_runtime() -> &'static tokio::runtime::Runtime {
    #[cfg(not(target_os = "android"))]
    return &RUNTIME;

    #[cfg(target_os = "android")]
    &ANDROID_RUNTIME.get().expect("Failed to get the Android runtime!")
}


#[cfg(target_os = "android")]
#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("JNI Error: {0}")]
    Jni(#[from] jni::errors::Error),

    #[error("Android class loader initialization has failed!")]
    ClassLoader,

    #[error("Tokio runtime initialization has failed!")]
    Runtime,

    #[allow(dead_code)]
    #[error("Uninitialized Java VM!")]
    JavaVM,
}

#[cfg(target_os = "android")]
#[allow(dead_code)]
static ANDROID_CLASS_LOADER: once_cell::sync::OnceCell<jni::objects::GlobalRef> = once_cell::sync::OnceCell::new();

#[cfg(target_os = "android")]
#[allow(dead_code)]
pub static ANDROID_JAVAVM: once_cell::sync::OnceCell<jni::JavaVM> = once_cell::sync::OnceCell::new();

#[cfg(target_os = "android")]
std::thread_local! {
    static ANDROID_JNI_ENV: std::cell::RefCell<Option<jni::AttachGuard<'static>>> = std::cell::RefCell::new(None);
}

#[cfg(target_os = "android")]
#[allow(dead_code)]
fn android_setup_class_loader(env: &jni::JNIEnv) -> Result<(), Error> {
    let thread = env
            .call_static_method(
                "java/lang/Thread",
                "currentThread",
                "()Ljava/lang/Thread;",
                &[],
            )?
            .l()?;

    let class_loader = env
            .call_method(
                thread,
                "getContextClassLoader",
                "()Ljava/lang/ClassLoader;",
                &[],
            )?
            .l()?;

    ANDROID_CLASS_LOADER
            .set(env.new_global_ref(class_loader)?)
            .map_err(|_| Error::ClassLoader)
}

#[cfg(target_os = "android")]
pub fn android_create_runtime() -> Result<(), Error> {
    let vm = ANDROID_JAVAVM.get().ok_or(Error::JavaVM)?;
    let env = vm.attach_current_thread().unwrap();

    android_setup_class_loader(&env)?;

    let runtime = {
        tokio::runtime::Builder::new_multi_thread()
                .enable_all()
                .thread_name_fn(|| {
                    static ATOMIC_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
                    let id = ATOMIC_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
                    format!("intiface-thread-{}", id)
                })
                .on_thread_stop(move || {
                    ANDROID_JNI_ENV.with(|f| *f.borrow_mut() = None);
                })
                .on_thread_start(move || {
                    let vm = ANDROID_JAVAVM.get().unwrap();
                    let env = vm.attach_current_thread().unwrap();

                    let thread = env
                            .call_static_method(
                                "java/lang/Thread",
                                "currentThread",
                                "()Ljava/lang/Thread;",
                                &[],
                            )
                            .unwrap()
                            .l()
                            .unwrap();
                    env.call_method(
                        thread,
                        "setContextClassLoader",
                        "(Ljava/lang/ClassLoader;)V",
                        &[ANDROID_CLASS_LOADER.get().unwrap().as_obj().into()],
                    )
                            .unwrap();
                    ANDROID_JNI_ENV.with(|f| *f.borrow_mut() = Some(env));
                })
                .build()
                .unwrap()
    };
    ANDROID_RUNTIME.set(runtime).map_err(|_| Error::Runtime)?;
    Ok(())
}

#[cfg(target_os = "android")]
#[no_mangle]
pub extern "C" fn JNI_OnLoad(vm: jni::JavaVM, _res: *const std::os::raw::c_void) -> jni::sys::jint {
    let env = vm.get_env().unwrap();
    jni_utils::init(&env).unwrap();
    btleplug::platform::init(&env).unwrap();
    let _ = ANDROID_JAVAVM.set(vm);
    jni::JNIVersion::V6.into()
}

I initialize it like this:

pub fn initialize() -> Result<(), Box<dyn std::error::Error + Send>> {
    if is_initialized() {
        return Err(Box::new(BleError::new(
            "The BLE library has already been initialized!",
        )));
    }

    #[cfg(target_os = "android")]
    {
        mylog!("Attempting BLE Android initialization...");
        if let Err(error) = crate::android_create_runtime() {
            return Err(Box::new(BleError::new(
                format!("The BLE library initialization has failed: '{error:?}'").as_str(),
            )));
        }
        mylog!("BLE Android initialization has succeeded!");
    }

    let adapters = get_runtime().block_on(get_adapters())?;
    for adapter in &adapters {
        get_runtime().block_on(start_scan(adapter))?;
    }

    Ok(())
}

Update: Noticed I was ignoring the return value for the start_scan method, so I made the following changes and added a log to JNI_OnLoad to be sure it's getting fired:

    if let Err(error) = adapter.start_scan(ScanFilter::default()).await {
        myerr!("Start scan failed: {error:?}");
    }

#[cfg(target_os = "android")]
#[no_mangle]
pub extern "C" fn JNI_OnLoad(vm: jni::JavaVM, _res: *const std::os::raw::c_void) -> jni::sys::jint {
    mylog!("Running JNI Onload...");

    let env = vm.get_env().unwrap();
    jni_utils::init(&env).unwrap();
    btleplug::platform::init(&env).unwrap();
    let _ = ANDROID_JAVAVM.set(vm);
    jni::JNIVersion::V6.into()
}

Now, I see the following outputs:

Running JNI Onload...
Start scan failed: Other(JniCall(ThreadDetached))