tauri-plugin-serialplugin 3.0.5

Tauri plugin for serial port access (desktop and Android).
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)
}