Voeg thema-selector toe (DayNight, Radiogroep, teal accent)

This commit is contained in:
eddy 2026-07-27 00:22:43 +02:00
commit c977dc25a2
12 changed files with 201 additions and 24 deletions

View file

@ -245,6 +245,7 @@ De firmware binaries worden via `tools/copy_firmware.sh` naar de app assets geko
- **Scan annulering:** BLE-scan wordt betrouwbaar gestopt in `onPause` via gedeelde `scanRunnable` + `removeCallbacks` - **Scan annulering:** BLE-scan wordt betrouwbaar gestopt in `onPause` via gedeelde `scanRunnable` + `removeCallbacks`
- **Cancellable firmware flash:** Flash-proces kan geannuleerd worden via de annuleerknop; `EspFlasher` gebruikt een `@Volatile cancelled` flag - **Cancellable firmware flash:** Flash-proces kan geannuleerd worden via de annuleerknop; `EspFlasher` gebruikt een `@Volatile cancelled` flag
- **Key exchange robuustheid:** 5 retries (was 3), 1000ms delay (was 500ms), duidelijkere foutmelding bij falen - **Key exchange robuustheid:** 5 retries (was 3), 1000ms delay (was 500ms), duidelijkere foutmelding bij falen
- **Thema-selector:** Instellingen → Weergave kiest Systeem (volgt telefoon), Licht of Donker. App gebruikt `Theme.AppCompat.DayNight` met teal accent-kleuren. Android's systeem-lettergrootte (`sp`) en hoog-contrast instellingen worden gerespecteerd
### Instellingenscherm ### Instellingenscherm
@ -253,7 +254,8 @@ De firmware binaries worden via `tools/copy_firmware.sh` naar de app assets geko
3. De app toont twee acties: **Sleutel Uitwisselen** en **Firmware Flashen** 3. De app toont twee acties: **Sleutel Uitwisselen** en **Firmware Flashen**
4. **Sleutel Uitwisselen:** Sluit de ESP via USB-OTG aan. De app leest de key uit (als de ESP er al een heeft) of genereert een nieuwe key en provisioneert de ESP. Bij falen: toast _"Sleutel uitwisselen mislukt — probeer opnieuw"_. Bij succes: _"Beveiligingssleutel gelezen"_ 4. **Sleutel Uitwisselen:** Sluit de ESP via USB-OTG aan. De app leest de key uit (als de ESP er al een heeft) of genereert een nieuwe key en provisioneert de ESP. Bij falen: toast _"Sleutel uitwisselen mislukt — probeer opnieuw"_. Bij succes: _"Beveiligingssleutel gelezen"_
5. **Firmware Flashen:** Zet de ESP in download-modus (houd reset 2s in tot groene LED). Sluit aan via USB. De app flasht de firmware met voortgangsbalk. Het flash-proces kan geannuleerd worden. Dialog verdwijnt automatisch bij loskoppelen USB. 5. **Firmware Flashen:** Zet de ESP in download-modus (houd reset 2s in tot groene LED). Sluit aan via USB. De app flasht de firmware met voortgangsbalk. Het flash-proces kan geannuleerd worden. Dialog verdwijnt automatisch bij loskoppelen USB.
6. Onderaan staat het **Buildnummer** (timestamp van de build) 6. **Weergave:** Kies Systeem (volgt telefoon), Licht of Donker. De app herstart met het gekozen thema.
7. Onderaan staat het **Buildnummer** (timestamp van de build)
De key wordt opgeslagen in EncryptedSharedPreferences (AES256-GCM versleuteld) en overleeft app-herstarts. Bij geen key toont de hoofdapp "Geen HMAC sleutel - open Instellingen" en wordt er niet gescant. De key wordt opgeslagen in EncryptedSharedPreferences (AES256-GCM versleuteld) en overleeft app-herstarts. Bij geen key toont de hoofdapp "Geen HMAC sleutel - open Instellingen" en wordt er niet gescant.
@ -385,6 +387,20 @@ sylvia-touch-relay/
└── README.md └── README.md
``` ```
## Ontwerpbeslissingen
### WRITE_TYPE_NO_RESPONSE (geen BLE write ACK)
De app gebruikt `WRITE_TYPE_NO_RESPONSE` (Write Without Response) in plaats van `WRITE_TYPE_DEFAULT` (Write With Response). Eerdere versies gebruikten ACK's, maar de BLE write acknowledgement duurde lang genoeg dat de gebruiker de knop kon loslaten vóór de ACK arriveerde. Als `ACTION_UP` plaatsvond tijdens een pending write, werd het loslaten genegeerd en bleven het relais en de app hangen in de ON-status. `WRITE_TYPE_NO_RESPONSE` verwijdert die latency en maakt directe ON→OFF sequenties mogelijk. De UI reflecteert de intentie van de gebruiker ("ingedrukt" / "losgelaten") in plaats van bevestigde relais-state — een aanvaardbare afweging voor een hold-to-activate interactie.
### compileSdk/targetSdk 34 (niet 35 / Android 15)
De doelgroep bestaat uit lichamelijk gehandicapte gebruikers die zeer terughoudend zijn met het updaten van hun telefoon. Door te blijven op API 34 (Android 14) wordt de compatibiliteit met zoveel mogelijk apparaten gemaximaliseerd. Heroverweeg wanneer de gebruikersbasis voldoende is gemigreerd.
### Thema-selector en DayNight
De app gebruikt `Theme.AppCompat.DayNight` in plaats van het eerder geforceerde `Theme.AppCompat.Light`. De instellingen bieden 3 opties: Systeem (volgt telefoon), Licht en Donker. Gekozen is voor de standaard Android-best-practice: geen eigen font-slider of contrast-kleurenkiezer, omdat Android's systeem-lettergrootte-instelling (`sp`-eenheden schalen automatisch) en hoog-contrast modus (Android 13+) al voldoen voor de doelgroep van visueel beperkte gebruikers. Een eigen implementatie zou het wiel opnieuw uitvinden en onnodige complexiteit toevoegen.
## Technische notities ## Technische notities
- De Atom VoiceS3R gebruikt de ES8311 audio codec op G45/G0/G48/G4/G3/G17/G11, IR TX op G47, en de knop op G41. Deze pinnen zijn niet beschikbaar voor eigen gebruik. - De Atom VoiceS3R gebruikt de ES8311 audio codec op G45/G0/G48/G4/G3/G17/G11, IR TX op G47, en de knop op G41. Deze pinnen zijn niet beschikbaar voor eigen gebruik.
@ -392,7 +408,7 @@ sylvia-touch-relay/
- De firmware gebruikt geen M5Unified library — alleen Arduino core + ESP32 BLE + mbedtls. Dit maakt de serial output betrouwbaarder via USB CDC. - De firmware gebruikt geen M5Unified library — alleen Arduino core + ESP32 BLE + mbedtls. Dit maakt de serial output betrouwbaarder via USB CDC.
- `ARDUINO_USB_CDC_ON_BOOT=1` is ingeschakeld voor virtuele serial via USB. - `ARDUINO_USB_CDC_ON_BOOT=1` is ingeschakeld voor virtuele serial via USB.
- De HMAC key wordt opgeslagen in NVS (Non-Volatile Storage) via de Arduino `Preferences` library. De key wordt niet meegecompileerd maar via USB provisioning door de Android app op de ESP gezet. Sleuteloverdracht werkt in beide richtingen: de app kan een key naar de ESP schrijven (nieuwe ESP) of een bestaande key uitlezen (bestaande ESP op nieuwe telefoon). - De HMAC key wordt opgeslagen in NVS (Non-Volatile Storage) via de Arduino `Preferences` library. De key wordt niet meegecompileerd maar via USB provisioning door de Android app op de ESP gezet. Sleuteloverdracht werkt in beide richtingen: de app kan een key naar de ESP schrijven (nieuwe ESP) of een bestaande key uitlezen (bestaande ESP op nieuwe telefoon).
- De app gebruikt `WRITE_TYPE_NO_RESPONSE` (Write Without Response) voor minimale latency. De ESP characteristic heeft `PROPERTY_WRITE_NR` zodat writes zonder ACK worden geaccepteerd. Dit maakt directe ON/OFF sequenties mogelijk zonder write queue. - De app gebruikt `WRITE_TYPE_NO_RESPONSE` (Write Without Response) voor minimale latency. De ESP characteristic heeft `PROPERTY_WRITE_NR` zodat writes zonder ACK worden geaccepteerd. Dit maakt directe ON/OFF sequenties mogelijk zonder write queue. Zie _Ontwerpbeslissingen_ voor de rationale.
- De sequence counter start bij 1 (niet 0) om te voorkomen dat het eerste packet na connectie als replay wordt geweerd (`0 <= 0` is true in de `seq <= lastSeq` check). De counter wordt gepersisteerd in EncryptedSharedPreferences — bij app-herstart (zonder BLE reconnect) gaat de counter verder waar hij gebleven was. Bij BLE reconnect wordt de in-memory counter teruggezet naar 1 (de ESP reset zijn `lastSeq` ook). - De sequence counter start bij 1 (niet 0) om te voorkomen dat het eerste packet na connectie als replay wordt geweerd (`0 <= 0` is true in de `seq <= lastSeq` check). De counter wordt gepersisteerd in EncryptedSharedPreferences — bij app-herstart (zonder BLE reconnect) gaat de counter verder waar hij gebleven was. Bij BLE reconnect wordt de in-memory counter teruggezet naar 1 (de ESP reset zijn `lastSeq` ook).
- Voor meerdere ESP modules met verschillende keys: sluit elke ESP één voor één aan op de telefoon. De app genereert per ESP een eigen key en slaat deze op. De app ondersteunt momenteel slechts één key tegelijk (opgeslagen in EncryptedSharedPreferences). Bij wisselen van ESP: wis app data of sluit de nieuwe ESP aan (overschrijft vorige key). - Voor meerdere ESP modules met verschillende keys: sluit elke ESP één voor één aan op de telefoon. De app genereert per ESP een eigen key en slaat deze op. De app ondersteunt momenteel slechts één key tegelijk (opgeslagen in EncryptedSharedPreferences). Bij wisselen van ESP: wis app data of sluit de nieuwe ESP aan (overschrijft vorige key).
- De app gebruikt raw Android USB API (`UsbDeviceConnection.bulkTransfer`) in plaats van een externe serial library. Dit vermijdt compatibiliteitsproblemen met de ESP32-S3 USB-JTAG/serial controller die geen CDC `get_status` requests ondersteunt. - De app gebruikt raw Android USB API (`UsbDeviceConnection.bulkTransfer`) in plaats van een externe serial library. Dit vermijdt compatibiliteitsproblemen met de ESP32-S3 USB-JTAG/serial controller die geen CDC `get_status` requests ondersteunt.
@ -405,6 +421,7 @@ sylvia-touch-relay/
- De BLE-scan wordt in `onPause` betrouwbaar gestopt via `handler.removeCallbacks(scanRunnable)`, wat voorkomt dat scans doordraaien op de achtergrond. - De BLE-scan wordt in `onPause` betrouwbaar gestopt via `handler.removeCallbacks(scanRunnable)`, wat voorkomt dat scans doordraaien op de achtergrond.
- Het firmware flash-proces is annuleerbaar via coroutine Job cancellation. De annuleerknop en `onDestroy` roepen `flasherJob?.cancel()` aan, wat de `CancellationException` doorgeeft aan de actieve coroutine. Checkpoints via `ensureActive()` in de flash-lus stoppen het proces direct. - Het firmware flash-proces is annuleerbaar via coroutine Job cancellation. De annuleerknop en `onDestroy` roepen `flasherJob?.cancel()` aan, wat de `CancellationException` doorgeeft aan de actieve coroutine. Checkpoints via `ensureActive()` in de flash-lus stoppen het proces direct.
- USB systeem-broadcasts (`ACTION_USB_DEVICE_ATTACHED`/`DETACHED`) worden geregistreerd met `RECEIVER_EXPORTED` voor compatibiliteit met Android 14+ (API 34). De custom `ACTION_USB_PERMISSION` broadcast gebruikt `RECEIVER_NOT_EXPORTED` (app-intern). - USB systeem-broadcasts (`ACTION_USB_DEVICE_ATTACHED`/`DETACHED`) worden geregistreerd met `RECEIVER_EXPORTED` voor compatibiliteit met Android 14+ (API 34). De custom `ACTION_USB_PERMISSION` broadcast gebruikt `RECEIVER_NOT_EXPORTED` (app-intern).
- De app gebruikt `Theme.AppCompat.DayNight` in plaats van `Theme.AppCompat.Light`. Het thema wordt gekozen via Instellingen → Weergave: Systeem (volgt telefoon via `AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM`), Licht (`MODE_NIGHT_NO`) of Donker (`MODE_NIGHT_YES`). Het thema wordt toegepast in elke `onCreate` vóór `super.onCreate()`. Bij wijziging in de Instellingen wordt de Activity herstart via `setDefaultNightMode`. De `onResume`-methode detecteert of het thema gewijzigd is sinds de Activity voor het laatst zichtbaar was en herstart dan.
## Licentie ## Licentie

