diff --git a/docs/superpowers/plans/2026-07-29-ble-refactor.md b/docs/superpowers/plans/2026-07-29-ble-refactor.md new file mode 100644 index 0000000..235b929 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-ble-refactor.md @@ -0,0 +1,776 @@ +# BLE Refactor: BleRelayManager Extractie + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extractie van alle BLE-logica uit MainActivity naar een aparte BleRelayManager klasse met callback interface. + +**Architecture:** BleRelayManager beheert scan, GATT, UUID verificatie, HMAC relay commands, key/seq in PrefsHelper. MainActivity wordt puur UI + lifecycle en reageert op BleCallbacks.onStatusChanged(). Touch listeners sturen commands naar de manager. + +**Tech Stack:** Kotlin, Android BLE API, PrefsHelper (SharedPreferences), javax.crypto.Mac + +--- + +## File Structure + +| Bestand | Actie | Verantwoordelijk | +|---|---|---| +| `BleStatus.kt` | Create | `BleStatus` enum + `BleCallbacks` interface | +| `BleRelayManager.kt` | Create | Alle BLE-logica: scan, GATT, UUID, HMAC, keys, seq, auto-retry | +| `MainActivity.kt` | Modify | UI + lifecycle, delegeert naar BleRelayManager | + +--- + +### Task 1: BleStatus enum + BleCallbacks interface + +**Files:** +- Create: `android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt` + +- [ ] **Step 1: Schrijf BleStatus.kt** + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +package com.eddy.atomrelay + +enum class BleStatus { + Scanning, + Connecting, + Connected, + Disconnected, + NoKey, + WrongDevice, + ServiceNotFound, + ServiceError, + CommError, + DeviceNotFound, + BluetoothDisabled, + PermissionsDenied +} + +interface BleCallbacks { + fun onStatusChanged(status: BleStatus) +} +``` + +- [ ] **Step 2: Build verificatie** + +Run: `./gradlew assembleDebug` +Workdir: `android/` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add android/app/src/main/java/com/eddy/atomrelay/BleStatus.kt +git commit -m "feat: add BleStatus enum and BleCallbacks interface" +``` + +--- + +### Task 2: BleRelayManager — class skeleton + companion + +**Files:** +- Create: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` + +- [ ] **Step 1: Schrijf class skeleton** + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +package com.eddy.atomrelay + +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.content.Context +import android.os.Handler +import android.os.Looper +import java.util.UUID + +class BleRelayManager( + private val context: Context, + private val callbacks: BleCallbacks +) { + companion object { + 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 SCAN_TIMEOUT_MS = 30000L + private const val RECONNECT_DELAY_MS = 2000L + 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 var seq = 1 + private var hmacKey: ByteArray? = null + private var deviceUuid: ByteArray? = null + private var triedAddresses = mutableSetOf() + + fun startScan() {} + fun stopScan() {} + fun sendRelayCommand(cmd: Byte) {} + fun disconnect() {} + fun updateKeys() {} + fun resetTriedAddresses() {} + fun hasPermissions(): Boolean = false + fun getRequiredPermissions(): Array = emptyArray() + fun destroy() {} +} +``` + +- [ ] **Step 2: Build verificatie** + +Run: `./gradlew assembleDebug` +Workdir: `android/` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt +git commit -m "feat: add BleRelayManager class skeleton" +``` + +--- + +### Task 3: BleRelayManager — private helpers (loadState, key/HMAC) + +**Files:** +- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` + +- [ ] **Step 1: Voeg imports en private helpers toe** + +Nieuwe imports: +```kotlin +import java.nio.ByteBuffer +import java.nio.ByteOrder +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +``` + +Verwijder `fun startScan() {}` etc stubs. + +Nieuwe companion constants: +```kotlin +private const val HMAC_PKT_SIZE = 37 +private const val HMAC_LEN = 32 +``` + +Nieuwe methodes: +```kotlin +fun updateKeys() { + loadState() +} + +fun resetTriedAddresses() { + triedAddresses.clear() +} + +fun destroy() { + handler.removeCallbacksAndMessages(null) +} + +private fun loadState() { + val prefs = PrefsHelper.getPrefs(context) + 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) +} + +private fun hexStringToBytes(hex: String): ByteArray? { + val cleaned = hex.uppercase().filter { it in "0123456789ABCDEF" } + if (cleaned.isEmpty() || cleaned.length % 2 != 0) return null + return cleaned.chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} + +private fun hmacSha256(data: ByteArray, key: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(data) +} + +private fun buildPacket(cmd: Byte, key: ByteArray): ByteArray { + val seqBytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(seq).array() + val hmacData = ByteArray(5) + hmacData[0] = cmd + System.arraycopy(seqBytes, 0, hmacData, 1, 4) + val hmac = hmacSha256(hmacData, key) + val packet = ByteArray(HMAC_PKT_SIZE) + packet[0] = cmd + System.arraycopy(seqBytes, 0, packet, 1, 4) + System.arraycopy(hmac, 0, packet, 5, HMAC_LEN) + return packet +} +``` + +- [ ] **Step 2: Build verificatie** + +Run: `./gradlew assembleDebug` +Workdir: `android/` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt +git commit -m "feat: add BleRelayManager key/HMAC helpers and loadState" +``` + +--- + +### Task 4: BleRelayManager — scan callback + startScan/stopScan + +**Files:** +- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` + +- [ ] **Step 1: Voeg scan logic toe** + +Nieuwe imports: +```kotlin +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.pm.PackageManager +import android.os.ParcelUuid +import androidx.core.app.ActivityCompat +``` + +Nieuwe velden + scanCallback: +```kotlin +private val scanRunnable = Runnable { startScan() } +private var scanTimeoutRunnable: Runnable? = null + +private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device ?: return + val address = device.address ?: return + if (triedAddresses.contains(address)) return + stopScan() + connectToDevice(device) + } +} +``` + +Methodes startScan, stopScan, hasPermissions, getRequiredPermissions, connectToDevice: +```kotlin +@SuppressLint("MissingPermission") +fun startScan() { + if (isScanning) return + if (!hasPermissions()) { + callbacks.onStatusChanged(BleStatus.PermissionsDenied) + return + } + val adapter = BluetoothAdapter.getDefaultAdapter() + if (adapter == null || !adapter.isEnabled) { + callbacks.onStatusChanged(BleStatus.BluetoothDisabled) + return + } + loadState() + if (hmacKey == null || deviceUuid == null) { + callbacks.onStatusChanged(BleStatus.NoKey) + return + } + isScanning = true + scanTimedOut = false + scanTimeoutRetries = 0 + callbacks.onStatusChanged(BleStatus.Scanning) + 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) + scanTimeoutRunnable?.let { handler.removeCallbacks(it) } + scanTimeoutRunnable = Runnable { + if (isScanning) { + scanTimedOut = true + stopScan() + } + } + handler.postDelayed(scanTimeoutRunnable!!, SCAN_TIMEOUT_MS) +} + +@SuppressLint("MissingPermission") +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++ + handler.postDelayed(scanRunnable, 2000L) + return + } + callbacks.onStatusChanged(BleStatus.DeviceNotFound) + } +} + +fun hasPermissions(): Boolean { + return getRequiredPermissions().all { + ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } +} + +fun getRequiredPermissions(): Array { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.ACCESS_FINE_LOCATION + ) + } else { + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION) + } +} + +@SuppressLint("MissingPermission") +private fun connectToDevice(device: android.bluetooth.BluetoothDevice) { + callbacks.onStatusChanged(BleStatus.Connecting) + bluetoothGatt = device.connectGatt(context, false, gattCallback) +} +``` + +- [ ] **Step 2: Build verificatie** + +Run: `./gradlew assembleDebug` +Workdir: `android/` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt +git commit -m "feat: add BleRelayManager scan logic with permissions" +``` + +--- + +### Task 5: BleRelayManager — GATT callback (connect, services, UUID verify, notifications) + +**Files:** +- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` + +- [ ] **Step 1: Voeg GATT callback toe** + +Nieuwe imports: +```kotlin +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.util.Log +``` + +Voeg lege gattCallback stub toe (die was er nog niet) of vervang bestaande lege: +```kotlin +private val gattCallback = object : android.bluetooth.BluetoothGattCallback() { + @SuppressLint("MissingPermission") + override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + if (hmacKey == null) { + Log.w("BleRelayManager", "GATT connected but no key -- disconnecting") + callbacks.onStatusChanged(BleStatus.NoKey) + gatt.disconnect() + return + } + Log.d("BleRelayManager", "GATT connected") + bluetoothGatt = gatt + gatt.discoverServices() + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + Log.d("BleRelayManager", "GATT disconnected") + callbacks.onStatusChanged(BleStatus.Disconnected) + gatt.close() + bluetoothGatt = null + relayCharacteristic = null + seq = 1 + handler.postDelayed(scanRunnable, RECONNECT_DELAY_MS) + } + } + + @SuppressLint("MissingPermission") + override fun onServicesDiscovered(gatt: android.bluetooth.BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w("BleRelayManager", "Service discovery failed (status=$status)") + callbacks.onStatusChanged(BleStatus.ServiceError) + return + } + Log.d("BleRelayManager", "Services discovered, requesting MTU...") + gatt.requestMtu(256) + } + + @SuppressLint("MissingPermission") + override fun onMtuChanged(gatt: android.bluetooth.BluetoothGatt, mtu: Int, status: Int) { + Log.d("BleRelayManager", "MTU changed to $mtu") + val service = gatt.getService(SERVICE_UUID) + if (service != null) { + relayCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID) + val uuidChar = service.getCharacteristic(UUID_CHAR_UUID) + if (relayCharacteristic != null && uuidChar != null) { + Log.d("BleRelayManager", "Reading device UUID characteristic...") + gatt.readCharacteristic(uuidChar) + } else { + Log.e("BleRelayManager", "Service or characteristics not found") + callbacks.onStatusChanged(BleStatus.ServiceNotFound) + } + } else { + Log.e("BleRelayManager", "Service not found") + callbacks.onStatusChanged(BleStatus.ServiceNotFound) + } + } + + @SuppressLint("MissingPermission") + override fun onCharacteristicRead(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.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("BleRelayManager", "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) + } + callbacks.onStatusChanged(BleStatus.Connected) + } else { + Log.w("BleRelayManager", "UUID mismatch -- disconnecting") + triedAddresses.add(gatt.device.address) + callbacks.onStatusChanged(BleStatus.WrongDevice) + gatt.disconnect() + } + } + } + + @SuppressLint("MissingPermission") + override fun onCharacteristicWrite(gatt: android.bluetooth.BluetoothGatt, characteristic: android.bluetooth.BluetoothGattCharacteristic, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w("BleRelayManager", "Write failed (status=$status)") + callbacks.onStatusChanged(BleStatus.CommError) + } + } +} +``` + +- [ ] **Step 2: Build verificatie** + +Run: `./gradlew assembleDebug` +Workdir: `android/` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt +git commit -m "feat: add BleRelayManager GATT callback (connect, UUID verify, notifications)" +``` + +--- + +### Task 6: BleRelayManager — sendRelayCommand + disconnect + +**Files:** +- Modify: `android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt` + +- [ ] **Step 1: Implementeer sendRelayCommand en disconnect** + +Vervang de stubs voor `sendRelayCommand` en `disconnect`: +```kotlin +@SuppressLint("MissingPermission") +fun sendRelayCommand(cmd: Byte) { + val char = relayCharacteristic ?: return + val gatt = bluetoothGatt ?: return + val key = hmacKey ?: return + + val packet = buildPacket(cmd, key) + char.value = packet + char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + gatt.writeCharacteristic(char) + seq++ + if (seq > PrefsHelper.SEQ_MAX) seq = 1 + PrefsHelper.getPrefs(context).edit().putInt(PrefsHelper.PREF_SEQ, seq).apply() +} + +@SuppressLint("MissingPermission") +fun disconnect() { + handler.removeCallbacks(scanRunnable) + scanTimeoutRunnable?.let { handler.removeCallbacks(it) } + stopScan() + bluetoothGatt?.disconnect() +} +``` + +- [ ] **Step 2: Build verificatie** + +Run: `./gradlew assembleDebug` +Workdir: `android/` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add android/app/src/main/java/com/eddy/atomrelay/BleRelayManager.kt +git commit -m "feat: add BleRelayManager sendRelayCommand and disconnect" +``` + +--- + +### Task 7: Refactor MainActivity — gebruik BleRelayManager + +**Files:** +- Modify: `android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt` + +- [ ] **Step 1: Herschrijf MainActivity.kt** + +Vervang de volledige file content: + +```kotlin +// SPDX-License-Identifier: GPL-3.0-or-later +package com.eddy.atomrelay + +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import android.widget.Button +import android.widget.ImageButton +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat + +class MainActivity : AppCompatActivity(), BleCallbacks { + + companion object { + private const val REQUEST_SETTINGS = 1 + private const val POST_SETTINGS_RESCAN_MS = 500L + private const val ON_RESUME_SCAN_DELAY_MS = 300L + } + + private lateinit var manager: BleRelayManager + private val handler = Handler(Looper.getMainLooper()) + + private lateinit var statusText: TextView + private lateinit var relay1Button: Button + private lateinit var relay2Button: Button + private var lastTheme: String? = null + + override fun onStatusChanged(status: BleStatus) { + when (status) { + BleStatus.Connected -> { + statusText.text = getString(R.string.status_connected) + setupRelayButton() + setupRelay2Button() + relay1Button.isEnabled = true + relay2Button.isEnabled = true + } + BleStatus.Disconnected -> { + statusText.text = getString(R.string.status_connecting) + relay1Button.isEnabled = false + relay2Button.isEnabled = false + } + BleStatus.Scanning -> statusText.text = getString(R.string.status_scanning) + BleStatus.Connecting -> statusText.text = getString(R.string.status_connecting) + BleStatus.NoKey -> statusText.text = getString(R.string.status_no_key) + BleStatus.WrongDevice -> statusText.text = getString(R.string.status_wrong_device) + BleStatus.ServiceNotFound -> statusText.text = getString(R.string.status_service_not_found) + BleStatus.ServiceError -> statusText.text = getString(R.string.status_service_error) + BleStatus.CommError -> statusText.text = getString(R.string.status_comm_error) + BleStatus.BluetoothDisabled -> statusText.text = getString(R.string.status_bluetooth_disabled) + BleStatus.PermissionsDenied -> { + statusText.text = getString(R.string.status_permissions_denied) + relay1Button.isEnabled = false + relay2Button.isEnabled = false + } + BleStatus.DeviceNotFound -> { + 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() + manager.startScan() + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system" + PrefsHelper.applyTheme(this) + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + manager = BleRelayManager(this, this) + + statusText = findViewById(R.id.statusText) + 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") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == REQUEST_SETTINGS && resultCode == SettingsActivity.RESULT_SCAN) { + manager.disconnect() + manager.resetTriedAddresses() + manager.updateKeys() + handler.postDelayed({ manager.startScan() }, POST_SETTINGS_RESCAN_MS) + } + } + + override fun onResume() { + super.onResume() + val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system" + if (current != lastTheme) { + lastTheme = current + recreate() + return + } + if (!manager.hasPermissions()) { + requestPermissions() + return + } + manager.resetTriedAddresses() + handler.postDelayed({ manager.startScan() }, ON_RESUME_SCAN_DELAY_MS) + } + + override fun onPause() { + super.onPause() + manager.stopScan() + } + + @Deprecated("Deprecated in Java") + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == 1) { + if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) { + manager.startScan() + } else { + onStatusChanged(BleStatus.PermissionsDenied) + } + } + } + + private fun setupRelayButton() { + relay1Button.setOnClickListener(null) + relay1Button.text = getString(R.string.button_hold) + relay1Button.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + manager.sendRelayCommand(0x01.toByte()) + relay1Button.text = getString(R.string.button_on) + true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + manager.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 -> { + manager.sendRelayCommand(0x02.toByte()) + relay2Button.text = getString(R.string.button_on) + true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + manager.sendRelayCommand(0x03.toByte()) + relay2Button.text = getString(R.string.button_hold) + true + } + else -> false + } + } + } + + private fun requestPermissions() { + val perms = manager.getRequiredPermissions() + ActivityCompat.requestPermissions(this, perms, 1) + } + + override fun onDestroy() { + super.onDestroy() + handler.removeCallbacksAndMessages(null) + manager.destroy() + } +} +``` + +- [ ] **Step 2: Build verificatie** + +Run: `./gradlew assembleDebug` +Workdir: `android/` +Expected: BUILD SUCCESSFUL + +- [ ] **Step 3: Commit** + +```bash +git add android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt +git commit -m "refactor: extract BLE logic from MainActivity to BleRelayManager" +``` + +--- + +### Task 8: Functionele verificatie — deploy en test + +- [ ] **Step 1: Deploy APK naar telefoon** + +Gebruik de adb-wireless skill om te verbinden, dan: +```bash +adb -s 192.168.1.43:5555 install -r android/app/build/outputs/apk/debug/app-debug.apk +``` + +- [ ] **Step 2: Test BLE scan, connect, UUID verify** + +Start de app. Verifieer: +- Status: "Scannen..." -> "Verbinden..." -> "Verbonden" +- Relay 1 en 2 buttons enabled +- HMAC key en UUID correct geladen uit PrefsHelper + +- [ ] **Step 3: Test relay knoppen** + +- Relay 1 indrukken: relais 1 aan, button tekst "AAN" +- Relay 1 loslaten: relais 1 uit, button tekst "INGEDRUKT HOUDEN" +- Relay 2 indrukken: relais 2 aan, button tekst "AAN" +- Relay 2 loslaten: relais 2 uit, button tekst "INGEDRUKT HOUDEN" +- Snel ON->OFF cyclen: geen vastlopers + +- [ ] **Step 4: Test disconnect + auto-reconnect** + +- ESP uitzetten, verifieer "Verbinden...", zet ESP aan, verifieer "Verbonden" + +- [ ] **Step 5: Test settings key exchange** + +- Wis keys in app, open Settings, doe key exchange +- Verifieer dat app opnieuw scant en verbindt + +- [ ] **Step 6: Test scan timeout** + +- ESP uitzetten tijdens scan, verifieer na ~90s: timeout + retry knop +- Druk "Opnieuw scannen", verifieer scan herstart