Skip to content

Support device probing and permission request on Android #150

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

wuwbobo2021
Copy link

Closes #86.

nusb::list_buses (for rooted usage) is disabled because of some problems caused by port_chain, device_version, max_packet_size_0 unavailable on Android. Could they be changed to Options?

I added #![allow(dead_code)] in platform/linux_usbfs/mod.rs because of the conditional compilation condition for Android caused many dead code warnings, maybe it can be solved in a better way.

Sorry for being a bit sloppy, here's how I did the initial test:

Please make sure the JDK, Android SDK and NDK is installed and configured, and the Rust target aarch64-linux-android is installed. Install cargo-apk and make sure the release keystore is configured.

Save the code files posted below, run cargo apk build -r; connect the Android phone to the PC via USB, then configure the adbd TCP port (adb tcpip 5555) and connect to it (adb connect xxx.xxx.xxx.xxx:5555). Install the APK package with adb install -r <apk_file> (the .apk file is located in target/release/apk).

Run adb logcat android_nusb_test:D '*:S' On PC for tracing, start the installed "android_nusb_test" on the phone; try to connect and disconnect some USB devices via an OTG USB converter.

(this draft code for testing can be changed in many ways in order to test all features thoroughly)

Cargo.toml
[package]
name = "android-nusb-test"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
log = "0.4"
futures-lite = "2.0"
android-activity = { version = "0.6", features = ["native-activity"] }
android_logger = "0.14"
nusb = { path = ".." }

[lib]
name = "android_nusb_test"
crate-type = ["cdylib"]
path = "lib.rs"

[package.metadata.android]
package = "com.example.android_nusb_test"
build_targets = ["aarch64-linux-android"]
resources = "./res"

[package.metadata.android.sdk]
min_sdk_version = 16
target_sdk_version = 30

[[package.metadata.android.uses_feature]]
name = "android.hardware.usb.host"
required = true

[[package.metadata.android.application.activity.intent_filter]]
actions = ["android.hardware.usb.action.USB_DEVICE_ATTACHED"]

# Please check <https://github.com/rust-mobile/cargo-apk/pull/67> if it fails.
# Otherwise comment out the lines below (request for permission purely at runtime).
[[package.metadata.android.application.activity.meta_data]]
name = "android.hardware.usb.action.USB_DEVICE_ATTACHED"
resource = "@xml/device_filter"
lib.rs
use android_activity::{AndroidApp, MainEvent, PollEvent};
use log::info;
use nusb::MaybeFuture;
use std::time::Duration;

#[unsafe(no_mangle)]
fn android_main(app: AndroidApp) {
    android_logger::init_once(
        android_logger::Config::default()
            .with_max_level(log::LevelFilter::Info)
            .with_tag("android_nusb_test"),
    );

    if let Some(startup_dev) = nusb::check_startup_intent() {
        info!("*** Startup intent: {startup_dev:#?}");
        assert!(startup_dev.open().wait().is_ok());
    } else {
        info!("Listing connected devices...");
        for dev in nusb::list_devices().wait().unwrap() {
            info!("*** {dev:#?}");
        }
    }

    // XXX: tell this thread to stop on activity's stop or destroy event
    let _back_thread = std::thread::spawn(|| hotplug_watch());

    let mut on_destroy = false;
    loop {
        app.poll_events(
            Some(Duration::from_secs(1)), // timeout
            |event| match event {
                PollEvent::Main(MainEvent::Start) => {
                    info!("Main Start.");
                }
                PollEvent::Main(MainEvent::Resume { loader: _, .. }) => {
                    info!("Main Resume.");
                }
                PollEvent::Main(MainEvent::Pause) => {
                    info!("Main Pause.");
                }
                PollEvent::Main(MainEvent::Stop) => {
                    info!("Main Stop.");
                }
                PollEvent::Main(MainEvent::Destroy) => {
                    info!("Main Destroy.");
                    on_destroy = true;
                }
                _ => (),
            },
        );
        if on_destroy {
            return;
        }
    }
}

fn hotplug_watch() {
    info!("Testing hotplug watch...");
    let watcher = futures_lite::stream::block_on(nusb::watch_devices().unwrap());
    for event in watcher {
        println!("{:#?}", event);
        match event {
            nusb::hotplug::HotplugEvent::Connected(dev) => {
                if !dev.has_permission().unwrap() {
                    info!("*** {dev:#?}");
                    let perm_req = dev.request_permission().unwrap_or(None);
                    if let Some(req) = perm_req {
                        info!("Performing permission request...");
                        let result = futures_lite::future::block_on(req);
                        info!("Request result: {result:?}");
                        if !result {
                            continue;
                        }
                    }
                }
                let conn = dev.open().wait().unwrap();
                info!("*** Opened, printing USB configurations:");
                for conf in conn.configurations() {
                    info!("{:#?}", conf);
                }
                std::thread::sleep(Duration::from_secs(1));
                info!("Closing the device.");
            }
            nusb::hotplug::HotplugEvent::Disconnected(dev_id) => {
                info!("*** Device disconnected: ({dev_id:?}).");
                continue;
            }
        }
    }
}

