android-usb-serial 0.1.0

Pure Rust USB serial drivers for Android (and Linux), built on nusb
Documentation

android-usb-serial

Pure Rust USB serial drivers for Android (and Linux), built on nusb.

Ported from usb-serial-for-android driver protocol logic — verified byte-for-byte against 567 golden JSON fixtures.

Crates.io Docs.rs License

Supported chips

Driver Examples (VID:PID)
FTDI FT232R 0403:6001, FT2232H 0403:6010
Silicon Labs CP21xx CP2102 10C4:EA60, CP2105 10C4:EA70
WCH CH34x CH340 1A86:7523
Prolific PL2303 HX / HXN / type 0x01 / TA
CDC ACM Castrated single-iface, IAD, multi-port
GSM modem Fibocom-style vendor ports
Chrome CCD / CR50 18D1:5014 (3 ports)

Install

[dependencies]
android-usb-serial = "0.1"

# Optional: serialport::SerialPort facade (enabled by default)
# android-usb-serial = { version = "0.1", default-features = false }

# Host / instrumentation tests
# android-usb-serial = { version = "0.1", features = ["fake-transport"] }

Requires Rust 1.79+. Real USB needs target_os = "android" or "linux" (pulls in nusb + libc).

Quick start (Android fd)

Kotlin/Java keeps UsbDeviceConnection and grants USB permission. Pass the raw fd into Rust:

use android_usb_serial::{
    from_raw_fd, open_port, DataBits, LineConfig, NusbTransport, Parity, StopBits, Transport,
};
use std::os::fd::RawFd;
use std::sync::Arc;

fn open_android_port(fd: RawFd) -> android_usb_serial::Result<()> {
    let device = from_raw_fd(fd)?;
    let transport = Arc::new(NusbTransport::from_device(device)?) as Arc<dyn Transport>;
    let mut port = open_port(transport, 0)?;
    port.set_line_config(LineConfig {
        baud_rate: 115_200,
        data_bits: DataBits::Eight,
        parity: Parity::None,
        stop_bits: StopBits::One,
    })?;
    port.set_dtr(true)?;
    port.write(b"AT\r\n")?;
    Ok(())
}

from_raw_fd dups the fd so Java can keep owning UsbDeviceConnection.

Using on Android

This crate talks USB only through a raw file descriptor from Android's UsbDeviceConnection. It does not call UsbManager, does not show permission dialogs, and does not register broadcast receivers — your app (or a thin Kotlin bridge) owns all of that.

