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

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>