From 35042ff04ffc50e55ff59a385849442be4429d6b Mon Sep 17 00:00:00 2001 From: cirakg Date: Mon, 19 Oct 2026 22:28:09 +0200 Subject: feat(ui): add the list, OTP detail, PIN entry, menu, info screens and glance List with live codes and countdowns, adaptive rows and lazy headers; OTP detail with ring or bar, next code and HOTP next; PIN keypad or button selector with create, unlock, change, lockout display and pending promotion; native menu; glance with the entry count; self-test after the first frame; option properties with defaults. Codes are computed one per timer event, apart from drawing, for the watchdog. A UI-test build (uitest.jungle) starts with fake entries, PIN 123456 and a frozen clock. Co-Authored-By: Claude Opus 5.5 --- source/app/AppInfo.mc | 2 +- source/app/AppState.mc | 114 +++++++++++++++++++++++++++++++++++++++++++++ source/app/Fixtures.mc | 83 +++++++++++++++++++++++++++++++++ source/app/Options.mc | 78 +++++++++++++++++++++++++++++++ source/app/OtpWalletApp.mc | 47 +++++++++++++------ 5 files changed, 310 insertions(+), 14 deletions(-) create mode 100644 source/app/AppState.mc create mode 100644 source/app/Fixtures.mc create mode 100644 source/app/Options.mc (limited to 'source/app') diff --git a/source/app/AppInfo.mc b/source/app/AppInfo.mc index ab65f57..ab5ceb9 100644 --- a/source/app/AppInfo.mc +++ b/source/app/AppInfo.mc @@ -12,5 +12,5 @@ import Toybox.Lang; module AppInfo { // Semantic version of the app: MAJOR.MINOR.PATCH. - const VERSION = "0.3.0"; + const VERSION = "0.4.0"; } diff --git a/source/app/AppState.mc b/source/app/AppState.mc new file mode 100644 index 0000000..05656e2 --- /dev/null +++ b/source/app/AppState.mc @@ -0,0 +1,114 @@ +// Author: cirakg & claude ai +// Summary: The app's shared state: options, the vault, the code cache, start-up errors and the crypto self-test. +// +// Created in getInitialView (never in glance mode, which has too little +// memory for the vault). Views reach the vault and options through here. + +import Toybox.Application; +import Toybox.Lang; +import Toybox.Time; + +module AppState { + + const SELF_TEST_KEY = "st"; + const SECONDS_PER_DAY = 86400; + + var options as Options = new Options(); + var vault as Vault? = null; + var codes as CodeCache? = null; + // A message that replaces the whole app (newer data, damaged metadata, + // failed self-test), or null. + var startError as String? = null; + // Codes are shown only after the self-test passed (README 7.10). + var cryptoReady as Boolean = false; + var selfTestSummary as String = "Not run yet"; + + // Load options and open the vault. Errors end up in startError. + function start() as Void { + options = new Options(); + options.load(); + if (TestMode.ON) { + Fixtures.resetStore(); + } + try { + var v = new Vault(new Persist.AppStorageBackend(), new TimeSource()); + v.eraseAfterFailures = options.eraseAfterFailures; + v.session.autoLockMs = TestMode.ON ? TestMode.AUTO_LOCK_MS : options.autoLockMs; + vault = v; + codes = new CodeCache(v); + if (TestMode.ON) { + Fixtures.load(v); + } + } catch (e instanceof VaultError) { + startError = e.code == VaultError.NEWER_SCHEMA + ? "Data from a newer version of OTP Wallet. Update the app." + : "Stored data is damaged. Reinstall the app to start over."; + } + } + + // The vault; only call after start() succeeded. + function theVault() as Vault { + return vault as Vault; + } + + function theCodes() as CodeCache { + return codes as CodeCache; + } + + // Run the known-answer self-test on the first start of each version and + // then once a day. Returns false (and sets startError) when it failed. + function runSelfTestIfDue() as Boolean { + var day = (Clock.nowUnix(0) / SECONDS_PER_DAY).toNumber(); + var last = Application.Storage.getValue(SELF_TEST_KEY); + var due = true; + if (last instanceof Array && last.size() == 2 && last[0] instanceof String && last[1] instanceof Number) { + due = !(last[0] as String).equals(AppInfo.VERSION) || (last[1] as Number) != day; + } + if (!due) { + selfTestSummary = "Passed (today)"; + cryptoReady = true; + return true; + } + var failed = SelfTest.run(); + if (failed != null) { + selfTestSummary = "FAILED: " + failed; + startError = "Crypto self-test failed on this watch (" + failed + "). Codes are not shown."; + return false; + } + Application.Storage.setValue(SELF_TEST_KEY, [AppInfo.VERSION, day] as Array); + selfTestSummary = "Passed"; + cryptoReady = true; + return true; + } + + // Note user activity (restarts the auto-lock timeout). + function activity() as Void { + var v = vault; + if (v != null) { + v.session.touch(); + } + } + + // Lock if the inactivity timeout passed. True when this call locked. + function checkAutoLock() as Boolean { + var v = vault; + if (v != null && v.session.checkExpiry()) { + theCodes().clear(); + return true; + } + return false; + } + + // Lock now and forget every computed code. + function lock() as Void { + var v = vault; + if (v != null) { + v.lock(); + theCodes().clear(); + } + } + + function now() as Long { + return Clock.nowUnix(options.timeOffset); + } +} diff --git a/source/app/Fixtures.mc b/source/app/Fixtures.mc new file mode 100644 index 0000000..0b641ee --- /dev/null +++ b/source/app/Fixtures.mc @@ -0,0 +1,83 @@ +// Author: cirakg & claude ai +// Summary: Debug-only fake entries for the simulator and screenshots; release builds get empty stubs. +// +// All data is fabricated: the secrets are the RFC 4226/6238 test keys and the +// names are made up. The UI-test build (source/util/TestMode.mc) wipes the +// store and loads these at every start; debug builds offer "Load demo +// entries" in the menu. FIXTURE_PIN is the PIN of the UI-test store. +// +// FIXTURE_WRAP is that PIN's wrap of FIXTURE_MASTER_KEY, precomputed by +// tools/gen_fixture_wrap.py (fake salt, IV and key): deriving the PIN key on +// the watch in one event trips the watchdog, so the fixture stores the +// result. Unlocking it with the app's own code also cross-checks the wrap +// format against the Python implementation (test/storage/PinTest.mc). + +import Toybox.Application; +import Toybox.Lang; + +(:debug) +module Fixtures { + + const FIXTURE_PIN = "123456"; + const FIXTURE_WRAP = "101112131415161718191a1b1c1d1e1f000007d001202122232425262728292a2b2c2d2e2f857b961d4854e06740f602627bfb952efaac5383e800768221bf3a7b292ee881e2ce2f76b9dd744b2ac32322473df351929bc6dfcbfc2941527ae8b90614a8a6a6bd0f72d33161bafd16b1039caa670c"; // lint: allow-literal (fake fixture) + const FIXTURE_MASTER_KEY = "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"; // lint: allow-literal (fake fixture) + + // Erase all app storage (UI-test build only). + function resetStore() as Void { + Application.Storage.clearValues(); + } + + // Add the demo entries. In the UI-test build this also sets FIXTURE_PIN + // so one entry can be locked; otherwise locked demo entries go to the + // pending queue until a PIN exists. + function load(vault as Vault) as Void { + if (TestMode.ON && !vault.hasPin()) { + Application.Storage.setValue(KeyManager.WRAP_KEY, Bytes.fromHex(FIXTURE_WRAP)); + vault.session.unlock(Bytes.fromHex(FIXTURE_MASTER_KEY)); + } + var rfcKey = "12345678901234567890"; // lint: allow-literal (RFC 4226 test key) + vault.add(otp("GitHub", "alice@example.com", "Work", Entry.OTP_TOTP, Hashes.SHA1, 6, 30, rfcKey, false)); + vault.add(otp("Google", "bob@example.com", "Personal", Entry.OTP_TOTP, Hashes.SHA256, 8, 30, + "12345678901234567890123456789012", false)); // lint: allow-literal (RFC 6238 test key) + vault.add(otp("Bank", "carol", "Finance", Entry.OTP_TOTP, Hashes.SHA512, 6, 30, + "1234567890123456789012345678901234567890123456789012345678901234", true)); // lint: allow-literal (RFC 6238 test key) + vault.add(otp("VPN", "dave", "Work", Entry.OTP_HOTP, Hashes.SHA1, 6, 30, rfcKey, false)); + vault.add(otp("Steam", "erin", "Games", Entry.OTP_STEAM, Hashes.SHA1, 5, 30, rfcKey, false)); + vault.add(otp("Library", "frank", "Personal", Entry.OTP_TOTP, Hashes.SHA1, 7, 60, rfcKey, false)); + var card = new Entry(); + card.kind = Entry.KIND_CODE; + card.type = 0; + card.name = "Gym card"; + card.group = "Personal"; + card.payload = Bytes.fromUtf8("0000-1111-2222"); + vault.add(card); + vault.lock(); + } + + function otp(name as String, account as String, group as String, type as Number, algorithm as Number, + digits as Number, period as Number, secret as String, locked as Boolean) as Entry { + var e = new Entry(); + e.name = name; + e.account = account; + e.group = group; + e.type = type; + e.algorithm = algorithm; + e.digits = digits < Otp.MIN_DIGITS ? Otp.MIN_DIGITS : digits; + e.period = period; + e.secret = Bytes.fromUtf8(secret); + e.locked = locked; + e.counter = type == Entry.OTP_HOTP ? 5l : 0l; + e.created = 1700000000l; + return e; + } +} + +(:release) +module Fixtures { + + function resetStore() as Void { + } + + function load(vault as Vault) as Void { + } +} diff --git a/source/app/Options.mc b/source/app/Options.mc new file mode 100644 index 0000000..0b4715c --- /dev/null +++ b/source/app/Options.mc @@ -0,0 +1,78 @@ +// Author: cirakg & claude ai +// Summary: The user's options (README 10.2, Options group), read from app properties with safe defaults. +// +// The properties are defined in resources/settings/properties.xml (reading +// an undefined one stops the app with an uncatchable system error); M7 adds +// the phone form. A value of the wrong type or out of range falls back to +// the default below. + +import Toybox.Application; +import Toybox.Lang; + +class Options { + + static const CODE_NORMAL = 0; + static const CODE_LARGE = 1; + static const CODE_HUGE = 2; + + static const THEME_DARK = 0; + static const THEME_LIGHT = 1; + + // optAutoLock list values, in ms: Immediately, 15 s, 30 s, 1 min, 2 min, 5 min. + static const AUTO_LOCK_CHOICES = [0, 15000, 30000, 60000, 120000, 300000]; + static const AUTO_LOCK_DEFAULT = 3; + static const MAX_TIME_OFFSET_S = 120; + + var autoLockMs as Number = 60000; + var eraseAfterFailures as Boolean = false; + var timeOffset as Number = 0; + var codeSize as Number = CODE_LARGE; + var theme as Number = THEME_DARK; + var showGroups as Boolean = true; + var encryptNames as Boolean = false; + var glanceCode as Boolean = false; + var fullBrightness as Boolean = true; + + function initialize() { + } + + // Read every option; anything missing or invalid keeps its default. + function load() as Void { + var autoLock = choice("optAutoLock", AUTO_LOCK_DEFAULT, AUTO_LOCK_CHOICES.size() - 1); + autoLockMs = AUTO_LOCK_CHOICES[autoLock] as Number; + eraseAfterFailures = flag("optWipe", false); + var offset = number("optTimeOffset"); + timeOffset = (offset != null && offset >= -MAX_TIME_OFFSET_S && offset <= MAX_TIME_OFFSET_S) ? offset : 0; + codeSize = choice("optCodeSize", CODE_LARGE, CODE_HUGE); + theme = choice("optTheme", THEME_DARK, THEME_LIGHT); + showGroups = flag("optGroups", true); + encryptNames = flag("optEncryptNames", false); + glanceCode = flag("optGlanceCode", false); + fullBrightness = flag("optBrightness", true); + } + + // A property, or null when it doesn't exist (before M7) or can't be read. + private function raw(key as String) as Application.Properties.ValueType or Null { + try { + return Application.Properties.getValue(key); + } catch (e instanceof Lang.Exception) { + return null; + } + } + + private function number(key as String) as Number? { + var v = raw(key); + return v instanceof Number ? v : null; + } + + // A list index in 0..max, or the default. + private function choice(key as String, defaultValue as Number, max as Number) as Number { + var v = number(key); + return (v != null && v >= 0 && v <= max) ? v : defaultValue; + } + + private function flag(key as String, defaultValue as Boolean) as Boolean { + var v = raw(key); + return v instanceof Boolean ? v : defaultValue; + } +} diff --git a/source/app/OtpWalletApp.mc b/source/app/OtpWalletApp.mc index 8858878..7419433 100644 --- a/source/app/OtpWalletApp.mc +++ b/source/app/OtpWalletApp.mc @@ -1,17 +1,19 @@ // Author: cirakg & claude ai -// Summary: Application entry point (Connect IQ AppBase): lifecycle and first view. +// Summary: Application entry point (Connect IQ AppBase): lifecycle, first view, glance, locking on exit. // -// OtpWalletApp: the application entry point (Connect IQ AppBase). +// Connect IQ lifecycle: initialize -> onStart -> getInitialView (or +// getGlanceView in glance mode) -> views run -> onStop. // -// M0 skeleton: the app shows a home screen with its name and version. Later -// milestones add the vault, settings processing, locking and the glance. +// The vault is opened in getInitialView, not onStart: onStart also runs in +// glance mode, which has too little memory for it. // -// Connect IQ lifecycle: initialize -> onStart -> getInitialView -> (views run) -// -> onStop. +// Security: onStop locks the vault (keys and decrypted codes are wiped). It +// runs when the user leaves the app, and also when Garmin's system passcode +// shuts running apps down. // -// Security note for later milestones: onStop is where every key and decrypted -// secret gets wiped. It runs when the user leaves the app, and also when -// Garmin's system passcode shuts running apps down. +// getGlanceView is (:glance), so the compiler checks this class against the +// glance scope too. The other methods never run in glance mode and use +// non-glance code, so they opt out with (:typecheck(disableGlanceCheck)). import Toybox.Application; import Toybox.Lang; @@ -19,22 +21,41 @@ import Toybox.WatchUi; class OtpWalletApp extends Application.AppBase { + // True once the full app (not the glance) has started. + private var _started as Boolean = false; + function initialize() { AppBase.initialize(); // Start of the start-up timer reported by `make perf` (debug builds only). Diag.markStart(); } - // Called after the app is loaded, before the first view is shown. function onStart(state as Dictionary?) as Void { } - // Called when the app exits, for any reason. + (:typecheck(disableGlanceCheck)) + // onStop also runs after the glance, where AppState was never loaded. function onStop(state as Dictionary?) as Void { + if (_started) { + AppState.lock(); + } } - // The first screen and its input handler. + // The list, or a full-screen message when the data can't be used. + (:typecheck(disableGlanceCheck)) function getInitialView() as [Views] or [Views, InputDelegates] { - return [new HomeView(), new HomeDelegate()]; + _started = true; + AppState.start(); + var error = AppState.startError; + if (error != null) { + return [new InfoView("OTP Wallet", [error], "error"), new InfoDelegate()]; + } + var list = new ListView(); + return [list, new ListDelegate(list)]; + } + + (:glance) + function getGlanceView() as [WatchUi.GlanceView] or [WatchUi.GlanceView, WatchUi.GlanceViewDelegate] or Null { + return [new OtpGlanceView()]; } } -- cgit v1.2.3