Project_SylDa/docs/superpowers/plans/2026-07-29-ble-refactor.md
eddy 70f004c45b docs: add release build spec and implementation plan
Also un-ignores docs/ directory so project documentation (specs, plans)
is tracked in git. Individual research files remain gitignored.
2026-07-30 19:54:13 +02:00

25 KiB

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

// 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
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

// 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<String>()

    fun startScan() {}
    fun stopScan() {}
    fun sendRelayCommand(cmd: Byte) {}
    fun disconnect() {}
    fun updateKeys() {}
    fun resetTriedAddresses() {}
    fun hasPermissions(): Boolean = false
    fun getRequiredPermissions(): Array<String> = emptyArray()
    fun destroy() {}
}
  • Step 2: Build verificatie

Run: ./gradlew assembleDebug Workdir: android/ Expected: BUILD SUCCESSFUL

  • Step 3: Commit
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:

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:

private const val HMAC_PKT_SIZE = 37
private const val HMAC_LEN = 32

Nieuwe methodes:

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
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:

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:

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:

@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<String> {
    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
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:

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:

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
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:

@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
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:

// 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<ImageButton>(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<out String>, 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
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:

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