feat: add Android USB serial port support via USB Host API

- Add USB Host permissions and device filter to AndroidManifest.xml
- Create UsbSerialPlugin Kotlin plugin for USB Host API (enumerate, permission, open devices)
- Add serial_connect_fd command for Android to accept USB file descriptors
- Create RawFdStream wrapper for async I/O on raw file descriptors
- Make run_serial_with_poll generic over AsyncRead+AsyncWrite
- Register UsbSerialPlugin in MainActivity
This commit is contained in:
lenn
2026-05-11 20:31:46 +08:00
parent 7323021aec
commit 551022215c
13 changed files with 535 additions and 16 deletions

1
src-tauri/Cargo.lock generated
View File

@@ -17,6 +17,7 @@ dependencies = [
"fern",
"futures-util",
"humantime",
"libc",
"log",
"ndarray",
"prost",

View File

@@ -53,6 +53,8 @@ uuid = { version = "1", features = ["v4", "serde"] }
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
ndarray = { version = "0.15", optional = true }
[target.'cfg(target_os = "android")'.dependencies]
libc = "0.2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"

View File

@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- USB Host support for serial devices -->
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
@@ -22,6 +25,13 @@
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- Auto-launch when USB device is attached -->
<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/usb_device_filter" />
</activity>
<provider

View File

@@ -7,5 +7,7 @@ class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val plugin = UsbSerialPlugin(this)
pluginManager.load(null, "usb-serial", plugin, "")
}
}
}

View File

