Rewrite USB key reader: raw USB API + retry logic

- 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
This commit is contained in:
eddy 2026-07-26 09:02:41 +02:00
commit c707c21d7f
3 changed files with 207 additions and 80 deletions

View file

@ -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'
}

View file

@ -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,74 +36,154 @@ 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<UsbDevice>(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
for (attempt in 1..MAX_RETRIES) {
Log.d(TAG, "Read attempt $attempt/$MAX_RETRIES")
var connection: UsbDeviceConnection? = null
try {
val driver = CdcAcmSerialDriver(device)
val connection = usbManager.openDevice(device)
val currentDevice = findEspDevice(usbManager) ?: device
connection = usbManager.openDevice(currentDevice)
if (connection == null) {
Log.e(TAG, "Failed to open USB device")
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
}
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() }
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 = ports[0]
port.open(connection)
port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
Log.d(TAG, "Serial port opened, sending KEY?")
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
}
port.purgeHwBuffers(true, true)
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
}
Log.d(TAG, "CDC interface claimed, endpoints ready (out max=${epOut.maxPacketSize}, in max=${epIn.maxPacketSize})")
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")
}
Thread.sleep(300)
val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII)
port.write(cmd, 500)
val written = connection.bulkTransfer(epOut, cmd, cmd.size, 1000)
Log.d(TAG, "Sent KEY? (${written} bytes)")
Thread.sleep(200)
Thread.sleep(300)
val buf = ByteArray(256)
val startTime = System.currentTimeMillis()
val buf = ByteArray(512)
val sb = StringBuilder()
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < KEY_TIMEOUT_MS) {
val len = port.read(buf, 200)
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")) {
@ -108,7 +193,7 @@ class UsbKeyReceiver : BroadcastReceiver() {
}
val response = sb.toString()
Log.d(TAG, "Serial response: $response")
Log.d(TAG, "Serial response: ${response.take(200)}")
val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") }
if (keyLine != null) {
@ -121,19 +206,63 @@ class UsbKeyReceiver : BroadcastReceiver() {
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 van ESP", Toast.LENGTH_SHORT).show() }
handler.post { Toast.makeText(context, "Ongeldig sleutel formaat", Toast.LENGTH_SHORT).show() }
return
}
} 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() }
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)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error reading key: ${e.message}", e)
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 { port?.close() } catch (e: Exception) {}
try { connection?.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
}
}

View file

@ -10,7 +10,6 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
rootProject.name = "AtomRelay"