res/xml/device_filter.xml:

<?xml version="1.0" encoding="utf-8"?>

<resources>
    <usb-device />
</resources>

(this means no filtering, any device plugged into the phone can trigger the prompt asking whether or not to open this "app".)

@wuwbobo2021
Copy link
Author

Excuse me, I just did a test of opening the same device in multiple threads: each thread opened the device successfully, but they got nusb::error: interface is busy (errno 16) when trying to claim an interface (the same behavior under linux on the computer).

for i in 0..100 {
    let dev = dev.clone();
    std::thread::spawn(move || {
        let conn = dev.open().wait().unwrap();
        info!("*** Opened in thread {i}, printing USB configurations:");
        std::thread::sleep(Duration::from_secs(1));
        for conf in conn.configurations() {
            info!("Thread {i}: {:?}", conf);
        }
        info!("Thread {i}: {:?}", conn.claim_interface(0).wait());
        std::thread::sleep(Duration::from_secs(1));
        info!("Thread {i} Closing the device.");
    });
}

@kevinmehall
Copy link
Owner

Device::claim_interface is expected to return an error if the interface is already claimed, whether by that process or another process. You can clone the Interface once claimed to share it, but the initial claim is guaranteed exclusive access.

@wuwbobo2021
Copy link
Author

The sloppy multi-thread open() test is inspired by jni-rs/jni-rs#574. It's about the safety of the only unsafe operation in android.rs (OwnedFd::from_raw_fd). My result shows it's okay on my device, but the access of usb_manager singleton might be changed to be protected by Mutex to make sure of compatability.

Another test shows that getting the Java UsbManager in different threads via getSystemService gets the same UsbManager instance:

Click to expand
    // insert in `usb_manager()`
    use std::sync::mpsc;
    let usb_man_2 = usb_man.clone();
    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        jni_with_env(|env| {
            let context = android_context();
            let usb_service_id = USB_SERVICE.new_jobject(env)?;
            let usb_man = env
                .call_method(
                    context,
                    "getSystemService",
                    "(Ljava/lang/String;)Ljava/lang/Object;",
                    &[(&usb_service_id).into()],
                )
                .get_object(env)?;
            tx.send(usb_man.is_same_object(&usb_man_2, env)).unwrap();
            Ok(())
        }).unwrap();
    });
    assert!(rx.recv().unwrap());

Yet another test shows that probing the same device in different threads gets different Java UsbDevice (info) instances (kind of senseless):

Click to expand
            // insert in `list_devices()`
            if !devices.is_empty() {
                use std::sync::mpsc;
                let (tx, rx) = mpsc::channel();
                std::thread::spawn(move || {
                    jni_with_env(|env| {
                        let ref_dev_list = env
                            .call_method(usb_man, "getDeviceList", "()Ljava/util/HashMap;", &[])
                            .get_object(env)?;
                        let map_dev = env.get_map(&ref_dev_list)?;
                        let mut iter_dev = map_dev.iter(env)?;
                        if let Some((_, dev)) = iter_dev.next(env)? {
                            tx.send(build_device_info(env, &dev)?).unwrap();
                        }
                        Ok(())
                    }).unwrap();
                });
                assert!(!rx.recv().unwrap().devinst.is_same_object(devices[0].devinst.as_ref(), env));
            }

@wuwbobo2021
Copy link
Author

from_device_info() is protected by an exclusive lock now.

I'm not sure if request_permission() should be a part of DeviceInfo::open() or from_device_info(); currently it's separated...

@dgramop
Copy link

dgramop commented Jul 21, 2025

excited to see these changes land. I took a peek at the way you source context in jni-min and it strikes me as pretty sound. I was a bit worried about possible compatibility issues between ndk-glue and android_activity based crates - but I don't use ndk-glue (and it seems android_activity is generally better anyhow?)

@wuwbobo2021
Copy link
Author

Well, I'm not familiar with ndk-glue. Applications based on this crate needs an ANativeActivity_onCreate cdylib entry too, see https://docs.rs/ndk-macro/0.3.0/src/ndk_macro/expand.rs.html. ndk_glue::init called in this entry function initializes ndk_context just like android_activity.

