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 { dependencies {
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1' 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.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice 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.hardware.usb.UsbManager
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import com.hoho.android.usbserial.driver.CdcAcmSerialDriver
import com.hoho.android.usbserial.driver.UsbSerialPort
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
class UsbKeyReceiver : BroadcastReceiver() { class UsbKeyReceiver : BroadcastReceiver() {
@ -20,7 +22,10 @@ class UsbKeyReceiver : BroadcastReceiver() {
private const val TAG = "UsbKeyReceiver" private const val TAG = "UsbKeyReceiver"
private const val ACTION_USB_PERMISSION = "com.eddy.atomrelay.USB_PERMISSION" private const val ACTION_USB_PERMISSION = "com.eddy.atomrelay.USB_PERMISSION"
private const val ESP_VENDOR_ID = 0x303a 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) { override fun onReceive(context: Context, intent: Intent) {
@ -31,74 +36,154 @@ class UsbKeyReceiver : BroadcastReceiver() {
if (device.vendorId != ESP_VENDOR_ID) return if (device.vendorId != ESP_VENDOR_ID) return
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
if (usbManager.hasPermission(device)) { val currentDevice = findEspDevice(usbManager) ?: device
Thread { readKey(context, usbManager, device) }.start() if (usbManager.hasPermission(currentDevice)) {
Thread { readKeyRaw(context, usbManager, currentDevice) }.start()
} else { } else {
val permIntent = PendingIntent.getBroadcast( requestPermission(context, usbManager, currentDevice)
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}")
} }
} }
ACTION_USB_PERMISSION -> { ACTION_USB_PERMISSION -> {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) ?: return if (!intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
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 {
Log.w(TAG, "USB permission denied") Log.w(TAG, "USB permission denied")
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
Toast.makeText(context, "USB-toegang geweigerd", Toast.LENGTH_SHORT).show() 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()) 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 { try {
val driver = CdcAcmSerialDriver(device) val currentDevice = findEspDevice(usbManager) ?: device
val connection = usbManager.openDevice(device) connection = usbManager.openDevice(currentDevice)
if (connection == null) { 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() } handler.post { Toast.makeText(context, "Kan USB-apparaat niet openen", Toast.LENGTH_SHORT).show() }
return return
} }
val ports = driver.ports val dataInterface = findCdcDataInterface(currentDevice)
if (ports.isEmpty()) { if (dataInterface == null) {
Log.e(TAG, "No serial ports found") Log.w(TAG, "Attempt $attempt: no CDC data interface")
handler.post { Toast.makeText(context, "Geen serial-poort gevonden", Toast.LENGTH_SHORT).show() } 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 return
} }
port = ports[0] if (!connection.claimInterface(dataInterface, true)) {
port.open(connection) Log.w(TAG, "Attempt $attempt: failed to claim interface")
port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) connection.close()
Log.d(TAG, "Serial port opened, sending KEY?") 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) 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 buf = ByteArray(512)
val startTime = System.currentTimeMillis()
val sb = StringBuilder() val sb = StringBuilder()
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < KEY_TIMEOUT_MS) { 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) { if (len > 0) {
sb.append(String(buf, 0, len, StandardCharsets.US_ASCII)) sb.append(String(buf, 0, len, StandardCharsets.US_ASCII))
if (sb.contains("HMAC_KEY:") && sb.contains("\n")) { if (sb.contains("HMAC_KEY:") && sb.contains("\n")) {
@ -108,7 +193,7 @@ class UsbKeyReceiver : BroadcastReceiver() {
} }
val response = sb.toString() 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:") } val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") }
if (keyLine != null) { if (keyLine != null) {
@ -121,19 +206,63 @@ class UsbKeyReceiver : BroadcastReceiver() {
handler.post { handler.post {
Toast.makeText(context, "HMAC sleutel gelezen van ESP!", Toast.LENGTH_LONG).show() Toast.makeText(context, "HMAC sleutel gelezen van ESP!", Toast.LENGTH_LONG).show()
} }
return
} else { } else {
Log.e(TAG, "Invalid key format: length=${hexKey.length}") 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 { } else {
Log.e(TAG, "No HMAC_KEY line found in response") Log.w(TAG, "Attempt $attempt: no HMAC_KEY in response, will retry")
handler.post { Toast.makeText(context, "Geen sleutel gevonden op de ESP", Toast.LENGTH_SHORT).show() } connection.close()
connection = null
if (attempt < MAX_RETRIES) {
Thread.sleep(RETRY_DELAY_MS)
}
} }
} catch (e: Exception) { } 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() } handler.post { Toast.makeText(context, "Fout bij uitlezen: ${e.message}", Toast.LENGTH_SHORT).show() }
} finally {
try { port?.close() } catch (e: Exception) {}
} }
} finally {
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 { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url 'https://jitpack.io' }
} }
} }
rootProject.name = "AtomRelay" rootProject.name = "AtomRelay"