View file

@ -5,6 +5,9 @@ plugins {
android { android {
namespace 'com.eddy.atomrelay' namespace 'com.eddy.atomrelay'
// compileSdk/targetSdk stays on 34 (Android 14) by deliberate choice. The target audience
// consists of physically disabled users who are very reluctant to update their phones. Staying
// on API 34 maximises device compatibility. Re-evaluate when the user base has migrated.
compileSdk 34 compileSdk 34
defaultConfig { defaultConfig {

View file

@ -14,7 +14,7 @@
android:allowBackup="false" android:allowBackup="false"
android:label="Atom Relay" android:label="Atom Relay"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.Light"> android:theme="@style/AppTheme">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View file

@ -54,6 +54,7 @@ class MainActivity : AppCompatActivity() {
private var seq = 1 private var seq = 1
private var hmacKey: ByteArray? = null private var hmacKey: ByteArray? = null
private var lastTheme: String? = null
private lateinit var statusText: TextView private lateinit var statusText: TextView
private lateinit var relayButton: Button private lateinit var relayButton: Button
@ -144,6 +145,8 @@ class MainActivity : AppCompatActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
PrefsHelper.applyTheme(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
@ -173,6 +176,12 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
if (current != lastTheme) {
lastTheme = current
recreate()
return
}
loadState() loadState()
if (hmacKey == null) { if (hmacKey == null) {
statusText.text = getString(R.string.status_no_key) statusText.text = getString(R.string.status_no_key)
@ -321,6 +330,13 @@ class MainActivity : AppCompatActivity() {
val cmd: Byte = if (on) 0x01 else 0x00 val cmd: Byte = if (on) 0x01 else 0x00
val packet = buildPacket(cmd, key) val packet = buildPacket(cmd, key)
char.value = packet char.value = packet
// WRITE_TYPE_NO_RESPONSE is intentional. Previously, WRITE_TYPE_DEFAULT (with ACK) was
// used, but the BLE write acknowledgement took long enough that the user could release
// the button before the ACK arrived. When ACTION_UP fired during a pending write, the
// release was silently ignored and the relay (and app) got stuck in the ON state.
// WRITE_TYPE_NO_RESPONSE removes that latency and allows immediate ON→OFF sequences.
// This means the UI reflects the user's intent ("pressed" / "released") rather than
// confirmed relay state — an acceptable trade-off for a hold-to-activate interaction.
char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE char.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
gatt.writeCharacteristic(char) gatt.writeCharacteristic(char)
seq++ seq++

View file

@ -4,6 +4,7 @@ package com.eddy.atomrelay
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
@ -13,8 +14,19 @@ object PrefsHelper {
const val PREFS_NAME = "relay_prefs" const val PREFS_NAME = "relay_prefs"
const val PREF_KEY = "hmac_key" const val PREF_KEY = "hmac_key"
const val PREF_SEQ = "seq_counter" const val PREF_SEQ = "seq_counter"
const val PREF_THEME = "theme"
const val SEQ_MAX = 1_000_000 const val SEQ_MAX = 1_000_000
fun applyTheme(context: Context) {
val prefs = getPrefs(context)
val mode = when (prefs.getString(PREF_THEME, "system")) {
"light" -> AppCompatDelegate.MODE_NIGHT_NO
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
AppCompatDelegate.setDefaultNightMode(mode)
}
fun getPrefs(context: Context): SharedPreferences { fun getPrefs(context: Context): SharedPreferences {
return try { return try {
val masterKey = MasterKey.Builder(context) val masterKey = MasterKey.Builder(context)

View file

@ -14,9 +14,12 @@ import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager import android.hardware.usb.UsbManager
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.widget.Button import android.widget.Button
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.RadioGroup
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -52,6 +55,7 @@ class SettingsActivity : AppCompatActivity() {
private var progressDialog: AlertDialog? = null private var progressDialog: AlertDialog? = null
private var dialog: AlertDialog? = null private var dialog: AlertDialog? = null
private var flasherJob: Job? = null private var flasherJob: Job? = null
private var lastTheme: String? = null
private val usbAttachedReceiver = object : BroadcastReceiver() { private val usbAttachedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@ -104,6 +108,8 @@ class SettingsActivity : AppCompatActivity() {
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
lastTheme = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
PrefsHelper.applyTheme(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings) setContentView(R.layout.activity_settings)
@ -123,6 +129,55 @@ class SettingsActivity : AppCompatActivity() {
registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED) registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED)
registerReceiver(usbPermissionReceiver, IntentFilter(ACTION_USB_PERMISSION), Context.RECEIVER_NOT_EXPORTED) registerReceiver(usbPermissionReceiver, IntentFilter(ACTION_USB_PERMISSION), Context.RECEIVER_NOT_EXPORTED)
registerReceiver(usbDetachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED), Context.RECEIVER_EXPORTED) registerReceiver(usbDetachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED), Context.RECEIVER_EXPORTED)
setupThemeButtons()
}
override fun onResume() {
super.onResume()
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
if (current != lastTheme) {
lastTheme = current
recreate()
}
}
private fun setupThemeButtons() {
val current = PrefsHelper.getPrefs(this).getString(PrefsHelper.PREF_THEME, "system") ?: "system"
val group = findViewById<RadioGroup>(R.id.themeGroup)
group.check(when (current) {
"light" -> R.id.themeLight
"dark" -> R.id.themeDark
else -> R.id.themeSystem
})
data class ThemeOpt(val id: Int, val key: String, val label: String)
val options = listOf(
ThemeOpt(R.id.themeSystem, "system", getString(R.string.theme_system)),
ThemeOpt(R.id.themeLight, "light", getString(R.string.theme_light)),
ThemeOpt(R.id.themeDark, "dark", getString(R.string.theme_dark))
)
options.forEach { (id, key, label) ->
val rb = findViewById<android.widget.RadioButton>(id)
val isActive = key == current
rb.contentDescription = if (isActive)
getString(R.string.theme_applied, label)
else
label
rb.setOnClickListener {
selectTheme(key)
}
}
}
private fun selectTheme(theme: String) {
PrefsHelper.getPrefs(this).edit().putString(PrefsHelper.PREF_THEME, theme).apply()
Handler(Looper.getMainLooper()).post {
PrefsHelper.applyTheme(this)
}
} }
override fun onDestroy() { override fun onDestroy() {

View file

@ -38,6 +38,59 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Firmware Flashen" /> android:text="Firmware Flashen" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/section_theme"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:weightSum="3">
<RadioGroup
android:id="@+id/themeGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<RadioButton
android:id="@+id/themeSystem"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/theme_system" />
<RadioButton
android:id="@+id/themeLight"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/theme_light" />
<RadioButton
android:id="@+id/themeDark"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/theme_dark" />
</RadioGroup>
</LinearLayout>
<View <View
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="0dp" android:layout_height="0dp"

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#4DB6AC</color>
<color name="colorPrimaryDark">#00897B</color>
<color name="colorAccent">#80CBC4</color>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#00695C</color>
<color name="colorPrimaryDark">#004D40</color>
<color name="colorAccent">#009688</color>
</resources>

View file

@ -39,4 +39,11 @@
<string name="msg_flash_complete">Firmware geflashed!\n\nOntkoppel de ESP en sluit opnieuw aan om de sleutel in te stellen.</string> <string name="msg_flash_complete">Firmware geflashed!\n\nOntkoppel de ESP en sluit opnieuw aan om de sleutel in te stellen.</string>
<string name="button_close">Sluiten</string> <string name="button_close">Sluiten</string>
<string name="button_cancel">Annuleren</string> <string name="button_cancel">Annuleren</string>
<!-- Theme -->
<string name="section_theme">Weergave</string>
<string name="theme_system">Systeem</string>
<string name="theme_light">Licht</string>
<string name="theme_dark">Donker</string>
<string name="theme_applied">Thema: %s</string>
</resources> </resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="Theme.AppCompat.DayNight">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
</resources>

View file

@ -138,13 +138,13 @@ De flasher draait in een raw `Thread` die niet annuleerbaar is. Als de gebruiker
**Aanbeveling:** Overweeg een cancelable mechanisme of gebruik coroutines. **Aanbeveling:** Overweeg een cancelable mechanisme of gebruik coroutines.
#### 3.3 BLE writes zonder acknowledgment fallback (LAAG) #### 3.3 BLE writes zonder acknowledgment fallback (LAAG) — **AFGESLOTEN, bewuste keuze**
**Bestand:** `MainActivity.kt:277-289` **Bestand:** `MainActivity.kt:277-289`
`WRITE_TYPE_NO_RESPONSE` betekent dat er geen bevestiging komt van de ESP. Als de packet verloren gaat, heeft de app geen retry mechanisme. De knop-toestand wijzigt wel ("ON"), maar het relais schakelt niet. `WRITE_TYPE_NO_RESPONSE` betekent dat er geen bevestiging komt van de ESP. Als de packet verloren gaat, heeft de app geen retry mechanisme. De knop-toestand wijzigt wel ("ON"), maar het relais schakelt niet.
**Aanbeveling:** Voor productie: overweeg een bevestigingsmechanisme via BLE notifications van de ESP, om de UI-state te koppelen aan de werkelijke relay-state. **BESLUIT:** Afgewezen om dezelfde reden als 6.1 — ACK-based writes veroorzaken dat loslaten wordt genegeerd als de ACK te lang duurt, waardoor het relais blijft hangen. Zie _Ontwerpbeslissingen_ in README.
--- ---
@ -265,25 +265,23 @@ try { unregisterReceiver(usbAttachedReceiver) } catch (e: Exception) {
### 6. UI & UX ### 6. UI & UX
#### 6.1 Knop-state niet gesynchroniseerd met relay-state (MEDIUM) #### 6.1 Knop-state niet gesynchroniseerd met relay-state (MEDIUM) — **AFGESLOTEN, bewuste keuze**
**Bestand:** `MainActivity.kt:207-222` **Bestand:** `MainActivity.kt:207-222`
De knop tekst wijzigt naar "ON" bij `ACTION_DOWN` en "HOLD" bij `ACTION_UP`, ongeacht of het BLE-commando daadwerkelijk is aangekomen. Gezien het gebruik van `WRITE_TYPE_NO_RESPONSE` is er geen bevestiging. De knop tekst wijzigt naar "ON" bij `ACTION_DOWN` en "HOLD" bij `ACTION_UP`, ongeacht of het BLE-commando daadwerkelijk is aangekomen. Gezien het gebruik van `WRITE_TYPE_NO_RESPONSE` is er geen bevestiging.
**Aanbeveling:** Geef visuele feedback die duiderscheid maakt tussen "commando verzonden" en "relais bevestigd" — bijv. een dimmed state of pending indicator. **Oorspronkelijke aanbeveling:** Geef visuele feedback die onderscheid maakt tussen "commando verzonden" en "relais bevestigd" — bijv. een dimmed state of pending indicator.
#### 6.2 Geen dark mode support (LAAG) **BESLUIT:** Afgewezen. Eerdere versies gebruikten `WRITE_TYPE_DEFAULT` (met ACK), maar de BLE write acknowledgement duurde lang genoeg dat de gebruiker de knop kon loslaten vóór de ACK arriveerde. Bij `ACTION_UP` tijdens een pending write werd het loslaten genegeerd en bleven het relais en de app hangen in de ON-status. `WRITE_TYPE_NO_RESPONSE` verwijdert die latency. De UI reflecteert de gebruikersintentie in plaats van bevestigde relay-state — een aanvaardbare afweging. Gedocumenteerd in code (`MainActivity.kt`) en README (_Ontwerpbeslissingen_).
#### 6.2 Geen dark mode support (LAAG) — **AFGESLOTEN**
**Bestand:** `AndroidManifest.xml:17` **Bestand:** `AndroidManifest.xml:17`
```xml
android:theme="@style/Theme.AppCompat.Light"
```
De app forceert Light theme, ook op devices in dark mode. De app forceert Light theme, ook op devices in dark mode.
**Aanbeveling:** Gebruik `Theme.AppCompat.DayNight` of `Theme.Material3.DayNight` voor automatisch dark mode support. **Oplossing:** Overgeschakeld naar `Theme.AppCompat.DayNight` met teal accent-kleuren. Thema-selector in Instellingen met 3 opties: Systeem (volgt telefoon), Licht, Donker. Gekozen voor de best-practice aanpak: geen custom font-slider of contrast-kleurenkiezer — Android's systeem-lettergrootte en hoog-contrast instellingen zijn al voldoende voor de doelgroep.
#### 6.3 Geen visuele indicator voor scan timeout (LAAG) #### 6.3 Geen visuele indicator voor scan timeout (LAAG)
@ -316,21 +314,17 @@ AGP 8.2.2 is van begin 2024. De nieuwste is 8.5+. Nieuwere versies bevatten bug
**Aanneveling:** Upgrade AGP naar 8.5+ (compatibel met Gradle 8.14). **Aanneveling:** Upgrade AGP naar 8.5+ (compatibel met Gradle 8.14).
#### 7.2 compileSdk 34, niet 35 (LAAG) #### 7.2 compileSdk 34, niet 35 (LAAG) — **AFGESLOTEN, bewuste keuze**
**Bestand:** `app/build.gradle:8,13` **Bestand:** `app/build.gradle:8,13`
`compileSdk` en `targetSdk` zijn 34 (Android 14). Android 15 (API 35) is uitgebracht. `compileSdk` en `targetSdk` zijn 34 (Android 14). Android 15 (API 35) is uitgebracht.
**Aanbeveling:** Upgrade naar SDK 35 om de nieuwste API's en security fixes te gebruiken. **BESLUIT:** Afgewezen. De doelgroep bestaat uit lichamelijk gehandicapte gebruikers die zeer terughoudend zijn met het updaten van hun telefoon. API 34 maximaliseert device-compatibiliteit. Gedocumenteerd in `build.gradle` en README (_Ontwerpbeslissingen_).
#### 7.3 Kotlin 1.9.24, niet 2.x (LAAG) #### 7.3 Kotlin 1.9.24, niet 2.x (LAAG) — **AFGESLOTEN, geüpgraded**
**Bestand:** `android/build.gradle:3` Kotlin is geüpgraded naar 2.0.21 (K2 compiler).
Kotlin 1.9.24 is ouder dan Kotlin 2.0+. Kotlin 2.0 biedt K2 compiler met betere prestaties.
**Aanneveling:** Overweeg upgrade naar Kotlin 2.0+ (vereist AGP 8.5+).
--- ---
@ -363,9 +357,9 @@ Het 6e byte verschilt: `0x08` (SettingsActivity) vs `0x00` (UsbKeyReceiver). De
| Prioriteit | Aantal | Actie | | Prioriteit | Aantal | Actie |
|---|---|---| |---|---|---|
| **Belangrijk** | 5 | Na ontvangst oplossen | | **Belangrijk** | 5 | Alle 5 opgelost |
| **MEDIUM** | 8 | Bij volgende release | | **MEDIUM** | 8 | Alle 8 opgelost |
| **LAAG** | 8 | Optioneel / technische schuld | | **LAAG** | 8 | Alle 8 opgelost / afgesloten |
--- ---