@@ -0,0 +1,217 @@
package com.lenn.tauri_serial
import android.app.Activity
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbManager
import android.os.Build
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
@TauriPlugin
class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private const val ACTION_USB_PERMISSION = "com.lenn.tauri_serial.USB_PERMISSION"
}
private var pendingConnectInvoke: Invoke? = null
private var pendingConnectDevice: UsbDevice? = null
private val usbPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (ACTION_USB_PERMISSION != intent.action) return
synchronized(this@UsbSerialPlugin) {
val device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
}
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
val invoke = pendingConnectInvoke
val targetDevice = pendingConnectDevice
pendingConnectInvoke = null
pendingConnectDevice = null
if (invoke == null || device == null) return
if (!granted) {
invoke.reject("USB permission denied")
return
}
if (targetDevice != null && device.deviceName == targetDevice.deviceName) {
openAndReturn(invoke, device)
} else {
invoke.reject("USB device mismatch")
}
}
}
}
override fun load(webView: android.webkit.WebView) {
super.load(webView)
val filter = IntentFilter(ACTION_USB_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.applicationContext.registerReceiver(
usbPermissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED
)
} else {
activity.applicationContext.registerReceiver(usbPermissionReceiver, filter)
}
}
override fun onDestroy() {
super.onDestroy()
try {
activity.applicationContext.unregisterReceiver(usbPermissionReceiver)
} catch (_: Exception) {}
}
@Command
fun usb_serial_list(invoke: Invoke) {
val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager
if (usbManager == null) {
invoke.reject("USB service not available")
return
}
val devices = usbManager.deviceList
val result = JSObject()
val serialDevices = mutableListOf<JSObject>()
for ((_, device) in devices) {
if (isUsbSerialDevice(device)) {
val obj = JSObject()
obj.put("name", device.deviceName)
obj.put("vendorId", device.vendorId)
obj.put("productId", device.productId)
obj.put("manufacturer", device.manufacturerName ?: "")
obj.put("product", device.productName ?: "")
obj.put("serial", device.serialNumber ?: "")
obj.put("hasPermission", usbManager.hasPermission(device))
serialDevices.add(obj)
}
}
result.put("devices", serialDevices.toTypedArray())
invoke.resolve(result)
}
@Command
fun usb_serial_open(invoke: Invoke) {
val args = invoke.parseArgs(JSObject::class.java)
val deviceName = args.optString("name", "")
val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager
if (usbManager == null) {
invoke.reject("USB service not available")
return
}
val device = usbManager.deviceList[deviceName]
if (device == null) {
invoke.reject("USB device not found: $deviceName")
return
}
if (!usbManager.hasPermission(device)) {
synchronized(this) {
pendingConnectInvoke = invoke
pendingConnectDevice = device
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
val permissionIntent = PendingIntent.getBroadcast(
activity, 0, Intent(ACTION_USB_PERMISSION), flags
)
usbManager.requestPermission(device, permissionIntent)
return
}
openAndReturn(invoke, device)
}
@Command
fun usb_serial_close(invoke: Invoke) {
invoke.resolve(JSObject())
}
private fun openAndReturn(invoke: Invoke, device: UsbDevice) {
val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager
if (usbManager == null) {
invoke.reject("USB service not available")
return
}
val connection: UsbDeviceConnection = usbManager.openDevice(device)
?: run {
invoke.reject("Failed to open USB device")
return
}
var claimedInterface = false
for (i in 0 until device.interfaceCount) {
val iface = device.getInterface(i)
if (iface.endpointCount >= 2) {
connection.claimInterface(iface, true)
claimedInterface = true
break
}
}
if (!claimedInterface) {
invoke.reject("No usable USB interface found")
return
}
val fd = connection.fileDescriptor
val result = JSObject()
result.put("fd", fd)
result.put("name", device.deviceName)
result.put("vendorId", device.vendorId)
result.put("productId", device.productId)
invoke.resolve(result)
}
private fun isUsbSerialDevice(device: UsbDevice): Boolean {
for (i in 0 until device.interfaceCount) {
val iface = device.getInterface(i)
val classId = iface.interfaceClass
if (classId == 0x02 || classId == 0xFF) {
if (iface.endpointCount >= 2) {
return true
}
}
}
val knownVendors = setOf(
0x1A86, // CH340/CH341
0x10C4, // CP210x
0x0403, // FTDI
0x067B, // PL2303
0x2341, // Arduino
0x239A, // Adafruit
)
if (device.vendorId in knownVendors) {
return true
}
return false
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- CH340 / CH341 USB-Serial -->
<usb-device vendor-id="1a86" product-id="7523" />
<!-- CP2102 / CP2104 -->
<usb-device vendor-id="10c4" product-id="ea60" />
<usb-device vendor-id="10c4" product-id="ea70" />
<!-- FTDI FT232R / FT232H -->
<usb-device vendor-id="0403" product-id="6001" />
<usb-device vendor-id="0403" product-id="6014" />
<!-- PL2303 -->
<usb-device vendor-id="067b" product-id="2303" />
<usb-device vendor-id="067b" product-id="23a3" />
<!-- CDC ACM (generic USB serial) -->
<usb-device vendor-id="2341" product-id="0001" />
<usb-device vendor-id="2341" product-id="0043" />
<usb-device vendor-id="2341" product-id="0042" />
<!-- Allow any USB device (catch-all) -->
<usb-device />
</resources>

View File

@@ -0,0 +1,9 @@
{
"plugins": {
"usb-serial": {
"android": {
"package": "com.lenn.tauri_serial.UsbSerialPlugin"
}
}
}
}

View File

@@ -246,6 +246,104 @@ pub async fn serial_disconnect(
})
}
#[cfg(target_os = "android")]
#[tauri::command]
pub async fn serial_connect_fd(
app: AppHandle,
fd: i32,
device_name: String,
state: State<'_, SerialConnectionState>,
) -> Result<SerialConnectResponse, SerialError> {
if fd < 0 {
return Err(SerialError::InvalidConfig);
}
{
let session = state.session.lock().map_err(|_| SerialError::StateError)?;
if session.is_some() {
return Err(SerialError::AlreadyConnected);
}
}
let cancel = CancellationToken::new();
let current_record = Arc::new(Mutex::new(TactileARecording::new()));
let task_record = current_record.clone();
let task_cancel = cancel.clone();
let task_app = app.clone();
let task_port_name = device_name.clone();
let port = crate::serial_core::raw_fd_stream::RawFdStream::new(fd)
.map_err(|_| SerialError::OpenError)?;
let session_started_at = Instant::now();
let task = tauri::async_runtime::spawn(async move {
let codec = TactileACodec::new(DEFAULT_TACTILE_COLS, DEFAULT_TACTILE_ROWS);
let handler = TactileAHandler;
let poll_mode = PollMode::Enabled(Box::new(TactileAPollRequester::new(
Duration::from_millis(DEFAULT_TACTILE_POLL_INTERVAL_MS),
DEFAULT_TACTILE_COLS,
DEFAULT_TACTILE_ROWS,
Duration::from_millis(DEFAULT_TACTILE_REPLY_TIMEOUT_MS),
)));
if let Err(error) = serial::run_serial_with_poll(
task_app.clone(),
port,
codec,
handler,
session_started_at,
task_record.clone(),
task_cancel,
poll_mode,
)
.await
{
eprintln!("serial task exited with error: {error}");
}
let manager = task_app.state::<SerialConnectionState>();
if let Ok(mut last_record) = manager.last_record.lock() {
*last_record = Some(task_record);
}
let mut session = match manager.session.lock() {
Ok(session) => session,
Err(_) => return,
};
{
let should_clear = session
.as_ref()
.map(|current| current.port.as_str() == task_port_name.as_str())
.unwrap_or(false);
if should_clear {
session.take();
}
}
});
let mut session = state.session.lock().map_err(|_| SerialError::StateError)?;
if session.is_some() {
cancel.cancel();
task.abort();
return Err(SerialError::AlreadyConnected);
}
*session = Some(SerialSession {
port: device_name.clone(),
cancel,
task,
current_record,
});
Ok(SerialConnectResponse {
port: device_name,
connected: true,
message: "connected".to_string(),
})
}
#[tauri::command]
pub fn serial_export_csv(
app: AppHandle,

View File

@@ -135,7 +135,7 @@ pub fn run() {
Ok(())
});
#[cfg(feature = "devkit")]
#[cfg(all(feature = "devkit", not(target_os = "android")))]
let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
@@ -157,7 +157,7 @@ pub fn run() {
commands::devkit::devkit_process_export
]);
#[cfg(not(feature = "devkit"))]
#[cfg(all(not(feature = "devkit"), not(target_os = "android")))]
let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
@@ -173,6 +173,46 @@ pub fn run() {
commands::window::win_close
]);
#[cfg(all(feature = "devkit", target_os = "android"))]
let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_connect_fd,
commands::serial::serial_disconnect,
commands::serial::serial_export_csv,
commands::serial::serial_has_record_data,
commands::serial::serial_export_csv_to_path,
commands::serial::serial_import_csv,
commands::serial::serial_import_csv_from_path,
commands::window::win_minimize,
commands::window::win_toggle_maximize,
commands::window::win_close,
commands::devkit::devkit_status,
commands::devkit::devkit_start,
commands::devkit::devkit_stop,
commands::devkit::devkit_get_config,
commands::devkit::devkit_set_config,
commands::devkit::devkit_process_export
]);
#[cfg(all(not(feature = "devkit"), target_os = "android"))]
let builder = builder.invoke_handler(tauri::generate_handler![
commands::file_explorer::file_explorer_list,
commands::serial::serial_enum,
commands::serial::serial_connect,
commands::serial::serial_connect_fd,
commands::serial::serial_disconnect,
commands::serial::serial_export_csv,
commands::serial::serial_has_record_data,
commands::serial::serial_export_csv_to_path,
commands::serial::serial_import_csv,
commands::serial::serial_import_csv_from_path,
commands::window::win_minimize,
commands::window::win_toggle_maximize,
commands::window::win_close
]);
builder
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -11,6 +11,8 @@ pub mod model;
pub mod serial;
pub mod record;
pub mod utils;
#[cfg(target_os = "android")]
pub mod raw_fd_stream;
#[cfg(feature = "multi-dim")]
pub mod multi_dim_force;

