Fix SettingsActivity: use UsbUtils, add lifecycle guard

This commit is contained in:
eddy 2026-07-26 12:52:01 +02:00
commit 475d4d9162

View file

@ -1,15 +1,26 @@
package com.eddy.atomrelay
import android.app.PendingIntent
import android.app.ProgressDialog
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
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.Bundle
import android.text.InputType
import android.text.TextWatcher
import android.text.Editable
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.Button
import android.widget.EditText
import android.widget.ImageButton
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import java.nio.charset.StandardCharsets
import java.security.SecureRandom
class SettingsActivity : AppCompatActivity() {
@ -17,9 +28,65 @@ class SettingsActivity : AppCompatActivity() {
const val PREFS_NAME = "relay_prefs"
const val PREF_KEY = "hmac_key"
const val RESULT_SCAN = 100
private const val TAG = "SettingsActivity"
private const val ESP_VENDOR_ID = 0x303a
private const val CDC_DATA_INTERFACE_CLASS = 0x0A
private const val KEY_TIMEOUT_MS = 5000L
private const val SET_KEY_TIMEOUT_MS = 5000L
private const val ACTION_USB_PERMISSION = "com.eddy.atomrelay.USB_PERMISSION"
}
private var isVisible = false
private enum class UsbAction { NONE, EXCHANGE_KEY, FLASH }
private var currentAction = UsbAction.NONE
private var dialog: ProgressDialog? = null
private val handler = Handler(Looper.getMainLooper())
private val usbAttachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED && currentAction != UsbAction.NONE) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device?.vendorId == ESP_VENDOR_ID) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
if (usbManager.hasPermission(device)) {
onDeviceReady(usbManager, device)
} else {
requestUsbPermission(usbManager, device)
}
}
}
}
}
private val usbPermissionReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_USB_PERMISSION && currentAction != UsbAction.NONE) {
val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (granted) {
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null) {
onDeviceReady(usbManager, device)
}
} else {
Toast.makeText(this@SettingsActivity, "USB-toegang geweigerd", Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}
private val usbDetachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE)
if (device?.vendorId == ESP_VENDOR_ID && dialog != null && currentAction == UsbAction.NONE) {
dialog?.dismiss()
dialog = null
}
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -28,71 +95,337 @@ class SettingsActivity : AppCompatActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = "Instellingen"
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val keyInput = findViewById<EditText>(R.id.keyInput)
val toggleBtn = findViewById<ImageButton>(R.id.toggleVisibility)
val saveButton = findViewById<Button>(R.id.saveButton)
val scanConnectButton = findViewById<Button>(R.id.scanConnectButton)
val keyError = findViewById<TextView>(R.id.keyError)
keyInput.setText(prefs.getString(PREF_KEY, ""))
toggleBtn.setOnClickListener {
isVisible = !isVisible
if (isVisible) {
keyInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
toggleBtn.setImageResource(R.drawable.ic_eye_open)
} else {
keyInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
toggleBtn.setImageResource(R.drawable.ic_eye_closed)
}
keyInput.setSelection(keyInput.text.length)
findViewById<Button>(R.id.exchangeKeyButton).setOnClickListener {
startKeyExchange()
}
keyInput.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
val cleaned = s?.toString()?.uppercase()?.filter { it in "0123456789ABCDEF" }
if (cleaned != null && cleaned != s.toString()) {
keyInput.removeTextChangedListener(this)
keyInput.setText(cleaned)
keyInput.setSelection(cleaned.length)
keyInput.addTextChangedListener(this)
}
val len = cleaned?.length ?: 0
if (len == 0 || len == 64) {
keyError.visibility = TextView.GONE
} else {
keyError.text = "Sleutel moet 64 hex tekens zijn (nu $len)"
keyError.visibility = TextView.VISIBLE
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
saveButton.setOnClickListener {
val key = keyInput.text.toString().uppercase().filter { it in "0123456789ABCDEF" }
if (key.length != 64) {
keyError.text = "Sleutel moet 64 hex tekens zijn (nu ${key.length})"
keyError.visibility = TextView.VISIBLE
return@setOnClickListener
}
prefs.edit().putString(PREF_KEY, key).apply()
finish()
findViewById<Button>(R.id.flashFirmwareButton).setOnClickListener {
startFlashing()
}
scanConnectButton.setOnClickListener {
val key = keyInput.text.toString().uppercase().filter { it in "0123456789ABCDEF" }
if (key.length == 64) {
prefs.edit().putString(PREF_KEY, key).apply()
}
setResult(RESULT_SCAN)
finish()
}
registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_NOT_EXPORTED)
registerReceiver(usbPermissionReceiver, IntentFilter(ACTION_USB_PERMISSION), Context.RECEIVER_NOT_EXPORTED)
registerReceiver(usbDetachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED), Context.RECEIVER_NOT_EXPORTED)
}
override fun onDestroy() {
super.onDestroy()
currentAction = UsbAction.NONE
try { unregisterReceiver(usbAttachedReceiver) } catch (e: Exception) {}
try { unregisterReceiver(usbPermissionReceiver) } catch (e: Exception) {}
try { unregisterReceiver(usbDetachedReceiver) } catch (e: Exception) {}
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
// === Key Exchange ===
private fun startKeyExchange() {
if (currentAction != UsbAction.NONE) return
currentAction = UsbAction.EXCHANGE_KEY
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null && usbManager.hasPermission(device)) {
doKeyExchange(usbManager, device)
} else {
showWaitingDialog("Sleutel Uitwisselen", "Sluit de ESP aan via USB.")
if (device != null) {
requestUsbPermission(usbManager, device)
}
}
}
private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog("Sleutel Uitwisselen", "Sleutel uitlezen...", ProgressDialog.STYLE_SPINNER)
Thread {
val result = readKeyFromEsp(usbManager, device)
if (isFinishing || isDestroyed) return@Thread
handler.post {
when (result) {
is KeyResult.Success -> {
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(PREF_KEY, result.hexKey).apply()
Toast.makeText(this, "Beveiligingssleutel gelezen", Toast.LENGTH_LONG).show()
cleanupAction()
setResult(RESULT_SCAN)
finish()
}
is KeyResult.Error -> {
Toast.makeText(this, result.message, Toast.LENGTH_LONG).show()
cleanupAction()
}
null -> {
Toast.makeText(this, "Sleutel niet gevonden", Toast.LENGTH_SHORT).show()
cleanupAction()
}
}
}
}.start()
}
// === Firmware Flashing ===
private fun startFlashing() {
if (currentAction != UsbAction.NONE) return
currentAction = UsbAction.FLASH
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
if (device != null && usbManager.hasPermission(device)) {
doFlashing(usbManager, device)
} else {
showWaitingDialog(
"Firmware Flashen",
"Zet de ESP in download-modus (houd reset 2s in tot groene LED) en sluit aan via USB."
)
if (device != null) {
requestUsbPermission(usbManager, device)
}
}
}
private fun doFlashing(usbManager: UsbManager, device: UsbDevice) {
showProgressDialog("Firmware Flashen", "Initialiseren...", ProgressDialog.STYLE_HORIZONTAL)
val flasher = EspFlasher(this)
flasher.flashDevice(usbManager, device, object : EspFlasher.FlashCallback {
override fun onProgress(percent: Int, message: String) {
runOnUiThread {
dialog?.progress = percent
dialog?.setMessage(message)
}
}
override fun onComplete() {
runOnUiThread {
dialog?.setMessage("Firmware geflashed!\n\nOntkoppel de ESP en sluit opnieuw aan om de sleutel in te stellen.")
dialog?.setCancelable(true)
dialog?.setButton(ProgressDialog.BUTTON_NEUTRAL, "Sluiten") { _, _ ->
dialog?.dismiss()
dialog = null
cleanupAction()
}
}
}
override fun onError(message: String) {
runOnUiThread {
dialog?.dismiss()
dialog = null
Toast.makeText(this@SettingsActivity, "Flashen mislukt: $message", Toast.LENGTH_LONG).show()
cleanupAction()
}
}
})
}
// === USB dispatch ===
private fun onDeviceReady(usbManager: UsbManager, device: UsbDevice) {
when (currentAction) {
UsbAction.EXCHANGE_KEY -> doKeyExchange(usbManager, device)
UsbAction.FLASH -> doFlashing(usbManager, device)
UsbAction.NONE -> {}
}
}
// === Dialog helpers ===
private fun showWaitingDialog(title: String, message: String) {
dialog?.dismiss()
dialog = ProgressDialog(this).apply {
setTitle(title)
setMessage(message)
setProgressStyle(ProgressDialog.STYLE_SPINNER)
setCancelable(true)
setButton(ProgressDialog.BUTTON_NEGATIVE, "Annuleren") { _, _ ->
cleanupAction()
}
show()
}
}
private fun showProgressDialog(title: String, message: String, style: Int) {
dialog?.dismiss()
dialog = ProgressDialog(this).apply {
setTitle(title)
setMessage(message)
setProgressStyle(style)
if (style == ProgressDialog.STYLE_HORIZONTAL) {
max = 100
progress = 0
}
setCancelable(false)
show()
}
}
private fun cleanupAction() {
currentAction = UsbAction.NONE
dialog?.dismiss()
dialog = null
}
private fun requestUsbPermission(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(
this, 0,
Intent(ACTION_USB_PERMISSION).setPackage(packageName),
flags
)
usbManager.requestPermission(device, permIntent)
}
// === ESP Key Exchange via USB ===
sealed class KeyResult {
data class Success(val hexKey: String) : KeyResult()
data class Error(val message: String) : KeyResult()
}
private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice): KeyResult? {
val intf = UsbUtils.findCdcDataInterface(device)
?: return KeyResult.Error("Geen CDC interface gevonden")
val connection = usbManager.openDevice(device)
?: return KeyResult.Error("Kan USB apparaat niet openen")
if (!connection.claimInterface(intf, true)) {
connection.close()
return KeyResult.Error("Kan USB interface niet claimen")
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: run { connection.close(); return KeyResult.Error("Geen USB OUT endpoint gevonden") }
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: run { connection.close(); return KeyResult.Error("Geen USB IN endpoint gevonden") }
try {
setupCdcControl(connection, device)
for (attempt in 1..3) {
Thread.sleep(500)
flushReadBuffer(connection, epIn)
val cmd = "KEY?\n".toByteArray(StandardCharsets.US_ASCII)
val written = connection.bulkTransfer(epOut, cmd, cmd.size, 1000)
Log.d(TAG, "Attempt $attempt: Sent KEY? ($written bytes)")
if (written < 0) {
Log.w(TAG, "Write failed on attempt $attempt")
continue
}
Thread.sleep(500)
val response = readResponse(connection, epIn, KEY_TIMEOUT_MS)
Log.d(TAG, "Attempt $attempt response: ${response.take(300)}")
val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") }
val noKey = response.lines().any { it.trim() == "NO_KEY" }
if (keyLine != null) {
val hexKey = keyLine.removePrefix("HMAC_KEY:").trim()
if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) {
return KeyResult.Success(hexKey.uppercase())
} else {
return KeyResult.Error("Ongeldig sleutel formaat (lengte=${hexKey.length})")
}
} else if (noKey) {
Log.d(TAG, "ESP has no key, provisioning new one")
val newKey = generateRandomHexKey()
val setKeyCmd = "SET_KEY:$newKey\n".toByteArray(StandardCharsets.US_ASCII)
val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, 1000)
Log.d(TAG, "Sent SET_KEY ($w bytes)")
if (w < 0) {
return KeyResult.Error("SET_KEY schrijven mislukt")
}
Thread.sleep(500)
val okResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS)
Log.d(TAG, "SET_KEY response: ${okResponse.take(200)}")
if (okResponse.contains("OK")) {
return KeyResult.Success(newKey)
} else {
return KeyResult.Error("ESP bevestigde sleutel niet")
}
}
Log.w(TAG, "Attempt $attempt: no KEY response, retrying")
}
return KeyResult.Error("Geen sleutel gevonden na 3 pogingen")
} catch (e: Exception) {
Log.e(TAG, "Key exchange error", e)
return KeyResult.Error("Fout: ${e.message}")
} finally {
try { connection.close() } catch (e: Exception) {}
}
}
private fun flushReadBuffer(connection: UsbDeviceConnection, epIn: UsbEndpoint) {
val buf = ByteArray(512)
while (true) {
val len = connection.bulkTransfer(epIn, buf, buf.size, 100)
if (len <= 0) break
Log.d(TAG, "Flushed $len bytes from read buffer")
}
}
private fun readResponse(connection: UsbDeviceConnection, epIn: UsbEndpoint, timeoutMs: Long): String {
val buf = ByteArray(512)
val sb = StringBuilder()
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < timeoutMs) {
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("NO_KEY") || sb.contains("OK")) && sb.contains("\n")) {
break
}
}
}
return sb.toString()
}
private fun setupCdcControl(connection: UsbDeviceConnection, device: UsbDevice) {
val ctrlIntf = (0 until device.interfaceCount)
.map { device.getInterface(it) }
.firstOrNull { it.interfaceClass == 0x02 }
if (ctrlIntf != null && ctrlIntf.endpointCount > 0) {
val lineCoding = byteArrayOf(
0x00.toByte(), 0xC2.toByte(), 0x01.toByte(), 0x00.toByte(),
0x00.toByte(), 0x08.toByte(), 0x08.toByte()
)
connection.controlTransfer(0x21, 0x20, 0, ctrlIntf.id, lineCoding, lineCoding.size, 500)
connection.controlTransfer(0x21, 0x22, 3, ctrlIntf.id, null, 0, 500)
Log.d(TAG, "CDC control interface configured")
} else {
Log.w(TAG, "No CDC control interface found, trying without")
}
}
private fun generateRandomHexKey(): String {
val random = SecureRandom()
val bytes = ByteArray(32)
random.nextBytes(bytes)
return bytes.joinToString("") { "%02X".format(it) }
}
}