diff --git a/android/app/src/main/assets/firmware/firmware.bin b/android/app/src/main/assets/firmware/firmware.bin index 2e5e5ad..644d9cc 100644 Binary files a/android/app/src/main/assets/firmware/firmware.bin and b/android/app/src/main/assets/firmware/firmware.bin differ diff --git a/android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt b/android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt index a53548a..8d4b111 100644 --- a/android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt +++ b/android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt @@ -78,6 +78,12 @@ class EspFlasher(private val context: Context) { val bootApp0 = assets.open("firmware/boot_app0.bin").readBytes() val firmware = assets.open("firmware/firmware.bin").readBytes() + // Flash sectie layout (partition table check): + // NVS 0x9000-0xDFFF — NIET overschreven (key/UUID blijft behouden) + // otadata 0xE000-0xFFFF — overschreven door boot_app0 + // app0 0x10000-... — firmware overschrijft + // Alleen schrijven naar benodigde adressen, geen full erase + val steps = listOf( FlashStep("Bootloader", 0x0, bootloader, 30, 10, false), FlashStep("Partitie tabel", 0x8000, partitions, 40, 10, false), @@ -105,9 +111,6 @@ class EspFlasher(private val context: Context) { callback.onProgress(20, "Stub laden...") val stub = detected.loadStub() - callback.onProgress(25, "Flash wissen...") - stub.eraseFlash() - for (step in steps) { coroutineContext.ensureActive() val sizeStr = if (step.data.size > 1024) "${step.data.size / 1024}KB" else "${step.data.size} bytes" diff --git a/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt b/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt index edf1543..b6cb3a8 100644 --- a/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt +++ b/android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt @@ -11,13 +11,16 @@ import android.bluetooth.BluetoothGattCharacteristic import android.bluetooth.BluetoothGattDescriptor import android.bluetooth.BluetoothProfile import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.ParcelUuid import android.util.Log import android.view.MotionEvent import android.widget.Button @@ -37,40 +40,50 @@ class MainActivity : AppCompatActivity() { private const val TAG = "AtomRelay" private val SERVICE_UUID: UUID = UUID.fromString("4fafc201-1fb5-459e-8fcc-c5c9c331914b") private val CHARACTERISTIC_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d8") + private val UUID_CHAR_UUID: UUID = UUID.fromString("beb5483e-36e1-4688-b7f5-ea07361f26d9") private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") - private const val DEVICE_NAME = "AtomS3R-Relay" + private const val DEVICE_NAME = "SylDa_Server" private const val HMAC_PKT_SIZE = 37 private const val HMAC_LEN = 32 private const val REQUEST_SETTINGS = 1 - private const val SCAN_TIMEOUT_MS = 10000L + private const val SCAN_TIMEOUT_MS = 30000L private const val RECONNECT_DELAY_MS = 2000L private const val POST_SETTINGS_RESCAN_MS = 500L private const val ON_RESUME_SCAN_DELAY_MS = 300L + private const val MAX_SCAN_RETRIES = 3 } private var bluetoothGatt: BluetoothGatt? = null private var relayCharacteristic: BluetoothGattCharacteristic? = null private var isScanning = false private var scanTimedOut = false + private var scanTimeoutRetries = 0 private val handler = Handler(Looper.getMainLooper()) private val scanRunnable = Runnable { startScan() } + private var scanTimeoutRunnable: Runnable? = null private var seq = 1 private var hmacKey: ByteArray? = null + private var deviceUuid: ByteArray? = null + private var triedAddresses = mutableSetOf() private var lastTheme: String? = null private lateinit var statusText: TextView - private lateinit var relayButton: Button + private lateinit var relay1Button: Button + private lateinit var relay2Button: Button private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device = result.device + val address = device?.address ?: return val name = device?.name ?: "" - Log.d(TAG, "Found device: $name (${device?.address})") - if (name == DEVICE_NAME) { - stopScan() - connectToDevice(device) + Log.d(TAG, "Found device: $name ($address)") + if (triedAddresses.contains(address)) { + Log.d(TAG, "Skipping already-tried device $address") + return } + stopScan() + connectToDevice(device) } } @@ -81,6 +94,12 @@ class MainActivity : AppCompatActivity() { Log.w(TAG, "GATT connection state change error: status=$status newState=$newState") } if (newState == BluetoothProfile.STATE_CONNECTED) { + if (hmacKey == null) { + Log.w(TAG, "GATT connected but no key — disconnecting") + runOnUiThread { statusText.text = getString(R.string.status_no_key) } + gatt.disconnect() + return + } Log.d(TAG, "GATT connected") bluetoothGatt = gatt runOnUiThread { statusText.text = getString(R.string.status_discovering_services) } @@ -89,7 +108,8 @@ class MainActivity : AppCompatActivity() { Log.d(TAG, "GATT disconnected") runOnUiThread { statusText.text = getString(R.string.status_connecting) - relayButton.isEnabled = false + relay1Button.isEnabled = false + relay2Button.isEnabled = false } gatt.close() bluetoothGatt = null @@ -119,16 +139,13 @@ class MainActivity : AppCompatActivity() { val service = gatt.getService(SERVICE_UUID) if (service != null) { relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) - if (relayCharacteristic != null) { - gatt.setCharacteristicNotification(relayCharacteristic, true) - val descriptor = relayCharacteristic!!.getDescriptor(CCCD_UUID) - descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - gatt.writeDescriptor(descriptor) - Log.d(TAG, "Ready, relay characteristic found") - runOnUiThread { - statusText.text = getString(R.string.status_connected) - relayButton.isEnabled = true - } + val uuidChar = service.getCharacteristic(UUID_CHAR_UUID) + if (relayCharacteristic != null && uuidChar != null) { + Log.d(TAG, "Reading device UUID characteristic...") + gatt.readCharacteristic(uuidChar) + } else { + Log.e(TAG, "Service or characteristics not found") + runOnUiThread { statusText.text = getString(R.string.status_service_not_found) } } } else { Log.e(TAG, "Service not found") @@ -136,6 +153,34 @@ class MainActivity : AppCompatActivity() { } } + @SuppressLint("MissingPermission") + override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int) { + if (characteristic.uuid == UUID_CHAR_UUID && status == BluetoothGatt.GATT_SUCCESS) { + val storedUuid = deviceUuid + if (storedUuid != null && value.contentEquals(storedUuid)) { + Log.d(TAG, "UUID verified, setting up relay") + relayCharacteristic?.let { rc -> + gatt.setCharacteristicNotification(rc, true) + val descriptor = rc.getDescriptor(CCCD_UUID) + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + gatt.writeDescriptor(descriptor) + } + runOnUiThread { + setupRelayButton() + setupRelay2Button() + statusText.text = getString(R.string.status_connected) + relay1Button.isEnabled = true + relay2Button.isEnabled = true + } + } else { + Log.w(TAG, "UUID mismatch — disconnecting") + triedAddresses.add(gatt.device.address) + runOnUiThread { statusText.text = getString(R.string.status_wrong_device) } + gatt.disconnect() + } + } + } + @SuppressLint("MissingPermission") override fun onCharacteristicWrite(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) { @@ -154,13 +199,15 @@ class MainActivity : AppCompatActivity() { setContentView(R.layout.activity_main) statusText = findViewById(R.id.statusText) - relayButton = findViewById(R.id.relayButton) + relay1Button = findViewById(R.id.relay1Button) + relay2Button = findViewById(R.id.relay2Button) val settingsButton = findViewById(R.id.settingsButton) settingsButton.setOnClickListener { startActivityForResult(Intent(this, SettingsActivity::class.java), REQUEST_SETTINGS) } setupRelayButton() + setupRelay2Button() } @Deprecated("Deprecated in Java") @@ -172,6 +219,7 @@ class MainActivity : AppCompatActivity() { bluetoothGatt = null relayCharacteristic = null seq = 1 + triedAddresses.clear() loadState() handler.postDelayed(scanRunnable, POST_SETTINGS_RESCAN_MS) } @@ -186,9 +234,10 @@ class MainActivity : AppCompatActivity() { return } loadState() - if (hmacKey == null) { + if (hmacKey == null || deviceUuid == null) { statusText.text = getString(R.string.status_no_key) } else if (!isScanning && bluetoothGatt == null) { + triedAddresses.clear() handler.postDelayed(scanRunnable, ON_RESUME_SCAN_DELAY_MS) } } @@ -203,8 +252,10 @@ class MainActivity : AppCompatActivity() { val prefs = PrefsHelper.getPrefs(this) val hexKey = prefs.getString(PrefsHelper.PREF_KEY, null) hmacKey = hexKey?.let { hexStringToBytes(it) } + val hexUuid = prefs.getString(PrefsHelper.PREF_UUID, null) + deviceUuid = hexUuid?.let { hexStringToBytes(it) } seq = prefs.getInt(PrefsHelper.PREF_SEQ, 1).coerceAtMost(PrefsHelper.SEQ_MAX) - Log.d(TAG, "HMAC key loaded: ${if (hmacKey != null) "${hmacKey!!.size} bytes" else "none"}, seq=$seq") + Log.d(TAG, "HMAC key loaded: ${if (hmacKey != null) "${hmacKey!!.size} bytes" else "none"}, UUID: ${if (deviceUuid != null) "${deviceUuid!!.size} bytes" else "none"}, seq=$seq") } private fun hexStringToBytes(hex: String): ByteArray? { @@ -240,24 +291,45 @@ class MainActivity : AppCompatActivity() { startScan() } else { statusText.text = getString(R.string.status_permissions_denied) - relayButton.isEnabled = false + relay1Button.isEnabled = false + relay2Button.isEnabled = false } } } private fun setupRelayButton() { - relayButton.setOnClickListener(null) - relayButton.text = getString(R.string.button_hold) - relayButton.setOnTouchListener { _, event -> + relay1Button.setOnClickListener(null) + relay1Button.text = getString(R.string.button_hold) + relay1Button.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { - sendRelayCommand(true) - relayButton.text = getString(R.string.button_on) + sendRelayCommand(0x01.toByte()) + relay1Button.text = getString(R.string.button_on) true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - sendRelayCommand(false) - relayButton.text = getString(R.string.button_hold) + sendRelayCommand(0x00.toByte()) + relay1Button.text = getString(R.string.button_hold) + true + } + else -> false + } + } + } + + private fun setupRelay2Button() { + relay2Button.setOnClickListener(null) + relay2Button.text = getString(R.string.button_hold) + relay2Button.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + sendRelayCommand(0x02.toByte()) + relay2Button.text = getString(R.string.button_on) + true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + sendRelayCommand(0x03.toByte()) + relay2Button.text = getString(R.string.button_hold) true } else -> false @@ -279,7 +351,7 @@ class MainActivity : AppCompatActivity() { return } - if (hmacKey == null) { + if (hmacKey == null || deviceUuid == null) { statusText.text = getString(R.string.status_no_key) return } @@ -287,31 +359,49 @@ class MainActivity : AppCompatActivity() { isScanning = true scanTimedOut = false statusText.text = getString(R.string.status_scanning) - Log.d(TAG, "Starting BLE scan for $DEVICE_NAME") + Log.d(TAG, "Starting BLE scan with service UUID filter") - adapter.bluetoothLeScanner.startScan(scanCallback) + val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build() + val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() + adapter.bluetoothLeScanner.startScan(listOf(filter), settings, scanCallback) - handler.postDelayed({ + scanTimeoutRunnable?.let { handler.removeCallbacks(it) } + scanTimeoutRunnable = Runnable { if (isScanning) { scanTimedOut = true stopScan() } - }, SCAN_TIMEOUT_MS) + } + handler.postDelayed(scanTimeoutRunnable!!, SCAN_TIMEOUT_MS) } @SuppressLint("MissingPermission") private fun stopScan() { if (!isScanning) return isScanning = false + scanTimeoutRunnable?.let { handler.removeCallbacks(it) } val adapter = BluetoothAdapter.getDefaultAdapter() adapter?.bluetoothLeScanner?.stopScan(scanCallback) if (scanTimedOut) { + if (scanTimeoutRetries < MAX_SCAN_RETRIES) { + scanTimeoutRetries++ + Log.d(TAG, "Scan timeout, retry $scanTimeoutRetries/$MAX_SCAN_RETRIES") + statusText.text = getString(R.string.status_scanning) + handler.postDelayed(scanRunnable, 2000L) + return + } statusText.text = getString(R.string.status_device_not_found) - relayButton.setOnTouchListener(null) - relayButton.text = getString(R.string.button_retry_scan) - relayButton.isEnabled = true - relayButton.setOnClickListener { + relay1Button.setOnTouchListener(null) + relay2Button.setOnTouchListener(null) + relay1Button.setOnClickListener(null) + relay2Button.setOnClickListener(null) + relay1Button.text = getString(R.string.button_retry_scan) + relay2Button.text = getString(R.string.button_hold) + relay2Button.isEnabled = false + relay1Button.isEnabled = true + relay1Button.setOnClickListener { setupRelayButton() + setupRelay2Button() startScan() } } @@ -325,12 +415,11 @@ class MainActivity : AppCompatActivity() { } @SuppressLint("MissingPermission") - private fun sendRelayCommand(on: Boolean) { + private fun sendRelayCommand(cmd: Byte) { val char = relayCharacteristic ?: return val gatt = bluetoothGatt ?: return val key = hmacKey ?: return - val cmd: Byte = if (on) 0x01 else 0x00 val packet = buildPacket(cmd, key) char.value = packet // WRITE_TYPE_NO_RESPONSE is intentional. Previously, WRITE_TYPE_DEFAULT (with ACK) was @@ -345,7 +434,7 @@ class MainActivity : AppCompatActivity() { seq++ if (seq > PrefsHelper.SEQ_MAX) seq = 1 PrefsHelper.getPrefs(this).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply() - Log.d(TAG, "Sent relay ${if (on) "ON" else "OFF"} (seq=${seq - 1})") + Log.d(TAG, "Sent relay cmd 0x${cmd.toString(16)} (seq=${seq - 1})") } private fun getRequiredPermissions(): Array { diff --git a/android/app/src/main/java/com/eddy/atomrelay/PrefsHelper.kt b/android/app/src/main/java/com/eddy/atomrelay/PrefsHelper.kt index 653eb91..10ffc0a 100644 --- a/android/app/src/main/java/com/eddy/atomrelay/PrefsHelper.kt +++ b/android/app/src/main/java/com/eddy/atomrelay/PrefsHelper.kt @@ -13,6 +13,7 @@ object PrefsHelper { private const val TAG = "PrefsHelper" const val PREFS_NAME = "relay_prefs" const val PREF_KEY = "hmac_key" + const val PREF_UUID = "device_uuid" const val PREF_SEQ = "seq_counter" const val PREF_THEME = "theme" const val SEQ_MAX = 1_000_000 diff --git a/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt b/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt index 7ba86a8..50fc1f9 100644 --- a/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt +++ b/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt @@ -18,12 +18,14 @@ import android.os.Handler import android.os.Looper import android.util.Log import android.widget.Button +import android.widget.CheckBox import android.widget.ProgressBar import android.widget.RadioGroup import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext @@ -220,19 +222,30 @@ class SettingsActivity : AppCompatActivity() { private fun doKeyExchange(usbManager: UsbManager, device: UsbDevice) { showProgressDialog(getString(R.string.title_key_exchange), getString(R.string.msg_reading_key), STYLE_SPINNER) + val prefs = PrefsHelper.getPrefs(this) + val appUuid = prefs.getString(PrefsHelper.PREF_UUID, null) + val appKey = prefs.getString(PrefsHelper.PREF_KEY, null) + lifecycleScope.launch(Dispatchers.IO) { - val result = readKeyFromEsp(usbManager, device) + val result = readKeyFromEsp(usbManager, device, appUuid, appKey) withContext(Dispatchers.Main) { if (isFinishing || isDestroyed) return@withContext + progressDialog?.dismiss() + progressDialog = null when (result) { is KeyResult.Success -> { - val prefs = PrefsHelper.getPrefs(this@SettingsActivity) - prefs.edit().putString(PrefsHelper.PREF_KEY, result.hexKey).apply() + prefs.edit() + .putString(PrefsHelper.PREF_UUID, result.deviceUuid) + .putString(PrefsHelper.PREF_KEY, result.hexKey) + .apply() Toast.makeText(this@SettingsActivity, getString(R.string.key_read_success), Toast.LENGTH_LONG).show() cleanupAction() setResult(RESULT_SCAN) finish() } + is KeyResult.Conflict -> { + showConflictDialog(usbManager, device, result.espUuid, result.espKey, appKey ?: "") + } is KeyResult.Error -> { Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_LONG).show() cleanupAction() @@ -385,11 +398,12 @@ class SettingsActivity : AppCompatActivity() { // === ESP Key Exchange via USB === sealed class KeyResult { - data class Success(val hexKey: String) : KeyResult() + data class Success(val deviceUuid: String, val hexKey: String) : KeyResult() + data class Conflict(val espUuid: String, val espKey: String) : KeyResult() data class Error(val message: String) : KeyResult() } - private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice): KeyResult? { + private fun readKeyFromEsp(usbManager: UsbManager, device: UsbDevice, appUuid: String?, appKey: String?): KeyResult? { val intf = UsbUtils.findCdcDataInterface(device) ?: return KeyResult.Error("Geen CDC interface gevonden") @@ -428,18 +442,36 @@ class SettingsActivity : AppCompatActivity() { Log.d(TAG, "Attempt $attempt response: ${response.take(300)}") val keyLine = response.lines().firstOrNull { it.startsWith("HMAC_KEY:") } + val uuidLine = response.lines().firstOrNull { it.startsWith("DEVICE_ID:") } val noKey = response.lines().any { it.trim() == "NO_KEY" } - if (keyLine != null) { + if (keyLine != null && uuidLine != null) { val hexKey = keyLine.removePrefix("HMAC_KEY:").trim() - if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" }) { - return KeyResult.Success(hexKey.uppercase()) + val hexUuid = uuidLine.removePrefix("DEVICE_ID:").trim() + if (hexKey.length == 64 && hexKey.all { it in "0123456789ABCDEFabcdef" } && + hexUuid.length == 32 && hexUuid.all { it in "0123456789ABCDEFabcdef" }) { + val espKeyUpper = hexKey.uppercase() + val espUuidUpper = hexUuid.uppercase() + if (appUuid != null && appKey != null && + (appUuid != espUuidUpper || appKey != espKeyUpper)) { + return KeyResult.Conflict(espUuidUpper, espKeyUpper) + } + return KeyResult.Success(espUuidUpper, espKeyUpper) } else { - return KeyResult.Error("Ongeldig sleutel formaat (lengte=${hexKey.length})") + return KeyResult.Error("Ongeldig sleutel/UUID formaat") } } else if (noKey) { - Log.d(TAG, "ESP has no key, provisioning new one") - val newKey = generateRandomHexKey() + val newKey: String + val newUuid: String + if (appUuid != null && appKey != null) { + Log.d(TAG, "ESP has no keys, using existing app keys") + newKey = appKey + newUuid = appUuid + } else { + Log.d(TAG, "Both have no keys, generating new pair") + newKey = generateRandomHexKey() + newUuid = generateRandomHex(16) + } val setKeyCmd = "SET_KEY:$newKey\n".toByteArray(StandardCharsets.US_ASCII) val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS) @@ -449,15 +481,29 @@ class SettingsActivity : AppCompatActivity() { } Thread.sleep(RESPONSE_WAIT_MS) + val okKeyResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS) + Log.d(TAG, "SET_KEY response: ${okKeyResponse.take(200)}") - 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 { + if (!okKeyResponse.contains("OK")) { return KeyResult.Error("ESP bevestigde sleutel niet") } + + val setIdCmd = "SET_ID:$newUuid\n".toByteArray(StandardCharsets.US_ASCII) + val w2 = connection.bulkTransfer(epOut, setIdCmd, setIdCmd.size, USB_BULK_TIMEOUT_MS) + Log.d(TAG, "Sent SET_ID ($w2 bytes)") + if (w2 < 0) { + return KeyResult.Error("SET_ID schrijven mislukt") + } + + Thread.sleep(RESPONSE_WAIT_MS) + val okUuidResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS) + Log.d(TAG, "SET_ID response: ${okUuidResponse.take(200)}") + + if (okUuidResponse.contains("OK")) { + return KeyResult.Success(newUuid, newKey) + } else { + return KeyResult.Error("ESP bevestigde UUID niet") + } } Log.w(TAG, "Attempt $attempt: no KEY response, retrying") @@ -490,7 +536,7 @@ class SettingsActivity : AppCompatActivity() { val len = connection.bulkTransfer(epIn, buf, buf.size, READ_CHUNK_TIMEOUT_MS) 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")) { + if ((sb.contains("HMAC_KEY:") || sb.contains("NO_KEY") || sb.contains("OK") || sb.contains("ERROR")) && sb.contains("\n")) { break } } @@ -539,4 +585,145 @@ class SettingsActivity : AppCompatActivity() { random.nextBytes(bytes) return bytes.joinToString("") { "%02X".format(it) } } + + private fun generateRandomHex(byteCount: Int): String { + val random = SecureRandom() + val bytes = ByteArray(byteCount) + random.nextBytes(bytes) + return bytes.joinToString("") { "%02X".format(it) } + } + + private fun showConflictDialog(usbManager: UsbManager, device: UsbDevice, + espUuid: String, espKey: String, appKey: String) { + val prefs = PrefsHelper.getPrefs(this) + val appUuid = prefs.getString(PrefsHelper.PREF_UUID, "") ?: "" + + AlertDialog.Builder(this) + .setTitle(getString(R.string.title_key_conflict)) + .setMessage(getString(R.string.msg_key_conflict)) + .setPositiveButton(getString(R.string.button_keep_esp_keys)) { _, _ -> + prefs.edit() + .putString(PrefsHelper.PREF_UUID, espUuid) + .putString(PrefsHelper.PREF_KEY, espKey) + .apply() + Toast.makeText(this, getString(R.string.key_read_success), Toast.LENGTH_LONG).show() + cleanupAction() + setResult(RESULT_SCAN) + finish() + } + .setNegativeButton(getString(R.string.button_overwrite_esp)) { _, _ -> + showOverwriteConfirmDialog(usbManager, device, espUuid, espKey, appKey, appUuid) + } + .setNeutralButton(getString(R.string.button_cancel)) { _, _ -> + cleanupAction() + } + .setCancelable(false) + .show() + } + + private fun showOverwriteConfirmDialog(usbManager: UsbManager, device: UsbDevice, + espUuid: String, espKey: String, + appKey: String, appUuid: String) { + val view = layoutInflater.inflate(R.layout.dialog_overwrite_confirm, null) + val checkBox = view.findViewById(R.id.confirmCheckBox) + val dialog = AlertDialog.Builder(this) + .setTitle(getString(R.string.title_overwrite_confirm)) + .setView(view) + .setPositiveButton(getString(R.string.button_confirm)) { _, _ -> + if (checkBox.isChecked) { + doOverwriteEspKeys(usbManager, device, appKey, appUuid) + } + } + .setNegativeButton(getString(R.string.button_cancel)) { _, _ -> } + .setCancelable(true) + .show() + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = false + checkBox.setOnCheckedChangeListener { _, isChecked -> + dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = isChecked + } + } + + private fun doOverwriteEspKeys(usbManager: UsbManager, device: UsbDevice, + appKey: String, appUuid: String) { + showProgressDialog(getString(R.string.title_overwriting_keys), getString(R.string.msg_writing_keys), STYLE_SPINNER) + + lifecycleScope.launch(Dispatchers.IO) { + val result = writeKeysToEsp(usbManager, device, appKey, appUuid) + withContext(Dispatchers.Main) { + if (isFinishing || isDestroyed) return@withContext + progressDialog?.dismiss() + progressDialog = null + when (result) { + is KeyResult.Success -> { + Toast.makeText(this@SettingsActivity, getString(R.string.key_read_success), Toast.LENGTH_LONG).show() + cleanupAction() + setResult(RESULT_SCAN) + finish() + } + is KeyResult.Error -> { + Toast.makeText(this@SettingsActivity, result.message, Toast.LENGTH_LONG).show() + cleanupAction() + } + else -> { + Toast.makeText(this@SettingsActivity, getString(R.string.key_exchange_failed), Toast.LENGTH_SHORT).show() + cleanupAction() + } + } + } + } + } + + private fun writeKeysToEsp(usbManager: UsbManager, device: UsbDevice, + appKey: String, appUuid: String): 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) + Thread.sleep(KEY_SETTLE_MS) + flushReadBuffer(connection, epIn) + + val setKeyCmd = "SET_KEY:$appKey\n".toByteArray(StandardCharsets.US_ASCII) + val w = connection.bulkTransfer(epOut, setKeyCmd, setKeyCmd.size, USB_BULK_TIMEOUT_MS) + if (w < 0) return KeyResult.Error("SET_KEY schrijven mislukt") + Log.d(TAG, "Sent SET_KEY ($w bytes)") + + Thread.sleep(RESPONSE_WAIT_MS) + val okKeyResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS) + if (!okKeyResponse.contains("OK")) return KeyResult.Error("ESP bevestigde sleutel niet") + + val setIdCmd = "SET_ID:$appUuid\n".toByteArray(StandardCharsets.US_ASCII) + val w2 = connection.bulkTransfer(epOut, setIdCmd, setIdCmd.size, USB_BULK_TIMEOUT_MS) + if (w2 < 0) return KeyResult.Error("SET_ID schrijven mislukt") + + Thread.sleep(RESPONSE_WAIT_MS) + val okUuidResponse = readResponse(connection, epIn, SET_KEY_TIMEOUT_MS) + if (!okUuidResponse.contains("OK")) return KeyResult.Error("ESP bevestigde UUID niet") + + val prefs = PrefsHelper.getPrefs(this) + prefs.edit() + .putString(PrefsHelper.PREF_UUID, appUuid) + .putString(PrefsHelper.PREF_KEY, appKey) + .apply() + return KeyResult.Success(appUuid, appKey) + } catch (e: Exception) { + Log.e(TAG, "Write keys error", e) + return KeyResult.Error("Fout: ${e.message}") + } finally { + try { connection.close() } catch (_: Exception) {} + } + } } diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 053bda9..419c6e7 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -13,14 +13,31 @@ android:background="?android:attr/selectableItemBackgroundBorderless" android:contentDescription="Instellingen" /> -