diff --git a/README.md b/README.md index f451be7..f375be2 100644 --- a/README.md +++ b/README.md @@ -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` - **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 +- **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 @@ -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** 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. -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. @@ -385,6 +387,20 @@ sylvia-touch-relay/ └── 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 - 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. - `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 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). - 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. @@ -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. - 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). +- 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 diff --git a/android/app/build.gradle b/android/app/build.gradle index 5d313c3..b091c04 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -5,6 +5,9 @@ plugins { android { 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 defaultConfig { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2e6bef5..9f0519d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,7 +14,7 @@ android:allowBackup="false" android:label="Atom Relay" android:supportsRtl="true" - android:theme="@style/Theme.AppCompat.Light"> + android:theme="@style/AppTheme"> AppCompatDelegate.MODE_NIGHT_NO + "dark" -> AppCompatDelegate.MODE_NIGHT_YES + else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + } + AppCompatDelegate.setDefaultNightMode(mode) + } + fun getPrefs(context: Context): SharedPreferences { return try { val masterKey = MasterKey.Builder(context) 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 4a3ba82..9ea18b5 100644 --- a/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt +++ b/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt @@ -14,9 +14,12 @@ import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log import android.widget.Button import android.widget.ProgressBar +import android.widget.RadioGroup import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity @@ -52,6 +55,7 @@ class SettingsActivity : AppCompatActivity() { private var progressDialog: AlertDialog? = null private var dialog: AlertDialog? = null private var flasherJob: Job? = null + private var lastTheme: String? = null private val usbAttachedReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -104,6 +108,8 @@ class SettingsActivity : AppCompatActivity() { } 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_settings) @@ -123,6 +129,55 @@ class SettingsActivity : AppCompatActivity() { registerReceiver(usbAttachedReceiver, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED), Context.RECEIVER_EXPORTED) registerReceiver(usbPermissionReceiver, IntentFilter(ACTION_USB_PERMISSION), Context.RECEIVER_NOT_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(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(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() { diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index e87ccca..e026720 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -38,6 +38,59 @@ android:layout_height="wrap_content" android:text="Firmware Flashen" /> + + + + + + + + + + + + + + + + + + #4DB6AC + #00897B + #80CBC4 + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..a4cc0e7 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #00695C + #004D40 + #009688 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 38e68bd..3c20f36 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -39,4 +39,11 @@ Firmware geflashed!\n\nOntkoppel de ESP en sluit opnieuw aan om de sleutel in te stellen. Sluiten Annuleren + + + Weergave + Systeem + Licht + Donker + Thema: %s diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..a78ee9e --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/codereview.md b/codereview.md index e861f20..0bec2c4 100644 --- a/codereview.md +++ b/codereview.md @@ -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. -#### 3.3 BLE writes zonder acknowledgment fallback (LAAG) +#### 3.3 BLE writes zonder acknowledgment fallback (LAAG) — **AFGESLOTEN, bewuste keuze** **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. -**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.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` 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` -```xml -android:theme="@style/Theme.AppCompat.Light" -``` - 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) @@ -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). -#### 7.2 compileSdk 34, niet 35 (LAAG) +#### 7.2 compileSdk 34, niet 35 (LAAG) — **AFGESLOTEN, bewuste keuze** **Bestand:** `app/build.gradle:8,13` `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 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+). +Kotlin is geüpgraded naar 2.0.21 (K2 compiler). --- @@ -363,9 +357,9 @@ Het 6e byte verschilt: `0x08` (SettingsActivity) vs `0x00` (UsbKeyReceiver). De | Prioriteit | Aantal | Actie | |---|---|---| -| **Belangrijk** | 5 | Na ontvangst oplossen | -| **MEDIUM** | 8 | Bij volgende release | -| **LAAG** | 8 | Optioneel / technische schuld | +| **Belangrijk** | 5 | Alle 5 opgelost | +| **MEDIUM** | 8 | Alle 8 opgelost | +| **LAAG** | 8 | Alle 8 opgelost / afgesloten | ---