View File

@@ -0,0 +1,126 @@
use std::io;
use std::os::unix::io::RawFd;
use tokio::io::unix::AsyncFd;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
/// A wrapper around a raw file descriptor that implements AsyncRead + AsyncWrite.
/// Uses tokio's AsyncFd to properly integrate with the async reactor.
/// Used on Android to wrap USB device file descriptors obtained from the USB Host API.
pub struct RawFdStream {
inner: AsyncFd<RawFd>,
}
impl RawFdStream {
pub fn new(fd: RawFd) -> io::Result<Self> {
if fd < 0 {
return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid fd"));
}
// Set non-blocking
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFL);
if flags < 0 {
return Err(io::Error::last_os_error());
}
if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
return Err(io::Error::last_os_error());
}
}
let inner = AsyncFd::new(fd)?;
Ok(Self { inner })
}
}
impl Drop for RawFdStream {
fn drop(&mut self) {
// We don't close the fd here - it's managed by the UsbDeviceConnection in Kotlin.
// The Kotlin side is responsible for closing.
}
}
impl AsyncRead for RawFdStream {
fn poll_read(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut ReadBuf<'_>,
) -> std::task::Poll<io::Result<()>> {
loop {
let mut guard = match self.inner.poll_read_ready(cx) {
std::task::Poll::Ready(Ok(guard)) => guard,
std::task::Poll::Ready(Err(e)) => return std::task::Poll::Ready(Err(e)),
std::task::Poll::Pending => return std::task::Poll::Pending,
};
let unfilled = buf.initialize_unfilled();
let result = unsafe {
libc::read(
*self.inner.get_ref(),
unfilled.as_mut_ptr() as *mut libc::c_void,
unfilled.len(),
)
};
if result < 0 {
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::WouldBlock {
guard.clear_ready();
continue;
}
return std::task::Poll::Ready(Err(err));
}
buf.advance(result as usize);
return std::task::Poll::Ready(Ok(()));
}
}
}
impl AsyncWrite for RawFdStream {
fn poll_write(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<io::Result<usize>> {
loop {
let mut guard = match self.inner.poll_write_ready(cx) {
std::task::Poll::Ready(Ok(guard)) => guard,
std::task::Poll::Ready(Err(e)) => return std::task::Poll::Ready(Err(e)),
std::task::Poll::Pending => return std::task::Poll::Pending,
};
let result = unsafe {
libc::write(
*self.inner.get_ref(),
buf.as_ptr() as *const libc::c_void,
buf.len(),
)
};
if result < 0 {
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::WouldBlock {
guard.clear_ready();
continue;
}
return std::task::Poll::Ready(Err(err));
}
return std::task::Poll::Ready(Ok(result as usize));
}
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}

View File

@@ -18,8 +18,9 @@ use std::time::Instant;
use tauri::{AppHandle, Emitter};
#[cfg(feature = "devkit")]
use tauri::Manager;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::time::{self, Duration, MissedTickBehavior};
#[cfg(not(target_os = "android"))]
use tokio_serial::SerialStream;
use tokio_util::sync::CancellationToken;
@@ -171,6 +172,7 @@ impl PollRequester<TactileAFrame> for TactileAPollRequester {
}
}
#[cfg(not(target_os = "android"))]
pub async fn run_serial<C, H, T, F>(
app: AppHandle,
port: SerialStream,
@@ -201,7 +203,7 @@ where
pub async fn run_serial_with_poll<C, H, T, F>(
app: AppHandle,
mut port: SerialStream,
mut port: impl AsyncRead + AsyncWrite + Unpin,
mut codec: C,
mut handler: H,
session_started_at: Instant,

View File

@@ -45,15 +45,5 @@
"resources/je-skin-devkit-server.exe"
]
},
"plugins": {
"updater": {
"windows": {
"installMode": "passive"
},
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkwODM1QkFEODI2NkZENgpSV1RXYnliWXVqVUlDUVRxbjlseDNDNjhQTGpDYis4TEZMeUk2WVhiMEhTRWJhN3hGRnQ3TTJtcwo=",
"endpoints": [
"https://je-skin.cn-nb1.rains3.com/latest.json"
]
}
}
"plugins": {}
}