package expo.modules.usbserial 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.UsbManager import android.os.Build import com.hoho.android.usbserial.driver.UsbSerialPort import com.hoho.android.usbserial.driver.UsbSerialProber import com.hoho.android.usbserial.driver.ProbeTable import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.records.Field import expo.modules.kotlin.records.Record import expo.modules.kotlin.Promise import expo.modules.kotlin.sharedobjects.SharedObject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.resume import kotlin.coroutines.coroutineContext import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap import java.util.ArrayList // --------------------------------------------------------------------------- // Options Records — passed from JS // --------------------------------------------------------------------------- private class ConnectOptions : Record { @Field var baudRate: Int = 9600 @Field var dataBits: Int = 8 @Field var stopBits: Int = 1 @Field var parity: String = "none" @Field var portIndex: Int = 0 } private class CustomDeviceOption : Record { @Field var vendorId: Int = 0 @Field var productId: Int = 0 @Field var driverName: String = "CdcAcmSerial" } // --------------------------------------------------------------------------- // Shared Object — maps directly to a JSI class instance on the JS side // --------------------------------------------------------------------------- class UsbSerialConnection( val port: UsbSerialPort, val deviceId: Int, private val module: ExpoUsbSerialModule ) : SharedObject() { var readJob: Job? = null fun write(data: ByteArray, timeoutMs: Int): Int { port.write(data, timeoutMs) return data.size } fun disconnect() { readJob?.cancel() readJob = null runCatching { port.close() } module.removeConnection(this) } fun setDtr(enabled: Boolean) { port.setDTR(enabled) } fun setRts(enabled: Boolean) { port.setRTS(enabled) } fun isConnected(): Boolean { return port.isOpen } override fun sharedObjectDidRelease() { disconnect() } } // --------------------------------------------------------------------------- // Expo Module Definition // --------------------------------------------------------------------------- class ExpoUsbSerialModule : Module() { private val moduleScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) /** * Map of deviceId → List of active UsbSerialConnection weak references. * Keeps weak references to prevent JVM Garbage Collector from leaking connections * when the JS execution engine releases its handles. */ private val activeConnections = ConcurrentHashMap>>() /** Custom USB devices registered dynamically via listDevices() (vendorId to productId -> driverName) */ private val customProberDevices = ConcurrentHashMap, String>() internal fun emit(name: String, body: Map) = sendEvent(name, body) internal fun addConnection(connection: UsbSerialConnection) { val list = activeConnections.getOrPut(connection.deviceId) { ArrayList() } list.add(WeakReference(connection)) } internal fun removeConnection(connection: UsbSerialConnection) { val list = activeConnections[connection.deviceId] ?: return list.removeAll { ref -> ref.get() == null || ref.get() == connection } if (list.isEmpty()) { activeConnections.remove(connection.deviceId) } } private val detachReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) { 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) } device?.let { usbDevice -> val deviceId = usbDevice.deviceId val connections = activeConnections[deviceId] if (connections != null && connections.isNotEmpty()) { val connectionsCopy = ArrayList(connections) for (ref in connectionsCopy) { val connection = ref.get() if (connection != null) { connection.emit("error", mapOf( "code" to "ERR_USB_DISCONNECTED", "message" to "Device was physically disconnected" )) connection.disconnect() } } } } } } } private fun getProber(): UsbSerialProber { if (customProberDevices.isEmpty()) { return UsbSerialProber.getDefaultProber() } val table = UsbSerialProber.getDefaultProbeTable() for ((key, driverName) in customProberDevices) { val driverClass = when (driverName.lowercase()) { "ftdiserial", "ftdi" -> com.hoho.android.usbserial.driver.FtdiSerialDriver::class.java "ch34xserial", "ch340", "ch34x" -> com.hoho.android.usbserial.driver.Ch34xSerialDriver::class.java "cp21xxserial", "cp210x", "cp21xx" -> com.hoho.android.usbserial.driver.Cp21xxSerialDriver::class.java "prolificserial", "pl2303", "prolific" -> com.hoho.android.usbserial.driver.ProlificSerialDriver::class.java else -> com.hoho.android.usbserial.driver.CdcAcmSerialDriver::class.java } table.addProduct(key.first, key.second, driverClass) } return UsbSerialProber(table) } override fun definition() = ModuleDefinition { Name("ExpoUsbSerial") // ── listDevices ────────────────────────────────────────────────────── AsyncFunction("listDevices") { customDevices: List, promise: Promise -> moduleScope.launch { try { customProberDevices.clear() for (cd in customDevices) { customProberDevices[Pair(cd.vendorId, cd.productId)] = cd.driverName } val context = requireContext() val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager val prober = getProber() val results = mutableListOf>() for ((_, device) in usbManager.deviceList) { val driver = prober.probeDevice(device) ?: continue results.add(buildDeviceMap(device, driver.javaClass.simpleName, driver.ports.size)) } promise.resolve(results) } catch (e: Exception) { promise.reject("ERR_USB_LIST", e.message ?: "Failed to list USB devices", e) } } } // ── requestPermission ──────────────────────────────────────────────── AsyncFunction("requestPermission") { deviceId: Int, promise: Promise -> moduleScope.launch(Dispatchers.Main) { try { val context = requireContext() val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager val device = findDevice(usbManager, deviceId) ?: run { promise.reject("ERR_USB_NOT_FOUND", "Device $deviceId not found", null); return@launch } if (usbManager.hasPermission(device)) { promise.resolve(true) return@launch } val action = "expo.modules.usbserial.USB_PERMISSION.$deviceId" var receiver: BroadcastReceiver? = null try { val granted = withTimeoutOrNull(30_000L) { suspendCancellableCoroutine { cont -> receiver = object : BroadcastReceiver() { override fun onReceive(ctx: Context, intent: Intent) { runCatching { ctx.unregisterReceiver(this) } receiver = null val result = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) if (cont.isActive) cont.resume(result) } } val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Context.RECEIVER_NOT_EXPORTED } else { 0 } context.registerReceiver(receiver, IntentFilter(action), flags) val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 val permIntent = PendingIntent.getBroadcast(context, deviceId, Intent(action), pendingFlags) usbManager.requestPermission(device, permIntent) cont.invokeOnCancellation { receiver?.let { r -> runCatching { context.unregisterReceiver(r) } } } } } if (granted != null) { promise.resolve(granted) } else { receiver?.let { r -> runCatching { context.unregisterReceiver(r) } } promise.reject("ERR_USB_PERMISSION_TIMEOUT", "Permission request timed out after 30 seconds", null) } } catch (e: Exception) { receiver?.let { r -> runCatching { context.unregisterReceiver(r) } } promise.reject("ERR_USB_PERMISSION", e.message ?: "Permission request failed", e) } } catch (e: Exception) { promise.reject("ERR_USB_PERMISSION", e.message ?: "Permission request failed", e) } } } // ── connect (JSI SharedObject factory) ──────────────────────────────── AsyncFunction("connect") { deviceId: Int, options: ConnectOptions, promise: Promise -> moduleScope.launch { try { val context = requireContext() val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager val device = findDevice(usbManager, deviceId) ?: run { promise.reject("ERR_USB_NOT_FOUND", "Device $deviceId not found", null); return@launch } val driver = getProber().probeDevice(device) ?: run { promise.reject("ERR_USB_NO_DRIVER", "No supported driver for device $deviceId", null); return@launch } if (options.portIndex >= driver.ports.size) { promise.reject("ERR_USB_INVALID_PORT", "Port index ${options.portIndex} out of range (device has ${driver.ports.size} port(s))", null) return@launch } val connection = usbManager.openDevice(device) ?: run { promise.reject("ERR_USB_OPEN", "Failed to open USB connection — check permissions", null); return@launch } val port = driver.ports[options.portIndex] port.open(connection) port.setParameters( options.baudRate, options.dataBits, mapStopBits(options.stopBits), mapParity(options.parity) ) val connObj = UsbSerialConnection(port, deviceId, this@ExpoUsbSerialModule) val readJob = moduleScope.launch { startReadLoop(deviceId, port, connObj) } connObj.readJob = readJob addConnection(connObj) promise.resolve(connObj) } catch (e: Exception) { promise.reject("ERR_USB_CONNECT", e.message ?: "Failed to connect", e) } } } // ── isConnected (synchronous module helper) ─────────────────────────── Function("isConnected") { deviceId: Int -> activeConnections.containsKey(deviceId) } // ── listConnected (synchronous module helper) ───────────────────────── Function("listConnected") { activeConnections.keys.toList() } // ── JSI SharedObject Class Mapping ──────────────────────────────────── Class(UsbSerialConnection::class) { Constructor { throw IllegalStateException("UsbSerialConnection cannot be instantiated from JavaScript. Use connect() instead.") } Events("data", "error") Function("write") { connection: UsbSerialConnection, data: ByteArray, timeoutMs: Int -> connection.write(data, timeoutMs) } AsyncFunction("writeAsync") { connection: UsbSerialConnection, data: ByteArray, timeoutMs: Int, promise: Promise -> moduleScope.launch { try { val written = connection.write(data, timeoutMs) promise.resolve(written) } catch (e: Exception) { promise.reject("ERR_USB_WRITE", e.message ?: "Write failed", e) } } } Function("disconnect") { connection: UsbSerialConnection -> connection.disconnect() } Function("setDtr") { connection: UsbSerialConnection, enabled: Boolean -> connection.setDtr(enabled) } Function("setRts") { connection: UsbSerialConnection, enabled: Boolean -> connection.setRts(enabled) } Function("isConnected") { connection: UsbSerialConnection -> connection.isConnected() } } // ── Lifecycle ───────────────────────────────────────────────────────── OnCreate { val context = requireContext() val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED) val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Context.RECEIVER_NOT_EXPORTED } else { 0 } context.registerReceiver(detachReceiver, filter, flags) } OnDestroy { activeConnections.values.flatten().forEach { ref -> ref.get()?.disconnect() } runCatching { requireContext().unregisterReceiver(detachReceiver) } moduleScope.cancel() } } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- private fun requireContext(): Context = appContext.reactContext ?: error("React context is not available") private fun findDevice(usbManager: UsbManager, deviceId: Int): UsbDevice? = usbManager.deviceList.values.firstOrNull { it.deviceId == deviceId } /** * Runs a tight read loop on the IO dispatcher, emitting raw byte array * events for every received chunk directly to JSI. */ private suspend fun startReadLoop(deviceId: Int, port: UsbSerialPort, connection: UsbSerialConnection) { val buffer = ByteArray(READ_BUFFER_SIZE) while (coroutineContext.isActive && port.isOpen) { try { val len = port.read(buffer, READ_TIMEOUT_MS) if (len > 0) { val chunk = buffer.copyOfRange(0, len) connection.emit("data", mapOf("data" to chunk)) } } catch (e: Exception) { if (!port.isOpen) break connection.emit( "error", mapOf( "code" to "ERR_USB_READ", "message" to (e.message ?: "Read error"), ) ) connection.disconnect() break } } } } // --------------------------------------------------------------------------- // Mapping helpers — UsbSerialPort constants // --------------------------------------------------------------------------- private fun mapStopBits(stopBits: Int): Int = when (stopBits) { 2 -> UsbSerialPort.STOPBITS_2 else -> UsbSerialPort.STOPBITS_1 } private fun mapParity(parity: String): Int = when (parity.lowercase()) { "odd" -> UsbSerialPort.PARITY_ODD "even" -> UsbSerialPort.PARITY_EVEN "mark" -> UsbSerialPort.PARITY_MARK "space" -> UsbSerialPort.PARITY_SPACE else -> UsbSerialPort.PARITY_NONE }