Fix SettingsActivity: use UsbUtils, add lifecycle guard
This commit is contained in:
parent
2a86fa5d39
commit
475d4d9162
1 changed files with 392 additions and 59 deletions
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue