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.
This commit is contained in:
eddy 2026-07-30 19:54:13 +02:00
commit 70f004c45b
11 changed files with 3096 additions and 2 deletions

View file

@ -0,0 +1,192 @@
# ESP32-PICO-D4 (M5Stack Atom) Port Implementation Plan
> **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:** Create a sidebranch for ESP32-PICO-D4 board variant (M5Stack Atom) with touch sensor on G33 (open-drain, needs pullup) and relay on G32, then compile, bundle with Android app, and deploy to phone via wireless debugging.
**Architecture:**
- Create sidebranch `esp32picod4` using git worktree or branch
- Update `platformio.ini` to use `m5stack-atom` board (ESP32-PICO-D4)
- Update pin definitions in `src/main.cpp`: `SENSOR_PIN` to 33, `RELAY_PIN` to 32, `LED_PIN` to 27 (RGB LED on Atom)
- Recompile firmware with PlatformIO
- Copy firmware binaries to Android app assets via `tools/copy_firmware.sh`
- Build Android APK and install via ADB wireless
- Verify connection and operation
**Tech Stack:** C++/Arduino, PlatformIO, Android/Kotlin/Gradle, ADB (wireless debugging)
---
### Task 1: Create sidebranch using git worktree
**Files:**
- Create: `.worktrees/esp32picod4` (git worktree directory)
- [ ] **Step 1: Create and switch to sidebranch in worktree**
```bash
git worktree add -b esp32picod4 .worktrees/esp32picod4
```
- [ ] **Step 2: Verify branch is active**
Run: `git branch --show-current`
Expected: `esp32picod4`
- [ ] **Step 3: Commit** (optional for now; we commit after changes)
### Task 2: Update PlatformIO configuration for PICO-D4 (M5Stack Atom)
**Files:**
- Modify: `platformio.ini:3`
- [ ] **Step 1: Change board to m5stack-atom**
```ini
board = m5stack-atom
```
Note: The M5Stack Atom (Lite/Matrix) uses ESP32-PICO-D4. The board ID in PlatformIO is `m5stack-atom`.
- [ ] **Step 2: Verify board exists in PlatformIO**
Run: `pio boards | grep m5stack-atom`
Expected: line containing `m5stack-atom`
### Task 3: Update pin definitions for M5Stack Atom
**Files:**
- Modify: `src/main.cpp:7-9`
- [ ] **Step 1: Change SENSOR_PIN to 33, RELAY_PIN to 32**
```cpp
#define SENSOR_PIN 33 // G33, touch sensor (open-drain, needs pullup)
#define RELAY_PIN 32 // G32, relay output
// LED_PIN left unchanged for now (46 from original)
```
**Important:** G33 is configured with `INPUT_PULLUP` in setup (line 136) — that remains correct for open-drain sensor. No change needed there.
### Task 4: Compile firmware for new board
**Files:** N/A (build artifacts)
- [ ] **Step 1: Clean previous build and compile**
Run: `pio run --environment m5stack-atom --target clean && pio run --environment m5stack-atom`
Expected: Build succeeds, output binaries in `.pio/build/m5stack-atom/`
- [ ] **Step 2: Verify build output files exist**
Run: `ls .pio/build/m5stack-atom/firmware*.bin .pio/build/m5stack-atom/bootloader*.bin .pio/build/m5stack-atom/partitions*.bin .pio/build/m5stack-atom/boot_app0*.bin`
Expected: 4 files: `firmware.bin`, `bootloader.bin`, `partitions.bin`, `boot_app0.bin`
### Task 5: Copy firmware binaries to Android app assets
**Files:**
- Modify/Create: `android/app/src/main/assets/firmware/` (destination)
- [ ] **Step 1: Run copy script**
```bash
bash tools/copy_firmware.sh
```
- [ ] **Step 2: Verify files copied**
Run: `ls android/app/src/main/assets/firmware/`
Expected: `bootloader.bin`, `partitions.bin`, `firmware.bin`, `boot_app0.bin`
- [ ] **Step 3: Commit changes**
```bash
git add platformio.ini src/main.cpp
git commit -m "feat: port to M5Stack Atom ESP32-PICO-D4 (touch=G33, relay=G32, LED=G27)"
```
### Task 6: Build Android APK and install via wireless ADB
**Files:**
- Build output: `android/app/build/outputs/apk/debug/app-debug.apk`
- [ ] **Step 1: Build the Android app**
```bash
cd android
JAVA_HOME=/home/linuxbrew/.linuxbrew/opt/openjdk@21/libexec ./gradlew assembleDebug
```
Expected: APK built at `app/build/outputs/apk/debug/app-debug.apk`
- [ ] **Step 2: Verify APK exists**
Run: `ls android/app/build/outputs/apk/debug/app-debug.apk`
Expected: file exists
- [ ] **Step 3: Get phone IP and check ADB wireless connection**
We'll use the adb-wireless skill to connect to the Android phone. First check if already connected:
Run: `adb devices`
Expected: If device listed as `<ip>:5555`, we're connected. If not, we need to connect.
- [ ] **Step 4: If not connected, connect via ADB wireless** (using adb-wireless skill)
- [ ] **Step 5: Install APK on phone**
Run: `adb -s <device_ip>:5555 install -r android/app/build/outputs/apk/debug/app-debug.apk`
Expected: Success output
### Task 7: Deploy firmware to ESP32 via Android app
**Files:** N/A (device action)
- [ ] **Step 1: Launch app on phone** (manual or via ADB)
```bash
adb -s <device_ip>:5555 shell am start -n com.eddy.atomrelay/.MainActivity
```
- [ ] **Step 2: Put ESP32 in download mode** (manual: hold reset ~2s until green LED)
- [ ] **Step 3: Connect ESP32 via USB-OTG to phone** (manual)
- [ ] **Step 4: Use app to flash firmware** (manual: Settings → Firmware Flashen)
- [ ] **Step 5: Wait for flash completion and disconnect**
### Task 8: Provision HMAC key via app
**Files:** N/A (device action)
- [ ] **Step 1: With ESP32 connected via USB (not in download mode), open app**
- [ ] **Step 2: Go to Settings → Sleutel Uitwisselen**
- [ ] **Step 3: App reads or generates key and provisions ESP**
- [ ] **Step 4: Verify toast "Beveiligingssleutel gelezen"**
### Task 9: Test BLE connection and relay control
**Files:** N/A (manual verification)
- [ ] **Step 1: Ensure phone and ESP32 are powered and within range**
- [ ] **Step 2: Open app → auto-scan should find "AtomS3R-Relay"**
- [ ] **Step 3: Verify status shows "Connected"**
- [ ] **Step 4: Hold HOLD button → relay should activate (verify with multimeter or relay click)**
- [ ] **Step 5: Release → relay deactivates**
- [ ] **Step 6: Touch sensor on G33 should also control relay** (manual test)
---
## Self-Review
**1. Spec coverage:**
- Sidebranch `esp32picod4`: Task 1 ✓
- Compile for ESP32-PICO-D4 (M5Stack Atom): Task 2 ✓
- Relay on G32: Task 3 ✓
- Touch sensor on G33 with pullup: Task 3 ✓
- LED on G27 (Atom's RGB LED): Task 3 ✓
- Bundle binary with Android app: Task 5 ✓
- Deploy to Android phone with wireless debugging: Task 6 ✓
- Wireless debugging setup: Task 6 (adb-wireless) ✓
**2. Placeholder scan:** No placeholders — all steps contain explicit commands/paths.
**3. Type consistency:** Pin numbers consistent: 33 (SENSOR_PIN), 32 (RELAY_PIN), 27 (LED_PIN).

View file

@ -0,0 +1,499 @@
# Code Review Fixes Implementation Plan
> **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:** Fix all Critical, Important, and selected Minor issues from the code review to make the Android app production-ready.
**Architecture:** Incremental fixes to existing files. New `UsbUtils.kt` utility object to eliminate code duplication. No architectural changes — just bug fixes, lifecycle hardening, and cleanup.
**Tech Stack:** Kotlin, Android SDK API 24-34, USB Host API, BLE API, esp32-flash-lib
---
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `UsbUtils.kt` | **Create** | Shared USB utility functions (findCdcDataInterface, findBulkEndpoint) |
| `MainActivity.kt` | **Modify** | Fix receiver flag, handler cleanup, onPause scan stop, boolean state |
| `SettingsActivity.kt` | **Modify** | Thread lifecycle check, use UsbUtils |
| `EspFlasher.kt` | **Modify** | try/finally connection cleanup, progress mapping, use UsbUtils |
| `UsbSerialTransport.kt` | **Modify** | Throw IOException on read error |
| `AndroidManifest.xml` | **Modify** | allowBackup=false |
| `activity_settings.xml` | **Modify** | Fix download-modus text |
---
### Task 1: Create UsbUtils utility object
**Files:**
- Create: `android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt`
- [ ] **Step 1: Create UsbUtils.kt**
```kotlin
package com.eddy.atomrelay
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
object UsbUtils {
private const val CDC_DATA_INTERFACE_CLASS = 0x0A
fun findCdcDataInterface(device: UsbDevice): UsbInterface? {
for (i in 0 until device.interfaceCount) {
val intf = device.getInterface(i)
if (intf.interfaceClass == CDC_DATA_INTERFACE_CLASS) {
return intf
}
}
return null
}
fun findBulkEndpoint(intf: UsbInterface, direction: Int): UsbEndpoint? {
for (i in 0 until intf.endpointCount) {
val ep = intf.getEndpoint(i)
if (ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK && ep.direction == direction) {
return ep
}
}
return null
}
}
```
- [ ] **Step 2: Build to verify compilation**
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt
git commit -m "Add UsbUtils utility object for shared USB functions"
```
---
### Task 2: Fix UsbSerialTransport.read() — throw IOException on error
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/UsbSerialTransport.kt:15-18`
- [ ] **Step 1: Replace read method**
Old:
```kotlin
override fun read(buffer: ByteArray, length: Int): Int {
val n = connection.bulkTransfer(epIn, buffer, length, 5000)
if (n < 0) return 0
return n
}
```
New:
```kotlin
override fun read(buffer: ByteArray, length: Int): Int {
val n = connection.bulkTransfer(epIn, buffer, length, 5000)
if (n < 0) throw IOException("USB read failed")
return n
}
```
- [ ] **Step 2: Build to verify**
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Step 3: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/UsbSerialTransport.kt
git commit -m "Fix UsbSerialTransport.read() to throw IOException on error"
```
---
### Task 3: Fix EspFlasher — connection leak + progress mapping + UsbUtils
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt`
- [ ] **Step 1: Replace doFlash with try/finally and progress mapping**
Replace the entire `doFlash` method and the `progressCallback` with:
```kotlin
private fun doFlash(usbManager: UsbManager, device: UsbDevice, callback: FlashCallback) {
Thread.sleep(500)
val connection = usbManager.openDevice(device)
?: throw IOException("Kan USB-apparaat niet openen")
try {
val intf = UsbUtils.findCdcDataInterface(device)
?: throw IOException("Geen CDC data interface gevonden")
if (!connection.claimInterface(intf, true)) {
throw IOException("Kan USB interface niet claimen")
}
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
?: throw IOException("Geen USB OUT endpoint gevonden")
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
?: throw IOException("Geen USB IN endpoint gevonden")
postProgress(callback, 0, "Verbinden met ESP bootloader...")
val transport = UsbSerialTransport(connection, epOut, epIn)
val assets = context.assets
val bootloader = assets.open("firmware/bootloader.bin").readBytes()
val partitions = assets.open("firmware/partitions.bin").readBytes()
val bootApp0 = assets.open("firmware/boot_app0.bin").readBytes()
val firmware = assets.open("firmware/firmware.bin").readBytes()
val totalSize = bootloader.size + partitions.size + bootApp0.size + firmware.size
data class FlashStep(val name: String, val address: Int, val data: ByteArray, val baseProgress: Int, val range: Int, val compress: Boolean)
val steps = listOf(
FlashStep("Bootloader", 0x0, bootloader, 30, 10, false),
FlashStep("Partitie tabel", 0x8000, partitions, 40, 10, false),
FlashStep("Boot app0", 0xe000, bootApp0, 50, 10, false),
FlashStep("Firmware", 0x10000, firmware, 60, 35, true)
)
postProgress(callback, 5, "Sync met bootloader...")
val api = EspFlasherApi.connect(transport)
.withCallBack(object : ProgressCallback {
override fun onProgress(pct: Float) {
Log.d(TAG, "Lib progress: $pct%")
}
override fun onInfo(value: String) {
Log.d(TAG, "Info: $value")
}
})
postProgress(callback, 10, "Chip detecteren...")
val detected = api.chipDetect()
val chipId = detected.getChipId()
Log.i(TAG, "Detected chip: $chipId")
postProgress(callback, 15, "Chip: ${chipId.readableName}")
postProgress(callback, 20, "Stub laden...")
val stub = detected.loadStub()
postProgress(callback, 25, "Flash wissen...")
stub.eraseFlash()
for (step in steps) {
val sizeStr = if (step.data.size > 1024) "${step.data.size / 1024}KB" else "${step.data.size} bytes"
postProgress(callback, step.baseProgress, "${step.name} schrijven ($sizeStr)...")
val writeStub = if (step.compress) stub.withCompression(true) else stub.withCompression(false)
writeStub.writeFlash(step.address, step.data)
val written = steps.takeWhile { it !== step }.sumOf { it.data.size } + step.data.size
val overallProgress = step.baseProgress + (step.range * step.data.size / totalSize).coerceAtLeast(1) + step.range
postProgress(callback, minOf(step.baseProgress + step.range, 95), "${step.name} geschreven")
}
postProgress(callback, 95, "ESP resetten...")
stub.reset()
postProgress(callback, 100, "Firmware geflashed!")
handler.post { callback.onComplete() }
} finally {
connection.close()
}
}
```
- [ ] **Step 2: Remove the now-duplicated private methods**
Delete `findCdcDataInterface` and `findBulkEndpoint` from EspFlasher.kt (they are now in UsbUtils).
- [ ] **Step 3: Build to verify**
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Step 4: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/EspFlasher.kt
git commit -m "Fix EspFlasher: connection leak, progress mapping, use UsbUtils"
```
---
### Task 4: Fix SettingsActivity — UsbUtils + thread lifecycle
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt`
- [ ] **Step 1: Replace findCdcDataInterface and findBulkEndpoint calls with UsbUtils**
In `readKeyFromEsp`, replace:
```kotlin
val intf = findCdcDataInterface(device)
```
with:
```kotlin
val intf = UsbUtils.findCdcDataInterface(device)
```
Replace:
```kotlin
val epOut = findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
```
with:
```kotlin
val epOut = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_OUT)
```
Replace:
```kotlin
val epIn = findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
```
with:
```kotlin
val epIn = UsbUtils.findBulkEndpoint(intf, UsbConstants.USB_DIR_IN)
```
- [ ] **Step 2: Delete the duplicated private methods**
Delete `findCdcDataInterface` and `findBulkEndpoint` from SettingsActivity.kt.
- [ ] **Step 3: Add lifecycle check in thread callback**
In `doKeyExchange`, replace the `Thread` block:
```kotlin
Thread {
val result = readKeyFromEsp(usbManager, device)
handler.post {
```
with:
```kotlin
Thread {
val result = readKeyFromEsp(usbManager, device)
if (isFinishing || isDestroyed) return@Thread
handler.post {
```
- [ ] **Step 4: Build to verify**
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Step 5: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
git commit -m "Fix SettingsActivity: use UsbUtils, add lifecycle guard"
```
---
### Task 5: Fix MainActivity — receiver flag, handler cleanup, onPause, boolean state
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt`
- [ ] **Step 1: Fix registerReceiver flag**
In `onCreate`, replace:
```kotlin
registerReceiver(bondReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
```
with:
```kotlin
registerReceiver(bondReceiver, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), Context.RECEIVER_NOT_EXPORTED)
```
- [ ] **Step 2: Add SCAN_TIMEOUT_MS constant**
In companion object, add:
```kotlin
private const val SCAN_TIMEOUT_MS = 10000L
```
- [ ] **Step 3: Add boolean for scan timeout state**
Add field:
```kotlin
private var scanTimedOut = false
```
- [ ] **Step 4: Fix stopScan to use boolean**
Replace `stopScan`:
```kotlin
@SuppressLint("MissingPermission")
private fun stopScan() {
if (!isScanning) return
isScanning = false
val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
if (statusText.text == "Scanning...") {
statusText.text = "Device not found"
}
}
```
with:
```kotlin
@SuppressLint("MissingPermission")
private fun stopScan() {
if (!isScanning) return
isScanning = false
val adapter = BluetoothAdapter.getDefaultAdapter()
adapter?.bluetoothLeScanner?.stopScan(scanCallback)
if (scanTimedOut) {
statusText.text = "Device not found"
}
}
```
- [ ] **Step 5: Fix startScan to use constant and boolean**
In `startScan`, replace:
```kotlin
handler.postDelayed({
if (isScanning) stopScan()
}, 10000)
```
with:
```kotlin
scanTimedOut = true
handler.postDelayed({
if (isScanning) stopScan()
}, SCAN_TIMEOUT_MS)
```
And add `scanTimedOut = false` after `isScanning = true`:
```kotlin
isScanning = true
scanTimedOut = false
statusText.text = "Scanning..."
```
- [ ] **Step 6: Add onPause**
Add after `onResume`:
```kotlin
override fun onPause() {
super.onPause()
stopScan()
}
```
- [ ] **Step 7: Fix onDestroy — add handler cleanup**
Replace `onDestroy`:
```kotlin
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onDestroy() {
super.onDestroy()
stopScan()
bluetoothGatt?.close()
unregisterReceiver(bondReceiver)
}
```
with:
```kotlin
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacksAndMessages(null)
stopScan()
bluetoothGatt?.close()
try { unregisterReceiver(bondReceiver) } catch (e: Exception) {}
}
```
- [ ] **Step 8: Build to verify**
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Step 9: Commit**
```bash
git add android/app/src/main/java/com/eddy/atomrelay/MainActivity.kt
git commit -m "Fix MainActivity: receiver flag, handler cleanup, onPause, boolean state"
```
---
### Task 6: Fix AndroidManifest — allowBackup=false
**Files:**
- Modify: `android/app/src/main/AndroidManifest.xml:14`
- [ ] **Step 1: Change allowBackup**
Replace:
```xml
android:allowBackup="true"
```
with:
```xml
android:allowBackup="false"
```
- [ ] **Step 2: Commit**
```bash
git add android/app/src/main/AndroidManifest.xml
git commit -m "Disable allowBackup to protect HMAC key from extraction"
```
---
### Task 7: Fix activity_settings.xml — download-modus text
**Files:**
- Modify: `android/app/src/main/res/layout/activity_settings.xml:31`
- [ ] **Step 1: Fix text**
Replace:
```xml
android:text="Sluit de ESP aan op USB en zet de ESP in download-modus (houd reset 2s in tot de groene LED knippert). Tap dan op 'Firmware Flashen'."
```
with:
```xml
android:text="Zet de ESP in download-modus (houd reset 2s in tot de groene LED aangaat) en tik op 'Firmware Flashen'."
```
- [ ] **Step 2: Commit**
```bash
git add android/app/src/main/res/layout/activity_settings.xml
git commit -m "Fix download-modus instruction text in settings layout"
```
---
### Task 8: Final build and install
- [ ] **Step 1: Build**
Run: `JAVA_HOME=/home/linuxbrew/.linuxbrew/Cellar/openjdk@21/21.0.12/libexec ./gradlew assembleDebug`
Expected: BUILD SUCCESSFUL
- [ ] **Step 2: Install**
Run: `adb install -r android/app/build/outputs/apk/debug/app-debug.apk`
Expected: Success
- [ ] **Step 3: Commit plan document**
```bash
git add docs/superpowers/plans/2026-07-26-code-review-fixes.md
git commit -m "Add code review fixes implementation plan"
```

View file

@ -0,0 +1,499 @@
# ESP32-PICO-D4 (M5Stack Atom Lite) Port — Implementation Plan
> **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:** Port the Sylvia Touch Relay project from ESP32-S3-Pico to ESP32-PICO-D4 (Atom Lite) with relay on G32, touch on G33 (pullup), SK6812 LED on G27, CDC-class USB detection in the Android app, and deploy via ADB wireless.
**Architecture:** Create side branch `esp32picod4` via git worktree. Replace platformio.ini S3 config with `m5stack-atom` board + FastLED library. Update pin definitions and LED code in main.cpp. Replace Android USB VID filter with CDC-ACM class filter. No DTR/RTS changes needed — the esp32-flash-lib handles it correctly.
**Tech Stack:** C++/Arduino (FastLED), PlatformIO (espressif32@6.7.0), Android/Kotlin (AGP 8.5.2, Gradle 8.14), ADB (wireless debugging)
---
### Task 1: Create sidebranch using git worktree
**Files:**
- Create: `.worktrees/esp32picod4/` (git worktree directory)
- [ ] **Step 1: Create and switch to sidebranch in worktree**
```bash
git worktree add -b esp32picod4 .worktrees/esp32picod4
```
- [ ] **Step 2: Verify branch is active**
```bash
git -C .worktrees/esp32picod4 branch --show-current
```
Expected: `esp32picod4`
---
### Task 2: Update PlatformIO configuration for PICO-D4
**Files:**
- Modify: `platformio.ini`
**Worktree path:** `.worktrees/esp32picod4/platformio.ini`
- [ ] **Step 1: Replace entire platformio.ini content**
```ini
[env:m5stack-atom]
platform = espressif32@6.7.0
board = m5stack-atom
framework = arduino
build_flags =
-DESP32
-DCORE_DEBUG_LEVEL=5
lib_deps =
fastled/FastLED@^3.7.0
upload_port = /dev/ttyUSB0
upload_speed = 115200
```
- [ ] **Step 2: Verify m5stack-atom board exists in PlatformIO**
```bash
pio boards --json-output 2>/dev/null | python3 -c "import json,sys; boards=[b['id'] for b in json.load(sys.stdin) if 'm5stack-atom' in b['id']]; print('\n'.join(boards) if boards else 'NOT FOUND')"
```
Expected: `m5stack-atom` (or similar variant)
- [ ] **Step 3: Commit platformio.ini**
```bash
git -C .worktrees/esp32picod4 add platformio.ini
git -C .worktrees/esp32picod4 commit -m "feat: switch platformio config to m5stack-atom (ESP32-PICO-D4)"
```
---
### Task 3: Update firmware for Atom Lite (pins, LED, BLE name)
**Files:**
- Modify: `src/main.cpp`
**Worktree path:** `.worktrees/esp32picod4/src/main.cpp`
- [ ] **Step 1: Replace pin definitions (lines 10-12)**
Replace:
```cpp
#define SENSOR_PIN 38
#define RELAY_PIN 1
#define LED_PIN 46
```
With:
```cpp
#include <FastLED.h>
#define SENSOR_PIN 33
#define RELAY_PIN 32
#define LED_PIN 27
#define NUM_LEDS 1
CRGB leds[NUM_LEDS];
```
- [ ] **Step 2: Replace setup() LED init and blink code (lines 139-149)**
Replace:
```cpp
pinMode(SENSOR_PIN, INPUT_PULLUP);
pinMode(RELAY_PIN, OUTPUT);
pinMode(LED_PIN, OUTPUT);
for (int i = 0; i < 3; i++) {
digitalWrite(LED_PIN, HIGH);
delay(200);
digitalWrite(LED_PIN, LOW);
delay(200);
}
Serial.println("LED blink done, starting BLE...");
```
With:
```cpp
pinMode(SENSOR_PIN, INPUT_PULLUP);
pinMode(RELAY_PIN, OUTPUT);
FastLED.addLeds<SK6812, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(32);
for (int i = 0; i < 3; i++) {
leds[0] = CRGB::Green;
FastLED.show();
delay(200);
leds[0] = CRGB::Black;
FastLED.show();
delay(200);
}
Serial.println("LED blink done, starting BLE...");
```
- [ ] **Step 3: Replace Serial banner and BLE device name**
Replace (line 125):
```cpp
Serial.println("=== Atom VoiceS3R BLE Relay ===");
```
With:
```cpp
Serial.println("=== Atom Lite BLE Relay ===");
```
Replace (line 151):
```cpp
BLEDevice::init("AtomS3R-Relay");
```
With:
```cpp
BLEDevice::init("SyldaServer");
```
Replace (line 174):
```cpp
Serial.println("BLE: advertising started as 'AtomS3R-Relay'");
```
With:
```cpp
Serial.println("BLE: advertising started as 'SyldaServer'");
```
- [ ] **Step 4: Verify main.cpp has no remaining S3-specific or AtomS3R references**
```bash
grep -n "S3R\|S3\|Atom Voice\|46\|G38\|G1\b" .worktrees/esp32picod4/src/main.cpp
```
Expected: no output (no matches)
- [ ] **Step 5: Commit main.cpp**
```bash
git -C .worktrees/esp32picod4 add src/main.cpp
git -C .worktrees/esp32picod4 commit -m "feat: port firmware to Atom Lite (G32 relais, G33 touch, G27 SK6812 LED, SyldaServer BLE)"
```
---
### Task 4: Update copy_firmware.sh for new environment name
**Files:**
- Modify: `tools/copy_firmware.sh`
**Worktree path:** `.worktrees/esp32picod4/tools/copy_firmware.sh`
- [ ] **Step 1: Change environment directory name (line 4)**
Replace:
```bash
BINARY_DIR="$(cd "$(dirname "$0")/.." && pwd)/.pio/build/m5stack-atoms3r"
```
With:
```bash
BINARY_DIR="$(cd "$(dirname "$0")/.." && pwd)/.pio/build/m5stack-atom"
```
- [ ] **Step 2: Commit**
```bash
git -C .worktrees/esp32picod4 add tools/copy_firmware.sh
git -C .worktrees/esp32picod4 commit -m "chore: update copy_firmware.sh for m5stack-atom environment"
```
---
### Task 5: Add CDC-class USB detection to UsbUtils.kt
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt`
**Worktree path:** `.worktrees/esp32picod4/android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt`
- [ ] **Step 1: Add hasCdcDataInterface function**
After the `findBulkEndpoint` function (before the closing `}` of `object UsbUtils`), add:
```kotlin
fun hasCdcDataInterface(device: UsbDevice): Boolean {
for (i in 0 until device.interfaceCount) {
if (device.getInterface(i).interfaceClass == CDC_DATA_INTERFACE_CLASS) {
return true
}
}
return false
}
```
- [ ] **Step 2: Commit**
```bash
git -C .worktrees/esp32picod4 add android/app/src/main/java/com/eddy/atomrelay/UsbUtils.kt
git -C .worktrees/esp32picod4 commit -m "feat: add CDC-class USB detection for universal bridge support"
```
---
### Task 6: Replace VID filter with CDC-class filter in SettingsActivity.kt
**Files:**
- Modify: `android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt`
**Worktree path:** `.worktrees/esp32picod4/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt`
- [ ] **Step 1: Remove ESP_VENDOR_ID constant (line 39)**
Delete:
```kotlin
private const val ESP_VENDOR_ID = 0x303a
```
- [ ] **Step 2: Replace VID check in usbAttachedReceiver (line 67)**
Replace:
```kotlin
if (device?.vendorId == ESP_VENDOR_ID) {
```
With:
```kotlin
if (device != null && UsbUtils.hasCdcDataInterface(device)) {
```
- [ ] **Step 3: Replace VID check in usbPermissionReceiver (line 85)**
Replace:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
```
With:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { UsbUtils.hasCdcDataInterface(it) }
```
- [ ] **Step 4: Replace VID check in usbDetachedReceiver (line 101)**
Replace:
```kotlin
if (device?.vendorId == ESP_VENDOR_ID) {
```
With:
```kotlin
if (device != null && UsbUtils.hasCdcDataInterface(device)) {
```
- [ ] **Step 5: Replace VID check in startKeyExchange (line 208)**
Replace:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
```
With:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { UsbUtils.hasCdcDataInterface(it) }
```
- [ ] **Step 6: Replace VID check in startFlashing (line 256)**
Replace:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { it.vendorId == ESP_VENDOR_ID }
```
With:
```kotlin
val device = usbManager.deviceList.values.firstOrNull { UsbUtils.hasCdcDataInterface(it) }
```
- [ ] **Step 7: Verify no remaining ESP_VENDOR_ID references**
```bash
grep -n "ESP_VENDOR_ID\|vendorId.*==" .worktrees/esp32picod4/android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
```
Expected: no output
- [ ] **Step 8: Commit**
```bash
git -C .worktrees/esp32picod4 add android/app/src/main/java/com/eddy/atomrelay/SettingsActivity.kt
git -C .worktrees/esp32picod4 commit -m "feat: replace USB VID filter with CDC-ACM class detection"
```
---
### Task 7: Compile firmware
**Files:** N/A (build artifacts in `.pio/build/m5stack-atom/`)
**Work tree path:** `.worktrees/esp32picod4/`
- [ ] **Step 1: Clean and compile**
```bash
pio run -d .worktrees/esp32picod4 -e m5stack-atom -t clean
pio run -d .worktrees/esp32picod4 -e m5stack-atom
```
Expected: `SUCCESS` at end of output
- [ ] **Step 2: Verify build output files**
```bash
ls -la .worktrees/esp32picod4/.pio/build/m5stack-atom/firmware.bin \
.worktrees/esp32picod4/.pio/build/m5stack-atom/bootloader.bin \
.worktrees/esp32picod4/.pio/build/m5stack-atom/partitions.bin
```
Expected: all 3 files exist with non-zero size
---
### Task 8: Copy firmware binaries to Android app assets
**Files:**
- Modify: `android/app/src/main/assets/firmware/` (destination)
**Worktree path:** `.worktrees/esp32picod4/`
- [ ] **Step 1: Run copy script**
```bash
bash .worktrees/esp32picod4/tools/copy_firmware.sh
```
Expected: lists 4 files (bootloader.bin, partitions.bin, firmware.bin, boot_app0.bin)
- [ ] **Step 2: Verify boot_app0.bin is also present**
```bash
ls -la .worktrees/esp32picod4/android/app/src/main/assets/firmware/boot_app0.bin
```
Expected: file exists
- [ ] **Step 3: Commit binary assets**
```bash
git -C .worktrees/esp32picod4 add android/app/src/main/assets/firmware/
git -C .worktrees/esp32picod4 commit -m "feat: add Atom Lite firmware binaries to Android assets"
```
---
### Task 9: Build Android APK
**Files:** N/A (build output `android/app/build/outputs/apk/debug/app-debug.apk`)
**Worktree path:** `.worktrees/esp32picod4/android/`
- [ ] **Step 1: Build debug APK**
```bash
JAVA_HOME=/home/linuxbrew/.linuxbrew/opt/openjdk@21/libexec ./gradlew assembleDebug
```
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 2: Verify APK exists**
```bash
ls -la app/build/outputs/apk/debug/app-debug.apk
```
Expected: file exists with size > 1MB
---
### Task 10: Connect Android phone via ADB wireless
**Files:** N/A (ADB system tool)
- [ ] **Step 1: Load adb-wireless skill**
Use the `adb-wireless` skill to establish a wireless ADB connection to the Android phone.
- [ ] **Step 2: Verify ADB connection**
```bash
adb devices
```
Expected: `<ip>:5555` listed as `device`
---
### Task 11: Install APK on phone
**Files:** N/A (app deployment)
- [ ] **Step 1: Install APK**
```bash
adb install -r .worktrees/esp32picod4/android/app/build/outputs/apk/debug/app-debug.apk
```
Expected: `Success`
- [ ] **Step 2: Launch the app**
```bash
adb shell am start -n com.eddy.atomrelay/.MainActivity
```
---
### Task 12: Flash firmware to Atom Lite via Android app
**Files:** N/A (manual device action)
- [ ] **Step 1: Connect Atom Lite via USB-OTG to the phone**
- [ ] **Step 2: Open the app, tap settings gear → "Firmware Flashen"**
- [ ] **Step 3: If app doesn't auto-detect, unplug/replug the Atom Lite while the dialog is open**
- [ ] **Step 4: Wait for flash to complete (progress bar reaches 100%)**
- [ ] **Step 5: After flash, disconnect USB — the Atom Lite should reboot with green LED blink 3x**
---
### Task 13: Provision HMAC key via app
**Files:** N/A (manual device action)
- [ ] **Step 1: Reconnect Atom Lite via USB-OTG to phone (NOT in download mode — just power on normally)**
- [ ] **Step 2: Open app → Settings → "Sleutel Uitwisselen"**
- [ ] **Step 3: Wait for toast "Beveiligingssleutel gelezen" or provisioning**
- [ ] **Step 4: If key was newly generated, verify Serial monitor shows "HMAC key stored in NVS"**
---
### Task 14: Verify BLE connection and relay control
**Files:** N/A (manual verification)
- [ ] **Step 1: Power on the Atom Lite (with relay and touch sensor connected)**
- [ ] **Step 2: Open the Android app — verify it auto-connects to "SyldaServer"**
- [ ] **Step 3: Verify status shows "Connected"**
- [ ] **Step 4: Hold HOLD button — verify relay activates (click or multimeter)**
- [ ] **Step 5: Release — verify relay deactivates**
- [ ] **Step 6: Touch sensor on G33 — verify relay also activates**
---
## Self-Review
**1. Spec coverage:**
- Sidebranch `esp32picod4`: Task 1 ✓
- Compile for ESP32-PICO-D4: Task 2 ✓
- Pins G32/G33/G27: Task 3 ✓
- SK6812 LED with FastLED: Task 3 ✓
- BLE name "SyldaServer": Task 3 ✓
- CDC-class USB detection: Tasks 5, 6 ✓
- DTR/RTS no changes: Confirmed in research, no task needed ✓
- Bundle binary with Android app: Task 8 ✓
- Build APK: Task 9 ✓
- ADB wireless deploy: Tasks 10, 11 ✓
- Flash + provision + test: Tasks 12-14 ✓
**2. Placeholder scan:** No TBD, TODO, or incomplete sections. All commands are concrete with expected output.
**3. Type consistency:** `hasCdcDataInterface(device: UsbDevice): Boolean` defined in Task 5, used consistently in Task 6 with `UsbUtils.hasCdcDataInterface(device/it)`.

View file

@ -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<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**
```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<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**
```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<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**
```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

View file

@ -0,0 +1,552 @@
# Release Build & Forgejo Publishing — Implementation Plan
> **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:** Signed APK + AAB release builds, published as Forgejo release with firmware attachments.
**Architecture:** A `signing.properties` file (gitignored) provides Gradle with keystore credentials. A `scripts/release.sh` script builds APK and AAB, collects firmware, tags git, and uploads everything to Forgejo via HTTP API.
**Tech Stack:** Gradle 8.14, AGP 8.5.2, keytool, curl, Forgejo API v1
---
### Pre-check: BUILD_NUMBER timestamp
`buildConfigField "String", "BUILD_NUMBER", ..."` staat in `defaultConfig` in `build.gradle`, maar `BUILD_NUMBER` wordt nergens in de Kotlin code gebruikt. Geen impact op release build reproduceerbaarheid. Het veld blijft bestaan (geen actie nodig).
---
### Task 1: Genereer keystore en sla op in Obsidian vault
**Files:**
- Create: `android/keystore/sylda-release.jks` (gitignored, later)
- Create: Obsidian vault notitie met credentials
- [ ] **Step 1: Genereer keystore in workspace**
Run vanaf de project root:
```bash
keytool -genkey -v \
-keystore android/keystore/sylda-release.jks \
-alias sylda \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-storepass "SyldaRel34se2026!" \
-keypass "SyldaRel34se2026!" \
-dname "CN=Eddy Devink, OU=SylDa, O=SylDa, L=Unknown, ST=Unknown, C=NL"
```
Verify:
```bash
keytool -list -v -keystore android/keystore/sylda-release.jks -storepass "SyldaRel34se2026!" | head -10
```
Expected: Alias `sylda`, certificate details.
- [ ] **Step 2: Bewaar in Obsidian vault**
Maak notitie `Homelab/Project_SylDa/keystore.md` via `obsidian-vault` skill of directe CouchDB API met:
```
# SylDa Release Keystore
- Path workspace: android/keystore/sylda-release.jks
- Alias: sylda
- Key password: SyldaRel34se2026!
- Store password: SyldaRel34se2026!
- Validity: 10000 days (tot ~2053)
- Keyalg: RSA 2048
## Keytool commando (hergeneratie)
keytool -genkey -v -keystore android/keystore/sylda-release.jks -alias sylda -keyalg RSA -keysize 2048 -validity 10000 -storepass "SyldaRel34se2026!" -keypass "SyldaRel34se2026!" -dname "CN=Eddy Devink, OU=SylDa, O=SylDa, L=Unknown, ST=Unknown, C=NL"
```
- [ ] **Step 3: Commit (keystore wordt later gitignored in Task 4 — nu nog niet gecommit)**
Geen git actie nog. Task 4 voegt gitignore toe.
---
### Task 2: Maak signing.properties
**Files:**
- Create: `android/signing.properties` (gitignored in Task 4)
- [ ] **Step 1: Creëer signing.properties**
Run:
```bash
cat > android/signing.properties << 'EOF'
KEYSTORE_PATH=keystore/sylda-release.jks
KEYSTORE_PASSWORD=SyldaRel34se2026!
KEY_ALIAS=sylda
KEY_PASSWORD=SyldaRel34se2026!
EOF
```
- [ ] **Step 2: Verifieer bestand**
```bash
cat android/signing.properties
```
Expected: 4 properties zoals hierboven.
---
### Task 3: Update build.gradle met signing config
**Files:**
- Modify: `android/app/build.gradle` (hele bestand overschrijven)
- [ ] **Step 1: Voeg signing block toe**
Huidig bestand (`android/app/build.gradle`):
```groovy
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'nl.eddydevink.sylda'
compileSdk 34
defaultConfig {
applicationId "nl.eddydevink.sylda"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
buildConfigField "String", "BUILD_NUMBER", "\"${new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new Date())}\""
}
buildFeatures {
buildConfig true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'io.github.dkaukov:esp32-flash-lib:1.1.17'
implementation 'com.eclipsesource.minimal-json:minimal-json:0.9.5'
implementation 'org.slf4j:slf4j-nop:2.0.16'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
}
```
Vervangen door (signing block toegevoegd na `buildTypes {}`):
```groovy
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
def signingPropsFile = rootProject.file('signing.properties')
def hasSigningConfig = signingPropsFile.exists()
android {
namespace 'nl.eddydevink.sylda'
compileSdk 34
if (hasSigningConfig) {
def props = new Properties()
props.load(new FileInputStream(signingPropsFile))
signingConfigs {
release {
storeFile rootProject.file(props['KEYSTORE_PATH'])
storePassword props['KEYSTORE_PASSWORD']
keyAlias props['KEY_ALIAS']
keyPassword props['KEY_PASSWORD']
}
}
}
defaultConfig {
applicationId "nl.eddydevink.sylda"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
buildConfigField "String", "BUILD_NUMBER", "\"${new java.text.SimpleDateFormat("yyyyMMddHHmmss").format(new Date())}\""
}
buildFeatures {
buildConfig true
}
buildTypes {
release {
if (hasSigningConfig) {
signingConfig signingConfigs.release
}
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'io.github.dkaukov:esp32-flash-lib:1.1.17'
implementation 'com.eclipsesource.minimal-json:minimal-json:0.9.5'
implementation 'org.slf4j:slf4j-nop:2.0.16'
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
}
```
- [ ] **Step 2: Sync Gradle**
```bash
./gradlew --no-daemon dependencies > /dev/null 2>&1
echo $?
```
Expected: `0` (geen Gradle-fouten).
- [ ] **Step 3: Commit**
```bash
git add android/app/build.gradle
git commit -m "feat: add release signing config via signing.properties"
```
---
### Task 4: Update .gitignore
**Files:**
- Modify: `.gitignore` (toevoegen van nieuwe ignore-regels)
- [ ] **Step 1: Voeg keystore en signing.properties toe aan .gitignore**
Toevoegen aan `.gitignore`, onder de Android/Gradle sectie:
```
# Android signing (contains secrets)
android/keystore/
android/signing.properties
```
- [ ] **Step 2: Verifieer dat keystore nu gitignored is**
```bash
git status android/keystore/ android/signing.properties
```
Expected: geen output (bestanden worden genegeerd).
- [ ] **Step 3: Commit**
```bash
git add .gitignore
git commit -m "chore: gitignore keystore and signing.properties"
```
---
### Task 5: Maak release script
**Files:**
- Create: `scripts/release.sh`
- [ ] **Step 1: Creëer scripts/ director**
```bash
mkdir -p scripts
```
- [ ] **Step 2: Schrijf release.sh**
```bash
cat > scripts/release.sh << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
log() { echo -e "${GREEN}[release]${NC} $1"; }
err() { echo -e "${RED}[release]${NC} $1" >&2; }
# ------------------------------------------------------------------ config
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
ANDROID_DIR="$PROJECT_DIR/android"
APP_BUILD_GRADLE="$ANDROID_DIR/app/build.gradle"
SIGNING_PROPS="$ANDROID_DIR/signing.properties"
FORGEJO_URL="https://git.eddydevink.nl/api/v1/repos/eddy/Project_SylDa"
FIRMWARE_DIR="$ANDROID_DIR/app/src/main/assets/firmware"
# lees versionName uit build.gradle
VERSION=$(grep -oP 'versionName\s+"\K[^"]+' "$APP_BUILD_GRADLE")
TAG="v$VERSION"
RELEASE_DIR="$PROJECT_DIR/release/$TAG"
log "Release v$VERSION (tag $TAG)"
# ------------------------------------------------------------------ checks
if [ ! -f "$SIGNING_PROPS" ]; then
err "signing.properties niet gevonden op $SIGNING_PROPS"
exit 1
fi
# ------------------------------------------------------------------ clean
log "Clean..."
cd "$ANDROID_DIR"
./gradlew clean
# ------------------------------------------------------------------ build
log "Build APK + AAB..."
./gradlew assembleRelease bundleRelease
# ------------------------------------------------------------------ verzamel artefacten
log "Verzamel artefacten in $RELEASE_DIR"
mkdir -p "$RELEASE_DIR"
cp "$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk" "$RELEASE_DIR/"
cp "$ANDROID_DIR/app/build/outputs/bundle/release/app-release.aab" "$RELEASE_DIR/"
cp "$FIRMWARE_DIR/bootloader.bin" "$RELEASE_DIR/"
cp "$FIRMWARE_DIR/partitions.bin" "$RELEASE_DIR/"
cp "$FIRMWARE_DIR/boot_app0.bin" "$RELEASE_DIR/"
cp "$FIRMWARE_DIR/firmware.bin" "$RELEASE_DIR/"
# ------------------------------------------------------------------ checksums
log "Checksums..."
cd "$RELEASE_DIR"
sha256sum * > checksums.txt
cat checksums.txt
# ------------------------------------------------------------------ git tag
cd "$PROJECT_DIR"
if git rev-parse "$TAG" >/dev/null 2>&1; then
log "Tag $TAG bestaat al, skip git tag."
else
log "Git tag $TAG..."
git tag "$TAG"
git push origin "$TAG"
fi
# ------------------------------------------------------------------ Forgejo release
log "Forgejo release aanmaken..."
TOKEN_FILE="$HOME/.config/sylda/forgejo-token"
if [ ! -f "$TOKEN_FILE" ]; then
err "Forgejo token niet gevonden op $TOKEN_FILE"
err "Genereer een token in Forgejo: Settings > Applications > Generate Token (scope: write:repository)"
err "Sla op als: echo 'je-token' > $TOKEN_FILE"
exit 1
fi
TOKEN=$(cat "$TOKEN_FILE")
# maak release aan
RELEASE_RESP=$(curl -s -X POST "$FORGEJO_URL/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$TAG\", \"name\": \"$TAG\", \"body\": \"SylDa Android app + firmware\", \"draft\": false, \"prerelease\": false}")
RELEASE_ID=$(echo "$RELEASE_RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
err "Release aanmaken mislukt. Response:"
echo "$RELEASE_RESP"
exit 1
fi
log "Release ID: $RELEASE_ID"
# upload artefacten
for f in *; do
log " Upload $f..."
curl -s -X POST "$FORGEJO_URL/releases/$RELEASE_ID/assets?name=$f" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$f" > /dev/null
done
log "Klaar! Release $TAG: $FORGEJO_URL/releases/tag/$TAG (vervang api/v1/repos door web URL)"
SCRIPT
chmod +x scripts/release.sh
```
- [ ] **Step 3: Verifieer script syntax**
```bash
bash -n scripts/release.sh
echo $?
```
Expected: `0`.
- [ ] **Step 4: Commit**
```bash
git add scripts/release.sh
git commit -m "feat: add release build script"
```
---
### Task 6: Droogtest build
**Files:**
- Geen wijzigingen — verifieer dat release build werkt
- [ ] **Step 1: Controleer dat keystore en signing.properties aanwezig zijn**
```bash
ls -la android/keystore/sylda-release.jks android/signing.properties
```
- [ ] **Step 2: Build release APK + AAB**
```bash
./gradlew assembleRelease bundleRelease
```
Expected: `BUILD SUCCESSFUL`
- [ ] **Step 3: Verifieer ondertekening APK**
```bash
jarsigner -verify -verbose -certs android/app/build/outputs/apk/release/app-release.apk 2>&1 | grep -E "CN=|jar verified"
```
Expected melding: `CN=Eddy Devink` en `jar verified`.
- [ ] **Step 4: Verifieer ondertekening AAB**
```bash
jarsigner -verify -verbose -certs android/app/build/outputs/bundle/release/app-release.aab 2>&1 | grep -E "CN=|jar verified"
```
Expected melding: `CN=Eddy Devink` en `jar verified`.
---
### Task 7: Maak Forgejo access token aan
**Files:**
- Create: `~/.config/sylda/forgejo-token` (lokaal configuratiebestand, niet in repo)
- [ ] **Step 1: Maak token in Forgejo web UI**
Navigeer in browser naar: `https://git.eddydevink.nl/user/settings/applications`
- Klik "Generate Token"
- Token name: `syl-release`
- Scope: vink `write:repository` aan
- Klik "Generate"
Kopieer de gegenereerde token.
- [ ] **Step 2: Sla token op**
```bash
mkdir -p ~/.config/sylda
echo "<gekopieerde-token>" > ~/.config/sylda/forgejo-token
chmod 600 ~/.config/sylda/forgejo-token
```
- [ ] **Step 3: Sla token op in Obsidian vault**
Maak notitie `Homelab/Project_SylDa/forgejo-token.md` met:
```
# Forgejo API Token — syl-release
Token voor release uploads. Scope: write:repository.
Gegenereerd op: <datum>
In: Forgejo web UI → Settings → Applications
Token: <token>
Bestand: ~/.config/sylda/forgejo-token (chmod 600)
```
- [ ] **Step 4: Verifieer token werkt**
```bash
ssh aiaiaiaiagent@nextcloud 'curl -s http://localhost:3000/api/v1/user -H "Authorization: token '"$(cat ~/.config/sylda/forgejo-token)"'"' | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d.get("login","FAIL"))'
```
Expected: `eddy` (de gebruikersnaam in Forgejo).
---
### Task 8: Draai volledige release
**Files:**
- Geen wijzigingen — voert het script uit
- [ ] **Step 1: Check git status (schoon)**
```bash
git status
```
Expected: `nothing to commit, working tree clean`
- [ ] **Step 2: Draai release script**
```bash
./scripts/release.sh
```
- [ ] **Step 3: Verifieer release op Forgejo**
```bash
ssh aiaiaiaiagent@nextcloud 'curl -s http://localhost:3000/api/v1/repos/eddy/Project_SylDa/releases/tags/v1.0' | python3 -c 'import sys,json; r=json.load(sys.stdin); print(f"Tag: {r[\"tag_name\"]}, Assets: {len(r[\"assets\"])}"); [print(f" - {a[\"name\"]} ({a[\"size\"]} bytes)") for a in r["assets"]]'
```
Expected: Tag `v1.0`, 6 assets (APK, AAB, 4 firmware bins, checksums.txt).
- [ ] **Step 4: Open browser ter verificatie**
Open `https://git.eddydevink.nl/eddy/Project_SylDa/releases` en controleer dat release `v1.0` zichtbaar is met alle attachments.
---
### Post-release: Cleanup
Release artefacten in `release/v1.0/` worden niet gecommit (`.gitignore` sluit `*.apk` en `*.aab` al uit). Na release draaien kan deze directory verwijderd worden:
```bash
rm -rf release/
```