package app.tauri.serialplugin.manager
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 android.util.Log
import androidx.core.content.ContextCompat
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executor
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.RejectedExecutionException
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
/**
* USB fd provider for Rust [android-usb-serial] (no vendored usbserial).
*/
class UsbFdBridge private constructor(
private val context: Context?,
private val nativeSink: NativeSink,
private val registerReceiver: Boolean,
private val testMode: Boolean,
) {
constructor(context: Context) : this(
context,
JniNativeSink,
true,
false,
)
companion object {
private const val TAG = "UsbFdBridge"
private const val ACTION_USB_PERMISSION = "app.tauri.serialplugin.USB_PERMISSION"
fun forTesting(sink: NativeSink = object : NativeSink {
override fun onPortListChange() {}
override fun onDeviceDetached(deviceName: String) {}
}): UsbFdBridge = UsbFdBridge(null, sink, false, true)
/** Robolectric: real [Context] + receiver registration, synchronous IO. */
fun forTesting(
context: Context,
sink: NativeSink = object : NativeSink {
override fun onPortListChange() {}
override fun onDeviceDetached(deviceName: String) {}
},
): UsbFdBridge = UsbFdBridge(context, sink, registerReceiver = true, testMode = true)
@JvmStatic
fun forIntegrationTest(): UsbFdBridge = forTesting(JniNativeSink)
}
interface NativeSink {
fun onPortListChange()
fun onDeviceDetached(deviceName: String)
}
@Volatile private var shutDown = false
/** Permission wait budget; short in testMode so denied/timeout tests stay fast. */
@Volatile private var permissionWaitMs: Long = if (testMode) 200L else 30_000L
private val ioExecutor: Executor = if (testMode) {
Executor { it.run() }
} else {
Executors.newSingleThreadExecutor { r -> Thread(r, "usb-fd-io").apply { isDaemon = true } }
}
private val usbManager = context?.getSystemService(Context.USB_SERVICE) as? UsbManager
private val connections = ConcurrentHashMap<String, UsbDeviceConnection>()
private val permissionFutures = ConcurrentHashMap<String, CompletableFuture<Boolean>>()
private val permissionLock = Any()
private val usbReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
when (intent.action) {
ACTION_USB_PERMISSION -> {
val device = deviceFromIntent(intent)
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
device?.deviceName?.let { permissionFutures[it]?.complete(granted) }
}
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
val device = deviceFromIntent(intent)
val name = device?.deviceName
if (name != null) {
closeDeviceFd(name)
nativeSink.onDeviceDetached(name)
}
nativeSink.onPortListChange()
}
UsbManager.ACTION_USB_DEVICE_ATTACHED -> nativeSink.onPortListChange()
}
}
}
init {
if (registerReceiver && context != null) {
val filter = IntentFilter(ACTION_USB_PERMISSION).apply {
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED)
}
if (Build.VERSION.SDK_INT >= 33 || testMode) {
context.registerReceiver(usbReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
ContextCompat.registerReceiver(
context, usbReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
}
}
/** Test-only: invoke the USB broadcast receiver directly (Robolectric). */
fun deliverBroadcastForTest(intent: Intent) {
check(testMode)
val ctx = context ?: throw IllegalStateException("no context")
usbReceiver.onReceive(ctx, intent)
}
/** Test-only: complete a pending USB permission request without a broadcast. */
fun completePermissionForTest(deviceName: String, granted: Boolean) {
check(testMode)
permissionFutures[deviceName]?.complete(granted)
}
/**
* Test-only: stretch [requestPermission] wait so shutdown can be asserted to
* unblock before the timeout (default test wait is 200ms).
*/
fun setPermissionWaitMsForTest(ms: Long) {
check(testMode)
permissionWaitMs = ms
}
/** Fail any in-flight permission waits so blocked IO threads can exit. */
private fun failPendingPermissionsLocked() {
permissionFutures.keys.toList().forEach { name ->
permissionFutures.remove(name)?.complete(false)
}
}
fun shutdown() {
// Flip + drain under the same lock as future registration so a waiter
// cannot insert after failPendingPermissions and then block for 30s.
synchronized(permissionLock) {
shutDown = true
failPendingPermissionsLocked()
}
if (testMode) {
connections.keys.toList().forEach { closeDeviceFd(it) }
if (registerReceiver && context != null) {
try {
context.unregisterReceiver(usbReceiver)
} catch (_: IllegalArgumentException) {
}
}
return
}
val pool = ioExecutor as ExecutorService
val ctx = context
val shouldUnregister = registerReceiver
try {
pool.execute {
try {
connections.keys.toList().forEach { closeDeviceFd(it) }
} finally {
if (shouldUnregister && ctx != null) {
try {
ctx.unregisterReceiver(usbReceiver)
} catch (_: IllegalArgumentException) {
}
}
pool.shutdown()
}
}
} catch (_: RejectedExecutionException) {
connections.keys.toList().forEach { closeDeviceFd(it) }
if (shouldUnregister && ctx != null) {
try {
ctx.unregisterReceiver(usbReceiver)
} catch (_: IllegalArgumentException) {
}
}
pool.shutdown()
}
}
fun runOnIoSync(block: () -> Unit) {
runOnIoSync<Unit> { block() }
}
fun <T> runOnIoSync(block: () -> T): T {
if (shutDown) throw IOException("USB fd bridge shut down")
if (testMode) return block()
return CompletableFuture.supplyAsync(block, ioExecutor).get()
}
fun enumerateJson(): String {
val mgr = usbManager ?: run {
Log.w(TAG, "enumerateJson: no UsbManager")
return JSONObject().put("ports", JSONObject()).toString()
}
val ports = JSONObject()
mgr.deviceList.values.forEach { device ->
val key = device.deviceName
try {
ports.put(key, deviceInfo(device))
} catch (e: Exception) {
Log.e(TAG, "enumerateJson: skip $key: ${e.message}", e)
}
}
val json = JSONObject().put("ports", ports).toString()
Log.i(TAG, "enumerateJson: ${ports.length()} device(s) json=$json")
return json
}
fun openDeviceFd(deviceName: String): Int {
if (shutDown) throw IOException("USB fd bridge shut down")
connections[deviceName]?.let {
Log.i(TAG, "openDeviceFd reuse $deviceName fd=${it.fileDescriptor}")
return it.fileDescriptor
}
val mgr = usbManager ?: throw IOException("no UsbManager")
val device = mgr.deviceList.values.find { it.deviceName == deviceName }
?: throw IOException("device not found: $deviceName")
if (!mgr.hasPermission(device)) requestPermission(device)
if (shutDown) throw IOException("USB fd bridge shut down")
// Do NOT claimInterface here — Rust nusb detach_and_claim owns claiming.
// Pre-claim makes the same fd report "io interface is busy" on open.
val conn = mgr.openDevice(device) ?: throw IOException("open failed: $deviceName")
connections[deviceName] = conn
Log.i(
TAG,
"openDeviceFd $deviceName fd=${conn.fileDescriptor} ifaces=${device.interfaceCount} (unclaimed for nusb)",
)
return conn.fileDescriptor
}
fun closeDeviceFd(deviceName: String) {
connections.remove(deviceName)?.close()
}
/** Test-only: inject a pre-opened connection (Robolectric / harness). */
fun adoptConnectionForTest(deviceName: String, connection: UsbDeviceConnection) {
check(testMode)
connections[deviceName] = connection
}
/** Nested [JSONObject] (not Map) — avoids ambiguous `JSONObject.put` wrap. */
private fun deviceInfo(device: UsbDevice): JSONObject {
fun safeName(block: () -> String?): String = try {
block() ?: ""
} catch (e: SecurityException) {
Log.w(TAG, "deviceInfo string denied for ${device.deviceName}: ${e.message}")
""
}
val ifaces = JSONArray()
for (i in 0 until device.interfaceCount) {
val iface = device.getInterface(i)
ifaces.put(
JSONObject()
.put("id", iface.id)
.put("class", iface.interfaceClass)
.put("subclass", iface.interfaceSubclass)
.put("protocol", iface.interfaceProtocol),
)
}
return JSONObject()
.put("type", "Usb")
.put("vid", "0x%04X".format(device.vendorId))
.put("pid", "0x%04X".format(device.productId))
.put("manufacturer", safeName { device.manufacturerName })
.put("product", safeName { device.productName })
.put("serial_number", safeName { device.serialNumber })
.put("interfaces", ifaces)
}
private fun requestPermission(device: UsbDevice) {
val mgr = usbManager ?: throw IOException("no UsbManager")
val ctx = context ?: throw IOException("no context")
val name = device.deviceName
val fut = CompletableFuture<Boolean>()
synchronized(permissionLock) {
if (shutDown) throw IOException("USB fd bridge shut down")
permissionFutures[name] = fut
}
val intent = Intent(ACTION_USB_PERMISSION).apply {
setPackage(ctx.packageName)
}
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
val pi = PendingIntent.getBroadcast(ctx, 0, intent, flags)
mgr.requestPermission(device, pi)
// Wait budget: short in normal tests; production / stretched tests use 30s.
// shutdown()'s failPendingPermissions completes the future early.
val granted = try {
fut.get(permissionWaitMs, TimeUnit.MILLISECONDS)
} catch (_: TimeoutException) {
false
} finally {
permissionFutures.remove(name, fut)
}
if (shutDown) throw IOException("USB fd bridge shut down")
if (!granted) {
throw IOException("USB permission denied for $name")
}
}
private fun deviceFromIntent(intent: Intent): UsbDevice? =
if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
}
}
private object JniNativeSink : UsbFdBridge.NativeSink {
override fun onPortListChange() = app.tauri.serialplugin.MobileBridge.onPortListChange()
override fun onDeviceDetached(deviceName: String) =
app.tauri.serialplugin.MobileBridge.onDeviceDetached(deviceName)
}