From c707c21d7fb517506b69c044eb57974562c4c861 Mon Sep 17 00:00:00 2001 From: eddy Date: Sun, 26 Jul 2026 09:02:41 +0200 Subject: [PATCH] Rewrite USB key reader: raw USB API + retry logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced usb-serial-for-android library with raw Android USB API (UsbDeviceConnection.bulkTransfer) — the library's testConnection() sent a get_status CDC request that the ESP32-S3 USB-JTAG/serial controller does not support, causing read failures - Added CDC control interface setup (line coding + DTR/RTS) to enable data flow before sending KEY? command - Added retry logic: 5 attempts, 1s delay between retries, to handle other apps grabbing the USB device first (JTAG/serial debug unit triggers multiple system dialogs before our app gets permission) - Re-lookup ESP device on each retry to handle device path changes - Removed usb-serial-for-android dependency and JitPack repository --- android/app/build.gradle | 1 - .../java/com/eddy/atomrelay/UsbKeyReceiver.kt | 281 +++++++++++++----- android/settings.gradle | 1 - 3 files changed, 205 insertions(+), 78 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 820dac1..4981ef8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -34,5 +34,4 @@ android { dependencies { implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.github.mik3y:usb-serial-for-android:3.7.3' } \ No newline at end of file diff --git a/android/app/src/main/java/com/eddy/atomrelay/UsbKeyReceiver.kt b/android/app/src/main/java/com/eddy/atomrelay/UsbKeyReceiver.kt index 7248043..44bd491 100644 --- a/android/app/src/main/java/com/eddy/atomrelay/UsbKeyReceiver.kt +++ b/android/app/src/main/java/com/eddy/atomrelay/UsbKeyReceiver.kt @@ -4,14 +4,16 @@ import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.hardware.usb.UsbConstants import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager import android.os.Handler import android.os.Looper import android.util.Log import android.widget.Toast -import com.hoho.android.usbserial.driver.CdcAcmSerialDriver -import com.hoho.android.usbserial.driver.UsbSerialPort import java.nio.charset.StandardCharsets class UsbKeyReceiver : BroadcastReceiver() { @@ -20,7 +22,10 @@ class UsbKeyReceiver : BroadcastReceiver() { private const val TAG = "UsbKeyReceiver" private const val ACTION_USB_PERMISSION = "com.eddy.atomrelay.USB_PERMISSION" private const val ESP_VENDOR_ID = 0x303a - private const val KEY_TIMEOUT_MS = 3000 + private const val KEY_TIMEOUT_MS = 5000 + private const val MAX_RETRIES = 5 + private const val RETRY_DELAY_MS = 1000L + private const val CDC_DATA_INTERFACE_CLASS = 0x0A } override fun onReceive(context: Context, intent: Intent) { @@ -31,109 +36,233 @@ class UsbKeyReceiver : BroadcastReceiver() { if (device.vendorId != ESP_VENDOR_ID) return val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager - if (usbManager.hasPermission(device)) { - Thread { readKey(context, usbManager, device) }.start() + val currentDevice = findEspDevice(usbManager) ?: device + if (usbManager.hasPermission(currentDevice)) { + Thread { readKeyRaw(context, usbManager, currentDevice) }.start() } else { - val permIntent = PendingIntent.getBroadcast( - context, 0, - Intent(ACTION_USB_PERMISSION) - .setClass(context, UsbKeyReceiver::class.java) - .putExtra(UsbManager.EXTRA_DEVICE, device), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - usbManager.requestPermission(device, permIntent) - Log.d(TAG, "Requesting USB permission for ${device.deviceName}") + requestPermission(context, usbManager, currentDevice) } } ACTION_USB_PERMISSION -> { - val device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) ?: return - if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { - Log.d(TAG, "USB permission granted for ${device.deviceName}") - val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager - Thread { readKey(context, usbManager, device) }.start() - } else { + if (!intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { Log.w(TAG, "USB permission denied") Handler(Looper.getMainLooper()).post { Toast.makeText(context, "USB-toegang geweigerd", Toast.LENGTH_SHORT).show() } + return + } + Log.d(TAG, "USB permission granted") + val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val device = findEspDevice(usbManager) + if (device != null) { + Thread { readKeyRaw(context, usbManager, device) }.start() + } else { + Log.e(TAG, "ESP device not found after permission grant") + Handler(Looper.getMainLooper()).post { + Toast.makeText(context, "ESP niet (meer) gevonden", Toast.LENGTH_SHORT).show() + } } } } } - private fun readKey(context: Context, usbManager: UsbManager, device: UsbDevice) { + private fun findEspDevice(usbManager: UsbManager): UsbDevice? { + return usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID } + } + + private fun requestPermission(context: Context, usbManager: UsbManager, device: UsbDevice) { + val flags = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val permIntent = PendingIntent.getBroadcast( + context, 0, + Intent(context, UsbKeyReceiver::class.java) + .setAction(ACTION_USB_PERMISSION), + flags + ) + usbManager.requestPermission(device, permIntent) + Log.d(TAG, "Requesting USB permission for ${device.deviceName}") + } + + private fun readKeyRaw(context: Context, usbManager: UsbManager, device: UsbDevice) { val handler = Handler(Looper.getMainLooper()) - var port: UsbSerialPort? = null - try { - val driver = CdcAcmSerialDriver(device) - val connection = usbManager.openDevice(device) - if (connection == null) { - Log.e(TAG, "Failed to open USB device") - handler.post { Toast.makeText(context, "Kan USB-apparaat niet openen", Toast.LENGTH_SHORT).show() } - return - } + for (attempt in 1..MAX_RETRIES) { + Log.d(TAG, "Read attempt $attempt/$MAX_RETRIES") + var connection: UsbDeviceConnection? = null - val ports = driver.ports - if (ports.isEmpty()) { - Log.e(TAG, "No serial ports found") - handler.post { Toast.makeText(context, "Geen serial-poort gevonden", Toast.LENGTH_SHORT).show() } - return - } + try { + val currentDevice = findEspDevice(usbManager) ?: device + connection = usbManager.openDevice(currentDevice) + if (connection == null) { + Log.w(TAG, "Attempt $attempt: openDevice returned null") + if (attempt < MAX_RETRIES) { + Thread.sleep(RETRY_DELAY_MS) + continue + } + handler.post { Toast.makeText(context, "Kan USB-apparaat niet openen", Toast.LENGTH_SHORT).show() } + return + } - port = ports[0] - port.open(connection) - port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) - Log.d(TAG, "Serial port opened, sending KEY?") + val dataInterface = findCdcDataInterface(currentDevice) + if (dataInterface == null) { + Log.w(TAG, "Attempt $attempt: no CDC data interface") + connection.close() + if (attempt < MAX_RETRIES) { + Thread.sleep(RETRY_DELAY_MS) + continue + } + handler.post { Toast.makeText(context, "Geen CDC interface gevonden op ESP", Toast.LENGTH_SHORT).show() } + return + } - port.purgeHwBuffers(true, true) + if (!connection.claimInterface(dataInterface, true)) { + Log.w(TAG, "Attempt $attempt: failed to claim interface") + connection.close() + if (attempt < MAX_RETRIES) { + Thread.sleep(RETRY_DELAY_MS) + continue + } + handler.post { Toast.makeText(context, "Kan USB interface niet claimen", Toast.LENGTH_SHORT).show() } + return + } - val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII) - port.write(cmd, 500) + val epOut = findBulkEndpoint(dataInterface, UsbConstants.USB_DIR_OUT) + val epIn = findBulkEndpoint(dataInterface, UsbConstants.USB_DIR_IN) + if (epOut == null || epIn == null) { + Log.w(TAG, "Attempt $attempt: missing endpoints") + connection.close() + if (attempt < MAX_RETRIES) { + Thread.sleep(RETRY_DELAY_MS) + continue + } + handler.post { Toast.makeText(context, "USB endpoints niet gevonden", Toast.LENGTH_SHORT).show() } + return + } - Thread.sleep(200) + Log.d(TAG, "CDC interface claimed, endpoints ready (out max=${epOut.maxPacketSize}, in max=${epIn.maxPacketSize})") - val buf = ByteArray(256) - val startTime = System.currentTimeMillis() - val sb = StringBuilder() + val controlIntf = findCdcControlInterface(currentDevice) + if (controlIntf != null) { + connection.claimInterface(controlIntf, true) + connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or 0x01, + 0x20, + 0, + controlIntf.id, + byteArrayOf(0x00, 0xC2.toByte(), 0x01, 0x00, 0x00, 0x00, 0x08), + 7, + 500 + ) + connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_CLASS or 0x01, + 0x22, + 3, + controlIntf.id, + null, + 0, + 500 + ) + Log.d(TAG, "CDC control interface configured (line coding + DTR/RTS)") + } else { + Log.w(TAG, "No CDC control interface found, trying without") + } - while (System.currentTimeMillis() - startTime < KEY_TIMEOUT_MS) { - val len = port.read(buf, 200) - if (len > 0) { - sb.append(String(buf, 0, len, StandardCharsets.US_ASCII)) - if (sb.contains("HMAC_KEY:") && sb.contains("\n")) { - break + Thread.sleep(300) + + val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII) + val written = connection.bulkTransfer(epOut, cmd, cmd.size, 1000) + Log.d(TAG, "Sent KEY? (${written} bytes)") + + Thread.sleep(300) + + val buf = ByteArray(512) + val sb = StringBuilder() + val startTime = System.currentTimeMillis() + + while (System.currentTimeMillis() - startTime < KEY_TIMEOUT_MS) { + val len = connection.bulkTransfer(epIn, buf, buf.size, 500) + if (len > 0) { + sb.append(String(buf, 0, len, StandardCharsets.US_ASCII)) + if (sb.contains("HMAC_KEY:") && sb.contains("\n")) { + break + } } } - } - val response = sb.toString() - Log.d(TAG, "Serial response: $response") + val response = sb.toString() + Log.d(TAG, "Serial response: ${response.take(200)}") - val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") } - if (keyLine != null) { - val hexKey = keyLine.removePrefix("HMAC_KEY:").trim() - if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) { - val upperKey = hexKey.uppercase() - val prefs = context.getSharedPreferences(SettingsActivity.PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit().putString(SettingsActivity.PREF_KEY, upperKey).apply() - Log.d(TAG, "HMAC key saved: $upperKey") - handler.post { - Toast.makeText(context, "HMAC sleutel gelezen van ESP!", Toast.LENGTH_LONG).show() + val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") } + if (keyLine != null) { + val hexKey = keyLine.removePrefix("HMAC_KEY:").trim() + if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) { + val upperKey = hexKey.uppercase() + val prefs = context.getSharedPreferences(SettingsActivity.PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putString(SettingsActivity.PREF_KEY, upperKey).apply() + Log.d(TAG, "HMAC key saved: $upperKey") + handler.post { + Toast.makeText(context, "HMAC sleutel gelezen van ESP!", Toast.LENGTH_LONG).show() + } + return + } else { + Log.e(TAG, "Invalid key format: length=${hexKey.length}") + handler.post { Toast.makeText(context, "Ongeldig sleutel formaat", Toast.LENGTH_SHORT).show() } + return } } else { - Log.e(TAG, "Invalid key format: length=${hexKey.length}") - handler.post { Toast.makeText(context, "Ongeldig sleutel formaat van ESP", Toast.LENGTH_SHORT).show() } + Log.w(TAG, "Attempt $attempt: no HMAC_KEY in response, will retry") + connection.close() + connection = null + if (attempt < MAX_RETRIES) { + Thread.sleep(RETRY_DELAY_MS) + } } - } else { - Log.e(TAG, "No HMAC_KEY line found in response") - handler.post { Toast.makeText(context, "Geen sleutel gevonden op de ESP", Toast.LENGTH_SHORT).show() } + } catch (e: Exception) { + Log.e(TAG, "Attempt $attempt error: ${e.message}", e) + if (attempt < MAX_RETRIES) { + Thread.sleep(RETRY_DELAY_MS) + } else { + handler.post { Toast.makeText(context, "Fout bij uitlezen: ${e.message}", Toast.LENGTH_SHORT).show() } + } + } finally { + try { connection?.close() } catch (e: Exception) {} } - } catch (e: Exception) { - Log.e(TAG, "Error reading key: ${e.message}", e) - handler.post { Toast.makeText(context, "Fout bij uitlezen: ${e.message}", Toast.LENGTH_SHORT).show() } - } finally { - try { port?.close() } catch (e: Exception) {} } + + Log.e(TAG, "All $MAX_RETRIES attempts failed") + handler.post { Toast.makeText(context, "Sleutel niet gevonden na $MAX_RETRIES pogingen", Toast.LENGTH_SHORT).show() } + } + + private fun findCdcDataInterface(device: UsbDevice): UsbInterface? { + for (i in 0 until device.interfaceCount) { + val intf = device.getInterface(i) + if (intf.interfaceClass == CDC_DATA_INTERFACE_CLASS) { + return intf + } + } + return null + } + + private fun findCdcControlInterface(device: UsbDevice): UsbInterface? { + for (i in 0 until device.interfaceCount) { + val intf = device.getInterface(i) + if (intf.interfaceClass == 0x02) { + return intf + } + } + return null + } + + private fun findBulkEndpoint(intf: UsbInterface, direction: Int): UsbEndpoint? { + for (i in 0 until intf.endpointCount) { + val ep = intf.getEndpoint(i) + if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK && ep.direction == direction) { + return ep + } + } + return null } } \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle index 03c4071..c37bb27 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -10,7 +10,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url 'https://jitpack.io' } } } rootProject.name = "AtomRelay"