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,109 +36,233 @@ 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
try { for (attempt in 1..MAX_RETRIES) {
val driver = CdcAcmSerialDriver(device) Log.d(TAG, "Read attempt $attempt/$MAX_RETRIES")
val connection = usbManager.openDevice(device) var connection: UsbDeviceConnection? = null
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
}
val ports = driver.ports try {
if (ports.isEmpty()) { val currentDevice = findEspDevice(usbManager) ?: device
Log.e(TAG, "No serial ports found") connection = usbManager.openDevice(currentDevice)
handler.post { Toast.makeText(context, "Geen serial-poort gevonden", Toast.LENGTH_SHORT).show() } if (connection == null) {
return 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] val dataInterface = findCdcDataInterface(currentDevice)
port.open(connection) if (dataInterface == null) {
port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) Log.w(TAG, "Attempt $attempt: no CDC data interface")
Log.d(TAG, "Serial port opened, sending KEY?") 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) val epOut = findBulkEndpoint(dataInterface, UsbConstants.USB_DIR_OUT)
port.write(cmd, 500) 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 controlIntf = findCdcControlInterface(currentDevice)
val startTime = System.currentTimeMillis() if (controlIntf != null) {
val sb = StringBuilder() 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) { Thread.sleep(300)
val len = port.read(buf, 200)
if (len > 0) { val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII)
sb.append(String(buf, 0, len, StandardCharsets.US_ASCII)) val written = connection.bulkTransfer(epOut, cmd, cmd.size, 1000)
if (sb.contains("HMAC_KEY:") && sb.contains("\n")) { Log.d(TAG, "Sent KEY? (${written} bytes)")
break
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() 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) {
val hexKey = keyLine.removePrefix("HMAC_KEY:").trim() val hexKey = keyLine.removePrefix("HMAC_KEY:").trim()
if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) { if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) {
val upperKey = hexKey.uppercase() val upperKey = hexKey.uppercase()
val prefs = context.getSharedPreferences(SettingsActivity.PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SettingsActivity.PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(SettingsActivity.PREF_KEY, upperKey).apply() prefs.edit().putString(SettingsActivity.PREF_KEY, upperKey).apply()
Log.d(TAG, "HMAC key saved: $upperKey") Log.d(TAG, "HMAC key saved: $upperKey")
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 {
Log.e(TAG, "Invalid key format: length=${hexKey.length}")
handler.post { Toast.makeText(context, "Ongeldig sleutel formaat", Toast.LENGTH_SHORT).show() }
return
} }
} else { } else {
Log.e(TAG, "Invalid key format: length=${hexKey.length}") Log.w(TAG, "Attempt $attempt: no HMAC_KEY in response, will retry")
handler.post { Toast.makeText(context, "Ongeldig sleutel formaat van ESP", Toast.LENGTH_SHORT).show() } connection.close()
connection = null
if (attempt < MAX_RETRIES) {
Thread.sleep(RETRY_DELAY_MS)
}
} }
} else { } catch (e: Exception) {
Log.e(TAG, "No HMAC_KEY line found in response") Log.e(TAG, "Attempt $attempt error: ${e.message}", e)
handler.post { Toast.makeText(context, "Geen sleutel gevonden op de ESP", Toast.LENGTH_SHORT).show() } 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
} }
} }

View file

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