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.
| 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) |
[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).
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.
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)
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.
Android requires runtime permission per device before openDevice():
- Check
usbManager.hasPermission(device). - If false, call
usbManager.requestPermission(device, pendingIntent). - Handle the result in a
BroadcastReceiver(EXTRA_PERMISSION_GRANTED). - On API 31+, use
PendingIntent.FLAG_MUTABLEon the permission intent. - Set
intent.setPackage(packageName)when building the permissionIntent(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.
After permission is granted:
val conn = usbManager.openDevice(device) ?: throw IOException("open failed")
// Do NOT call conn.claimInterface() here.
val fd = conn.fileDescriptorRust/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.
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.
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 inwrite— seeEndpointPair::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 neededLink liblog if you emit Android logcat from Rust (__android_log_write).
| 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 assembleDebugserialport-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| 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 |
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.
#[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");
}| Feature | Default | Description |
|---|---|---|
serialport-compat |
yes | Implements serialport::SerialPort |
fake-transport |
no | FakeTransport + golden_record binary |
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.
# 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_recordBreakdown: 560 Java control sequences, 6 RX-filter pairs, 1 probe table → 567 JSON files under tests/fixtures/.
# On device: ANDROID_USB_SPIKE_FD=<UsbDeviceConnection fd>
cargo test -p android-usb-serial --features fake-transport spike_fd_hardware -- --ignoredLicensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Driver protocols include logic derived from usb-serial-for-android (MIT); see NOTICE.
# 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-serialVersion bumps: edit version in this crate’s Cargo.toml and the android-usb-serial = { …, version = "…" } entry in the workspace plugin Cargo.toml.
- tauri-plugin-serialplugin — Tauri integration (
UsbFdBridge→ this crate) - examples/usb-driver-tester — standalone hardware tester
- examples/serialport-test/ANDROID.md — Tauri Android dev workflow
- Standalone apps can depend on this crate alone + a thin Kotlin fd bridge