Add Android USB serial bridge docs

This commit is contained in:
lenn
2026-05-11 22:30:45 +08:00
parent c5f4f854bf
commit 360b57e3e2
10 changed files with 395 additions and 100 deletions

View File

@@ -18,4 +18,17 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
-keepattributes RuntimeVisibleAnnotations,RuntimeInvisibleAnnotations,*Annotation*,Signature,InnerClasses,EnclosingMethod
-keep class app.tauri.annotation.** { *; }
-keep class app.tauri.plugin.** { *; }
-keep class com.lenn.tauri_serial.MainActivity { *; }
-keep class com.lenn.tauri_serial.UsbSerialPlugin { *; }
-keepclassmembers class com.lenn.tauri_serial.UsbSerialPlugin {
public *;
}
-keep class com.hoho.android.usbserial.** { *; }

View File

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

View File

@@ -10,20 +10,36 @@ import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbManager
import android.os.Build
import android.os.ParcelFileDescriptor
import android.system.Os
import android.system.OsConstants
import app.tauri.annotation.Command
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import com.hoho.android.usbserial.driver.UsbSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import com.hoho.android.usbserial.driver.UsbSerialProber
import java.io.FileDescriptor
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
import org.json.JSONArray
@TauriPlugin
class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private const val ACTION_USB_PERMISSION = "com.lenn.tauri_serial.USB_PERMISSION"
private const val BAUD_RATE = 921600
private const val READ_TIMEOUT_MS = 100
private const val WRITE_TIMEOUT_MS = 100
}
private var pendingConnectInvoke: Invoke? = null
private var pendingConnectDevice: UsbDevice? = null
private var pendingConnectDeviceName: String? = null
private var activeBridge: SerialBridge? = null
private val usbPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@@ -39,10 +55,10 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
val invoke = pendingConnectInvoke
val targetDevice = pendingConnectDevice
val targetDeviceName = pendingConnectDeviceName
pendingConnectInvoke = null
pendingConnectDevice = null
pendingConnectDeviceName = null
if (invoke == null || device == null) return
@@ -51,8 +67,8 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
return
}
if (targetDevice != null && device.deviceName == targetDevice.deviceName) {
openAndReturn(invoke, device)
if (targetDeviceName != null && device.deviceName == targetDeviceName) {
openAndReturn(invoke, device.deviceName)
} else {
invoke.reject("USB device mismatch")
}
@@ -65,7 +81,9 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
val filter = IntentFilter(ACTION_USB_PERMISSION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.applicationContext.registerReceiver(
usbPermissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED
usbPermissionReceiver,
filter,
Context.RECEIVER_NOT_EXPORTED
)
} else {
activity.applicationContext.registerReceiver(usbPermissionReceiver, filter)
@@ -74,45 +92,66 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
override fun onDestroy() {
super.onDestroy()
activeBridge?.close()
activeBridge = null
try {
activity.applicationContext.unregisterReceiver(usbPermissionReceiver)
} catch (_: Exception) {}
} catch (_: Exception) {
}
}
@Command
fun usb_serial_list(invoke: Invoke) {
listDevices(invoke)
}
@Command
fun usbSerialList(invoke: Invoke) {
listDevices(invoke)
}
private fun listDevices(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 = JSONArray()
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)
}
for (driver in UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)) {
val device = driver.device
val obj = JSObject()
obj.put("name", device.deviceName)
obj.put("vendorId", device.vendorId)
obj.put("productId", device.productId)
obj.put("manufacturer", safeDeviceString { device.manufacturerName })
obj.put("product", safeDeviceString { device.productName })
obj.put("serial", safeDeviceString { device.serialNumber })
obj.put("hasPermission", usbManager.hasPermission(device))
serialDevices.put(obj)
}
result.put("devices", serialDevices.toTypedArray())
result.put("devices", serialDevices)
invoke.resolve(result)
}
@Command
fun usb_serial_open(invoke: Invoke) {
openDevice(invoke)
}
@Command
fun usbSerialOpen(invoke: Invoke) {
openDevice(invoke)
}
private fun openDevice(invoke: Invoke) {
val args = invoke.parseArgs(JSObject::class.java)
val deviceName = args.optString("name", "")
val vendorId = if (args.has("vendorId")) args.optInt("vendorId") else null
val productId = if (args.has("productId")) args.optInt("productId") else null
val usbManager = activity.getSystemService(Context.USB_SERVICE) as? UsbManager
if (usbManager == null) {
@@ -120,98 +159,230 @@ class UsbSerialPlugin(private val activity: Activity) : Plugin(activity) {
return
}
val device = usbManager.deviceList[deviceName]
val device = resolveDevice(usbManager, deviceName, vendorId, productId)
if (device == null) {
invoke.reject("USB device not found: $deviceName")
val available = usbManager.deviceList.values.joinToString(", ") { it.deviceName }
invoke.reject("USB device not found: $deviceName; available: $available")
return
}
if (!usbManager.hasPermission(device)) {
synchronized(this) {
pendingConnectInvoke = invoke
pendingConnectDevice = device
pendingConnectDeviceName = device.deviceName
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
0
PendingIntent.FLAG_UPDATE_CURRENT
}
val permissionRequest = Intent(ACTION_USB_PERMISSION).setPackage(activity.packageName)
val permissionIntent = PendingIntent.getBroadcast(
activity, 0, Intent(ACTION_USB_PERMISSION), flags
activity,
0,
permissionRequest,
flags
)
usbManager.requestPermission(device, permissionIntent)
return
}
openAndReturn(invoke, device)
openAndReturn(invoke, device.deviceName)
}
@Command
fun usb_serial_close(invoke: Invoke) {
closeBridge()
invoke.resolve(JSObject())
}
private fun openAndReturn(invoke: Invoke, device: UsbDevice) {
@Command
fun usbSerialClose(invoke: Invoke) {
closeBridge()
invoke.resolve(JSObject())
}
private fun closeBridge() {
activeBridge?.close()
activeBridge = null
}
private fun openAndReturn(invoke: Invoke, deviceName: String) {
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")
val driver = findDriver(usbManager, deviceName)
if (driver == null) {
invoke.reject("USB serial driver not found: $deviceName")
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)
val connection = usbManager.openDevice(driver.device)
if (connection == null) {
invoke.reject("Failed to open USB device")
return
}
val port = driver.ports.firstOrNull()
if (port == null) {
connection.close()
invoke.reject("No serial port found on USB device")
return
}
try {
port.open(connection)
port.setParameters(
BAUD_RATE,
UsbSerialPort.DATABITS_8,
UsbSerialPort.STOPBITS_1,
UsbSerialPort.PARITY_NONE
)
val rustSide = FileDescriptor()
val bridgeSide = FileDescriptor()
Os.socketpair(OsConstants.AF_UNIX, OsConstants.SOCK_STREAM, 0, rustSide, bridgeSide)
val rustFd = ParcelFileDescriptor.dup(rustSide).detachFd()
Os.close(rustSide)
activeBridge?.close()
activeBridge = SerialBridge(bridgeSide, port, connection).also { it.start() }
val result = JSObject()
result.put("fd", rustFd)
result.put("name", driver.device.deviceName)
result.put("vendorId", driver.device.vendorId)
result.put("productId", driver.device.productId)
invoke.resolve(result)
} catch (error: Exception) {
try {
port.close()
} catch (_: Exception) {
}
connection.close()
invoke.reject(error.message ?: "Failed to open USB serial port")
}
}
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
private fun findDriver(usbManager: UsbManager, deviceName: String): UsbSerialDriver? {
return UsbSerialProber.getDefaultProber()
.findAllDrivers(usbManager)
.firstOrNull { it.device.deviceName == deviceName || it.device.deviceName.equals(deviceName, ignoreCase = true) }
}
private fun resolveDevice(
usbManager: UsbManager,
deviceName: String,
vendorId: Int?,
productId: Int?
): UsbDevice? {
usbManager.deviceList[deviceName]?.let { return it }
val devices = usbManager.deviceList.values.toList()
devices.firstOrNull { it.deviceName.equals(deviceName, ignoreCase = true) }?.let { return it }
if (vendorId != null && productId != null) {
devices.firstOrNull { it.vendorId == vendorId && it.productId == productId }?.let { return it }
}
val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(usbManager)
drivers.firstOrNull {
it.device.deviceName == deviceName || it.device.deviceName.equals(deviceName, ignoreCase = true)
}?.device?.let { return it }
if (drivers.size == 1) {
return drivers.first().device
}
if (devices.size == 1) {
return devices.first()
}
return null
}
private fun safeDeviceString(read: () -> String?): String {
return try {
read() ?: ""
} catch (_: SecurityException) {
""
}
}
private class SerialBridge(
private val bridgeFd: FileDescriptor,
private val port: UsbSerialPort,
private val connection: UsbDeviceConnection
) {
private val running = AtomicBoolean(false)
private lateinit var serialToRustThread: Thread
private lateinit var rustToSerialThread: Thread
fun start() {
running.set(true)
serialToRustThread = Thread(::copySerialToRust, "JE-Skin-usb-serial-rx")
rustToSerialThread = Thread(::copyRustToSerial, "JE-Skin-usb-serial-tx")
serialToRustThread.start()
rustToSerialThread.start()
}
fun close() {
if (!running.getAndSet(false)) return
try {
Os.close(bridgeFd)
} catch (_: Exception) {
}
try {
port.close()
} catch (_: Exception) {
}
connection.close()
}
private fun copySerialToRust() {
val output = FileOutputStream(bridgeFd)
val buffer = ByteArray(4096)
while (running.get()) {
try {
val count = port.read(buffer, READ_TIMEOUT_MS)
if (count > 0) {
output.write(buffer, 0, count)
output.flush()
}
} catch (_: IOException) {
close()
} catch (_: Exception) {
close()
}
}
}
val knownVendors = setOf(
0x1A86, // CH340/CH341
0x10C4, // CP210x
0x0403, // FTDI
0x067B, // PL2303
0x2341, // Arduino
0x239A, // Adafruit
)
if (device.vendorId in knownVendors) {
return true
}
private fun copyRustToSerial() {
val input = FileInputStream(bridgeFd)
val buffer = ByteArray(4096)
return false
while (running.get()) {
try {
val count = input.read(buffer)
if (count < 0) {
close()
return
}
if (count > 0) {
port.write(buffer.copyOf(count), WRITE_TIMEOUT_MS)
}
} catch (_: IOException) {
close()
} catch (_: Exception) {
close()
}
}
}
}
}
}

View File

@@ -13,10 +13,10 @@ allprojects {
repositories {
google()
mavenCentral()
maven(url = "https://jitpack.io")
}
}
tasks.register("clean").configure {
delete("build")
}