I don't know if ndk-glue and android_activity can be dependencies of the same application crate, but I guess it's not suggested, see "Middleware Crates" section in https://crates.io/crates/android-activity.

@kevinmehall
Copy link
Owner

For v0.2, #158 cfg-s out the unavailable DeviceInfo fields, and temporarily hides list_devices, watch_devices and list_buses for Android so this can be added without a breaking change.

let ver_minor: u16 = ver_parser.next().unwrap();

Ok(DeviceInfo {
devinst: Arc::new(env.new_global_ref(dev)?),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this called devinst in other Android APIs, or should this be called jni_ref or something more specific?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the mismatch. The name is not from the Android API, and it's not a conventional name used in other Android support crate. I will change the name according to your suggestion.

}
self.vendor_id == other.vendor_id
&& self.product_id == other.product_id
&& self.id() == other.id()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole point of DeviceInfo::id is to uniquely identify a device, so if self.id() == other.id() is not sufficient alone then we need a different id.

I'm wondering if UsbDevice::getDeviceId would be a better ID than using the bus number and address like Linux, especially since the busnum and address are parsed from the path whose format is not explicitly documented.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just did a test and found that UsbDevice::getDeviceId() returns integer decimal 1004 for /dev/bus/usb/001/004; it became 1005, 1006... when plugging over and over again.

Description of UsbDevice.getDeviceName(): "Returns the name of the device. In the standard implementation, this is the path of the device file for the device in the usbfs file system."

Despite of the bus number and device address number "contained" in the returned values of getDeviceId() and getDeviceName(), I may still ignore the possibility of extracting and providing these values since there might be non-standard implementations for the Android API.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, WebUSB also doesn't expose bus number and address, so I made limited them to Windows, Mac, Linux in #158. There is maybe some use case for the bus number on Android laptops or tablets with multiple USB ports (if they're even separate host controllers), but I think it would be fine to skip those fields.

@kevinmehall
Copy link
Owner

kevinmehall commented Jul 27, 2025

Did I somehow push to your main branch instead of mine? Though b0b4a54 was never on this branch, so I'm not sure why the force-push message is showing here. In any case, I restored and saved a copy of this branch at https://github.com/kevinmehall/nusb/compare/wuwbobo2021-android.

@wuwbobo2021
Copy link
Author

No, it's definitely not caused by your operation. I gave up merging conflicts online and synchronized my branch forcefully. I should consider my modifications again before reopening the PR. Of course I have an offline backup of my previous attempt.

@wuwbobo2021 wuwbobo2021 reopened this Jul 28, 2025
@wuwbobo2021
Copy link
Author

I just made a mistake: I added the "Android-specific note" for DeviceInfo::open, claiming that it may call DeviceInfo::request_permission, and blocking on it may get stuck forever in the UI thread (activity main thread). However I forgot to actually do so; and now I wonder if it should be done, do you think it's an implicit operation leading to a pitfall? Should it require the caller to request permission manually?

@kevinmehall
Copy link
Owner

I'd lean towards making the permission request explicit, since it's an extra step that an Android developer will need to think about and figure out where it fits in their app's workflow.

I was hoping we could add a common API to handle permission requests on both Android and WebUSB, but they work differently enough that I don't think that will be possible. On WebUSB, you can't list devices prior to requesting permission. The permission request API takes a set of filters, the user is prompted with a list of devices matching those filters (in browser-owned UI), and only the selected and approved device is returned. While on Android, it looks like the filtering and selection of the device is up to the app developer, and the permission is requested for a specific device.

My plan was to add an API that accepts a set of WebUSB-style filters but potentially returns multiple devices. On desktop OSs it would just filter the list_devices() list and return immediately since it doesn't need a permission prompt. However, on Android, if the app passed a filter that matches multiple devices, I don't think we'd want to display a permission prompt for each one. The app developer would need to implement their own UI to choose a device between listing devices and requesting permission. Maybe that's an edge case though -- while it's possible to use a hub and multiple devices on a phone, normally there would only be one.

@martinling
Copy link
Contributor

Android runs on a lot more than just phones. I don't think we should assume there will only be a single USB device connected to an Android system.

@wuwbobo2021
Copy link
Author

To implement the WebUSB style (device filtering and opening?) API for Android, https://crates.io/crates/jni-min-helper has provided an example of showing and receiving result of a chooser dialog on Android, it doesn't have to be provided by the application. Still I guess the current “Android style” API can be preserved too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Android support for enumerating devices via JNI
4 participants