aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-18 19:47:15 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-18 19:47:15 +0200
commit5396ace6a695ecf530c2ecc9793dfe01f523e70d (patch)
tree2e8ec230251255cceb155edfc4e025cbabf48965
Inital commit.
-rw-r--r--Garmin OTP Prompt.md166
-rw-r--r--README.md1026
-rw-r--r--docs/FEASIBILITY.md288
3 files changed, 1480 insertions, 0 deletions
diff --git a/Garmin OTP Prompt.md b/Garmin OTP Prompt.md
new file mode 100644
index 0000000..3121bb6
--- /dev/null
+++ b/Garmin OTP Prompt.md
@@ -0,0 +1,166 @@
+# Prompt: Garmin Connect IQ OTP Authenticator and Code Wallet
+
+Copy everything below the line into your coding model (Claude Code works best, since it can run Docker and files).
+
+---
+
+## ROLE
+
+You are a senior embedded and Garmin Connect IQ engineer (Monkey C), a security-minded developer, and a technical writer. You work carefully, verify claims against the official Garmin documentation, and never invent APIs. If an API, permission or device capability is not documented, you say so explicitly and propose an alternative instead of guessing.
+
+## GOAL
+
+Build a **Garmin Connect IQ watch app** that works as an **OTP authenticator** (TOTP and HOTP) and also as a **wallet for other barcode and QR codes** (loyalty cards, tickets, etc.).
+
+- All data is stored **on the watch**. No cloud, no backend, no analytics, no network calls.
+- Configuration and import are done through **Garmin Connect IQ app settings** (the settings screen in the Garmin Connect / Connect IQ phone app).
+- The watch UI must be **very simple and very fast**.
+
+## HARD REQUIREMENTS
+
+1. **TOTP and HOTP** per RFC 6238 and RFC 4226. Support SHA1, SHA256 and SHA512, 6 to 8 digits, and custom periods (default 30 s). Optionally support Steam Guard style codes as a clearly separated, optional feature.
+2. **Import from Aegis Authenticator** (Aegis vault JSON export):
+ - Support the **plain-text** export.
+ - Support the **encrypted** export if feasible (scrypt + AES-256-GCM, slots). If it is not feasible on the watch, handle decryption **outside the watch** (see "Import pipeline") and document why.
+ - Also accept `otpauth://` URIs (single and bulk, one per line) and `otpauth-migration://` (Google Authenticator export) if feasible.
+3. **Generic barcode and QR code storage and display.** Support at least: QR, Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, ITF (Interleaved 2 of 5), Codabar, Data Matrix, PDF417, Aztec. The watch **stores the payload string and symbology** and **renders the code on screen**. State clearly which symbologies you implemented, which are deferred, and how you verified each one is scannable.
+4. **Per-entry lock.** Each entry (OTP, barcode, QR) has its own setting "Require PIN to display". Locked entries show a lock icon and no code until unlocked.
+5. **Encryption at rest.** Secrets are stored encrypted and are only decrypted in memory after the user unlocks. Locked entries are never decrypted before unlock, and decrypted data is wiped from memory as soon as possible (on screen exit and on timeout).
+6. **Simple UX on the watch.** List of entries, tap or select to open, big readable code, countdown ring for TOTP, tap to advance an HOTP counter. No deep menus. Works with touch and with button-only devices.
+7. **Fast.** Target: app start to first code visible in under 1 second on a mid-range device for the unlocked entries, and a TOTP refresh with no visible lag. Measure it, report the numbers and document how you measured them.
+8. **No bugs.** Every module is covered by tests. Nothing is declared done until tests pass in Docker.
+9. **Everything in Docker and fully sandboxed**, see "Docker and sandbox rules".
+10. **Comment the code thoroughly** (what, why, edge cases, security notes) and **write documentation**.
+11. **Manual entry in the Garmin Connect IQ settings UI**, in addition to the Aegis import. The user must be able to create, edit, reorder and delete entries by hand in the Connect IQ app settings, without any import file. The form must cover everything needed to generate each kind of code:
+ - **OTP entries:** type (TOTP, HOTP, optionally Steam), issuer, account label, secret (Base32, validated as it is entered), algorithm (SHA1/SHA256/SHA512), digits (6 to 8), period (TOTP) or initial counter (HOTP), group, and the per-entry "Require PIN to display" switch.
+ - **Barcode and QR entries:** name, symbology (QR, Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, ITF, Codabar, Data Matrix, PDF417, Aztec), the payload text/digits, symbology-specific options (for example QR error-correction level, or whether to show human-readable text under a linear barcode), group, and the per-entry "Require PIN to display" switch.
+ - **Validation with clear error messages:** check the payload against the chosen symbology (allowed characters, length, EAN/UPC check digits with automatic calculation or correction offered, QR capacity for the chosen error-correction level, Base32 validity for OTP secrets) and report problems in a status message instead of failing silently.
+ - If the settings UI cannot create a dynamic list of entries (a typical Connect IQ limitation), design the workable alternative (for example a fixed number of entry slots, or a compact text format for one entry per line with a validated parser), explain the trade-offs, and ask me which to choose before building.
+ - Manually entered and imported entries must be stored and behave identically on the watch.
+ - **Test this too:** unit tests for the entry form parsing and validation, covering valid and invalid input for every symbology and OTP type, and an end-to-end test that a manually entered entry shows the correct code or barcode on the watch (in the simulator).
+
+## CRITICAL PLATFORM CONSTRAINTS TO INVESTIGATE FIRST
+
+Before writing any plan or code, research and verify each of these against the current official Connect IQ documentation and SDK. Write the findings in a section called "Feasibility findings" with a clear verdict for each (supported / partially / not possible) and a workaround. Do not skip this section. I expect some of my requirements may not be fully possible as phrased, and I want you to tell me honestly.
+
+1. **Garmin device PIN / lock.** I want entries to be unlockable with the Garmin device PIN. Determine whether Connect IQ exposes any API to read, verify, or hook into the device lock or PIN. If it does not (my expectation is that it does not), propose the best alternatives and let me choose, for example:
+ - an app-level PIN entered on the watch (digits via touch or buttons) that derives the encryption key;
+ - an app-level PIN plus lock on app exit and a timeout;
+ - anything else you find that is actually supported.
+ Explain the security trade-offs of each, including what happens after too many wrong attempts (lockout, optional wipe, off by default).
+2. **Key derivation and crypto.** Check what `Toybox.Cryptography` provides on which API levels and devices (hashes, HMAC, ciphers, key generation) and what is missing. If SHA1, SHA256, SHA512, HMAC, PBKDF2 or AES are not all available, decide between a pure Monkey C implementation (with performance cost measured) and reduced device support. Never invent your own crypto algorithms: use standard, well-known constructions and test them against official test vectors (RFC 6238, RFC 4226, NIST).
+3. **Settings capabilities.** Check what Connect IQ app settings can hold (types, length limits, total size, number of items, whether file upload exists). My expectation is that **file upload is not supported**, so design the import pipeline around that (see below).
+4. **Storage limits.** Check `Application.Storage` and `Application.Properties` size limits, per-key limits, and behavior across app updates. Design the data model to fit and to be migratable between versions.
+5. **Memory limits per device.** Check the app memory budget on the devices you target and design for the weakest one. List the target devices and justify the choice (e.g. recent Forerunner, Fenix, Venu, Vivoactive families), and their API levels.
+6. **Rendering barcodes.** The watch has no camera, so codes are never scanned on the watch. They are generated and drawn. Check drawing performance, screen resolution and contrast limits. Large 2D codes (PDF417, Data Matrix, QR with long payloads) may not fit on small round screens, so describe the limits and what the UI does in that case (for example, a warning at import time).
+7. **App type.** Decide between a Watch App and a Widget (and glance, if supported) for the fastest access. Justify it.
+8. **Privacy of settings.** Check how settings values are stored by the Garmin phone app and Garmin servers. This matters because secrets entered in settings pass through the phone app. Document this honestly in a "Threat model" section, and describe how to minimize exposure (for example, import encrypted data only, and have the watch clear the settings field after import).
+
+## IMPORT PIPELINE (design, then implement)
+
+Because settings have no file upload, propose and implement the best option. Present at least these candidates with pros and cons, then implement the one I approve (ask me with a short question before building):
+
+- **A. Paste into settings.** User pastes text (`otpauth://` lines or a compact JSON) into a settings text field, and the watch parses it on next sync and then **clears the field**. Includes chunking if there is a length limit.
+- **B. Offline converter tool (runs locally in Docker, no network).** A small CLI included in the repo, which takes an Aegis export (plain or encrypted, and the vault password for encrypted) and outputs a **compact, already-encrypted import blob** (and optionally a QR or text chunks) that is easy to paste into settings. This keeps plaintext secrets off Garmin's servers.
+- **C. Companion approach** if something else is genuinely supported.
+
+The converter must be well tested with sample Aegis files (plain and encrypted, with several entry types: TOTP, HOTP, Steam, different algorithms/digits/periods, groups, icons, notes). Use **fake test data only**, never real secrets.
+
+Also define clearly: duplicate handling, validation errors shown to the user, partial import behavior, and how to remove or edit entries after import.
+
+## SETTINGS (Connect IQ app settings)
+
+Define the settings schema and the XML for it. At minimum:
+
+- Import field(s) and import status/result message.
+- Manual entry form(s) for OTP and barcode/QR entries (see hard requirement 11), with a validation status message per entry.
+- Global options: auto-lock timeout, PIN mode, clock-skew/time-offset handling, theme/size of the code font, grouping on or off.
+- **Per-entry options** (name, issuer, label, type, group, display order, "require PIN", digits/period/algorithm where relevant, counter for HOTP, symbology for codes).
+- Explain how per-entry settings are represented if the settings UI cannot create dynamic lists (a typical limitation). Design a workable approach and document it.
+
+## DATA MODEL AND SECURITY DESIGN
+
+Write a design for:
+
+- Entry types and fields (OTP and codes), versioned schema, migration strategy.
+- Encryption design: key derivation from PIN or other chosen secret (with a salt, with as many iterations as the device can afford, measured), authenticated encryption, nonce handling, key and plaintext zeroization in memory, and what metadata is stored in plain text (for the list view; e.g. names) and why. Offer an option to also encrypt names.
+- Locked versus unlocked entries and how the list screen looks for each.
+- Brute-force protection on the watch.
+- Time handling for TOTP (UTC, clock source, drift) and what happens when the time is wrong.
+- Threat model: what this protects against and what it does not (lost watch, phone compromise, Garmin cloud, malicious app on the phone, etc.).
+
+## ARCHITECTURE
+
+Propose a clear module layout in Monkey C, for example: `crypto/` (hash, HMAC, KDF, cipher wrappers), `otp/` (HOTP, TOTP, Steam), `codes/` (barcode and QR encoders and renderers), `storage/` (encrypted store, migration), `import/` (parsers for otpauth, Aegis, migration), `ui/` (views, delegates, lock screen, list, detail), `settings/` and `util/`. Keep UI code thin and logic testable. Avoid allocations in hot paths. Precompute and cache rendered codes where memory allows.
+
+## DOCKER AND SANDBOX RULES
+
+- **Everything** runs in Docker: SDK download and install, build, unit tests, simulator tests, linting, packaging the `.iq` file, the converter tool, and documentation build. Nothing is installed on my host besides Docker itself.
+- Provide a `Dockerfile` (and `docker-compose.yml` if useful) and a `Makefile` with simple targets, for example `make build`, `make test`, `make sim`, `make package`, `make lint`, `make docs`, `make clean`, **`make nuke`** (removes containers, images, volumes and generated files created by this project).
+- Use **named volumes or a project-local `.cache/` folder** for the Connect IQ SDK, developer key and caches, so deleting the project folder plus running `make nuke` removes everything.
+- The Connect IQ SDK requires accepting Garmin's license and downloading through the SDK Manager. Research how to do this **headlessly in Docker** and what needs my Garmin login or manual acceptance. If anything needs a manual step from me, **stop and ask me clearly with exact instructions**.
+- The Connect IQ simulator needs a display. Use a virtual display (e.g. Xvfb) inside the container for headless simulator test runs, and describe how I can optionally see the simulator on my host (e.g. VNC or X forwarding) without installing anything else.
+- **Developer signing key:** generate it inside the sandbox, never commit it, put it in `.gitignore`, and document backup and the consequences of losing it.
+- Run as a **non-root user inside the container**, no privileged mode, no host networking, mount only the project directory. Pin base image and tool versions where possible.
+- If any step needs `sudo` on my host (for example installing Docker or adding me to the docker group), **ask me first**, tell me the exact command and why, and wait for my confirmation. Never run sudo yourself without asking.
+- Document how to completely remove everything (a "Uninstall / cleanup" section).
+
+## TESTING
+
+Write the test strategy first, then tests alongside code (not after).
+
+- **Unit tests** with the Connect IQ test framework (`Toybox.Test`) run through the SDK in Docker. Cover: HOTP/TOTP against all RFC test vectors (SHA1, SHA256, SHA512), digit counts, periods, boundary times (period rollover, leap-year dates, year 2038 and later), Base32 decoding (padding, lowercase, whitespace, invalid chars), `otpauth://` parsing (URL-encoded labels, missing issuer, odd parameters), Aegis parsing (plain and encrypted), KDF and cipher against known vectors, encrypted store round-trip, tamper detection, wrong PIN, lockout logic, migration between schema versions.
+- **Barcode tests:** for each symbology, compare the encoder output against known-good reference matrices or bar patterns, and (where possible in Docker) decode the rendered output with an open-source decoder such as ZXing or zbar to prove it scans.
+- **Converter tool tests:** run with fixtures, including malformed and hostile input.
+- **Property or fuzz tests** for parsers where practical (no crashes, no hangs, clear errors).
+- **Simulator and UI tests:** screenshot-based checks on at least one round and one rectangular device, plus a button-only device.
+- **Performance tests:** measure start-up, TOTP generation, unlock (KDF) time and render time, on simulator and document the limits of simulator timing versus a real watch. Define budgets and fail the build if they are exceeded where measurable.
+- **Memory tests:** check peak memory on the weakest target device in the simulator.
+- **Static checks:** Monkey C compiler with the strictest type-check level, plus any available linters. Treat warnings as errors where possible.
+- Provide a CI-style `make ci` target that runs everything.
+- Include a **manual test checklist** for the real watch (sideload steps, things the simulator cannot verify such as real clock, real crypto speed, button feel).
+
+## CODE QUALITY RULES
+
+- Heavy, useful **comments** on every module, class and non-trivial function (purpose, inputs, outputs, errors, security notes), plus inline comments for tricky logic.
+- Handle every error path with a clear user-facing message on the watch. No silent failures, no crashes on bad data.
+- No secrets in logs. No secrets in source. No debug code left in release builds.
+- Small functions, clear names, constants instead of magic numbers.
+- Validate all external input (settings values, imported data).
+- Keep a `CHANGELOG.md` and use semantic versioning.
+
+## DOCUMENTATION DELIVERABLES
+
+1. **`README.md` (write this FIRST, before any code)** containing:
+ - Project overview and scope, what is and is not supported.
+ - Feasibility findings (see above) with honest verdicts and the decisions I need to make.
+ - Architecture and module diagram (Mermaid is fine).
+ - Data model, security and threat model.
+ - Import pipeline design.
+ - UX design: screens, flows and wireframes in text or Mermaid.
+ - Docker and sandbox setup, all `make` targets, cleanup.
+ - Detailed **step-by-step build plan** broken into milestones, each with deliverables, tests and a definition of done.
+ - Test plan (as above), performance budgets, risk list with mitigations.
+ - Target devices and API levels.
+2. `docs/USER_GUIDE.md`: install on the watch, import from Aegis, configure per-entry PIN lock, daily use, backup and migration to a new watch, troubleshooting.
+3. `docs/DEVELOPER_GUIDE.md`: repo layout, how to build/test/debug in Docker, how to add a new barcode symbology or device, coding conventions.
+4. `docs/SECURITY.md`: threat model, crypto choices and parameters, known limitations, how to report issues.
+5. `docs/TESTING.md` and `docs/PERFORMANCE.md` with results.
+6. In-code documentation as described above.
+
+## WORKING PROCESS
+
+1. **Phase 0, research:** read the official docs, produce "Feasibility findings", and ask me any blocking questions (PIN approach, import approach, target devices, sudo/SDK login steps) in **one short batch**.
+2. **Phase 1, plan:** write the complete `README.md` and wait for my approval.
+3. **Phase 2, scaffolding:** Docker, Makefile, empty project that builds and runs one trivial test in the sandbox.
+4. **Phase 3 onward:** implement milestone by milestone: crypto and OTP core, then storage, then UI, then import, then barcodes, then settings, then polish and performance. After each milestone: run the full test suite in Docker, update docs, and give me a short status report with test results.
+5. **Final:** run `make ci`, produce the packaged `.iq`/`.prg`, finalize docs, list known limitations honestly.
+
+## HONESTY AND SAFETY RULES
+
+- Do not claim something works unless you ran it. Show test output.
+- If a requirement is impossible or unsafe as stated, say so and offer the closest safe alternative. Do not silently drop or fake a feature.
+- Do not use any real OTP secrets or real personal data. Use fabricated fixtures only.
+- Ask before any action that touches my host system outside the project folder.
+
+Start with Phase 0 now. \ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..aa1af67
--- /dev/null
+++ b/README.md
@@ -0,0 +1,1026 @@
+# OTP Wallet for Connect IQ
+
+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: Phase 1 (plan), waiting for your approval.** Nothing is built yet. This README is the design and the build plan. The Phase 0 research, with sources, is in [docs/FEASIBILITY.md](docs/FEASIBILITY.md).
+
+"OTP Wallet" is a working title.
+
+## Contents
+
+1. [Scope](#1-scope)
+2. [Decisions and open questions](#2-decisions-and-open-questions)
+3. [Feasibility findings](#3-feasibility-findings)
+4. [How the app reaches your watch](#4-how-the-app-reaches-your-watch)
+5. [Architecture](#5-architecture)
+6. [Data model](#6-data-model)
+7. [Security design](#7-security-design)
+8. [Threat model](#8-threat-model)
+9. [Time handling](#9-time-handling)
+10. [Settings design](#10-settings-design)
+11. [Import design](#11-import-design)
+12. [Barcodes and QR codes](#12-barcodes-and-qr-codes)
+13. [UX design](#13-ux-design)
+14. [Target devices and API levels](#14-target-devices-and-api-levels)
+15. [Docker and sandbox](#15-docker-and-sandbox)
+16. [Build plan](#16-build-plan)
+17. [Test plan](#17-test-plan)
+18. [Performance budgets](#18-performance-budgets)
+19. [Risks and mitigations](#19-risks-and-mitigations)
+20. [Documentation](#20-documentation)
+21. [Repository layout](#21-repository-layout)
+22. [Versioning](#22-versioning)
+
+## 1. Scope
+
+### What it will do
+
+- **One-time passwords**: TOTP (RFC 6238) and HOTP (RFC 4226) with SHA-1, SHA-256 or SHA-512, 6 to 8 digits, and any period from 1 to 300 seconds (default 30). Steam Guard codes are a separate, optional entry type.
+- **Barcodes and QR codes**: QR, Code 128, Code 39, EAN-13, EAN-8, UPC-A, UPC-E, ITF, Codabar, Data Matrix, PDF417 and Aztec. The watch stores the text and the symbology, then encodes and draws the code itself. A symbology ships only once it passes the checks in [section 12.4](#124-how-each-symbology-is-proven-scannable). Anything that doesn't pass is listed as deferred.
+- **Per-entry lock**: each entry has its own "Require PIN to display" setting. One app PIN unlocks a session.
+- **Encryption at rest** for every entry.
+- **Manual entry from your phone**: you add, edit, reorder and delete entries in the Connect IQ app settings, using an "add" form for each kind and a "manage" form.
+- **Import**: paste `otpauth://` URIs or an unencrypted Aegis JSON export into the app settings.
+- **Simple, fast watch UI**: a list of entries, a big code with a countdown ring, tap to advance an HOTP counter. It works with touch screens and with buttons only.
+
+### What it won't do
+
+| Not supported | Why |
+|---|---|
+| Unlocking with the watch's own PIN or passcode | Connect IQ has no API for it |
+| Encrypted Aegis exports | Decrypting one needs 32 MiB of RAM; watch apps get under 0.8 MB. You chose plain JSON import over an off-watch converter (decision D2) |
+| Aegis icons and notes, mOTP and Yandex entries | The watch doesn't decode images, notes may hold private text, and mOTP/Yandex weren't requested. They're skipped with a message |
+| Everyday use as a sideloaded app | Sideloaded apps get no phone settings, so you couldn't add entries. Sideloaded builds are for testing only |
+| Exporting or backing up entries from the watch | Deliberate: secrets only flow *into* the watch. Your Aegis export and your own records are the backup |
+| Scanning codes with the watch | There is no camera |
+| Very long linear codes and large 2D codes on small screens | They don't fit at a scannable size. The watch tells you instead of drawing an unreadable code |
+| Strong protection of locked entries if someone copies the data off the watch | A short PIN can be guessed offline ([section 8](#8-threat-model)) |
+| Any network feature | The app requests no permissions |
+
+## 2. Decisions and open questions
+
+Decided on 2026-10-18:
+
+| # | Decision | Choice |
+|---|---|---|
+| D1 | PIN policy | Session unlock: enter the PIN once per session. Everything re-locks when you leave the app or after an inactivity timeout |
+| D2 | Import | Paste `otpauth://` URIs or an unencrypted Aegis JSON export into the settings; the watch parses them. No converter tool, no encrypted Aegis import |
+| D3 | Manual-entry layout | Inbox forms ("Add OTP", "Add code") plus a "Manage entry" form |
+| D4 | Targets | Watches with API 5.0 or newer and at least 512 KB for apps |
+
+Still open. None of these block this plan:
+
+| # | Question | Needed by |
+|---|---|---|
+| D5 | Docker access: join the `docker` group, or set up rootless Docker? | Phase 2 |
+| D6 | Your Garmin account: accept the SDK licence and log in inside the container; create a developer account for the beta | Phase 2 and M1 |
+| Q1 | Which watch will you test on? | M1 |
+| Q2 | Is your phone Android or iPhone? The iPhone Garmin app reportedly rejects `&` and `\`, which affects both paste formats | M1 |
+
+## 3. Feasibility findings
+
+A summary. The details, evidence and sources are in [docs/FEASIBILITY.md](docs/FEASIBILITY.md).
+
+| # | Question | Verdict | Consequence |
+|---|---|---|---|
+| 1 | Unlock entries with the Garmin device PIN | Not possible | App PIN with session unlock (D1). Garmin's system passcode, where a watch has one, shuts apps down when it locks and complements the app lock |
+| 2 | SHA-1/256/512, HMAC, PBKDF2, AES on the watch | Partially | Native: SHA-1, SHA-256, AES-ECB/CBC, HMAC-SHA256, random bytes. Built from their standards in Monkey C: HMAC-SHA1, SHA-512, PBKDF2, HKDF, encrypt-then-MAC |
+| 3 | Import and manual entry through app settings | Partially | Text fields only, no file upload. Settings only work for Store installs, so delivery is a private beta. Array settings are broken (CIQQA-4751), hence the inbox design (D3) |
+| 4 | Storage limits | Partially | Garmin's pages disagree. Design for at most 8 KB per value and about 100 KB in total |
+| 5 | Memory | Supported on the targets | 512 to 768 KB per app on API 5.x and 6.x watches |
+| 6 | Barcode rendering | Partially | All 12 symbologies can be drawn. Big codes don't fit small round screens. Scanner type matters |
+| 7 | App type | Supported | Device app with a glance |
+| 8 | Privacy of settings | Partially (undocumented) | Treat anything typed into settings as stored by Garmin. A consequence of D2 and D3 |
+| 9 | Encrypted Aegis export on the watch | Not possible | Out of scope (D2) |
+| 10 | TOTP after 2038 | Partially | The clock value is 32-bit. It's reinterpreted as unsigned, which is correct until 2106 |
+| 11 | Headless SDK, simulator and tests in Docker | Partially | Your Garmin login is needed once for device files. Ubuntu 22.04 base image |
+
+## 4. How the app reaches your watch
+
+Garmin's app settings only work for apps installed from the Connect IQ Store, so:
+
+1. `make package` builds a signed `.iq` file in Docker.
+2. You upload it on Garmin's developer website as a **beta app**, which only your account can see. Every new version is a new upload.
+3. You install it on your watch from the Connect IQ Store app.
+4. You manage entries in the Garmin Connect app on your phone: your watch, then Connect IQ apps, then OTP Wallet, then Settings.
+
+Development and automated tests use the simulator in Docker. For speed and rendering checks on a real watch, a sideloaded debug build contains fake fixture data and needs no settings.
+
+## 5. Architecture
+
+```mermaid
+flowchart TB
+ subgraph Phone["Phone: Garmin Connect app settings"]
+ F["Add OTP / Add code / Manage entry / Import / Options"]
+ end
+ subgraph Watch["Watch app (Monkey C)"]
+ APP["app: lifecycle, glance entry"]
+ UI["ui: list, OTP, code, PIN, menu, results, glance"]
+ SET["settings: form reader, inbox, manage, options, status"]
+ IMP["import: otpauth, Aegis JSON, JSON tokenizer"]
+ ST["storage: vault, records, keys, session, lockout, migrations"]
+ OTP["otp: Base32, HOTP, TOTP, Steam"]
+ COD["codes: encoders, Reed-Solomon, validators, renderer"]
+ CR["crypto: hashes, HMAC, SHA-512, PBKDF2, HKDF, AES-CBC + HMAC"]
+ UT["util: clock, jobs, bytes, errors, debug log"]
+ end
+ F -- "values, via Garmin" --> SET
+ APP --> UI
+ APP --> SET
+ SET --> IMP
+ SET --> ST
+ SET --> COD
+ IMP --> ST
+ UI --> ST
+ UI --> OTP
+ UI --> COD
+ ST --> CR
+ OTP --> CR
+ SET --> UT
+ ST --> UT
+ UI --> UT
+```
+
+| Module | Responsibility |
+|---|---|
+| `app/` | `AppBase`: lifecycle, initial view, glance view, `onSettingsChanged`, locking in `onStop` |
+| `crypto/` | Wrappers around the native hashes, HMAC (RFC 2104), SHA-512 (FIPS 180-4), PBKDF2 (RFC 8018), HKDF (RFC 5869), AES-CBC with PKCS#7 padding, encrypt-then-MAC records, constant-time comparison, zeroing, random bytes, start-up self-test |
+| `otp/` | Base32, HOTP, TOTP, Steam, parameter validation |
+| `codes/` | Bit-matrix and bar-pattern types, Reed–Solomon over GF(2^m) and GF(929), one encoder per symbology, validators, layout and renderer |
+| `storage/` | Vault (records and index), record codec, key manager (device key, locked master key, PIN wrapping), session, lockout, migrations, quotas, pending queue |
+| `import/` | `otpauth://` parser, streaming JSON tokenizer, Aegis reader (database versions 1 to 3), import service (duplicates, partial imports, multi-part buffer, report) |
+| `settings/` | Reads settings into typed forms; processes the inbox, manage, import and options forms; writes status fields and the catalog |
+| `ui/` | Views and input delegates; layouts for round, rectangular and semi-octagon screens |
+| `util/` | Clock (UTC, unsigned widening, offset, trusted-clock check), job scheduler, byte and string helpers, error types, debug-only log |
+
+Design rules:
+
+- **Thin UI.** Views draw view models. The logic lives in plain modules that tests can run without a screen.
+- **No network, no permissions.** Anyone can check this in `manifest.xml`.
+- **No watchdog trips.** Long work (key derivation, SHA-512 batches, 2D encoding, large imports) runs as resumable jobs on a timer, and the UI shows progress.
+- **No allocations in hot paths.** The 1 Hz countdown redraw reuses precomputed strings and geometry. Each code is computed once per period.
+- **Caches**: encoded barcode matrices (encrypted, in storage), drawn bitmaps per screen, and computed codes with the time they expire.
+- **Secrets are `ByteArray`s**, zeroed after use. The only secret-derived `String` is the short-lived code on screen.
+- **Debug and test code is excluded from release builds**: debug-only code (fixtures, performance overlay, logging) is annotated `(:debug)`, and tests are annotated `(:test)`.
+- **Code style**: heavy comments (purpose, inputs, outputs, errors, security notes), small functions, named constants, and every external input validated.
+
+## 6. Data model
+
+### 6.1 Entry fields
+
+| Field | Applies to | Where it's stored | Notes |
+|---|---|---|---|
+| `id` | all | header | 4 characters from `23456789ABCDEFGHJKMNPQRSTVWXYZ`, random and unique. Shown on the watch and used by the Manage form |
+| `kind` | all | header | OTP or code |
+| `type` | OTP | header | TOTP, HOTP or Steam |
+| `symbology` | code | header | One of the 12 |
+| `name` | all | header, or encrypted when "Encrypt names" is on | The issuer for OTP entries, the card name for codes |
+| `account` | OTP | same as `name` | The account label |
+| `group` | all | same as `name` | Optional |
+| `position` | all | index | Display order |
+| `locked` | all | header | "Require PIN to display" |
+| `source` | all | header | Manual or import. Used by "Replace imported entries" |
+| `favorite` | all | header | Comes from Aegis. The first favourite unlocked TOTP entry can appear in the glance |
+| `counter` | HOTP | header, covered by the record's MAC | Changes on every "next". Not secret |
+| `algorithm`, `digits`, `period` | OTP | encrypted | Not secret, but kept next to the secret so tampering is detected |
+| `secret` | OTP | encrypted | Raw bytes, decoded from Base32 on input |
+| `payload` | code | encrypted | UTF-8 text |
+| `options` | code | encrypted | Error-correction level, human-readable text, check-digit mode |
+| `created` | all | header | Used in reports |
+
+### 6.2 Storage layout (`Application.Storage`)
+
+| Key | Content | Protected by |
+|---|---|---|
+| `m` | Metadata: schema version, entry index (the order of IDs), KDF salt and iteration count, wrapped locked master key, lockout state, hashes of applied forms | The wrapped key is encrypted. The rest is not secret |
+| `dk` | Device key, 32 random bytes | Stored as is ([7.4](#74-what-stays-readable)) |
+| `e.<id>` | One entry record | Device key, or the locked master key for locked entries |
+| `x.<id>` | Cached barcode matrix for a code entry | The same key as its entry |
+| `p.<id>` | Pending locked entry, waiting for your PIN | Device key ([7.7](#77-pending-locked-entries)) |
+| `imp` | Partial import text, kept between syncs | Device key |
+
+Limits: at most 8 KB per value and about 100 KB in total (the stricter of Garmin's two documented figures). The entry cap, initially 150, is set after M3 measures the real record size. A full store produces a clear message, never a crash.
+
+### 6.3 Schema versions and migration
+
+- `m.schema` holds the data version. Each record also carries its own format version.
+- At start-up: an older schema runs migrations step by step (v1 to v2 to v3...). Each migration is a small, idempotent, unit-tested function. Before overwriting anything, a record is written under a temporary key and the index is switched to it, so a crash or power loss mid-migration loses nothing.
+- A newer schema (after a downgrade) is never modified. The app shows "Data from a newer version — update the app".
+- Unreadable or damaged records show as "Damaged entry K7F2 — re-add it" instead of crashing.
+- Garmin doesn't document what happens to storage on app updates. The manual test checklist verifies that an update keeps your data.
+
+## 7. Security design
+
+### 7.1 Keys
+
+```mermaid
+flowchart TD
+ PIN["PIN typed on the watch"] --> KDF["PBKDF2-HMAC-SHA256: random salt, iterations calibrated to about 1 s"]
+ KDF --> PK["PIN key: RAM only"]
+ PK -- "unwraps: AES-CBC + HMAC-SHA256" --> LMK["Locked master key: 32 random bytes, stored wrapped"]
+ DK["Device key: 32 random bytes, stored"]
+ LMK -- "HKDF" --> LK["encryption key + MAC key"]
+ DK -- "HKDF" --> DKK["encryption key + MAC key"]
+ LK --> LR["Locked entries"]
+ DKK --> UR["Unlocked entries, pending items, import buffer"]
+```
+
+### 7.2 PIN and key derivation
+
+- The PIN is 4 to 10 digits. The app warns below 6.
+- `PIN key = PBKDF2-HMAC-SHA256(PIN digits as ASCII, 16-byte random salt, N iterations, 32 bytes)`.
+- N is calibrated on your watch when you create or change the PIN, so one derivation takes about 1 s. It runs as a resumable job and is stored next to the salt. The lower and upper bounds for N are set from the M2 measurements.
+- The PIN key unwraps the locked master key. A correct PIN is exactly "the wrap's MAC verifies". No PIN hash is stored anywhere.
+- Changing the PIN only re-wraps the master key. Entries are not re-encrypted.
+
+### 7.3 Record encryption
+
+```text
+keys: Kenc, Kmac = HKDF-SHA256(master key, info = "OTPW enc v1" / "OTPW mac v1")
+record: v = 1 record format version
+ h = TLV(header fields) readable metadata (6.1)
+ kr = 0 | 1 0 = device key, 1 = locked master key
+ iv = 16 random bytes new on every write
+ ct = AES-CBC(Kenc, iv, PKCS7(TLV(sensitive fields)))
+ tag = HMAC-SHA256(Kmac, "OTPW-REC-1" || id || v || kr || h || iv || ct)
+```
+
+- **Encrypt-then-MAC.** The tag is checked in constant time *before* anything is decrypted, so tampering anywhere (header, IV, ciphertext) is detected.
+- **TLV** is a small deterministic byte encoding, so the MAC input is canonical.
+- **AES-256** if every target passes the M2 tests with it, otherwise AES-128. The PIN, not the cipher, is the weakest link either way.
+- **The PIN wrap** uses the same scheme, with its own keys derived from the PIN key and the label "OTPW-WRAP-1". The salt and iteration count are included in the MAC.
+- Only standard constructions are used: PBKDF2, HKDF, AES-CBC, HMAC, encrypt-then-MAC. Each is tested against official vectors (section 17.2).
+
+### 7.4 What stays readable
+
+- **The device key.** It must be on the watch so unlocked entries open without a PIN. Unlocked entries are therefore only obscured against someone reading raw storage. Garmin says app storage isn't accessible to users, and that Store-signed apps get an encrypted object store. The design doesn't rely on either.
+- **Record headers**: ID, kind, type or symbology, locked flag, HOTP counter, position, creation date, and names unless "Encrypt names" is on. This lets the list draw without decrypting anything.
+- **"Encrypt names"** (off by default) moves name, account and group into the ciphertext. Unlocked entries' names are then decrypted at start-up with the device key (the cost is measured), and locked entries show as "Locked entry K7F2" until you unlock.
+
+### 7.5 Session and auto-lock
+
+- **Unlocking**: PIN, then PIN key, then the master key is unwrapped and held in RAM.
+- **Locking happens** when you leave the app (`onStop`), after the inactivity timeout, or through "Lock now" in the menu. The timeout setting offers Immediately, 15 s, 30 s, 1 min (default), 2 min and 5 min. "Immediately" locks as soon as you leave a locked entry, which gives you the "PIN every time" behaviour whenever you want it.
+- **On lock**: the master key and derived keys are zeroed and dropped, cached codes and bitmaps of locked entries are discarded, and any open locked entry closes back to the list.
+
+### 7.6 Wrong PINs
+
+- The failure count and the next allowed attempt time are written to storage *before* each check (`Storage.setValue` writes to disk immediately) and reset on success. Restarting the app or the watch doesn't help an attacker.
+- Four free attempts. From the 5th failure, delays of 30 s, 1 min, 2 min, 4 min and so on, capped at 1 hour.
+- Delays are timed with the watch's trusted clock (`Time.CURRENT_TIME_RTC`, synced from GPS; the user can't change it). If that clock isn't synced, the delay counts app uptime instead and starts again in full after a restart: never shorter, sometimes longer.
+- The optional "Erase locked entries after 10 wrong PINs" setting is off by default. It deletes the wrapped master key, all locked entries and all pending items.
+- Forgotten PIN: "Reset PIN" erases the locked entries (they can't be recovered, by design) and sets a new PIN.
+
+### 7.7 Pending locked entries
+
+A locked entry can arrive from settings or an import while the watch is locked, or before any PIN exists. It can't be encrypted with the master key yet. It's stored under the device key as **pending**, which is no worse than the plain text that just came through settings, and hidden from the list. On the next open, the app asks for the PIN (or to create one), re-encrypts the entry under the master key, and zeroes and deletes the pending copy.
+
+### 7.8 Memory hygiene
+
+- Keys and decrypted secrets are `ByteArray`s, overwritten with zeros right after use. An unlocked session holds only the master key.
+- To show a code, the app decrypts the secret, computes the code and zeroes the secret. Only the short code string is kept, together with the time it expires.
+- **Limit**: Monkey C gives no guarantee that the VM didn't copy data (for example inside native crypto objects), and the system doesn't clear freed memory. This is best effort, and documented as such.
+- **No secrets in logs.** Release builds contain no logging. Debug logging goes through one function that refuses byte arrays and secret fields, and a lint rule enforces it.
+
+### 7.9 Integrity of changes coming from the phone
+
+Anyone who controls your phone or Garmin account can change the settings. So:
+
+- Actions on locked entries (edit, replace secret, unlock, delete) wait in a queue until you confirm them with the PIN on the watch.
+- Deleting or editing *unlocked* entries applies directly, and the watch shows what changed.
+- New entries always apply, and they're visible in the list.
+
+### 7.10 Start-up self-test
+
+On the first start after an install or update, and then once a day, the app runs known-answer tests: SHA-1, SHA-256 and SHA-512, HMAC, AES, and PBKDF2 with a low iteration count. If one fails, the app shows "Crypto self-test failed on this watch" and refuses to show codes rather than show wrong ones.
+
+## 8. Threat model
+
+**Assets**: OTP secrets, barcode contents, the PIN, entry names.
+
+| Scenario | Protected? | Notes |
+|---|---|---|
+| Someone picks up your watch while the app is closed | Locked entries: yes. Unlocked entries: no, by design | Lock what matters. Turn on Garmin's system passcode if your watch has one |
+| Someone grabs the watch during an unlocked session | Until auto-lock (default: 60 s without input) | Choose a shorter timeout, or "Immediately" |
+| Lost or stolen watch, attacker uses the screen | Locked entries: yes | Lockout delays, optional erase |
+| Lost or stolen watch, attacker extracts the storage | Unlocked entries: no. Locked entries: only as strong as the PIN | 6 digits means 1,000,000 guesses, each costing one ~1 s PBKDF2 run on the watch but far less on a GPU: minutes to hours. Longer PINs help. Garmin's object-store encryption for Store apps may stop this; it's undocumented, so it isn't relied on |
+| Garmin's servers and the Garmin Connect app | No, for anything typed or pasted into settings | A consequence of D2 and D3: secrets travel in the clear. The watch clears the fields after reading them, but Garmin may keep copies |
+| Malware on your phone (clipboard, accessibility services) | No | It can read what you paste. Clear the clipboard and delete the Aegis export after importing |
+| Someone controlling your phone or Garmin account changes settings | Partly | They can add, or delete unlocked, entries. Changes to locked entries need your PIN on the watch ([7.9](#79-integrity-of-changes-coming-from-the-phone)) |
+| Other Connect IQ apps on the watch | Yes | Garmin sandboxes each app's storage |
+| Network attackers | Yes | No network permission |
+| Shoulder surfing | Partly | A code is visible by nature. PIN digits show as dots |
+| Changing the watch clock to harvest future codes | Partly | Locked entries still need an unlocked session. The app warns when the clock disagrees with the trusted clock |
+| Compromised build tools | Partly | SDK from Garmin; versions pinned; images pinned by digest; Python packages pinned with hashes |
+| Out of scope | | Compromised watch firmware, RAM forensics on a running watch, hardware attacks, Garmin itself |
+
+## 9. Time handling
+
+- **Codes use UTC.** `Time.now()` gives UTC seconds, plus your offset setting (−120 to +120 s, default 0) for skew.
+- **2038**: Garmin stores time as a 32-bit signed integer. The app reinterprets negative values as unsigned 64-bit, which is correct until 2106. The OTP maths uses 64-bit time throughout, so the RFC 6238 test vector for the year 2603 passes.
+- **Trusted clock**: `Time.getCurrentTime({:currentTimeType => Time.CURRENT_TIME_RTC})` is synced from GPS and can't be changed by the user. If it isn't synced, a small clock icon warns that codes may be off. If it differs from the displayed time by more than 30 s, the app warns "Watch time differs from GPS time".
+- **If the time is wrong, codes are wrong.** The app can't fix that, but the warnings and the offset setting make it visible and correctable.
+- **HOTP**: the stored counter is the one whose code is on screen. "Next" saves counter + 1 first, then shows the new code, so a crash never reuses a code. If you also use the same HOTP account elsewhere, the counters drift apart.
+- **Refresh**: on the detail screen, a 1-second timer moves the countdown ring. The code is recomputed only at the period boundary, and in the last 5 seconds the next code is shown as a preview.
+
+## 10. Settings design
+
+### 10.1 Why inbox and manage forms
+
+Garmin's "array settings" (growable lists) are broken today. A failed save resets all of the app's settings (CIQQA-4751). Instead, there is one **Add OTP** form and one **Add code** form that the watch reads and then clears, plus one **Manage entry** form that acts on an entry by its ID. The watch is the database. The forms are how you send it requests.
+
+### 10.2 Groups and fields
+
+**Add OTP**
+
+| Property | Type | Field | Notes |
+|---|---|---|---|
+| `otpType` | number | list | TOTP, HOTP, Steam |
+| `otpIssuer` | string | alphaNumeric (max 40) | |
+| `otpAccount` | string | alphaNumeric (max 64) | |
+| `otpSecret` | string | password | Base32. Masked on screen only |
+| `otpAlgorithm` | number | list | SHA1, SHA256, SHA512 (ignored for Steam) |
+| `otpDigits` | number | list | 6, 7, 8 (Steam always shows 5 characters) |
+| `otpPeriod` | number | numeric (1 to 300) | TOTP. Steam is always 30 |
+| `otpCounter` | long | numeric (≥ 0) | HOTP starting counter |
+| `otpGroup` | string | alphaNumeric (max 32) | Optional |
+| `otpLocked` | boolean | switch | Require PIN to display |
+| `otpAdd` | boolean | switch | **Add this entry**: the watch only acts when this is on |
+| `otpStatus` | string | read-only | Result, written by the watch |
+
+**Add code**
+
+| Property | Type | Field | Notes |
+|---|---|---|---|
+| `codeName` | string | alphaNumeric (max 40) | |
+| `codeSymbology` | number | list | The 12 symbologies |
+| `codePayload` | string | alphaNumeric | The code's text or digits |
+| `codeEcLevel` | number | list | Low, Medium (default), Quartile, High. Mapped per symbology (QR L/M/Q/H; Aztec 10/23/36/50 %; PDF417 auto or levels 2/4/5/6) |
+| `codeCheckDigit` | number | list | Add or verify (default), Fix if wrong, Off |
+| `codeShowText` | boolean | switch | Human-readable text under linear codes |
+| `codeGroup` | string | alphaNumeric (max 32) | |
+| `codeLocked` | boolean | switch | Require PIN to display |
+| `codeAdd` | boolean | switch | **Add this entry** |
+| `codeStatus` | string | read-only | |
+
+**Manage entry**
+
+| Property | Type | Field | Notes |
+|---|---|---|---|
+| `mgId` | string | alphaNumeric | The entry ID shown on the watch, for example `K7F2` (case-insensitive) |
+| `mgAction` | number | list | Edit details, Move to position, Require PIN, Don't require PIN, Replace secret or contents, Set HOTP counter, Delete |
+| `mgName`, `mgAccount`, `mgGroup` | string | alphaNumeric | Blank means unchanged. `-` clears the group |
+| `mgPosition` | number | numeric (≥ 1) | |
+| `mgSecret` | string | password | New secret (Base32) or new code contents |
+| `mgCounter` | long | numeric | |
+| `mgApply` | boolean | switch | **Apply** |
+| `mgStatus` | string | read-only | |
+
+**Import**
+
+| Property | Type | Field | Notes |
+|---|---|---|---|
+| `impText1` to `impText8` | string | alphaNumeric | Joined in order. The number of fields is set after the settings spike (M1) |
+| `impMode` | number | list | Append (default), Replace imported entries, Cancel pending import |
+| `impLocked` | boolean | switch | Require PIN for every entry in this import |
+| `impApply` | boolean | switch | **Import now** |
+| `impStatus` | string | read-only | |
+
+**Options**
+
+| Property | Field | Default |
+|---|---|---|
+| `optAutoLock` | list: Immediately, 15 s, 30 s, 1 min, 2 min, 5 min | 1 min |
+| `optWipe` | switch: Erase locked entries after 10 wrong PINs | off |
+| `optTimeOffset` | numeric, −120 to 120 s | 0 |
+| `optCodeSize` | list: Normal, Large, Huge (code font) | Large |
+| `optTheme` | list: Dark, Light (barcodes are always black on white) | Dark |
+| `optGroups` | switch: Show groups | on |
+| `optEncryptNames` | switch | off |
+| `optGlanceCode` | switch: Show the favourite code in the glance | off |
+| `optBrightness` | switch: Full brightness while showing a barcode | on |
+
+**About** (all read-only, written by the watch): `catalog` (entry IDs and names, shortened to fit), `version`, `storageUse`.
+
+### 10.3 Processing rules
+
+1. **When**: from `onSettingsChanged` while the app runs, and at every app start. Processing runs *after* the first screen is drawn, so it never slows start-up.
+2. **Switches**: a form is processed only when its switch is on. Afterwards the watch turns the switch off and clears the form, secret fields first.
+3. **Idempotency**: Garmin doesn't document whether the phone shows those cleared values ([FEASIBILITY 3e](docs/FEASIBILITY.md#3e-writing-values-back-unknown)). So the watch also remembers a hash of each of the last 32 applied forms and ignores a form it has already applied. Re-sending the same form does nothing; changing anything makes it a new request. Manage actions use stable IDs and absolute positions, so applying one twice can't hit a different entry.
+4. **Results** go to the form's status field and to a "Settings results" screen on the watch. That screen opens by itself after processing if the app is open, and is always available from the menu.
+5. **Status texts are short and specific**, for example:
+ - "Added K7F2 GitHub (alice)"
+ - "Secret: '1' is not a Base32 character (position 5)"
+ - "EAN-13: check digit should be 7, got 3. Choose 'Fix if wrong' to correct it"
+6. **Order**: Import, then Add OTP, then Add code, then Manage, then Options.
+
+### 10.4 Validation (OTP form)
+
+- **Secret**: Base32 (`A`–`Z`, `2`–`7`, any case). Spaces and hyphens are ignored, and `=` padding is optional. It must decode to 1 to 128 bytes, with a warning below 10 bytes (80 bits). Errors name the character and its position.
+- **Digits**: 6 to 8. Steam always uses 5 characters.
+- **Period**: 1 to 300 s. Steam always uses 30.
+- **Counter**: 0 to 2^63−1.
+- **Algorithm**: SHA1, SHA256 or SHA512. Steam always uses SHA1.
+- **Issuer and account**: at least one must be filled; no control characters.
+
+Code validation, per symbology, is in [section 12.1](#121-symbologies).
+
+### 10.5 XML excerpt
+
+The full files are written in M7.
+
+```xml
+<!-- resources/settings/properties.xml (excerpt) -->
+<properties>
+ <property id="otpType" type="number">0</property>
+ <property id="otpIssuer" type="string"></property>
+ <property id="otpSecret" type="string"></property>
+ <property id="otpDigits" type="number">6</property>
+ <property id="otpPeriod" type="number">30</property>
+ <property id="otpCounter" type="long">0</property>
+ <property id="otpLocked" type="boolean">false</property>
+ <property id="otpAdd" type="boolean">false</property>
+ <property id="otpStatus" type="string"></property>
+</properties>
+```
+
+```xml
+<!-- resources/settings/settings.xml (excerpt) -->
+<settings>
+ <group id="addOtp" title="@Strings.GroupAddOtp" description="@Strings.GroupAddOtpHelp">
+ <setting propertyKey="@Properties.otpType" title="@Strings.OtpType">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.TypeTotp</listEntry>
+ <listEntry value="1">@Strings.TypeHotp</listEntry>
+ <listEntry value="2">@Strings.TypeSteam</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.otpIssuer" title="@Strings.OtpIssuer">
+ <settingConfig type="alphaNumeric" maxLength="40" />
+ </setting>
+ <setting propertyKey="@Properties.otpSecret" title="@Strings.OtpSecret" prompt="@Strings.OtpSecretPrompt">
+ <settingConfig type="password" />
+ </setting>
+ <setting propertyKey="@Properties.otpDigits" title="@Strings.OtpDigits">
+ <settingConfig type="list">
+ <listEntry value="6">@Strings.Digits6</listEntry>
+ <listEntry value="7">@Strings.Digits7</listEntry>
+ <listEntry value="8">@Strings.Digits8</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.otpPeriod" title="@Strings.OtpPeriod">
+ <settingConfig type="numeric" min="1" max="300" errorMessage="@Strings.OtpPeriodError" />
+ </setting>
+ <setting propertyKey="@Properties.otpLocked" title="@Strings.RequirePin">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.otpAdd" title="@Strings.AddThisEntry">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.otpStatus" title="@Strings.Status">
+ <settingConfig type="alphaNumeric" readonly="true" />
+ </setting>
+ </group>
+</settings>
+```
+
+## 11. Import design
+
+### 11.1 Accepted formats
+
+- **`otpauth://` URIs** for `totp`, `hotp` and `steam`. Paste one or many, separated by spaces or line breaks, or simply run together: a new URI starts at every `otpauth://`.
+- **Aegis unencrypted JSON export**: vault format version 1, database versions 1, 2 and 3. Detected by a leading `{`.
+- **Stretch goal**: Google Authenticator `otpauth-migration://offline?data=...` exports. The format is reverse-engineered (Base64 and protobuf). It's built in M5 if time allows; otherwise it's listed as deferred.
+
+### 11.2 Getting the text into settings
+
+- **Fields**: the eight import fields are joined in order. If the text is longer than one field accepts, continue in the next one.
+- **Several syncs**: if the JSON is still incomplete after joining, the watch keeps what it got (encrypted under the device key) and reports: "Waiting for more: received 3,214 characters. Paste the next part into the import fields and turn 'Import now' on again." "Cancel pending import" discards it. The buffer is capped (initially 32 KB, set after measurements) and expires after 24 hours.
+- **Size reality**: pretty-printed Aegis JSON takes about 400 to 500 characters per entry without icons. Icons add several KB each and are the main reason a paste might not fit. An `otpauth://` URI takes about 80 to 200 characters.
+- **iPhone**: Garmin Connect on iOS reportedly rejects `&` (present in every URI with parameters) and `\` (used in JSON escapes). The settings spike checks this. If it's confirmed, the parser also accepts `;` in place of `&`, and the user guide explains the workaround.
+
+### 11.3 Parsing rules
+
+**`otpauth://` URIs**
+
+- The label is percent-decoded and split on the first `:` (or `%3A`) into issuer and account, with surrounding spaces trimmed.
+- An `issuer` parameter wins over the label's issuer. A mismatch is noted in the report.
+- `secret` is required and follows the Base32 rules in 10.4.
+- Defaults: `algorithm` SHA1, `digits` 6, `period` 30. `counter` is required for `hotp`.
+- A `steam` type, or the `encoder=steam` parameter, means Steam.
+- Unknown parameters (`image`, `color` and so on) are ignored.
+- Errors name the URI's position, for example "URI 3: digits=9 is not supported (6–8)".
+
+**Aegis JSON**
+
+- **Encrypted exports are refused** with a message explaining how to export unencrypted. They're recognised by `header.slots` not being null, or `db` being a string.
+- **Types**: `totp`, `hotp` and `steam` are imported. `motp`, `yandex` and unknown types are skipped and reported.
+- **Fields used**: `name` (the account), `issuer`, `info.secret`, `info.algo`, `info.digits`, `info.period`, `info.counter`, `favorite` and groups (UUIDs resolved through the top-level `groups` list in database version 3, or the older `group` string). If an entry has several groups, the first is used.
+- **Ignored**: `icon`, `icon_mime` and `icon_hash` (skipped without loading them into memory) and `note` (may hold private text).
+- **Robust parsing**: the JSON tokenizer streams over the UTF-8 bytes, handles escapes and `\u` sequences, and enforces depth and length limits. It never builds large intermediate objects, and runs as a resumable job.
+
+### 11.4 Duplicates, partial imports and the report
+
+- **Duplicate**: the same type and the same secret as an existing entry. It's skipped and reported ("duplicate of K7F2").
+- **Same issuer and account, different secret**: imported as a new entry and flagged ("possible replacement for K7F2 — delete the old one with Manage entry").
+- **Partial imports**: every valid entry is stored, and invalid ones are listed with reasons. Entries are stored one at a time, so a crash or power loss leaves only whole entries.
+- **Modes**: Append, or Replace imported entries (deletes entries whose source is "import", then imports). Manual entries are never touched by an import.
+- **Locking**: "Require PIN for imported entries" locks every entry in that import. They wait as pending items until you enter your PIN ([7.7](#77-pending-locked-entries)).
+- **Report**, shown on the watch and in the Import status field, for example: "Imported 12 · 2 duplicates skipped · 1 error (entry 7 'GitHub': 9 digits not supported) · 1 skipped (mOTP)".
+- **Clean-up**: after a successful import, the watch clears the import fields and deletes the buffer.
+
+### 11.5 After an import
+
+Imported entries behave exactly like manual ones. You rename, regroup, move, lock, unlock, replace the secret of, or delete them with the Manage entry form, using the ID shown on the watch. The user guide also tells you to delete the exported JSON file from your phone and to keep it out of cloud-synced folders.
+
+## 12. Barcodes and QR codes
+
+### 12.1 Symbologies
+
+| Symbology | Accepted content | Options | Validation | Scope |
+|---|---|---|---|---|
+| QR | Any text (UTF-8) | Error correction L/M/Q/H (default M) | Capacity for the chosen level; fits this screen | Versions 1 to 40, numeric/alphanumeric/byte modes with automatic segmentation, all 8 masks with standard penalty scoring. No Kanji, ECI or Micro QR |
+| Code 128 | ASCII 32 to 126 | Human-readable text | Characters; fits | Automatic switching between code sets A, B and C for the shortest result. GS1-128 (FNC1) deferred |
+| Code 39 | `0–9`, `A–Z`, space, `- . $ / + %` | Mod-43 check digit on or off; text | Characters; fits | Full ASCII mode deferred |
+| EAN-13 | 12 or 13 digits | Check digit: add or verify, fix, off; text | Digits only; check digit | EAN-2/EAN-5 add-ons deferred |
+| EAN-8 | 7 or 8 digits | As EAN-13 | As EAN-13 | |
+| UPC-A | 11 or 12 digits | As EAN-13 | As EAN-13 | |
+| UPC-E | 6, 7 or 8 digits (number system 0 or 1) | As EAN-13 | Must convert to UPC-A; check digit from the expanded form | |
+| ITF | An even number of digits | Mod-10 check digit on or off | Digits; an odd length needs a check digit or a leading 0 | Bearer bars (ITF-14 frames) deferred |
+| Codabar | `0–9 - $ : / . +` between start/stop characters `A–D` | | Characters. Missing start/stop characters are added as `A…A`, and the status says so | |
+| Data Matrix | Any text | | Capacity; fits | ECC 200, square sizes 10×10 to 144×144, ASCII (with digit pairs) and Base256 encoding. Rectangular sizes and C40/Text/X12/EDIFACT deferred |
+| PDF417 | Any text | Error correction (automatic by default) | Capacity; fits | Text, byte and numeric compaction; columns chosen to fit the screen. Compact, Micro and Macro PDF417 deferred |
+| Aztec | Any text | Error correction % (default 23 %) | Capacity; fits | Compact (1 to 4 layers) and full range (up to 32 layers). Structured append and runes deferred |
+
+### 12.2 Rendering
+
+- **Area**: 2D codes use the largest centred square (round screens) or rectangle. Linear codes and PDF417 use the widest horizontal band.
+- **Module size**: the largest whole number of pixels per module that fits, including the quiet zone (QR 4 modules, Data Matrix 1, PDF417 2, linear codes 10). The minimum is 2 px to start with. Scan tests in M6 may raise it, or allow 1 px for linear codes on 218 px screens.
+- **Colours**: always black modules on a white area that includes the quiet zone, whatever the theme.
+- **Human-readable text**: digits under linear codes when enabled and there's room.
+- **Brightness**: on AMOLED watches the app asks for full brightness while a code is shown, for up to about a minute. That's the burn-in protection limit; a tap boosts it again. On MIP watches the backlight is turned on.
+- **Caching**: the encoded matrix is stored encrypted (`x.<id>`) when the entry is saved, so opening it is instant. The drawn bitmap is cached for the session.
+- **Too big**: the watch says, for example, "Too large for this screen: needs 2 px per module, a 300 px square". The same warning goes to the status field when the entry is added.
+
+### 12.3 What fits (QR, byte mode, error correction M)
+
+| Screen | Largest centred square | At 3 px per module | At 2 px per module |
+|---|---|---|---|
+| 218 px round (Forerunner 255S) | 154 px | version 6, 106 bytes | version 13, 331 bytes |
+| 260 px round (fēnix 7, Forerunner 255) | 184 px | version 9, 180 bytes | version 16, 450 bytes |
+| 390 px round (vívoactive 5) | 276 px | version 16, 450 bytes | version 28 |
+| 454 px round (Forerunner 965) | 321 px | version 20, 666 bytes | version 33 |
+
+Linear: EAN-13 needs 113 modules, which means 1 px per module on a 218 px screen, 2 px on 260 px and 3 px on 390 px and larger. A 20-character Code 128 needs about 275 modules and doesn't fit 218 to 260 px round screens at all.
+
+### 12.4 How each symbology is proven scannable
+
+1. **Reference comparison**: Python reference encoders in Docker (zxing-cpp's writer, segno for QR with forced version and mask, and others) generate expected bar patterns and matrices for fixed parameters. The watch encoder must match them exactly wherever the output is deterministic: all linear codes, QR with forced version and mask, and Data Matrix in ASCII mode.
+2. **Decoding**: the simulator tests dump every encoder output. It's rendered at each device's module size and decoded with zxing-cpp, which reads all 12 symbologies. The decoded text must equal the input, for hundreds of seeded random payloads per symbology.
+3. **Screenshots**: simulator screenshots of the code screen on Forerunner 255S, Venu Sq 2 and Forerunner 965 must decode with zxing-cpp.
+4. **Real world** (manual checklist): your phone's camera, plus any real shop scanner you can try, indoors and outdoors, on MIP and AMOLED.
+
+A symbology counts as implemented only when checks 1 to 3 pass. Otherwise it's listed as deferred, with the reason, in this README and the changelog.
+
+## 13. UX design
+
+### 13.1 Screens
+
+- **List (home)**: entries in your order, with group headers if enabled.
+ - 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.
+ - A banner appears when there are new settings results.
+- **OTP detail**: issuer and account, the code in large digits (grouped 3+3, 3+4 or 4+4), and a countdown ring along the bezel (a bar on rectangular screens) with the seconds left. In the last 5 seconds, the next code is shown. HOTP shows "Next" and the counter. Steam shows 5 characters. The entry ID is in small print at the bottom.
+- **Code detail**: the code centred on white, the name in small print if there's room, or a "Too large" message.
+- **PIN**: a keypad on touch screens; a digit selector on button-only watches. Digits show as dots. A wrong PIN vibrates (where available) and shows any waiting time.
+- **Create or change PIN**: enter it twice. Warns below 6 digits.
+- **Menu**: Lock now, Change PIN, Reset PIN (erases locked entries), Settings results, Entry IDs, Clock status, About (version, storage use, self-test result).
+- **Glance**: "OTP Wallet · 12 entries". If you turn it on, it shows the favourite unlocked TOTP code with a progress bar instead.
+
+### 13.2 Flow
+
+```mermaid
+stateDiagram-v2
+ [*] --> List
+ List --> OtpDetail: select unlocked OTP
+ List --> CodeDetail: select unlocked code
+ List --> Pin: select locked entry, or pending items exist
+ Pin --> OtpDetail: correct PIN, OTP entry
+ Pin --> CodeDetail: correct PIN, code entry
+ Pin --> List: back, or waiting after wrong PINs
+ OtpDetail --> List: back, or auto-lock
+ CodeDetail --> List: back, or auto-lock
+ List --> Menu: menu
+ Menu --> Pin: change or reset PIN
+ Menu --> Results: settings results
+ Results --> List: back
+ Menu --> List: back
+ List --> [*]: back, which locks everything
+```
+
+### 13.3 Wireframes (round screen)
+
+```text
+ List OTP detail Code detail
+ .-----------------. .-----------------. .-----------------.
+ / OTP Wallet \ / GitHub \ / +-------------+ \
+ | GitHub | | alice | | | ## # ### # # | |
+ | alice 482 913 o | | | | | # ## # ### # | |
+ | Google | | 482 913 | | | ### # # ## # | |
+ | bob 705 228 o | | | | +-------------+ |
+ | [lock] Bank | | next 119 004 | | Gym card |
+ \ [bar] Gym card / \ 18 s / \ /
+ '-----------------' '-----------------' '-----------------'
+ (ring along the bezel = time left)
+
+ PIN, touch PIN, buttons only Glance
+ .-----------------. .-----------------. +----------------------+
+ / Enter PIN \ / Enter PIN \ | OTP Wallet |
+ | * * * o o o | | * * [5] o o o | | 12 entries |
+ | 1 2 3 | | ^ | +----------------------+
+ | 4 5 6 | | UP/DOWN digit | (opt-in: favourite code
+ | 7 8 9 | | START next | and a progress bar)
+ \ < 0 OK / \ BACK delete /
+ '-----------------' '-----------------'
+```
+
+### 13.4 Input
+
+| Action | Touch | Buttons |
+|---|---|---|
+| Scroll the list | Swipe up and down | UP and DOWN |
+| Open an entry | Tap it | 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 |
+
+## 14. Target devices and API levels
+
+- **Manifest**: type `watch-app`, `minApiLevel` 5.0.0, no permissions, English.
+- **Products**: generated in M0 from the downloaded device definitions: every watch with API 5.0 or newer and at least 512 KB for watch apps. That includes fēnix 7, 8 and E; epix 2; Forerunner 165, 255, 265, 570, 955, 965 and 970; Venu 2, 3, 4, Sq 2 and X1; vívoactive 5 and 6; Instinct 3 AMOLED; Enduro 3; and related MARQ, quatix, tactix, D2, Descent and Approach models.
+- **Simulator test devices**:
+ - Forerunner 255S: the weakest target (218 px round MIP, buttons only, 512 KB).
+ - Venu Sq 2: rectangular AMOLED with touch.
+ - Forerunner 965: 454 px round AMOLED with touch.
+ - Spot checks: fēnix 7 (MIP with touch and buttons), Instinct 3 AMOLED (buttons only), Venu X1 (the largest rectangle).
+- **Memory**: watch app 512 to 768 KB; glance 64 KB. Budgets are in [section 18](#18-performance-budgets).
+
+## 15. Docker and sandbox
+
+### 15.1 Images
+
+| Image | Base | Contents | Network while building |
+|---|---|---|---|
+| `garmin-otp/dev` | `ubuntu:22.04` pinned by digest, apt from a dated `snapshot.ubuntu.com` mirror | OpenJDK 17; simulator libraries (`libwebkit2gtk-4.0-37`, `libusb-1.0-0`, `libsm6`, GTK); Xvfb, openbox, x11vnc, noVNC, xdotool, ImageMagick; a Python 3.10 virtual environment (pytest, hypothesis, zxing-cpp, segno, Pillow, all exact versions with hashes); `connect-iq-sdk-manager-cli` v0.8.4 (SHA-256 checked); make, openssl | yes |
+| `garmin-otp/docs` | Node LTS pinned by digest | markdownlint-cli2, mermaid-cli | yes |
+
+The Garmin SDK and device files are never baked into an image (Garmin's licence). They live in `.cache/`.
+
+### 15.2 How containers run
+
+- **Non-root**: as your user and group ID, with `--cap-drop ALL` and `--security-opt no-new-privileges`. No privileged mode, no host networking, and a read-only root filesystem with a tmpfs `/tmp` wherever the tools allow it.
+- **One mount**: only the project folder, at `/work`. `HOME` inside the container is `/work/.cache/home`, so the SDK, device files and login token stay inside the project.
+- **Network**: `--network none` for build, test, lint, package and docs runs. The network is used only to build images and in `make sdk` (Garmin downloads). `make sim` publishes noVNC on 127.0.0.1 only. That needs a bridge network for the port, but the app itself has no network permission.
+- **Removable build cache**: a dedicated buildx builder named `garmin-otp`, so its cache can be removed without touching other projects.
+- **Labels**: every container, image and the builder carry `project=garmin-otp`.
+
+### 15.3 First-time setup (your steps)
+
+1. **Docker access** (D5). Either:
+ - `sudo usermod -aG docker cirakg`, then log out and back in. Membership in that group is equivalent to root on this machine.
+ - Or rootless Docker, which needs the AUR package `docker-rootless-extras`.
+2. **`make image`** builds the images.
+3. **`make sdk`**:
+ - Shows Garmin's agreements (`agreement view`). You accept them (`agreement accept`) and log in, both interactively.
+ - Downloads SDK 9.2.0 and the device files for the manifest's products into `.cache/home/.Garmin/ConnectIQ`.
+ - Fallback if the third-party CLI's login fails: run Garmin's own SDK Manager inside the container and use it through noVNC.
+4. **`make key`** generates your developer key into `.cache/keys/` and prints backup instructions.
+5. **`make test`** and **`make sim`** confirm everything works.
+
+### 15.4 Make targets
+
+| Target | What it does | Network |
+|---|---|---|
+| `make image` | Build the Docker images | yes |
+| `make sdk` | Licence, login, download the SDK and device files | yes (Garmin only) |
+| `make key` | Generate the developer key (never overwrites an existing one) | no |
+| `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 perf` | Performance measurements against the budgets | no |
+| `make memtest` | Peak memory on Forerunner 255S, app and glance | no |
+| `make lint` | Strict type check (`-l 3`) with warnings as errors, project lint rules, settings XML schema check, Python lint for the tools | no |
+| `make package` | Signed `.iq` for the Store beta, plus per-device `.prg` files | no |
+| `make docs` | Markdown lint, link check, Mermaid render check | no (yes when building the docs image) |
+| `make ci` | lint, test, screenshots, perf, memtest, package, docs | no |
+| `make shell` | Shell in the dev container | no |
+| `make clean` | Delete build outputs | no |
+| `make nuke` | Delete everything this project created ([15.7](#157-uninstall-and-cleanup)) | no |
+
+### 15.5 Watching the simulator
+
+Run `make sim DEVICE=fr965`, then open http://127.0.0.1:6080/vnc.html in your browser. There's nothing to install. Forwarding your own X display into the container would also work, but it gives the container access to your desktop session, so it isn't the default.
+
+### 15.6 Developer key
+
+- **What**: an RSA-4096 key in PKCS#8 DER format, generated with openssl inside the container into `.cache/keys/developer_key.der` (plus `.pem`), mode 600. It's ignored by git and never baked into an image.
+- **Back it up**: copy `.cache/keys/` somewhere encrypted, such as a password-manager attachment or an encrypted USB drive.
+- **If you lose it**: Garmin rejects updates signed with a different key. You would have to create a new beta app (a new app ID), install it, and add every entry again. The watch data belongs to the old app ID and can't be exported.
+- `make nuke` deletes it, and asks you to confirm you have a backup first.
+
+### 15.7 Uninstall and cleanup
+
+1. **`make nuke`** asks for confirmation, then:
+ - Stops and removes containers labelled `project=garmin-otp`.
+ - Removes the images, and the buildx builder with its cache.
+ - Deletes `.cache/` (SDK, device files, login token, developer key) and `build/`.
+ - Removes the Ubuntu and Node base images too, unless another image on your machine still uses them.
+2. **Delete the project folder.**
+3. **If you joined the `docker` group** for this project: `sudo gpasswd -d cirakg docker`.
+4. **On the watch**: uninstall the app in the Connect IQ Store app, which deletes its data. Optionally delete the beta from your developer account.
+
+Nothing else is installed on your host: no packages, no `~/.Garmin`, no shell profile changes.
+
+## 16. Build plan
+
+The order follows your brief: crypto and OTP core, storage, UI, import, barcodes, settings, then polish. M1 (the settings spike) runs in parallel because it needs your phone and watch.
+
+**After every milestone**: `make ci` runs green in Docker, the docs are updated, `CHANGELOG.md` gets an entry, and you get a short status report with the test output.
+
+### M0: Scaffolding (Phase 2)
+
+- **Deliverables**:
+ - `docker/Dockerfile`, `Makefile` and the container scripts (SDK setup, headless simulator, test runner that parses the `monkeydo` output).
+ - `.gitignore`, `CHANGELOG.md`.
+ - `manifest.xml` with the generated product list, and `monkey.jungle` with debug, release and test configurations.
+ - A minimal app that shows "OTP Wallet", plus one unit test.
+- **Tests**:
+ - `make test` passes headless on the 3 test devices.
+ - `make lint` passes with strict type checking.
+ - `make package` produces an `.iq`.
+ - `make nuke` leaves no project containers, images, builder or files behind; checked by listing before and after.
+- **Done when**: everything works from a clean checkout plus the first-time setup. The host has nothing besides Docker, and the network was used only for images and SDK downloads.
+- **Your part**: Docker access (D5); accepting the SDK licence and logging in (D6).
+
+### M1: Settings spike (needs you; runs alongside M2)
+
+- **Deliverables**:
+ - A tiny spike app with its own beta app ID and a few test fields (long text, password, read-only status, switch, list, numeric). It shows on the watch what it received.
+ - Test strings of increasing length, with markers.
+ - `docs/SETTINGS_SPIKE.md`, recording the results.
+- **What we measure**:
+ - Maximum length per field and in total, on your phone.
+ - Which characters are accepted (`& < > \ " { } %`, non-ASCII).
+ - Whether values the watch writes back (status, cleared fields) show up on the phone.
+ - When `onSettingsChanged` fires.
+ - How password fields behave.
+- **Done when**: the limits are recorded, and the number of import fields, the buffer size and the write-back assumptions in sections 10 and 11 are updated.
+- **Your part**: create a developer account, upload the spike as a beta, run the phone tests (about 30 minutes).
+
+### M2: Crypto and OTP core
+
+- **Deliverables**:
+ - `crypto/`: hash wrappers, HMAC, SHA-512, PBKDF2, HKDF, AES-CBC with PKCS#7, encrypt-then-MAC records, constant-time comparison, zeroing, random, self-test.
+ - `otp/`: Base32, HOTP, TOTP, Steam.
+ - `util/clock` and `util/jobs`.
+- **Tests**: every vector listed in 17.2; the 2038 widening; worst-case inputs with no watchdog trip; speed of each primitive on the 3 devices; PBKDF2 calibration.
+- **Done when**: all vectors pass on the 3 devices, the numbers are in `docs/PERFORMANCE.md`, and AES-256 vs AES-128 is decided.
+
+### M3: Storage, keys and PIN logic
+
+- **Deliverables**: `storage/` (vault, TLV codec, key manager, session, lockout, migrations, quotas, pending queue).
+- **Tests**:
+ - Round trips for every entry type.
+ - Tamper detection: every byte of a record flipped in turn.
+ - Wrong PIN; the lockout schedule with an injected clock, including after a restart; the erase option.
+ - PIN change and reset.
+ - Migration from a v0 test fixture to v1, and refusal of a newer schema.
+ - A full store; zeroed buffers after use.
+- **Done when**: everything passes, the record size is measured, the entry cap is set, and the record format is in `docs/SECURITY.md`.
+
+### M4: UI core
+
+- **Deliverables**:
+ - Views: list, OTP detail (ring, HOTP next, next-code preview), PIN entry (touch and buttons), menu, results screen, basic glance.
+ - Layouts for round, rectangular and semi-octagon screens; theme and code-size options.
+ - Debug-only fixture loader and performance overlay.
+- **Tests**:
+ - Screenshot goldens of every screen on the 3 test devices, plus the spot checks.
+ - Scripted input flows: open and close, correct PIN, wrong PIN, lockout display, auto-lock.
+ - Start-up time and memory checkpoints.
+- **Done when**: you've reviewed the screenshots (attached to the status report), and start-up and memory are within budget in the simulator.
+
+### M5: Import
+
+- **Deliverables**:
+ - `import/`: `otpauth://` parser, streaming JSON tokenizer, Aegis reader, import service with duplicate detection, partial imports, multi-part buffer and report.
+ - Google migration import if time allows.
+ - Wiring of the Import form.
+- **Tests**: parser edge cases, the Aegis fixtures and the fuzz corpus (17.2, 17.3), and an end-to-end import that's then checked in screenshots.
+- **Done when**: everything passes, and a 100-entry fake vault imports within the time and memory budgets.
+
+### M6: Barcodes
+
+Four parts:
+
+- **M6a**: the linear codes (EAN-13/8, UPC-A/E, Code 128, Code 39, ITF, Codabar), the renderer (fit, quiet zones, text, brightness) and validators.
+- **M6b**: QR.
+- **M6c**: Data Matrix and PDF417.
+- **M6d**: Aztec.
+
+Tests: the four checks in 12.4 for each symbology, plus validation tests (valid and invalid content, check digits added and fixed, capacity per error-correction level, fit warnings) and encode time and memory.
+
+Done when: each symbology is either marked implemented with every check green, or marked deferred with the reason.
+
+### M7: Settings
+
+- **Deliverables**:
+ - `properties.xml` and `settings.xml` with every group.
+ - Settings reader; inbox processor (Add OTP, Add code); manage processor; pending queue for locked entries.
+ - Options applied live; status writer, results screen and catalog.
+- **Tests**:
+ - Form parsing and validation for every OTP type and every symbology, valid and invalid.
+ - Idempotency: the same form applied twice.
+ - Manage actions on unlocked and locked entries.
+ - End-to-end: a manually entered entry, set through a debug fixture that takes the same path as the phone, shows the right TOTP code or a barcode that decodes correctly, on screenshots.
+- **Done when**: everything passes, the XML validates against the SDK schema, and you've confirmed it on your phone and watch with the beta.
+
+### M8: Polish, performance, release
+
+- **Deliverables**:
+ - Tuning to the budgets; the memory check on Forerunner 255S; the glance favourite code (opt-in); AMOLED brightness handling.
+ - Final `docs/` and the manual test checklist.
+ - `make ci` green; signed `.iq` and per-device `.prg` files; `CHANGELOG` 1.0.0; the final list of known limitations.
+- **Done when**: `make ci` is green from a clean checkout, you've completed the on-watch checklist, and 1.0.0 is tagged.
+
+## 17. Test plan
+
+Tests are written alongside the code, never after.
+
+### 17.1 Layers
+
+| Layer | Tool | Runs in |
+|---|---|---|
+| Unit tests (Monkey C) | Garmin's Run No Evil framework, `(:test)` functions | Headless simulator, 3 devices |
+| Test-vector generation, reference encoders, decoding, screenshot analysis | Python: pytest, hypothesis, zxing-cpp, segno, Pillow | Dev container |
+| UI and screenshot tests | Simulator, Xvfb, ImageMagick, xdotool | Dev container |
+| Performance and memory | Debug-build instrumentation in the simulator | Dev container |
+| Static checks | `monkeyc -l 3`, project lint, XML schema, ruff and mypy | Dev container |
+| Manual | The checklist in `docs/TESTING.md` | Your watch and phone |
+
+### 17.2 Unit test coverage
+
+- **crypto**:
+ - FIPS 180-4 examples for SHA-1, SHA-256 and SHA-512: empty, "abc", the 448- and 896-bit messages, and one million "a" fed in chunks.
+ - RFC 2202 (HMAC-SHA1); RFC 4231 (HMAC-SHA256 and HMAC-SHA512, all cases).
+ - RFC 7914 §11 (PBKDF2-HMAC-SHA256); RFC 5869 (HKDF cases 1 to 3); NIST SP 800-38A (AES-128 and AES-256 CBC).
+ - PKCS#7 edge cases; record tamper tests (every byte flipped); constant-time comparison; zeroing.
+- **otp**:
+ - RFC 4226 (the 10 HOTP values) and RFC 6238 (6 times × 3 algorithms, 8 digits).
+ - Truncation to 6, 7 and 8 digits; periods 15, 30, 60, 90 and odd values.
+ - Rollover at each period boundary (k·p − 1, k·p, k·p + 1); the 2038 boundary (0x7FFFFFFF, and 0x80000000 read as unsigned); the year-2603 vector.
+ - Steam, cross-checked against a Python reference.
+ - Base32: the RFC 4648 §10 vectors, lowercase, whitespace, hyphens, padding, invalid characters, overlong input.
+- **storage**: the M3 list.
+- **import**:
+ - `otpauth://`: percent-encoded labels, missing issuer, issuer mismatch, missing secret, invalid parameters, `hotp` without a counter, Steam variants, unknown parameters, `;` separators.
+ - Aegis fixtures, all fake data: TOTP, HOTP, Steam, mOTP and Yandex; SHA1, SHA256 and SHA512; 6, 7 and 8 digits; periods 20, 30, 50 and 60; groups in database versions 1, 2 and 3; icons, notes and favourites; an encrypted vault; malformed and truncated (multi-part) JSON; huge strings, deep nesting, Unicode, duplicate keys.
+ - Duplicates, partial imports, replace mode.
+- **settings**: every field of every form, valid and invalid, per OTP type and per symbology; idempotency; manage actions.
+- **codes**: section 12.4.
+- **util**: clock widening, offset and trusted-clock fallback; the job scheduler staying within a step budget; string helpers.
+
+### 17.3 Fuzz and property tests
+
+- **Inputs**: Python generates seeded random and mutated inputs (URIs, JSON documents, Base32 strings, settings forms, barcode contents), which are compiled into the test build.
+- **Monkey C assertions**: no unhandled exception; the result is either valid or a typed error with a message; the work stays within the watchdog step budget.
+- **Python side**: hypothesis checks the reference implementations used as test oracles.
+
+### 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.
+- **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.
+
+### 17.5 End-to-end
+
+- **Manual entry**: a debug fixture sets the properties with `Properties.setValue` and calls the settings handler, the same path the phone triggers. The entry then goes through storage and into the list and detail screenshots, which must show the expected TOTP code or a barcode that decodes correctly.
+- **Imports**: the same path, with the import fixtures.
+
+### 17.6 Performance and memory
+
+See [section 18](#18-performance-budgets). `make perf` and `make memtest` fail the build when a budget is exceeded.
+
+### 17.7 Static checks
+
+- **Compiler**: `monkeyc -l 3` (strict) for every product, and any compiler warning fails the build.
+- **Project lint rules**:
+ - No logging outside `(:debug)` code.
+ - No secret-looking literals outside the fake-fixture folder.
+ - Every string resource is used and defined.
+ - No `TODO` in release code.
+- **Schema**: the settings and manifest XML are validated against the SDK's schemas.
+- **Python tools**: ruff and mypy.
+
+### 17.8 `make ci`
+
+Runs lint, test, screenshots, perf, memtest, package and docs in that order, and stops at the first failure.
+
+### 17.9 Manual checklist on the real watch
+
+The full checklist will be in `docs/TESTING.md`. It covers what the simulator can't verify:
+
+- Installing the beta from the Store.
+- Adding and managing entries from your phone; the settings spike results.
+- Real clock and GPS sync; codes matching Aegis on your phone for the same accounts.
+- Crypto and start-up speed, using the performance overlay in a sideloaded debug build with fake data.
+- Button feel and touch targets.
+- Scanning with your phone and with real scanners, on MIP and AMOLED, indoors and outdoors.
+- Auto-lock and locking on exit; the system passcode, if your watch has one.
+- An app update keeps the data; uninstalling removes it; the glance.
+
+## 18. Performance budgets
+
+| Metric | Target on a watch | Simulator gate | How it's measured |
+|---|---|---|---|
+| Cold start to the first unlocked TOTP code drawn | ≤ 1.0 s on a mid-range watch (Forerunner 255 or 265) | ≤ 2× the M4 baseline | `System.getTimer()` from `AppBase.initialize` to the end of the first list draw. On the watch, the debug overlay |
+| TOTP with SHA-1 or SHA-256 | ≤ 30 ms | ≤ 2× baseline | Timer around the computation, averaged over 100 runs |
+| TOTP with SHA-512 (pure Monkey C) | ≤ 400 ms | ≤ 2× baseline | Same |
+| Countdown redraw | No frame over 100 ms; no allocations | Allocation counter in the debug build | Timer and `System.getSystemStats()` |
+| PIN unlock (key derivation) | 0.7 to 1.5 s, calibrated | Calibration logic tested | Timer |
+| Opening a code entry (cached matrix) | ≤ 300 ms | ≤ 2× baseline | Timer |
+| Encoding a QR version 10 without cache | ≤ 3 s, in chunks, with a progress indicator | ≤ 2× baseline | Timer |
+| Peak memory, app on Forerunner 255S | ≤ 70 % of 512 KB | Hard gate | `usedMemory` checkpoints and the simulator memory view |
+| Peak memory, glance | ≤ 70 % of 64 KB | Hard gate | Same |
+| Storage | ≤ 100 KB | Hard gate | Sum of record sizes |
+
+The simulator runs on your PC's CPU, so its timings don't represent a watch. In CI they only catch regressions against a baseline. Real numbers come from the debug overlay on your watch (manual checklist) and are reported in `docs/PERFORMANCE.md` with the method used.
+
+## 19. Risks and mitigations
+
+| Risk | Impact | Mitigation |
+|---|---|---|
+| Real settings limits are smaller than assumed, or write-back doesn't work | Pastes don't fit; fields don't clear | Spike in M1, early; multi-part import; idempotent forms; results on the watch |
+| The iPhone Garmin app rejects `&` and `\` | Paste import fails on iPhone | Confirm in the spike; `;` accepted in URIs; Android workaround documented |
+| Plain-text secrets in settings (D2, D3) | Garmin and the phone see secrets | Documented in the threat model; fields cleared; advice to delete export files |
+| Array settings fixed later, or other settings bugs | | The inbox design doesn't depend on them |
+| Watchdog or slowness (SHA-512, PBKDF2, 2D encoders, big imports) | Crashes or lag | Resumable jobs; measured early (M2); caches |
+| Memory on 512 KB watches with every encoder loaded | Out of memory | Measured in M6 and M8; lean encoders; code size tracked per build |
+| The simulator's crypto differs from a real watch's (for example AES-256 support) | Wrong codes on the watch | Start-up self-test; manual device checks |
+| Ubuntu 22.04 support ends in April 2027; the simulator needs `libwebkit2gtk-4.0` | Image can't be rebuilt someday | Images pinned by digest; snapshot mirror; revisit when Garmin updates its requirements |
+| The third-party SDK-manager CLI breaks after a Garmin login change | Can't download device files | Fallback: Garmin's own SDK Manager inside the container, through noVNC |
+| Shop scanners can't read the watch screen (laser on AMOLED, MIP indoors) | Codes unusable at some tills | Brightness boost, quiet zones, minimum module size, manual scan tests; documented |
+| Large codes don't fit small screens | Some cards can't be shown | Validation and warnings at entry time |
+| Developer key lost | No updates possible | Backup instructions; `make nuke` confirmation |
+| Garmin changes the beta process | Can't install | Documented manual steps; checked in M1 |
+| Short PINs can be broken offline | Locked secrets exposed if storage is extracted | Documented; longer PIN option; Garmin's object-store encryption as a bonus |
+| The ~1 s PIN derivation feels slow | UX | Calibrated, with progress; one PIN per session (D1) |
+| Strict typing friction or compiler bugs | Slower development | SDK version pinned; issues documented |
+
+## 20. Documentation
+
+| Document | Content |
+|---|---|
+| `README.md` | This plan, kept up to date as the project's overview |
+| [`docs/FEASIBILITY.md`](docs/FEASIBILITY.md) | Phase 0 findings with sources |
+| `docs/USER_GUIDE.md` | Install the beta, import from Aegis, add codes, PIN and locking, daily use, moving to a new watch, troubleshooting |
+| `docs/DEVELOPER_GUIDE.md` | Repository layout, build, test and debug in Docker, adding a symbology or a device, coding conventions |
+| `docs/SECURITY.md` | Threat model, crypto choices and parameters, record formats, known limitations, how to report issues |
+| `docs/TESTING.md` | Test strategy, how to run everything, the manual checklist, results |
+| `docs/PERFORMANCE.md` | Budgets, methods and measured numbers |
+| `docs/SETTINGS_SPIKE.md` | The settings limits measured in M1 |
+| `CHANGELOG.md` | Every change, by version |
+
+## 21. Repository layout
+
+```text
+.
+├── README.md plan and overview
+├── CHANGELOG.md
+├── Makefile host entry point; every target runs in Docker
+├── manifest.xml
+├── monkey.jungle debug, release and test configurations
+├── docker/
+│ ├── Dockerfile garmin-otp/dev
+│ ├── docs.Dockerfile garmin-otp/docs
+│ └── scripts/ SDK setup, headless simulator, test runner, VNC
+├── source/
+│ ├── app/ crypto/ otp/ codes/ storage/ import/ settings/ ui/ util/
+├── test/ Monkey C (:test) modules, mirroring source/
+│ └── data/ generated vectors and fake fixtures
+├── resources/
+│ ├── settings/ properties.xml, settings.xml
+│ ├── strings/ drawables/ fonts/
+├── tools/ Python: vector generation, reference encoders, decoding, screenshots, reports, lint
+├── docs/
+└── .cache/ build/ git-ignored: SDK, device files, keys, outputs
+```
+
+## 22. Versioning
+
+Semantic versioning, with `CHANGELOG.md` in the "Keep a Changelog" style. Versions are 0.x during the milestones and 1.0.0 at M8. The app version is shown in the About screen and in the settings.
diff --git a/docs/FEASIBILITY.md b/docs/FEASIBILITY.md
new file mode 100644
index 0000000..e150a48
--- /dev/null
+++ b/docs/FEASIBILITY.md
@@ -0,0 +1,288 @@
+# Feasibility findings (Phase 0)
+
+Researched on 2026-10-18 against the Connect IQ SDK 9.2.0 documentation (API reference generated 2026-10-06), Garmin's device reference pages, and, where Garmin's documentation is silent, Garmin developer-forum reports. Forum-only evidence is marked **(forum)**. Nothing has been built yet. Anything marked **to measure** gets measured in the milestones, not estimated.
+
+This file becomes the "Feasibility findings" section of `README.md` in Phase 1.
+
+Verdicts: **Supported**, **Partially** (works with a workaround or a limit), **Not possible**.
+
+## Summary
+
+| # | Question | Verdict | Consequence |
+|---|---|---|---|
+| 1 | Unlock entries with the Garmin device PIN | Not possible | App-level PIN instead (decision D1) |
+| 2 | SHA-1/256/512, HMAC, PBKDF2, AES on the watch | Partially | Native: SHA-1, SHA-256, AES-ECB/CBC, HMAC-SHA256, ECDH, random bytes. SHA-512 and PBKDF2 are built from their standards in Monkey C |
+| 3 | Import and manual entry through app settings | Partially | Text fields only, no file upload. Settings only work for apps installed from the Connect IQ Store (a private beta counts). Dynamic lists are broken today |
+| 4 | Storage limits | Partially | Garmin's pages disagree (8 KB vs 32 KB per value). Design for 8 KB values and about 100 KB total |
+| 5 | Memory | Supported on 2021+ watches | 512 to 768 KB per app on API 5.x/6.x. Older and low-end models get 96 to 128 KB |
+| 6 | Barcode rendering | Partially | All 12 symbologies can be drawn. Long linear codes and large 2D codes don't fit small round screens. Scanner type matters |
+| 7 | App type | Supported | Device app with a glance |
+| 8 | Privacy of settings | Partially (undocumented) | Assume anything typed into settings is stored by Garmin |
+| 9 | Decrypt an encrypted Aegis export on the watch | Not possible | Needs 32 MiB RAM. Decrypt in the offline converter |
+| 10 | Correct TOTP after 2038 | Partially | The clock value is 32-bit. Reinterpret it as unsigned (good until 2106) |
+| 11 | Headless SDK, simulator and tests in Docker | Partially | Needs your Garmin login once for device files. Ubuntu 22.04 base image |
+| 12 | Your host | Action needed | Your user can't use Docker yet (not in the `docker` group) |
+
+## 1. Garmin device PIN / lock: Not possible
+
+- There is no API for it. `Toybox.System.DeviceSettings` has no lock, passcode or PIN field. No Toybox module or manifest permission relates to the device lock. A 2026 forum thread asking how to detect or simulate the passcode got no answer from Garmin.
+- What does exist: some recent watches have a system passcode that engages when the watch comes off the wrist. A developer reports that on a Forerunner 265 (firmware 28.05) it shuts down running Connect IQ apps and calls `onStop` **(forum)**. This app wipes its keys in `onStop`, so on watches that have the system passcode, enabling it adds protection for free. The app can neither detect it nor require it.
+- The Garmin Pay wallet passcode is separate and not exposed to apps.
+
+Alternatives (decision **D1**):
+
+| Option | How it works | Trade-off |
+|---|---|---|
+| **A. App PIN, session unlock** (recommended) | 4 to 10 digit PIN (6 or more advised), entered with an on-screen keypad (touch) or up/down/start (buttons). PBKDF2 turns it into a key that unwraps the key for locked entries. Everything stays unlocked until you leave the app, or until an inactivity timeout (default 60 s). | One PIN entry per session. |
+| **B. PIN every time** | Same derivation, but the key is discarded as soon as you leave a locked entry. | Safer if the watch is grabbed mid-session. You wait for the key derivation (about 1 s, to measure) every time. |
+| **C. Per-entry choice of A or B** | Each entry's lock setting picks "session" or "every time". | Most flexible. More UI states to build and test. |
+
+Wrong attempts, for every option:
+
+- The attempt counter is saved *before* the PIN is checked, so restarting the app or the watch doesn't reset it.
+- After 5 failures, delays escalate (30 s, 1 min, 2 min, up to 1 h). They are timed with the watch's GPS-synced clock, which the user can't change.
+- An optional "wipe locked entries after 10 failures" setting exists and is off by default.
+
+**Limit to accept:** the lockout only stops guessing *on the watch*. Someone who copies the encrypted data off the watch can try all 1,000,000 six-digit PINs offline. At any key-derivation cost a watch can afford, that is a short job for a GPU. Longer PINs raise the cost. Garmin states that apps signed by the Store get an object store that is "encrypted and unreadable from the Connect IQ developer tools". The mechanism isn't documented, and it isn't stated whether beta installs count, so the design treats it as a bonus layer and doesn't rely on it.
+
+## 2. Cryptography: Partially
+
+`Toybox.Cryptography` exists since API 3.0.0 and needs no manifest permission. Every candidate watch in section 5 appears in the Hash, Cipher and HMAC device lists (the original vívoactive 3 is missing from Cipher and HMAC).
+
+| Primitive | Native? | Notes |
+|---|---|---|
+| SHA-1, SHA-256, MD5 | Yes | `Hash` class, streaming |
+| SHA-512 | **No** | |
+| HMAC | SHA-256 only | Docs: "HASH_SHA256 is the only supported algorithm in Connect IQ 3.x" |
+| AES-128, AES-256 | Yes | ECB and CBC modes only. No padding: CBC input must be a multiple of 16 bytes |
+| AES-CMAC | AES-128 only | |
+| ECDH | secp224r1, secp256r1 | Keys use little-endian byte order, so interoperability with other tools must be tested |
+| Random bytes | Yes | `randomBytes()`, documented as "cryptographically random" |
+| PBKDF2, scrypt, HKDF, AES-GCM, AES-CTR | **No** | |
+
+Plan. Standard constructions only, each tested against official test vectors:
+
+- **HMAC-SHA1 and HMAC-SHA256** (most TOTP secrets use SHA-1): RFC 2104 built on the native hashes, using native HMAC-SHA256 where it applies. Vectors: RFC 2202, RFC 4231.
+- **SHA-512 and HMAC-SHA512**: pure Monkey C following FIPS 180-4, on 64-bit `Long`. Vectors: FIPS 180-4 examples, RFC 4231. Only SHA-512 OTP entries pay its cost (to measure).
+- **Key derivation from the PIN**: PBKDF2-HMAC-SHA256 (RFC 8018) on the native HMAC. Vectors: RFC 7914 §11. The iteration count is calibrated on each watch to about 1 s and stored with the salt.
+- **Encryption at rest**: AES-CBC plus HMAC-SHA256 (encrypt-then-MAC), with separate keys derived by HKDF (RFC 5869), PKCS#7 padding and a random IV per record. AES-256 if every target supports it in testing, otherwise AES-128. The PIN, not the cipher, is the weak point either way.
+- **Start-up self-test** against known vectors. If a primitive misbehaves on some firmware, the app refuses to show codes rather than show wrong ones.
+
+## 3. App settings: Partially
+
+### 3a. No file upload: Not possible
+
+Setting types are `alphaNumeric`, `numeric`, `boolean`, `list`, `date`, `email`, `phone`, `url` and `password`.
+
+- `password` only masks the input on screen, and it cannot be read-only.
+- Read-only text fields work, so the watch can report status messages.
+- Settings can be grouped (no nesting), and `enableIfTrue` hides a group until a switch is on.
+- Built-in validation is limited to `min`, `max`, `maxLength` and `required`. Check digits, Base32 and QR capacity have to be validated on the watch, which reports the result in a read-only status field.
+
+### 3b. Settings only work for apps installed from the Store
+
+This changes how the app gets onto your watch. Garmin's developer FAQ: app settings "depend on the Connect IQ App Store to function properly. When an app is side loaded, this connection to the Connect IQ App Store is never established."
+
+The supported route is a **beta app**: you upload the `.iq` file to your Garmin developer account with "Beta App" ticked, and only your account can see it. You install it from the Store, and its settings then work in Garmin Connect and Garmin Express. So requirement 11, and every settings-based import, needs a Garmin developer account and a manual upload through Garmin's website for each release.
+
+Forum posts describe copying a simulator-generated `.SET` file onto a sideloaded watch. The file format is undocumented, so this is not proposed.
+
+### 3c. Dynamic lists ("array settings"): documented, broken in practice
+
+Garmin documents growable lists that the app reads as an array of dictionaries. As of this month they cannot be saved from Garmin Connect Mobile. Bug CIQQA-4751 was reported on 2026-10-01 (Forerunner 965, Garmin Connect 5.29 on Android) and acknowledged by Garmin on 2026-10-02, with no fix yet. It says a failed save silently resets *all* of the app's settings, and nothing saves again until the app is reinstalled. Older array bugs (CIQQA-1339 and CIQQA-1380, from 2022) are still open.
+
+Verdict: not usable. Alternatives (decision **D3**):
+
+| Option | How it works | Trade-off |
+|---|---|---|
+| **S1. Fixed slots** (recommended) | N slot groups per kind (for example 10 OTP and 10 code slots, a build-time constant). Each slot has every field from requirement 11, plus "enabled" and "order". Edit in place, reorder with "order", delete by switching off or clearing. Secret fields are write-only: blank means "keep the stored secret". | Hard cap on hand-entered entries. The phone shows groups named "OTP slot 1" and so on, because group titles can't be dynamic. Imported entries aren't shown in slots; you delete, reorder and lock them on the watch. Works even if write-back (3e) doesn't. |
+| **S2. Inbox plus manage form** | One "new OTP" and one "new code" form that the watch reads and then clears. A "manage" form takes an entry ID (shown on the watch) and an action: edit fields, delete, move to position, lock. | No cap on entries and a small settings payload. You need the entry ID from the watch. The watch must ignore forms it has already applied. |
+| **S3. One entry per line** | One text field with one line per entry (`otpauth://...` lines and a similar `code://...` format), parsed and validated on the watch with an error per line. | Compact, for power users. Limited by field length (about 800 characters, see 3d) and the `&` problem on iOS. Secrets stay in the field unless write-back works. |
+
+### 3d. Size and character limits: undocumented
+
+Forum reports only **(forum)**:
+
+- Garmin Express caps a text field at 800 characters.
+- Long values fail to save; one report attributes this to a total settings payload of about 8 KB (unverified).
+- Garmin Connect on iOS rejects `<`, `>`, `&` and `\`.
+
+Every `otpauth://` URI with parameters contains `&`, so pasting raw URIs may fail on an iPhone. Import data will use an `&`-free alphabet and be split across several fields, and several syncs if needed. The real limits get measured on your phone in an early "settings spike" milestone.
+
+### 3e. Writing values back: unknown
+
+The watch can change its own settings values with `Properties.setValue` (saved when the app stops). Garmin doesn't document whether the phone shows those values the next time you open the settings. This decides whether "clear the field after import" and status messages are visible on the phone. The design works either way, and the settings spike measures it.
+
+## 4. Storage: Partially
+
+- `Application.Storage` (API 2.4.0) stores numbers, strings, `ByteArray`, arrays and dictionaries.
+- The limits disagree. The API reference says values up to 32 KB, a total that "can vary between devices", and `StorageFullException` when full. The core-topic page says "Keys and values are limited to 8 KB each, and a total of 128 KB of storage is available." The design uses the stricter numbers: at most 8 KB per value and about 100 KB in total, with one key per entry plus small index keys.
+- Garmin says storage data "is only available to the application and is not accessible by the end user", and that Store-signed apps get an encrypted object store.
+- What happens to storage across app updates and on uninstall is **not documented**. Storage normally survives updates, but the design doesn't assume it. Every record carries a schema version, migrations are tested, and unreadable data fails safe (the app asks for a re-import instead of crashing).
+- `Application.Properties` only holds settings declared in `properties.xml`. Values are written to disk when the app stops, and background processes can't write them.
+
+## 5. Memory per device: Supported on 2021+ watches
+
+Memory in KB from Garmin's device reference pages, API level from the compatible-devices table:
+
+| Device | API | Screen | Touch | Watch app | Glance |
+|---|---|---|---|---|---|
+| Forerunner 255S | 5.2 | 218×218 round, MIP 64 colours | no | 512 | 64 |
+| Forerunner 255 | 5.2 | 260×260 round, MIP | no | 512 | 64 |
+| fēnix 7 / 7S / 7X | 5.2 | 240 to 280 round, MIP | yes | 768 | 64 |
+| epix 2, Forerunner 265 / 965, Forerunner 165, vívoactive 5, Venu 3S | 5.2 | 360 to 454 round, AMOLED | yes | 768 | 64 |
+| Forerunner 955 | 5.2 | 260×260 round, MIP | yes | 768 | 64 |
+| Venu 2S, Venu 2 Plus | 5.0 | 360 to 416 round, AMOLED | yes | 768 | 64 |
+| Venu Sq 2 | 5.0 | 320×360 rectangle, AMOLED | yes | 768 | 64 |
+| fēnix 8, fēnix E, Forerunner 570 / 970, Venu 4, vívoactive 6 | 6.0 | 260 to 454 round | yes | 768 | 64 |
+| Venu X1 | 6.0 | 448×486 rectangle, AMOLED | yes | 768 | 64 |
+| Instinct 3 AMOLED | 6.0 | 390×390 round, AMOLED | no | 768 | 64 |
+| fēnix 6 (non-Pro), Forerunner 245, Forerunner 55 | 3.3 to 3.4 | 208 to 260 round, MIP | no | 128 | 32 |
+| Instinct 2 / 2S / Crossover | 3.4 | 163 to 176 semi-octagon, 2 colours | no | 96 | 32 |
+| Instinct 3 Solar, Instinct E | 6.0 | 166 to 176 semi-octagon, 2 colours | no | 128 | 32 |
+
+Recommendation (decision **D4**): set `minApiLevel` to 5.0 and target every watch with at least 512 KB.
+
+- Weakest target: Forerunner 255S (smallest round screen, buttons only, 512 KB).
+- Simulator test trio: Forerunner 255S (round, buttons only), Venu Sq 2 (rectangle, touch), Forerunner 965 (AMOLED, touch).
+- The 96 to 128 KB watches would need a reduced build (linear codes and QR only) and their own test runs. Only worth it if you own one.
+
+## 6. Rendering barcodes: Partially
+
+- Drawing is plain filled rectangles, rendered once into a buffered bitmap and then reused. Speed is to measure, but there is little work per frame.
+- Space: the largest centred square on a round screen is its width divided by 1.41. That's 154 px on a 218 px screen, 184 px on 260 px, 276 px on 390 px and 321 px on 454 px.
+- **QR**, with the standard 4-module quiet zone:
+ - 218 px screen: version 6 at 3 px per module (about 106 bytes at error-correction level M), or version 13 at 2 px (about 331 bytes).
+ - 454 px screen: version 20 at 3 px (about 666 bytes).
+ - Data Matrix and Aztec behave similarly. PDF417 is wide and short, which suits the horizontal middle of a round screen.
+- **Linear codes** use the widest horizontal band of the screen:
+ - EAN-13 needs 113 modules including quiet zones: 1 px per module on a 218 px screen, 2 px on 260 px, 3 px on 390 px and larger.
+ - A 20-character Code 128 needs about 275 modules and does not fit a 218 to 260 px round screen at all.
+- When a code can't be drawn at a scannable module size (at least 2 px, to be confirmed by scan tests), the watch says so instead of drawing it. Entry validation warns in the status field, and the converter warns for every target screen.
+- **Scanners**:
+ - AMOLED screens emit light and have high contrast. The app can raise brightness while a code is shown, but watches with burn-in protection throw an exception if the screen is held on for more than about a minute, so the boost is time-limited.
+ - Camera-based scanners and phones read screens. Laser scanners usually can't read emissive screens.
+ - MIP screens reflect ambient light: good outdoors, backlight needed indoors.
+ - Real checkout scanners vary, so you'll need to test them.
+- **Verification in Docker**: render each encoder's output at watch scale and decode it with zxing-cpp (open source, reads all 12 symbologies); decode simulator screenshots of the real watch UI; compare deterministic linear codes bar-for-bar with reference encoders.
+
+## 7. App type: Supported (device app with a glance)
+
+- Widgets time out after inactivity (documented). Device apps are not documented to.
+- On API 4.0+ watches, apps that implement a glance appear in the glance list: one scroll from the watch face and one press to open.
+- Glances run in 64 KB (32 KB on older watches) and get no input. The default glance shows the app name and the number of entries. An optional glance (off by default) could show your favourite unlocked code, if the crypto fits the glance memory budget (to measure).
+- Hot keys and shortcut menus are device features that apps can't configure. The user guide will list where they exist.
+
+## 8. Privacy of settings: Partially (undocumented)
+
+- Garmin doesn't document where setting *values* are stored. What is known:
+ - Settings only work for Store installs.
+ - The settings definition is stored in the Store.
+ - Editing settings needs an internet connection **(forum, with a reply from Garmin staff)**.
+ - The values reach the watch, which keeps them in its settings file.
+- So treat anything typed into settings as disclosed to your phone, to Garmin's servers (possibly kept indefinitely) and to the watch's settings file. `password` fields hide characters on screen and do nothing more.
+- Minimising exposure:
+ 1. Import secrets only as encrypted data from the offline converter. The decryption code is typed on the watch and never goes through the phone.
+ 2. The watch clears import and secret fields after reading them. Whether the phone sees the cleared value depends on 3e.
+ 3. Typing a Base32 secret into the manual form sends it in the clear. The settings screen and the user guide warn about this.
+
+## 9. Encrypted Aegis export on the watch: Not possible
+
+- Aegis password slots use scrypt with N = 32,768, r = 8 and p = 1, which needs 128 × r × N = 32 MiB of RAM. Watch apps get at most 0.77 MB.
+- The vault itself is encrypted with AES-256-GCM, which isn't native on the watch.
+- Decryption therefore happens in the offline converter (Docker, no network). The watch only ever receives the app's own encrypted format.
+- The converter also handles plain Aegis exports, `otpauth://` lists and Google Authenticator `otpauth-migration://` exports.
+- Aegis features the watch can't use are reported, not silently dropped:
+ - Icons and notes are not imported.
+ - mOTP and Yandex entries are skipped with a message.
+ - Steam entries are imported as the optional Steam type.
+
+## 10. Time: Partially
+
+- `Time.now()` returns UTC seconds, but the value is a 32-bit signed integer that wraps on 2038-01-19. The OTP core takes a 64-bit time, and the clock reading is reinterpreted as unsigned, which is correct until 2106. Tests cover period rollover, leap years, 2038, and the RFC 6238 vector for the year 2603 (the maths only).
+- `Time.getCurrentTime({:currentTimeType => Time.CURRENT_TIME_RTC})` (API 3.0.10) returns a clock the user can't override, synced from trusted sources such as GPS. It throws if that clock isn't synced yet. The app warns when that clock is unsynced or disagrees with the displayed time, and a setting allows a manual offset for skew.
+
+## 11. Docker, SDK and simulator: Partially
+
+- **SDK**: the latest is 9.2.0 (2026-06-09). The Linux zip downloads without a login from Garmin's SDK list (checked: HTTP 200, 214 MB). Downloading it means accepting Garmin's SDK licence, so you make that step.
+- **Device definitions**, which you need to build for any watch, only come through the SDK Manager with a Garmin account.
+ - Headless option: `connect-iq-sdk-manager-cli` (open source, Apache-2.0, v0.8.4 from 2026-02-22) has `agreement view`, `agreement accept`, an interactive `login` and `device download --manifest`.
+ - You'd log in once inside the container. The login token stays in the project's `.cache/` folder.
+ - Third-party Docker images that bundle Garmin's device files are avoided for licence reasons.
+- **Simulator**:
+ - Garmin supports only Ubuntu on Linux, and the simulator needs `libwebkit2gtk-4.0`, which Ubuntu 24.04 no longer ships. The image is therefore Ubuntu 22.04 with Java 17, Xvfb, libusb and libsm. Ubuntu 22.04 standard support ends in April 2027, a risk to track.
+ - To watch the simulator, a VNC server in the container is published on 127.0.0.1 only and opened in your browser through noVNC. Nothing to install.
+- **Unit tests** (Garmin's Run No Evil framework) only run inside the simulator: build with `--unit-test`, then run `monkeydo app.prg <device> -t`. Its exit code is reported to always be non-zero **(forum)**, so the test runner parses the result lines instead.
+- **Strict type checking**: `monkeyc -l 3`.
+- **No network**: the app requests no permissions at all, so it can't use `Toybox.Communications`. Anyone can check this in `manifest.xml`.
+- **Watchdog**: code that runs too long without returning to the system is killed ("Watchdog Tripped"). Forum posts report a per-device bytecode budget (examples of 80,000 to 120,000) and about 5 s on hardware **(forum)**. Key derivation, SHA-512 batches and 2D encoding therefore run as resumable jobs across timer ticks. The exact budgets come from the device files in Phase 2.
+- **Developer key**: an RSA-4096 key generated inside the container into `.cache/`, which git ignores. The Store rejects an update signed with a different key. Losing it means a new app ID and re-importing your entries on the watch.
+
+## 12. Your host: Action needed
+
+Docker 29.9.1 with buildx is installed and running. Your user isn't in the `docker` group, so every Docker command currently fails with "permission denied". The Compose plugin isn't installed; the Makefile won't need it.
+
+## 13. Import pipeline candidates (decision D2)
+
+| Candidate | Pros | Cons |
+|---|---|---|
+| **A. Paste `otpauth://` lines into settings.** The watch parses them and clears the field. | No tools needed. | Secrets pass through the phone and Garmin in the clear. iOS may reject the `&` in every URI. Only a handful of entries fit per field. Needs the Store beta. |
+| **B. Offline converter to an encrypted blob** (recommended). The converter runs in Docker with no network and turns an Aegis export (plain or encrypted), `otpauth://` lists or Google migration exports into an encrypted blob. You paste the blob into settings and type a 12-digit transfer code on the watch once. | Only ciphertext reaches Garmin. Validation and warnings (for example, a code too big for your screen) happen before anything reaches the watch. Compact, about 70 characters per entry. The blob is encrypted, so any channel can carry it to your phone; the converter can also show it as QR codes for your phone's camera. | Needs a computer. Needs the Store beta. The transfer code is worth about 40 bits times the key-derivation cost: fine against bulk exposure, not against a determined attacker who also gets hold of the blob. |
+| **B′. Like B, with ECDH pairing instead of a transfer code** | No code to type, and full 128-bit security. | A one-time pairing step: you copy the watch's public key from the phone settings into the converter. Connect IQ's little-endian key format needs interoperability testing on a real watch. |
+| **C. USB sideload build with the encrypted vault built in** | Nothing goes through the phone or Garmin. No Store account needed. | That build has no phone settings at all, so no manual entry form. Every change means a rebuild and a USB copy. |
+
+Rules that apply to every path (detailed in the README):
+
+- **Duplicates**: same type, issuer, account and secret means skip and report.
+- **Partial imports**: valid entries are imported, invalid ones are listed with reasons.
+- **Status**: one read-only status field summarises the last import.
+- **Editing and removing after import**: on the watch (delete, reorder, lock), or by re-importing.
+
+## Decisions
+
+Answered on 2026-10-18:
+
+- **D1 PIN policy: A, session unlock.** One PIN entry per session. Everything re-locks when you leave the app or after the inactivity timeout.
+- **D2 Delivery and import: plain text, imported on the watch.** Two formats are pasted into settings and parsed on the watch: `otpauth://` URIs, and Aegis's *unencrypted* JSON export. There is no converter tool. Consequences:
+ - Encrypted Aegis exports are not supported (section 9 explains why the watch can't decrypt them).
+ - Secrets pass through the phone and Garmin in the clear (section 8). The watch clears the fields after import.
+ - Delivery is the private Store beta, because settings don't work for sideloaded apps (3b).
+ - An Aegis JSON export is about 400 to 500 characters per entry without icons, so large vaults may need several fields and syncs. The settings spike measures the real limits.
+- **D3 Manual-entry layout: S2, inbox plus manage form.**
+- **D4 Targets: API 5.0+ watches** with at least 512 KB for apps. No low-memory build.
+
+Still open:
+
+- Which watch you'll test on, and whether your phone is Android or iPhone (the `&` and `\` problem in 3d affects iPhone only).
+- **D5 Docker access** (needs sudo, which you run):
+ - Option 1: `sudo usermod -aG docker cirakg`, then log out and back in. Membership in the `docker` group is equivalent to root on this machine.
+ - Option 2: rootless Docker, which needs the AUR package `docker-rootless-extras`.
+- **D6 Garmin account steps** (Phase 2):
+ - Accept the SDK licence and log in once inside the container.
+ - Create a Garmin developer account.
+ - Upload the beta yourself.
+
+## Sources
+
+Official Garmin documentation:
+
+- Cryptography: [module](https://developer.garmin.com/connect-iq/api-docs/Toybox/Cryptography.html), [Hash](https://developer.garmin.com/connect-iq/api-docs/Toybox/Cryptography/Hash.html), [HMAC](https://developer.garmin.com/connect-iq/api-docs/Toybox/Cryptography/HashBasedMessageAuthenticationCode.html), [Cipher](https://developer.garmin.com/connect-iq/api-docs/Toybox/Cryptography/Cipher.html), [KeyAgreement](https://developer.garmin.com/connect-iq/api-docs/Toybox/Cryptography/KeyAgreement.html)
+- Storage and settings: [Storage](https://developer.garmin.com/connect-iq/api-docs/Toybox/Application/Storage.html), [Properties](https://developer.garmin.com/connect-iq/api-docs/Toybox/Application/Properties.html), [Persisting Data](https://developer.garmin.com/connect-iq/core-topics/persisting-data/), [Properties and App Settings](https://developer.garmin.com/connect-iq/core-topics/properties-and-app-settings/), [Beta Apps](https://developer.garmin.com/connect-iq/core-topics/beta-apps/), [Security](https://developer.garmin.com/connect-iq/core-topics/security/), [developer FAQ: app settings and sideloading](https://forums.garmin.com/developer/connect-iq/w/wiki/4/new-developer-faq)
+- App model: [App Types](https://developer.garmin.com/connect-iq/connect-iq-basics/app-types/), [Glances](https://developer.garmin.com/connect-iq/core-topics/glances/), [Manifest and Permissions](https://developer.garmin.com/connect-iq/core-topics/manifest-and-permissions/), [Resources](https://developer.garmin.com/connect-iq/core-topics/resources/)
+- Devices: [Device Reference](https://developer.garmin.com/connect-iq/device-reference/), [Compatible Devices](https://developer.garmin.com/connect-iq/compatible-devices/), [DeviceSettings](https://developer.garmin.com/connect-iq/api-docs/Toybox/System/DeviceSettings.html)
+- Time and display: [Time](https://developer.garmin.com/connect-iq/api-docs/Toybox/Time.html), [Moment](https://developer.garmin.com/connect-iq/api-docs/Toybox/Time/Moment.html), [Attention.backlight](https://developer.garmin.com/connect-iq/api-docs/Toybox/Attention.html)
+- Tooling: [Unit Testing](https://developer.garmin.com/connect-iq/core-topics/unit-testing/), [Monkey Types (type checking)](https://developer.garmin.com/connect-iq/monkey-c/monkey-types/), [Command line](https://developer.garmin.com/connect-iq/reference-guides/monkey-c-command-line-setup/), [Getting Started / SDK Manager](https://developer.garmin.com/connect-iq/connect-iq-basics/getting-started/), [SDK release list](https://developer.garmin.com/downloads/connect-iq/sdks/sdks.json)
+
+Garmin developer forum:
+
+- Array settings bugs: [CIQQA-4751](https://forums.garmin.com/developer/connect-iq/i/bug-reports/array-settings-still-cannot-be-saved-from-garmin-connect-mobile-corrupts-saving-of-all-app-settings-store-released-data-field), [CIQQA-1339](https://forums.garmin.com/developer/connect-iq/i/bug-reports/confusion-and-bugs-in-array-settings), [CIQQA-1380](https://forums.garmin.com/developer/connect-iq/i/bug-reports/array-settings-maxlength-causes-all-the-settings-after-the-arra-not-to-be-displayed)
+- [System passcode and Connect IQ apps](https://forums.garmin.com/developer/connect-iq/f/discussion/439041/passcode-behaviour-for-connectiq-apps)
+- Settings transport: [internet connection needed](https://forums.garmin.com/developer/connect-iq/f/discussion/6825/why-is-it-necessary-to-have-internet-connection-to-change-the-settings-of-an-app), [settings for sideloaded apps](https://forums.garmin.com/developer/connect-iq/f/discussion/429848/solved-settings-connect-iq-app-for-sideloaded-app---is-this-possible)
+- Settings limits: [alphaNumeric max length](https://forums.garmin.com/developer/connect-iq/f/discussion/217336/max-length-of-alphanumeric-setting), [editor inconsistencies](https://forums.garmin.com/developer/connect-iq/f/discussion/258276/alphanumeric-type-inconsistencies-across-app-settings-editors), [Garmin Express length](https://forums.garmin.com/developer/connect-iq/f/discussion/4882/problem-with-settings-in-garminexpress-with-length-of-alphanumeric), [long value not saved](https://forums.garmin.com/developer/connect-iq/f/discussion/298660/property-value-with-a-long-string-40-characters-cannot-be-saved)
+- [Watchdog limits](https://forums.garmin.com/developer/connect-iq/f/discussion/4580/watchdog-tripped-error---code-executed-too-long---workaround-for-tests)
+
+Other:
+
+- [Aegis vault format](https://github.com/beemdevelopment/Aegis/blob/master/docs/vault.md)
+- [connect-iq-sdk-manager-cli](https://github.com/lindell/connect-iq-sdk-manager-cli)
+- Headless simulator references, read but not used directly: [matco/connectiq-tester](https://github.com/matco/connectiq-tester), [wistrand/garmin-dev](https://github.com/wistrand/garmin-dev)