App (Kotlin)                         android-usb-serial (Rust)
─────────────────────────────────────────────────────────────
UsbManager.deviceList                ProbeTable::find(vid, pid, ifaces)
enumerateJson + interfaces[]    →    expand to port keys (device / device#N)
requestPermission + openDevice  →    from_raw_fd → NusbTransport → open_port
keep UsbDeviceConnection alive       dup(fd); claim interfaces inside nusb
closeDeviceFd on detach/close        driver I/O (write, reader, modem lines)

Manifest

Declare USB host support and (optionally) attach a device filter so Android can launch your activity when a known adapter is plugged in:

<uses-feature android:name="android.hardware.usb.host" android:required="false" />

<application>
    <activity android:name=".MainActivity" android:exported="true">
        <intent-filter>
            <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
        </intent-filter>
        <meta-data
            android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
            android:resource="@xml/device_filter" />
    </activity>
</application>

device_filter.xml should list VID/PID pairs you care about. Align entries with ProbeTable::default_table() — a full filter ships with tauri-plugin-serialplugin. A catch-all <usb-device /> at the end also matches generic CDC ACM devices.

USB permission

Android requires runtime permission per device before openDevice():

  1. Check usbManager.hasPermission(device).
  2. If false, call usbManager.requestPermission(device, pendingIntent).
  3. Handle the result in a BroadcastReceiver (EXTRA_PERMISSION_GRANTED).
  4. On API 31+, use PendingIntent.FLAG_MUTABLE on the permission intent.
  5. Set intent.setPackage(packageName) when building the permission Intent (see UsbFdBridge).

Minimal pattern (standalone app):

private const val ACTION_USB_PERMISSION = "your.app.USB_PERMISSION"

fun openWithPermission(device: UsbDevice) {
    val mgr = getSystemService(USB_SERVICE) as UsbManager
    if (!mgr.hasPermission(device)) {
        val pi = PendingIntent.getBroadcast(
            this, 0,
            Intent(ACTION_USB_PERMISSION),
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
        )
        mgr.requestPermission(device, pi)
        return
    }
    val conn = mgr.openDevice(device) ?: return
    val fd = conn.fileDescriptor
    // pass fd to Rust (JNI) — keep `conn` alive until close
    nativeOpenPort(fd, device.vendorId, device.productId)
}

Register for UsbManager.ACTION_USB_DEVICE_ATTACHED / DETACHED to refresh the port list and close fds when the cable is unplugged.

Open fd — do not pre-claim interfaces

After permission is granted:

val conn = usbManager.openDevice(device) ?: throw IOException("open failed")
// Do NOT call conn.claimInterface() here.
val fd = conn.fileDescriptor

Rust/nusb calls detach_and_claim on the duplicated fd. If Kotlin claims the interface first, open fails with io interface is busy. The reference implementation lives in UsbFdBridge.openDeviceFd.

Keep the UsbDeviceConnection open for the whole session; call close() only after Rust has closed the port and released endpoints.

Enumerate (no fd, no claim)

List attached devices from Kotlin and pass metadata to Rust for driver probing. Include USB interface descriptors so multi-port chips (FT2232, dual CDC, etc.) expand to separate port keys:

{
  "ports": {
    "/dev/bus/usb/001/002": {
      "type": "Usb",
      "vid": "0x1A86",
      "pid": "0x7523",
      "manufacturer": "",
      "product": "",
      "serial_number": "",
      "interfaces": [
        { "id": 0, "class": 255, "subclass": 0, "protocol": 0 }
      ]
    }
  }
}

Rust maps each entry to one or more paths: deviceName or deviceName#N (port index). manufacturer / product / serial_number may be empty when Android denies string reads (SecurityException) — probing uses VID/PID + interfaces[] only.

JNI / Rust entry

Typical sequence after you have fd:

let device = from_raw_fd(fd as RawFd)?;
let transport = Arc::new(NusbTransport::from_device(device)?);
let mut port = open_port(transport, port_index)?; // 0 for single-port CH340
port.set_line_config(LineConfig { baud_rate: 115_200, .. })?;
port.set_dtr(true)?;
port.start_reader()?; // optional background bulk IN
port.write(b"AT\r\n")?;
  • write() opens bulk OUT only; bulk IN belongs to the reader (do not reopen IN in write — see EndpointPair::write).
  • Prefer start_reader() after line config and DTR/RTS — some chips (CH340) misbehave if bulk IN starts too early.
  • On detach, expect errors like USB device detached (unplug, power loss, or protocol error on bulk IN).

Build the native library for Android:

cargo ndk -t arm64-v8a -o app/src/main/jniLibs build --release
# or: aarch64-linux-android, armeabi-v7a, x86_64 as needed

Link liblog if you emit Android logcat from Rust (__android_log_write).

Examples in this repository

Example Purpose
examples/usb-driver-tester Minimal standalone app: permission → fd → from_raw_fd → probe/open/write/read. Self-test runs fake-transport matrix; Device test hits real hardware. Use to isolate CH340 power vs plugin bugs.
examples/serialport-test Full Tauri Android app (pnpm tauri android build). See ANDROID.md for wireless ADB, install, and logcat tags.
android/ Tauri plugin Kotlin bridge (UsbFdBridge, UsbNative, SerialPlugin).
examples/serialport-test/android-integration/ Instrumented JNI tests with FakeTransport (no real USB).

usb-driver-tester build:

cd examples/usb-driver-tester/rust
cargo ndk -t arm64-v8a -o ../app/src/main/jniLibs build --release
cd .. && gradle assembleDebug

serialport-test on device:

cd examples/serialport-test
pnpm tauri android build --debug
adb install -r src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk
adb logcat -s UsbFdBridge SerialPlugin Console

Debugging checklist

Symptom Likely cause
io interface is busy Kotlin called claimInterface before Rust
endpoint already in use bulk IN opened twice (writer + reader)
USB permission denied user declined dialog or PendingIntent not mutable
Device listed, UI empty enumerate JSON missing interfaces[] or wrong port key
CH340 vanishes after open (URB ep 82 status=-71) OTG power / cable — confirm with usb-driver-tester on same phone + cable
enumerateJson: 0 after disconnect normal after detach; replug or powered USB hub

Tauri plugin consumers

If you use tauri-plugin-serialplugin instead of wiring JNI yourself, merge the plugin's device_filter.xml, ensure USB host feature in your app manifest, and let the plugin handle enumerate / permission / fd. Your frontend calls the same serial API as on desktop.

Fake transport (tests)

#[cfg(feature = "fake-transport")]
fn example() {
    use android_usb_serial::{open_port, FakeTransport, Transport};
    use std::sync::Arc;

    let fake = FakeTransport::cdc_single_iface();
    let transport: Arc<dyn Transport> = Arc::new(fake.clone());
    let mut port = open_port(transport, 0).unwrap();
    port.write(b"PING").unwrap();
    assert_eq!(fake.take_tx(), b"PING");
}

Features

Feature Default Description
serialport-compat yes Implements serialport::SerialPort
fake-transport no FakeTransport + golden_record binary

Architecture

UsbManager (Kotlin) → permission → UsbDeviceConnection → fd (unclaimed)
        ↓
   from_raw_fd / NusbTransport (this crate)
        ↓
   ProbeTable → Ftdi / Cp21xx / Ch34x / … drivers
        ↓
   SerialPortHandle (write / reader / modem / purge)

See Using on Android for manifest, permissions, enumerate JSON, and example apps. Kotlin owns USB policy; Rust owns protocol + bulk I/O.

Golden fixtures

# Full parity gate (Java-sourced controls + bulkOut + rx_filter + probe)
cargo test -p android-usb-serial --features fake-transport --test golden_parity

# All crate tests
cargo test -p android-usb-serial --features fake-transport

# Optional: regen Rust-only driver fixtures (never overwrites source=java)
cargo run -p android-usb-serial --features fake-transport --bin golden_record

Breakdown: 560 Java control sequences, 6 RX-filter pairs, 1 probe table → 567 JSON files under tests/fixtures/.

Hardware spike

# On device: ANDROID_USB_SPIKE_FD=<UsbDeviceConnection fd>
cargo test -p android-usb-serial --features fake-transport spike_fd_hardware -- --ignored

License

Licensed under either of

at your option.

Driver protocols include logic derived from usb-serial-for-android (MIT); see NOTICE.

Publish (maintainers)

# From workspace root
cargo test -p android-usb-serial --features fake-transport
cargo package -p android-usb-serial --allow-dirty   # review pack list
cargo publish -p android-usb-serial --dry-run
cargo publish -p android-usb-serial

Version bumps: edit version in this crate’s Cargo.toml and the android-usb-serial = { …, version = "…" } entry in the workspace plugin Cargo.toml.

Related