aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-19 22:28:09 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-19 22:28:09 +0200
commit81a5b903297e59d599dd0d6f580b89597630909d (patch)
tree1af601e39e46957fdb57f684befea6bfe3de7849
parent2373b6f02ef027a423762d5d6349cd9e0f36090e (diff)
docs: record M4 results and simulator findings; bump to 0.4.0v0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--CHANGELOG.md36
-rw-r--r--README.md18
-rw-r--r--docs/DEVELOPER_GUIDE.md40
-rw-r--r--docs/FEASIBILITY.md7
-rw-r--r--docs/PERFORMANCE.md15
-rw-r--r--docs/SECURITY.md5
-rw-r--r--docs/TESTING.md31
7 files changed, 132 insertions, 20 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4a7737..75f3850 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,42 @@ All notable changes to this project are documented here. The format follows
## [Unreleased]
+## [0.4.0] - 2026-10-19
+
+Milestone M4: the screens. The app is usable in the simulator with demo
+entries; adding real entries comes with M5 (import) and M7 (settings).
+
+### Added
+
+- List of entries with live codes, countdowns, lock and barcode icons, and
+ groups. Rows adapt to narrow screens, and headers are read only for the
+ visible rows.
+- OTP detail: large grouped code, countdown ring (bar on rectangular screens),
+ next code in the last 5 seconds, HOTP "next" with the counter, entry ID.
+- PIN screen: touch keypad or button digit selector. It covers create (twice,
+ with a warning below 6 digits), unlock and change. Wrong PINs vibrate, and
+ the lockout wait is shown. Pending locked entries are promoted after
+ unlocking.
+- Menu: lock now, set/change/reset PIN, settings results, entry IDs, clock
+ status, about. Debug builds add demo entries, a performance overlay and an
+ on-watch benchmark.
+- Glance with the number of entries; the crypto self-test on first start and
+ daily; auto-lock while a locked entry is open.
+- Option properties with defaults (`resources/settings/properties.xml`).
+- UI-test build with fake entries, PIN and a frozen clock. A scripted UI flow
+ (`tools/ui_flow.py`) checks 10 screens on each of seven simulated watches.
+ Also new: `tools/gen_fixture_wrap.py`, and 7 Monkey C and 6 Python tests.
+
+### Changed
+
+- PBKDF2 XORs 32-bit words: 2.3× faster, and 100 iterations per job step,
+ because 200 tripped the watchdog on the Forerunner 255S.
+- Codes are computed in their own timer events, at most one SHA-512 code per
+ event.
+- `make screenshots` runs the scripted flow; goldens are now
+ `test/golden/<device>/<screen>.png`. Start-up budgets are set from the M4
+ measurements.
+
## [0.3.0] - 2026-10-19
Milestone M3: storage, keys and PIN logic. Nothing user-visible yet; the
diff --git a/README.md b/README.md
index 9c1543d..266919b 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
A Garmin Connect IQ watch app that shows one-time passwords (TOTP, HOTP, and optional Steam Guard codes) and stores barcodes and QR codes (loyalty cards, tickets) to show at a scanner. Everything lives encrypted on the watch. There is no cloud, no backend and no analytics, and the app requests no permissions, so it cannot use the network.
-> **Status: M3 (storage, keys and PIN logic) done; M1 (settings spike) ready for your phone test.** The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline. M2 added the crypto and OTP core (SHA-512, HMAC, PBKDF2, HKDF, AES-256 records, Base32, HOTP, TOTP, Steam), verified against official test vectors. M3 added the encrypted vault: records, device and PIN keys, wrong-PIN lockout, session auto-lock, pending entries and migrations ([docs/SECURITY.md](docs/SECURITY.md)). Next is M4, the first real screens. The settings spike (`make spike`, [docs/SETTINGS_SPIKE.md](docs/SETTINGS_SPIKE.md)) waits for your test. The Phase 0 research, with sources, is in [docs/FEASIBILITY.md](docs/FEASIBILITY.md); the daily workflow is in [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md).
+> **Status: M4 (UI core) done; M1 (settings spike) ready for your phone test.** The plan below was approved on 2026-10-18. M0 built the Docker toolchain and pipeline. M2 added the crypto and OTP core, verified against official test vectors. M3 added the encrypted vault ([docs/SECURITY.md](docs/SECURITY.md)). M4 added the screens: the list with live codes, OTP detail with countdown ring, PIN entry (keypad or button selector) with lockout, menu, info screens and the glance, tested by a scripted UI flow with screenshots on seven simulated watches. Next is M5, import. The settings spike (`make spike`, [docs/SETTINGS_SPIKE.md](docs/SETTINGS_SPIKE.md)) waits for your test. The Phase 0 research, with sources, is in [docs/FEASIBILITY.md](docs/FEASIBILITY.md); the daily workflow is in [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md).
"OTP Wallet" is a working title.
@@ -597,7 +597,7 @@ A symbology counts as implemented only when checks 1 to 3 pass. Otherwise it's l
### 13.1 Screens
-- **List (home)**: entries in your order, with group headers if enabled.
+- **List (home)**: entries in your order. With "Show groups" on, each row shows its group in front of the account ("Work · alice"). Separate group header lines were planned, but they need every entry's header at start-up, and M3 measured that as too slow for long lists.
- Unlocked TOTP and HOTP entries show their current code and a small countdown.
- Locked entries show a lock icon and their name (unless names are encrypted), and no code.
- Codes show a barcode or QR icon and their name.
@@ -661,12 +661,12 @@ stateDiagram-v2
| Action | Touch | Buttons |
| --- | --- | --- |
| Scroll the list | Swipe up and down | UP and DOWN |
-| Open an entry | Tap it | START |
+| Open an entry | Tap it (START opens the focused row on watches that also have buttons) | START |
| Back | Swipe right, or BACK | BACK |
| Next HOTP code | Tap the code | START |
-| Menu | Long-press the screen, or MENU | MENU (hold UP on most watches) |
-| Enter a PIN digit | Tap the keypad | UP/DOWN change the digit, START confirms it, BACK deletes |
-| Confirm the PIN | OK | Hold START, or START after the last digit |
+| Menu | Long-press the list, or MENU | MENU (hold UP on most watches) |
+| Enter a PIN digit | Tap the keypad; the delete key deletes | UP/DOWN choose a digit, START adds it, BACK deletes |
+| Confirm the PIN | OK on the keypad, or START | UP/DOWN to "OK", then START |
## 14. Target devices and API levels
@@ -724,7 +724,7 @@ The Garmin SDK and device files are never baked into an image (Garmin's licence)
| `make build` | Debug build for `DEVICE` (default `fr255s`) | no |
| `make test` | Unit tests in the headless simulator on the 3 test devices | no |
| `make sim` | Run the app in the simulator; view it at http://127.0.0.1:6080/vnc.html | localhost only |
-| `make screenshots` | UI screenshot tests and barcode decoding from screenshots | no |
+| `make screenshots` | Scripted UI flow with a screenshot of every screen on `TEST_DEVICES` and `SPOT_DEVICES` (barcode decoding from M6) | no |
| `make screenshots-approve` | Save the current screens as the approved images, after you've reviewed them | no |
| `make icons` | Regenerate the per-size launcher icons after the product list changes | no |
| `make perf` | Performance measurements against the budgets | no |
@@ -922,8 +922,8 @@ Tests are written alongside the code, never after.
### 17.4 Simulator UI tests
-- **Setup**: a debug build with a fake fixture vault and a fixed clock, driven by scripted input through xdotool.
-- **Screenshots** of every screen on the 3 test devices and the spot-check devices are compared with approved goldens, with a pixel tolerance.
+- **Setup**: the UI-test build (`uitest.jungle`, `source/util/TestMode.mc`) starts with fake entries, the PIN 123456 and a frozen clock. `tools/ui_flow.py` drives it through xdotool: it clicks the buttons from the device files, and taps and swipes on touch screens. It waits for each screen's `DIAG shown` line instead of sleeping.
+- **Screenshots** of every screen on the 4 test devices and the 3 spot-check devices are compared with approved goldens in `test/golden/<device>/`. At most 0.01 % of pixels may differ.
- **Barcode screens** must decode with zxing-cpp.
- **TOTP screens** are checked against the expected codes, from the debug build's view-model log and the golden image.
diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md
index 283e51a..7b56f3b 100644
--- a/docs/DEVELOPER_GUIDE.md
+++ b/docs/DEVELOPER_GUIDE.md
@@ -38,10 +38,10 @@ Use other devices with `TEST_DEVICES="fr70 venu3"` on any target that takes a de
| --- | --- |
| `source/` | The watch app (Monkey C), one folder per module (README section 5) |
| `test/` | Monkey C unit tests (`(:test)` functions), compiled only into test builds |
-| `test/golden/` | Approved simulator screenshots, one per test device |
-| `resources/` | Strings, the default launcher icon and, from M7, the settings |
+| `test/golden/` | Approved simulator screenshots: one folder per device, one image per screen of the UI flow |
+| `resources/` | Strings, the default launcher icon, the option properties and, from M7, the settings form |
| `resources-icons/`, `icons.jungle` | Launcher icons at each size the products need, generated by `make icons` |
-| `manifest.xml`, `monkey.jungle`, `test.jungle` | App manifest and build configuration |
+| `manifest.xml`, `monkey.jungle`, `test.jungle`, `uitest.jungle` | App manifest and build configuration |
| `docker/` | Images (`Dockerfile`, `docs.Dockerfile`), hash-locked dependencies, and the container scripts |
| `tools/` | Python tools (test-log judge, screenshot checker, linters, icon generator) and their tests |
| `docs/` | This guide and the other documents |
@@ -53,7 +53,8 @@ Use other devices with `TEST_DEVICES="fr70 venu3"` on any target that takes a de
`docker/scripts/build-app.sh` compiles one device in one mode:
-- `debug`: what `make build`, `make sim`, `make perf` and `make screenshots` use. It contains the `(:debug)` diagnostics.
+- `debug`: what `make build`, `make sim` and `make perf` use. It contains the `(:debug)` diagnostics, the debug menu items (demo entries, performance overlay, on-watch benchmark) and the fixtures.
+- `uitest`: a debug build plus `uitest.jungle`, for `make screenshots`. It keeps the `(:uitest)` half of `source/util/TestMode.mc`, so the app starts with a wiped store, fake entries, the PIN 123456, a frozen clock and a 10 s auto-lock.
- `release`: `monkeyc -r` removes every `(:debug)` declaration; used by `make package`.
- `test`: adds `test.jungle` and `--unit-test`.
@@ -67,6 +68,9 @@ The simulator is a GTK program. The scripts start it on a virtual display (Xvfb
- It keeps a single-instance lock file, `~/Sim-dev`, holding its process ID. That file survives in `.cache/home`, and a later container often reuses the process ID for something else, so the simulator would believe it's already running. `ciq_start_simulator` deletes the stale lock first.
- `monkeydo` exits with 1 even when every unit test passed, so `tools/ciq_test_report.py` judges the console output instead.
- For a normal (non-test) run, `monkeydo` blocks while the app runs and streams the app's `System.println` output. Quotes in that output arrive escaped, which is why the diagnostics use plain `key=value` pairs.
+- **It starts apps that have a glance in the glance.** `ciq_launch_app` waits for the glance's `DIAG shown glance` line, then opens the app the way the simulator accepts. On touch screens that's a tap on the glance; on button-only watches it's DOWN (which selects the glance) and then START. START alone does nothing in the simulator. The press is retried if the app doesn't open.
+- **A second simulator in the same container opens a second window**, and input may go to the wrong one. `ciq_start_simulator` reuses one that's already listening.
+- Both `monkeydo` and the harness append to the app log; with `>` one would overwrite the other's lines.
### Monkey C facts that matter here (verified with SDK 9.2.0)
@@ -74,14 +78,32 @@ The simulator is a GTK program. The scripts start it on a virtual display (Xvfb
- Assigning a value above 255 to a ByteArray element throws; mask with `& 0xFF`.
- Native HMAC supports only SHA-256; HMAC-SHA1 and HMAC-SHA512 go through `Hmac.generic`.
- Function calls are expensive in the VM; hot loops inline small helpers (SHA-512 went 37 % faster that way).
-- The simulator doesn't enforce the watchdog in test runs. Anything long runs as a `Jobs.Job` with a bounded step.
+- **The watchdog** ("Code Executed Too Long") is not enforced in unit-test runs, but it is in normal simulator runs, with the device's `watchdogCount` per event: 120,000 bytecodes on the Forerunner 255/255S, 240,000 on the others. M4 measured what it means. 200 byte-wise PBKDF2 iterations in one event tripped it on the 255S, and so did two SHA-512 codes in one draw. So:
+ - Anything long runs as a `Jobs.Job` with a bounded step (`Kdf.ITERATIONS_PER_STEP` = 100).
+ - Screens compute at most one SHA-512 code per event, in a timer event separate from drawing.
+ - The scripted UI flow runs on the 255S profile, so a regression trips there.
- `StringUtil.convertEncodedString` stops the app with an uncatchable "System Error" on invalid UTF-8; `try` doesn't help. Decode untrusted bytes with `Bytes.toUtf8`, which validates first.
- `Application.Storage` stores ByteArrays (its `ValueType` includes them, unlike `Application.PropertyValueType`), so records are stored as single ByteArrays of known size. Dictionaries passed to it must be typed `Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>`; `Dictionary<String, ...>` doesn't type-check.
- `Array.slice` returns `Array<Object or Null>`; cast it back (`as Array<String>`).
+- `Application.Properties.getValue` on a property that isn't in `properties.xml` stops the app with an uncatchable "Unexpected Type Error". Every option is therefore declared in `resources/settings/properties.xml`.
+- **On touch screens, a tap is delivered as the `onSelect` behaviour first**, and `onTap` only runs if `onSelect` returns false. Delegates that need the tap position (the list, the PIN keypad) return false from `onSelect` on touch screens, and handle the START button in `onKey`.
+- **Glances:** because `getGlanceView` is `(:glance)`, the compiler checks the whole app class against the glance scope. Methods that never run in glance mode opt out with `(:typecheck(disableGlanceCheck))`.
+ - `AppBase.initialize` runs before non-glance code is loaded, so `Diag` (which it calls) is `(:glance)`.
+ - `onStop` also runs after the glance, so it only touches `AppState` once the full app has started.
+- `Attention.vibrate` makes the simulator shake the device drawing, which spoils screenshots; the UI-test build doesn't vibrate.
+- A `ConfirmationDelegate` closes its dialog after `onResponse`, so a view switch from `onResponse` must wait for a short timer.
+- `Attention has :vibrate` inside a class that has a private `vibrate` method warns (the symbol lookup finds the method); the helper is called `buzz`.
-### Screenshots
+### Screenshots and the UI flow
-`tools/sim_screenshot.py` doesn't assume where the watch is drawn in the simulator window. It searches for the device drawing (named in the device's `simulator.json`) in the window capture, comparing only the bezel, because the app draws inside the display area. The match is near-exact (mean difference below 0.3 out of 255); the drawing sits at offset (1, 26) because of the window border and the menu bar. The display rectangle from `simulator.json` is then cut out, round screens get their corners blacked out, and the result is compared with `test/golden/<device>.png`.
+`make screenshots` builds the UI-test variant and runs `tools/ui_flow.py` on every device in `TEST_DEVICES` and `SPOT_DEVICES`.
+
+1. **Input.** The flow clicks the buttons where the device's `simulator.json` draws them. It taps and swipes on touch screens, and long-presses for the menu where there's no MENU button.
+2. **Waiting.** After each input it waits for the screen's `DIAG shown <screen>` line. The list and the OTP screen print it only once every visible code is drawn.
+3. **Touch targets.** It reads where to tap from `DIAG list_focus` (the focused row) and `DIAG pin_keys` (the keypad).
+4. **Comparison.** Each screen is cut out of the window capture by `tools/sim_screenshot.py` and compared with `test/golden/<device>/<screen>.png`. That tool finds the device drawing by its bezel, so it doesn't assume a window layout. Differences go to `build/screenshots/<device>/<screen>-diff.png`.
+
+Because the entry IDs, the clock and the codes are fixed in the UI-test build, every run is pixel-identical. Native `Menu2` menus print nothing, so the flow waits a fixed 1.5 s for them. Touch-only watches show menus as a freely scrolling list without focus, so About is reached only on watches with buttons.
### Performance and memory
@@ -100,5 +122,7 @@ Debug builds print `DIAG first_frame startup_ms=... mem_used=... mem_total=...`
## Adding things
- **A device**: add `<iq:product id="..."/>` to `manifest.xml`, then `make sdk` (downloads its files), `make icons` and `make products` (checks it meets the target rule: a watch, API 5.0 or newer, at least 512 KB).
-- **A test device**: add it to `TEST_DEVICES` in the `Makefile`, run `make screenshots`, review `build/screenshots/<device>.png` and run `make screenshots-approve`.
+- **A test device**: add it to `TEST_DEVICES` (unit tests, perf and screenshots) or `SPOT_DEVICES` (screenshots only) in the `Makefile`. Then run `make screenshots`, review `build/screenshots/<device>/`, and run `make screenshots-approve`.
+- **A screen in the UI flow**: print `Diag.shown("<name>")` after its first complete draw, add the steps to `run_flow` in `tools/ui_flow.py`, and approve the new goldens after reviewing them.
+- **On-watch numbers**: sideload a debug build (`make build DEVICE=fr70`, copy `build/debug/fr70.prg` to the watch's `GARMIN/APPS` folder over USB). Then open the menu and choose "Debug: benchmark".
- **A barcode symbology** (from M6): one encoder module under `source/codes/`, its validator, unit tests with reference patterns, and the checks in [README 12.4](../README.md#124-how-each-symbology-is-proven-scannable).
diff --git a/docs/FEASIBILITY.md b/docs/FEASIBILITY.md
index c16b0ce..f586f10 100644
--- a/docs/FEASIBILITY.md
+++ b/docs/FEASIBILITY.md
@@ -267,6 +267,13 @@ With SDK 9.2.0 and the device files of all 72 products installed:
- **Invalid UTF-8 crashes the string conversion.** `StringUtil.convertEncodedString` stops the app with an uncatchable system error instead of throwing; stored text is validated before decoding.
- **Store-full handling is tested only against a simulated store.** The in-memory test store throws `StorageFullException` at a set size. How the real store behaves when full is checked on the watch (real-watch checklist in [TESTING.md](TESTING.md)).
+## Verified during M4 (2026-10-19)
+
+- **Watchdog (2): enforced in normal simulator runs, not in unit tests.** The limit is the device's `watchdogCount` per event. On the Forerunner 255S (120,000) it stopped 200 PBKDF2 iterations in one event and two SHA-512 codes in one draw. One SHA-512 TOTP code per event fits. Long work is split accordingly ([PERFORMANCE.md](PERFORMANCE.md)).
+- **Glances (7):** the simulator starts a watch app that has a glance in the glance, and the app opens with a tap (touch) or DOWN then START (buttons). The glance and the full app run separately: the app object's `initialize` runs before the non-glance code is loaded.
+- **Properties:** reading a property that isn't declared in `properties.xml` stops the app with an error `try` can't catch. The option properties are declared ahead of M7's form.
+- **Touch input:** on watches with touch and buttons, a tap arrives first as the select behaviour. The Venu X1 has no MENU button, and the Venu Sq 2 has no UP/DOWN.
+
## Decisions
Answered on 2026-10-18:
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index fbc9709..8c5adb4 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -11,7 +11,7 @@ The targets are in [README section 18](../README.md#18-performance-budgets). Thi
- **In-app start-up** (`startup_ms`): debug builds take `System.getTimer()` when the app object is created and again after the first complete frame, and print the difference (`source/util/Diag.mc`). It doesn't include the time the watch needs to load the program before the app object exists.
- **Launch to first frame** (`first_frame_wall_ms`): `docker/scripts/app-diag.sh` measures the wall-clock time from launching `monkeydo` until the app's first-frame line appears. This includes the Java start of `monkeydo`, sending the program to the simulator and loading it, so it is mostly tool overhead; it's only useful to catch large regressions.
- **Memory** (`mem_used_fraction`): `System.getSystemStats()` after the first frame: used memory divided by the memory the device gives watch apps.
-- **On the watch**: the numbers that matter are measured on your Forerunner 70 with a sideloaded debug build that shows them on screen. That arrives with M4; the simulator runs on your PC's CPU and says nothing about watch speed.
+- **On the watch**: the numbers that matter are measured on your Forerunner 70 with a sideloaded debug build: menu, "Debug: benchmark" (and "Debug: overlay" for draw time and memory). The simulator runs on your PC's CPU and says nothing about watch speed.
`make perf` and `make memtest` fail when a value exceeds `tools/budgets.json`.
@@ -19,12 +19,21 @@ The targets are in [README section 18](../README.md#18-performance-budgets). Thi
| Value | Budget | Why |
| --- | --- | --- |
-| `startup_ms` | 3000 ms | Placeholder until M4 sets about 2× the measured baseline |
-| `first_frame_wall_ms` | 15000 ms | Regression guard only (tool overhead dominates) |
+| `startup_ms` | 100 ms | M4 measured 1 to 18 ms; the margin absorbs simulator jitter |
+| `first_frame_wall_ms` | 36000 ms | Regression guard only: about 2× M4's 16-18 s, which include loading the glance and opening the app from it |
| `mem_used_fraction` | 70 % | Hard limit, leaves room for spikes on the 512 KB watches |
## Results
+### M4, 2026-10-19 (version 0.4.0, screens)
+
+- **Start-up.** In-app start-up (object creation to the first complete frame) measured 1 to 18 ms in the simulator; the `startup_ms` budget is now 100 ms. Launch to first frame rose to 16-18 s, because the simulator first loads the glance and the scripts then open the app from it. Its regression guard is now 36 s.
+- **Memory and size.** Memory after the first frame is 12 % on the Forerunner 255S and 8 % on the 768 KB watches. The signed package for 72 products is 4.5 MB.
+- **Watchdog.** The scripted UI flow showed it is enforced in normal simulator runs, at the device's limit per event (120,000 bytecodes on the Forerunner 255S, 240,000 on the others):
+ - **PBKDF2:** 200 byte-wise iterations in one step tripped it on the 255S. Rewritten to XOR 32-bit words, PBKDF2 takes 22 ms per 1,000 iterations in the simulator, down from 52 ms, and a step is now 100 iterations.
+ - **SHA-512:** one SHA-512 code per event fits on the 255S; two in one draw don't. The list and the detail screen compute codes in their own timer events, at most one SHA-512 code per event.
+- **On the watch.** The debug build's menu has "Debug: benchmark", which times TOTP with SHA-1 and SHA-512, PBKDF2 iterations per second, header reading and the self-test, one per timer event. Those are the numbers for README 18's watch targets; they come from your Forerunner 70 (real-watch checklist).
+
### M3, 2026-10-19 (version 0.3.0, storage)
`test/BenchTest.mc` with an in-memory store, simulator in Docker, Forerunner 70 profile:
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
index 6a30397..ee2f94d 100644
--- a/docs/SECURITY.md
+++ b/docs/SECURITY.md
@@ -148,3 +148,8 @@ A locked entry that arrives while the session is locked, or before a PIN exists,
This is best effort, as README 7.8 says.
- **Invalid UTF-8 is checked before decoding.** `StringUtil.convertEncodedString` stops the app with an uncatchable error on invalid UTF-8, so `Bytes.toUtf8` validates first, and damaged text can't crash the app.
+
+## Test builds and fixtures
+
+- **The UI-test build** (`uitest.jungle`) wipes the store at every start and fills it with fake entries. It sets the PIN 123456 by storing a precomputed wrap of a fixed fake master key (`tools/gen_fixture_wrap.py`), and freezes the clock. All of this is `(:uitest)` or `(:debug)` code; release builds, and therefore the Store package, contain none of it.
+- **The PIN screen hands the derivation a copy of the typed digits** and wipes both the copy and its own buffer once the derivation is done. The native HMAC object may keep its own copy (see above).
diff --git a/docs/TESTING.md b/docs/TESTING.md
index a38fd76..54a331e 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -26,6 +26,34 @@ Test devices (`TEST_DEVICES`): Forerunner 255S (weakest: 218 px round MIP, butto
## Results
+### M4, 2026-10-19 (version 0.4.0)
+
+`make ci` passed in full.
+
+- **Monkey C unit tests:** 72 of 72 on each of the four test devices. The 7 new ones cover the code cache (same codes as the OTP module, expiry at the period end, the HOTP counter, invalidation, refusal while locked), code grouping, text helpers and the option defaults. One also opens the UI-test fixture's PIN wrap, which was computed in Python by `tools/gen_fixture_wrap.py`. That cross-checks the wrap format between two implementations.
+- **Python tool tests:** 50 passed. They now include the UI-flow driver's parsing and the fixture-wrap generator, checked against RFC 5869 and against the constant in the app.
+- **Scripted UI flow** (`make screenshots`, `tools/ui_flow.py`) on seven simulated watches: the Forerunner 255S, Venu Sq 2, Forerunner 70 and Forerunner 965, plus the spot checks fēnix 7, Instinct 3 AMOLED 45 mm and Venu X1. Each run does this:
+ 1. Opens the app from its glance.
+ 2. Opens a TOTP entry and a HOTP entry, and gets the next HOTP code.
+ 3. Opens the locked entry: PIN screen, a wrong PIN, the right PIN, then auto-lock closing it.
+ 4. Opens the menu and About (watches with buttons).
+ 5. Enters five wrong PINs until the 30 s wait appears.
+
+ Ten screens per watch (nine on the touch-only Venu Sq 2 and Venu X1) match their approved images, and two runs give identical pixels.
+- **Codes on screen:** the TOTP, HOTP and next codes in the screenshots match an independent Python computation for the frozen time 1800000026. GitHub 768 147, next 050 219; Google 2624 9947; Bank (SHA-512) 849 419; HOTP counters 5 and 6: 254 676 and 287 922.
+
+Screen shapes: of the 72 target products, 69 have round screens and 3 rectangular ones (Venu Sq 2, Venu Sq 2 Music, Venu X1); both kinds are in the flow. None has a semi-round or semi-octagon screen. The code treats semi-round like round and other shapes like rectangles, untested on a real profile.
+
+What the flow found and what changed because of it:
+
+- **The watchdog is enforced in normal simulator runs** (unlike unit-test runs). Two things tripped it on the Forerunner 255S profile (120,000 bytecodes per event):
+ - 200 PBKDF2 iterations per step. The inner loop now XORs 32-bit words, which makes it 2.3× faster, and a step is 100 iterations.
+ - Two SHA-512 codes in one draw. Screens now compute at most one code per timer event, separately from drawing.
+- **Opening the app needs care in the simulator.** It starts in the glance, and START alone doesn't open the app from there.
+- **Taps:** on touch screens a tap first arrives as "select", so the keypad and the list didn't see where the finger was.
+- **Venu X1:** this watch has no MENU button. The list now opens the menu on a long press.
+- **Missing glyph:** the backspace glyph doesn't exist in the watch fonts, so the delete key is drawn.
+
### M3, 2026-10-19 (version 0.3.0)
`make ci` passed in full. Monkey C unit tests: 65 of 65 on each of the four test devices. The 34 new tests in `test/storage/` and `test/util/` cover:
@@ -71,3 +99,6 @@ Tests never use real secrets or personal data. Fixtures are generated or hand-wr
Things the simulator can't verify, done by you on the Forerunner 70. The checklist grows with each milestone; it starts with M1.
- [ ] M1: install the settings spike from the Store beta and run tests A to H in [SETTINGS_SPIKE.md](SETTINGS_SPIKE.md); record the results there.
+- [ ] M4: sideload a debug build (`make build DEVICE=fr70`, then copy `build/debug/fr70.prg` to `GARMIN/APPS` over USB). Open the menu, choose "Debug: benchmark", and send me the numbers. They set the real PBKDF2 iteration count and the SHA-512 cost on your watch.
+- [ ] M4: with "Debug: demo entries", check the list, a code, the countdown ring, HOTP "next", and the PIN keypad with your finger. Also check a wrong PIN (vibration) and the 30 s wait after five wrong PINs.
+- [ ] M4: check that leaving the app and coming back asks for the PIN again for the locked demo entry. Check that the glance in the glance loop shows the number of entries.