diff options
| -rw-r--r-- | monkey.jungle | 4 | ||||
| -rw-r--r-- | resources/settings/properties.xml | 22 | ||||
| -rw-r--r-- | source/app/AppInfo.mc | 2 | ||||
| -rw-r--r-- | source/app/AppState.mc | 114 | ||||
| -rw-r--r-- | source/app/Fixtures.mc | 83 | ||||
| -rw-r--r-- | source/app/Options.mc | 78 | ||||
| -rw-r--r-- | source/app/OtpWalletApp.mc | 47 | ||||
| -rw-r--r-- | source/storage/Vault.mc | 14 | ||||
| -rw-r--r-- | source/ui/CodeCache.mc | 127 | ||||
| -rw-r--r-- | source/ui/DebugMenu.mc | 110 | ||||
| -rw-r--r-- | source/ui/GlanceView.mc | 37 | ||||
| -rw-r--r-- | source/ui/InfoView.mc | 122 | ||||
| -rw-r--r-- | source/ui/ListDelegate.mc | 88 | ||||
| -rw-r--r-- | source/ui/ListView.mc | 391 | ||||
| -rw-r--r-- | source/ui/MainMenu.mc | 172 | ||||
| -rw-r--r-- | source/ui/Nav.mc | 66 | ||||
| -rw-r--r-- | source/ui/OtpView.mc | 305 | ||||
| -rw-r--r-- | source/ui/PinView.mc | 503 | ||||
| -rw-r--r-- | source/ui/Ui.mc | 211 | ||||
| -rw-r--r-- | source/util/Clock.mc | 6 | ||||
| -rw-r--r-- | source/util/Diag.mc | 51 | ||||
| -rw-r--r-- | source/util/TestMode.mc | 30 | ||||
| -rw-r--r-- | uitest.jungle | 8 |
23 files changed, 2575 insertions, 16 deletions
diff --git a/monkey.jungle b/monkey.jungle index e0fbe95..623a55e 100644 --- a/monkey.jungle +++ b/monkey.jungle @@ -11,3 +11,7 @@ project.manifest = manifest.xml base.sourcePath = source base.resourcePath = resources + +# Code annotated (:uitest) only exists in the UI-test build (uitest.jungle), +# which swaps this for the (:no_uitest) alternatives (source/util/TestMode.mc). +base.excludeAnnotations = uitest diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml new file mode 100644 index 0000000..9e2da95 --- /dev/null +++ b/resources/settings/properties.xml @@ -0,0 +1,22 @@ +<!-- + Author: cirakg & claude ai + Summary: App properties for the Options group (README 10.2) with their defaults; the phone form follows in M7. +--> +<!-- + Values the watch reads through source/app/Options.mc. M4 defines only the + Options group, so the app can read them (reading an undefined property + stops the app with a system error that can't be caught). M7 adds the + settings.xml form that edits them on the phone, and the other groups. + List values are indexes; see Options.mc for their meaning. +--> +<properties> + <property id="optAutoLock" type="number">3</property> + <property id="optWipe" type="boolean">false</property> + <property id="optTimeOffset" type="number">0</property> + <property id="optCodeSize" type="number">1</property> + <property id="optTheme" type="number">0</property> + <property id="optGroups" type="boolean">true</property> + <property id="optEncryptNames" type="boolean">false</property> + <property id="optGlanceCode" type="boolean">false</property> + <property id="optBrightness" type="boolean">true</property> +</properties> 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<Application.Storage.ValueType>); + 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()]; } } diff --git a/source/storage/Vault.mc b/source/storage/Vault.mc index 0bb3cbc..95f77c9 100644 --- a/source/storage/Vault.mc +++ b/source/storage/Vault.mc @@ -352,7 +352,7 @@ class Vault { private function newId() as String { var id = ""; do { - var random = Cryptography.randomBytes(Entry.ID_LENGTH); + var random = TestMode.ON ? testIdBytes() : Cryptography.randomBytes(Entry.ID_LENGTH); var alphabetSize = Entry.ID_ALPHABET.length(); id = ""; for (var i = 0; i < Entry.ID_LENGTH; i++) { @@ -364,6 +364,18 @@ class Vault { return id; } + // UI-test build only: predictable IDs, so screenshots are reproducible. + private var _testIdCounter as Number = 0; + + private function testIdBytes() as ByteArray { + _testIdCounter++; + var out = new [Entry.ID_LENGTH]b; + for (var i = 0; i < Entry.ID_LENGTH; i++) { + out[i] = (_testIdCounter * (7 + i * 3) + i * 11) & 0xFF; + } + return out; + } + // Store a value, turning a store-full error into VaultError FULL. private function write(key as String, value as Application.Storage.ValueType) as Void { try { diff --git a/source/ui/CodeCache.mc b/source/ui/CodeCache.mc new file mode 100644 index 0000000..d8c61f2 --- /dev/null +++ b/source/ui/CodeCache.mc @@ -0,0 +1,127 @@ +// Author: cirakg & claude ai +// Summary: Computes OTP codes for display and caches each until it expires, so secrets are decrypted once per period. +// +// README 7.8: to show a code the entry is decrypted, the code computed and the +// secret wiped at once; only the short code string is kept, with the unix +// time it stops being valid. The 1 Hz redraw then reuses the string. +// +// Watchdog: an SHA-512 code costs about 50 times an SHA-1 code (PERFORMANCE.md), +// so callers that compute many codes in one event (the list) spend a budget +// in cost units and continue in the next event. + +import Toybox.Lang; + +class CodeCache { + + // Cost units per code: SHA-512 runs in Monkey C, the others natively. + static const COST_NATIVE = 1; + static const COST_SHA512 = 4; + // Units the list may spend per event. + static const BUDGET_PER_EVENT = 4; + // A code that can't be computed (damaged entry) is shown as this. + static const UNAVAILABLE = "------"; + static const FOREVER = 0x7FFFFFFFFFFFFFFFl; + + private var _vault as Vault; + private var _codes as Dictionary<String, String> = {} as Dictionary<String, String>; + private var _validUntil as Dictionary<String, Long> = {} as Dictionary<String, Long>; + // What the encrypted part revealed when the code was computed: the period + // (for countdowns) and the cost (for the list's budget). Not secret. + private var _periods as Dictionary<String, Number> = {} as Dictionary<String, Number>; + private var _costs as Dictionary<String, Number> = {} as Dictionary<String, Number>; + + function initialize(vault as Vault) { + _vault = vault; + } + + // The cached code, or null when there is none or it has expired. + function cached(key as String, now as Long) as String? { + var until = _validUntil[key]; + if (until == null || now >= until) { + return null; + } + return _codes[key]; + } + + // Cost units computing this entry's code takes. The algorithm is + // encrypted, so until the code was computed once, assume the worst (Steam + // is always SHA-1). + function costOf(header as Entry) as Number { + var known = _costs[header.id]; + if (known != null) { + return known; + } + return header.type == Entry.OTP_STEAM ? COST_NATIVE : COST_SHA512; + } + + // The entry's period in seconds, once its code was computed (else 30). + function periodOf(id as String) as Number { + var p = _periods[id]; + return p != null ? p : Otp.STEAM_PERIOD; + } + + // The current code (offset 0) or the next one (offset 1) of an OTP entry, + // computing and caching it when needed. Throws VaultError LOCKED for a + // locked entry while the session is locked. + function code(id as String, now as Long, offset as Number) as String { + var key = offset == 0 ? id : id + "+"; + var hit = cached(key, now); + if (hit != null) { + return hit; + } + var text = UNAVAILABLE; + var until = now + 1; + try { + var entry = _vault.open(id); + var period = entry.type == Entry.OTP_STEAM ? Otp.STEAM_PERIOD : entry.period; + var secret = entry.secret as ByteArray; + var at = now + offset * period; + _periods[id] = period; + _costs[id] = entry.algorithm == Hashes.SHA512 ? COST_SHA512 : COST_NATIVE; + if (entry.type == Entry.OTP_HOTP) { + text = Otp.hotp(entry.algorithm as Hashes.Algorithm, secret, entry.counter + offset, entry.digits); + until = FOREVER; + } else { + text = entry.type == Entry.OTP_STEAM ? Otp.steam(secret, at) + : Otp.totp(entry.algorithm as Hashes.Algorithm, secret, at, period, entry.digits); + until = now + Otp.secondsRemaining(now, period); + } + entry.wipeSecrets(); + } catch (e instanceof VaultError) { + if (e.code == VaultError.LOCKED) { + throw e; + } + // Damaged: show the placeholder, try again in a second. + } + _codes[key] = text; + _validUntil[key] = until; + return text; + } + + // Forget one entry's codes (after a HOTP "next" or an edit). + function invalidate(id as String) as Void { + _codes.remove(id); + _validUntil.remove(id); + _codes.remove(id + "+"); + _validUntil.remove(id + "+"); + } + + // Forget everything (on lock, so no locked entry's code stays in memory). + function clear() as Void { + _codes = {} as Dictionary<String, String>; + _validUntil = {} as Dictionary<String, Long>; + } + + // The code split for reading: 3+3, 3+4 or 4+4 digits; other lengths + // (Steam's 5 characters) stay whole. + static function split(text as String) as Array<String> { + var n = text.length(); + if (n == 6 || n == 7) { + return [text.substring(0, 3) as String, text.substring(3, n) as String]; + } + if (n == 8) { + return [text.substring(0, 4) as String, text.substring(4, 8) as String]; + } + return [text]; + } +} diff --git a/source/ui/DebugMenu.mc b/source/ui/DebugMenu.mc new file mode 100644 index 0000000..f1ab407 --- /dev/null +++ b/source/ui/DebugMenu.mc @@ -0,0 +1,110 @@ +// Author: cirakg & claude ai +// Summary: Debug-only menu items: load demo entries, the performance overlay and the on-watch benchmark. +// +// Release builds compile the empty (:release) module instead, so none of this +// reaches the Store build. The benchmark is how the watch's real speed gets +// measured (docs/PERFORMANCE.md): sideload a debug build and run it. + +import Toybox.Lang; +import Toybox.System; +import Toybox.Timer; +import Toybox.WatchUi; + +(:debug) +module DebugMenu { + + function addItems(menu as WatchUi.Menu2) as Void { + menu.addItem(new WatchUi.MenuItem("Debug: demo entries", "Adds fake entries", :debugDemo, null)); + menu.addItem(new WatchUi.MenuItem("Debug: overlay", Diag.overlayOn ? "On" : "Off", :debugOverlay, null)); + menu.addItem(new WatchUi.MenuItem("Debug: benchmark", "Takes a few seconds", :debugBench, null)); + } + + function handle(id as Object?) as Void { + if (id == :debugDemo) { + try { + Fixtures.load(AppState.theVault()); + } catch (e instanceof VaultError) { + // Entry cap or full store: load what fits. + } + AppState.theCodes().clear(); + WatchUi.popView(WatchUi.SLIDE_DOWN); + } else if (id == :debugOverlay) { + Diag.overlayOn = !Diag.overlayOn; + WatchUi.popView(WatchUi.SLIDE_DOWN); + } else if (id == :debugBench) { + var runner = new BenchRunner(); + bench = runner; + WatchUi.switchToView(new InfoView("Benchmark", ["Measuring…"], "bench_running"), new InfoDelegate(), + WatchUi.SLIDE_LEFT); + runner.start(); + } + } + + // The running benchmark (kept so its timer isn't collected). + var bench as BenchRunner? = null; + + // Times the operations the budgets depend on, one per timer event, so no + // event comes near the watchdog limit (it is enforced on the watch and in + // normal simulator runs). + class BenchRunner { + + private var _lines as Array<String> = [] as Array<String>; + private var _step as Number = 0; + private var _timer as Timer.Timer? = null; + + function initialize() { + } + + function start() as Void { + var t = new Timer.Timer(); + t.start(method(:tick), 200, true); + _timer = t; + } + + function tick() as Void { + var t = System.getTimer(); + if (_step == 0) { + for (var i = 0; i < 5; i++) { + Otp.totp(Hashes.SHA1, new [20]b, 1800000000l, 30, 6); + } + _lines.add("TOTP SHA-1: " + (System.getTimer() - t) / 5 + " ms"); + } else if (_step == 1) { + Otp.totp(Hashes.SHA512, new [64]b, 1800000000l, 30, 6); + _lines.add("TOTP SHA-512: " + (System.getTimer() - t) + " ms"); + } else if (_step == 2) { + _lines.add("PIN derivation: " + Kdf.calibrate(1000, Kdf.ITERATIONS_PER_STEP) + " iterations per second"); + } else if (_step == 3) { + var vault = AppState.theVault(); + var ids = vault.ids(); + for (var i = 0; i < ids.size(); i++) { + vault.header(ids[i]); + } + _lines.add("Headers: " + ids.size() + " in " + (System.getTimer() - t) + " ms"); + } else if (_step == 4) { + SelfTest.run(); + _lines.add("Self-test: " + (System.getTimer() - t) + " ms"); + } else { + var stats = System.getSystemStats(); + _lines.add("Memory: " + stats.usedMemory / 1024 + " of " + stats.totalMemory / 1024 + " KB"); + var timer = _timer; + if (timer != null) { + timer.stop(); + } + bench = null; + WatchUi.switchToView(new InfoView("Benchmark", _lines, "bench"), new InfoDelegate(), WatchUi.SLIDE_IMMEDIATE); + return; + } + _step++; + } + } +} + +(:release) +module DebugMenu { + + function addItems(menu as WatchUi.Menu2) as Void { + } + + function handle(id as Object?) as Void { + } +} diff --git a/source/ui/GlanceView.mc b/source/ui/GlanceView.mc new file mode 100644 index 0000000..a7207ce --- /dev/null +++ b/source/ui/GlanceView.mc @@ -0,0 +1,37 @@ +// Author: cirakg & claude ai +// Summary: The glance (widget-list preview): app name and number of entries. +// +// Glances run with very little memory, so this reads only the entry index +// from storage and opens no vault and no keys. The opt-in favourite code +// (README 13.1) comes with M8. + +import Toybox.Application; +import Toybox.Graphics; +import Toybox.Lang; +import Toybox.WatchUi; + +(:glance) +class OtpGlanceView extends WatchUi.GlanceView { + + function initialize() { + GlanceView.initialize(); + } + + function onUpdate(dc as Graphics.Dc) as Void { + var count = 0; + var meta = Application.Storage.getValue("m"); + if (meta instanceof Dictionary) { + var ids = meta["ids"]; + if (ids instanceof Array) { + count = ids.size(); + } + } + var h = dc.getHeight(); + dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT); + dc.drawText(0, h / 2 - dc.getFontHeight(Graphics.FONT_GLANCE), Graphics.FONT_GLANCE, "OTP WALLET", Graphics.TEXT_JUSTIFY_LEFT); + dc.setColor(Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT); + dc.drawText(0, h / 2, Graphics.FONT_GLANCE, count == 1 ? "1 entry" : count + " entries", Graphics.TEXT_JUSTIFY_LEFT); + // The simulator starts in the glance; launch scripts open the app from it. + Diag.shown("glance"); + } +} diff --git a/source/ui/InfoView.mc b/source/ui/InfoView.mc new file mode 100644 index 0000000..3b75764 --- /dev/null +++ b/source/ui/InfoView.mc @@ -0,0 +1,122 @@ +// Author: cirakg & claude ai +// Summary: A scrollable text screen with a title: settings results, entry IDs, clock status, about, errors. + +import Toybox.Graphics; +import Toybox.Lang; +import Toybox.WatchUi; + +class InfoView extends WatchUi.View { + + private var _title as String; + private var _text as Array<String>; + private var _screen as String; + private var _lines as Array<String>? = null; // wrapped, on first draw + private var _first as Number = 0; + private var _visible as Number = 1; + private var _shownLogged as Boolean = false; + + // screen names the view in "DIAG shown" lines for UI scripts. + function initialize(title as String, text as Array<String>, screen as String) { + View.initialize(); + _title = title; + _text = text; + _screen = screen; + } + + function onShow() as Void { + _shownLogged = false; + } + + // Scroll by delta lines; returns false when already at that end. + function scroll(delta as Number) as Boolean { + var lines = _lines; + if (lines == null) { + return false; + } + var max = lines.size() - _visible > 0 ? lines.size() - _visible : 0; + var next = _first + delta; + next = next < 0 ? 0 : (next > max ? max : next); + if (next == _first) { + return false; + } + _first = next; + WatchUi.requestUpdate(); + return true; + } + + function onUpdate(dc as Graphics.Dc) as Void { + Ui.clear(dc); + var w = dc.getWidth(); + var h = dc.getHeight(); + var font = Graphics.FONT_XTINY; + var lineH = dc.getFontHeight(font); + var y = Ui.isRound() ? h / 8 : 6; + dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT); + dc.drawText(w / 2, y, Graphics.FONT_SMALL, Ui.fit(dc, _title, Graphics.FONT_SMALL, 2 * Ui.halfWidthAt(dc, y)), + Graphics.TEXT_JUSTIFY_CENTER); + y += dc.getFontHeight(Graphics.FONT_SMALL) + 4; + var bottom = Ui.isRound() ? h - h / 10 : h - 4; + var lines = _lines; + if (lines == null) { + // Wrap to the narrowest width the text area has. + var width = 2 * Ui.halfWidthBetween(dc, y, bottom) - 8; + lines = [] as Array<String>; + for (var i = 0; i < _text.size(); i++) { + lines.addAll(Ui.wrap(dc, _text[i], font, width)); + } + _lines = lines; + } + _visible = (bottom - y) / lineH; + dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); + for (var i = _first; i < lines.size() && i < _first + _visible; i++) { + dc.drawText(w / 2, y, font, lines[i], Graphics.TEXT_JUSTIFY_CENTER); + y += lineH; + } + if (_first + _visible < lines.size()) { + var cx = w / 2; + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.fillPolygon([[cx - 6, bottom], [cx + 6, bottom], [cx, bottom + 6]] as Array<Graphics.Point2D>); + } + if (!_shownLogged) { + _shownLogged = true; + Diag.shown(_screen); + } + } +} + +// Input on a text screen: scroll, BACK closes (and leaves the app when this +// is the only screen, as for start-up errors). +class InfoDelegate extends WatchUi.BehaviorDelegate { + + function initialize() { + BehaviorDelegate.initialize(); + } + + private function view() as InfoView? { + var current = WatchUi.getCurrentView()[0]; + return current instanceof InfoView ? current : null; + } + + function onNextPage() as Boolean { + AppState.activity(); + var v = view(); + if (v != null) { + v.scroll(3); + } + return true; + } + + function onPreviousPage() as Boolean { + AppState.activity(); + var v = view(); + if (v != null) { + v.scroll(-3); + } + return true; + } + + function onBack() as Boolean { + AppState.activity(); + return false; + } +} diff --git a/source/ui/ListDelegate.mc b/source/ui/ListDelegate.mc new file mode 100644 index 0000000..a7c8bf0 --- /dev/null +++ b/source/ui/ListDelegate.mc @@ -0,0 +1,88 @@ +// Author: cirakg & claude ai +// Summary: Input on the list (README 13.4): scroll with UP/DOWN or swipes, open with START or a tap, menu (MENU or long press), back exits. + +import Toybox.Lang; +import Toybox.WatchUi; + +class ListDelegate extends WatchUi.BehaviorDelegate { + + private var _view as ListView; + + function initialize(view as ListView) { + BehaviorDelegate.initialize(); + _view = view; + } + + function onNextPage() as Boolean { + AppState.activity(); + _view.move(1); + return true; + } + + function onPreviousPage() as Boolean { + AppState.activity(); + _view.move(-1); + return true; + } + + // On touch screens a tap arrives as the select behaviour first; returning + // false passes it on to onTap, which opens the row under the finger. The + // START button then comes through onKey. + function onSelect() as Boolean { + if (Ui.isTouch()) { + return false; + } + openFocused(); + return true; + } + + function onKey(event as WatchUi.KeyEvent) as Boolean { + if (Ui.isTouch() && event.getKey() == WatchUi.KEY_ENTER) { + openFocused(); + return true; + } + return false; + } + + private function openFocused() as Void { + AppState.activity(); + var id = _view.focusedId(); + if (id != null) { + Nav.openEntry(id); + } + } + + // A tap opens the row under the finger. + function onTap(event as WatchUi.ClickEvent) as Boolean { + AppState.activity(); + var index = _view.rowAt(event.getCoordinates()[1]); + if (index < 0) { + return true; + } + _view.focus = index; + var id = _view.focusedId(); + if (id != null) { + Nav.openEntry(id); + } + return true; + } + + // A long press opens the menu too: watches without a MENU button + // (Venu X1) have no other way to reach it (README 13.4). + function onHold(event as WatchUi.ClickEvent) as Boolean { + AppState.activity(); + Nav.openMenu(); + return true; + } + + function onMenu() as Boolean { + AppState.activity(); + Nav.openMenu(); + return true; + } + + // BACK leaves the app; onStop locks everything. + function onBack() as Boolean { + return false; + } +} diff --git a/source/ui/ListView.mc b/source/ui/ListView.mc new file mode 100644 index 0000000..ab0e594 --- /dev/null +++ b/source/ui/ListView.mc @@ -0,0 +1,391 @@ +// Author: cirakg & claude ai +// Summary: The home screen: entries in your order with their current codes, a focus row, and the empty state. +// +// Layout (README 13.1, 13.3): a title, then rows of two lines each (name; +// group and account) with the code and a countdown on the right for unlocked +// OTP entries, a padlock for locked entries and a barcode icon for codes. +// The focused row is outlined; the list scrolls to keep it visible and +// centres short lists. On round screens each row is as wide as the circle +// allows at its height. +// +// Performance (PERFORMANCE.md, M3): headers are read only for the visible +// rows and cached while the screen is shown. Drawing never computes codes: +// missing ones show as dots and are computed in a separate timer event, at +// most CodeCache.BUDGET_PER_EVENT cost units per event, so a page of SHA-512 +// entries is spread over several events (watchdog). + +import Toybox.Graphics; +import Toybox.Lang; +import Toybox.System; +import Toybox.Timer; +import Toybox.WatchUi; + +class ListView extends WatchUi.View { + + // Pause before continuing code computation that didn't fit in one draw. + static const CONTINUE_MS = 50; + + var focus as Number = 0; + + private var _ids as Array<String> = [] as Array<String>; + private var _headers as Dictionary<String, Entry> = {} as Dictionary<String, Entry>; + private var _damaged as Dictionary<String, Boolean> = {} as Dictionary<String, Boolean>; + private var _top as Number = 0; + // Hit testing: the first visible index and each visible row's top edge. + private var _rowTops as Array<Number> = [] as Array<Number>; + private var _rowHeight as Number = 1; + private var _ticker as Timer.Timer? = null; + private var _continue as Timer.Timer? = null; + private var _firstFrameDone as Boolean = false; + private var _shownLogged as Boolean = false; + private var _startupDone as Boolean = false; + private var _loggedFocusY as Number = -1; + + function initialize() { + View.initialize(); + } + + function onShow() as Void { + reload(); + _shownLogged = false; + var ticker = new Timer.Timer(); + ticker.start(method(:onTick), 1000, true); + _ticker = ticker; + } + + function onHide() as Void { + var ticker = _ticker; + if (ticker != null) { + ticker.stop(); + _ticker = null; + } + } + + // Re-read the entry order (after changes) and drop cached headers. + function reload() as Void { + _ids = AppState.theVault().ids(); + _headers = {} as Dictionary<String, Entry>; + _damaged = {} as Dictionary<String, Boolean>; + if (focus >= _ids.size()) { + focus = _ids.size() > 0 ? _ids.size() - 1 : 0; + } + } + + function count() as Number { + return _ids.size(); + } + + // Move the focus by delta rows, wrapping at both ends. + function move(delta as Number) as Void { + var n = _ids.size(); + if (n == 0) { + return; + } + focus = ((focus + delta) % n + n) % n; + WatchUi.requestUpdate(); + } + + function focusedId() as String? { + return focus < _ids.size() ? _ids[focus] : null; + } + + // The entry index drawn at screen position y, or -1. + function rowAt(y as Number) as Number { + for (var k = 0; k < _rowTops.size(); k++) { + var top = _rowTops[k]; + if (y >= top && y < top + _rowHeight) { + return _top + k; + } + } + return -1; + } + + function onTick() as Void { + AppState.checkAutoLock(); + WatchUi.requestUpdate(); + } + + // After the first frame: the self-test, then the PIN prompt for pending + // locked entries (README 7.7). + private function startupChecks() as Void { + _startupDone = true; + if (!AppState.runSelfTestIfDue()) { + WatchUi.switchToView(new InfoView("OTP Wallet", [AppState.startError as String], "error"), new InfoDelegate(), + WatchUi.SLIDE_IMMEDIATE); + return; + } + var vault = AppState.theVault(); + if (vault.pendingIds().size() > 0 && !vault.session.isUnlocked()) { + var mode = vault.hasPin() ? PinView.MODE_UNLOCK : PinView.MODE_CREATE; + WatchUi.pushView(new PinView(mode, null), new PinDelegate(), WatchUi.SLIDE_LEFT); + } + } + + // One-shot timer: start-up checks after the first frame, and further + // code computation that didn't fit in the previous draw. + function onContinue() as Void { + _continue = null; + if (!_startupDone) { + startupChecks(); + } else { + computeMissing(); + } + WatchUi.requestUpdate(); + } + + private function scheduleContinue() as Void { + if (_continue == null) { + var t = new Timer.Timer(); + t.start(method(:onContinue), CONTINUE_MS, false); + _continue = t; + } + } + + function onUpdate(dc as Graphics.Dc) as Void { + var frameStart = System.getTimer(); + Ui.clear(dc); + var w = dc.getWidth(); + var h = dc.getHeight(); + var titleFont = Graphics.FONT_XTINY; + var titleY = Ui.isRound() ? h / 12 : 4; + dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT); + dc.drawText(w / 2, titleY, titleFont, "OTP Wallet", Graphics.TEXT_JUSTIFY_CENTER); + var listTop = titleY + dc.getFontHeight(titleFont) + 2; + var listBottom = Ui.isRound() ? h - h / 12 : h - 4; + + var complete = true; + if (_ids.size() == 0) { + drawEmpty(dc, listTop, listBottom); + } else { + complete = drawRows(dc, listTop, listBottom); + } + + Diag.overlay(dc, frameStart); + if (!_firstFrameDone) { + _firstFrameDone = true; + Diag.firstFrame(); + Diag.data("device", "touch=" + (Ui.isTouch() ? 1 : 0)); + scheduleContinue(); + } + if (!complete) { + scheduleContinue(); + } else if (!_shownLogged && _startupDone) { + // UI scripts screenshot the list only once every code is drawn. + _shownLogged = true; + Diag.shown("list"); + } + } + + private function drawEmpty(dc as Graphics.Dc, top as Number, bottom as Number) as Void { + var w = dc.getWidth(); + var y = (top + bottom) / 2 - dc.getFontHeight(Graphics.FONT_SMALL); + dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); + dc.drawText(w / 2, y, Graphics.FONT_SMALL, "No entries yet", Graphics.TEXT_JUSTIFY_CENTER); + y += dc.getFontHeight(Graphics.FONT_SMALL) + 4; + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + var lines = Ui.wrap(dc, "Add them in the app's settings in Garmin Connect on your phone.", Graphics.FONT_XTINY, + 2 * Ui.halfWidthAt(dc, y + dc.getFontHeight(Graphics.FONT_XTINY) * 2)); + for (var i = 0; i < lines.size(); i++) { + dc.drawText(w / 2, y, Graphics.FONT_XTINY, lines[i], Graphics.TEXT_JUSTIFY_CENTER); + y += dc.getFontHeight(Graphics.FONT_XTINY); + } + } + + // Returns false while some visible code is still to be computed. + private function drawRows(dc as Graphics.Dc, top as Number, bottom as Number) as Boolean { + var n = _ids.size(); + var tinyH = dc.getFontHeight(Graphics.FONT_TINY); + var xtinyH = dc.getFontHeight(Graphics.FONT_XTINY); + _rowHeight = dc.getFontHeight(Graphics.FONT_SMALL) + (tinyH > xtinyH ? tinyH : xtinyH) + 4; + var visible = (bottom - top) / _rowHeight; + if (visible < 1) { + visible = 1; + } + if (focus < _top) { + _top = focus; + } else if (focus >= _top + visible) { + _top = focus - visible + 1; + } + if (_top > n - visible) { + _top = n - visible > 0 ? n - visible : 0; + } + var shown = n - _top < visible ? n - _top : visible; + // Centre a short list in the available space. + var y = top + (bottom - top - shown * _rowHeight) / 2; + var incomplete = false; + _rowTops = [] as Array<Number>; + for (var k = 0; k < shown; k++) { + _rowTops.add(y); + if (_top + k == focus && y + _rowHeight / 2 != _loggedFocusY) { + // Where UI scripts tap to open the focused row. + _loggedFocusY = y + _rowHeight / 2; + Diag.data("list_focus", "y=" + _loggedFocusY); + } + if (!drawRow(dc, _top + k, y)) { + incomplete = true; + } + y += _rowHeight; + } + drawScrollMarks(dc, top, bottom, n, shown); + return !incomplete; + } + + // Draws one row. Returns false when its code is still to be computed. + // + // Two layouts: when the name and the code fit on one line, the code sits + // right of the name and the second line (group, account) has the full + // width; on narrow rows the code moves to the second line, right of the + // account, so the name keeps its width. + private function drawRow(dc as Graphics.Dc, index as Number, y as Number) as Boolean { + var id = _ids[index]; + var header = headerFor(id); + var w = dc.getWidth(); + var hw = Ui.halfWidthBetween(dc, y, y + _rowHeight); + var x0 = w / 2 - hw; + var x1 = w / 2 + hw; + var nameFont = Graphics.FONT_SMALL; + var smallFont = Graphics.FONT_XTINY; + var nameH = dc.getFontHeight(nameFont); + var lineY = y + 2; + var line2Y = lineY + nameH; + var line2H = _rowHeight - nameH - 4; + if (index == focus) { + dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT); + dc.setPenWidth(2); + dc.drawRoundedRectangle(x0, y + 1, x1 - x0, _rowHeight - 2, 8); + dc.setPenWidth(1); + } + var left = x0 + 8; + var right = x1 - 8; + + if (header == null) { + dc.setColor(Ui.WARN_COLOR, Graphics.COLOR_TRANSPARENT); + dc.drawText(left, lineY, nameFont, Ui.fit(dc, "Damaged " + id, nameFont, right - left), Graphics.TEXT_JUSTIFY_LEFT); + return true; + } + var name = header.name.length() > 0 ? header.name : (header.locked ? "Locked entry " + id : id); + var second = header.account; + if (AppState.options.showGroups && header.group.length() > 0) { + second = second.length() > 0 ? header.group + " · " + second : header.group; + } + + var nameRight = right; + var secondRight = right; + var complete = true; + var iconSize = nameH * 2 / 3; + if (header.kind == Entry.KIND_CODE) { + Ui.drawBarcode(dc, right - iconSize, lineY + (nameH - iconSize) / 2, iconSize, Ui.dim()); + nameRight -= iconSize + 6; + } else if (header.locked) { + Ui.drawLock(dc, right - iconSize, lineY + (nameH - iconSize) / 2, iconSize, Ui.dim()); + nameRight -= iconSize + 6; + } else { + var now = AppState.now(); + var text = AppState.cryptoReady ? AppState.theCodes().cached(header.id, now) : null; + if (text == null) { + complete = false; + text = "···"; + } + var parts = CodeCache.split(text); + var display = parts.size() == 2 ? parts[0] + " " + parts[1] : parts[0]; + var oneLine = dc.getTextWidthInPixels(name, nameFont) + codeWidth(dc, header, display, nameFont) + 8 <= right - left; + if (oneLine) { + nameRight = drawCode(dc, header, display, nameFont, right, lineY, nameH, now) - 8; + } else { + secondRight = drawCode(dc, header, display, Graphics.FONT_TINY, right, line2Y, line2H, now) - 8; + } + } + + dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); + dc.drawText(left, lineY, nameFont, Ui.fit(dc, name, nameFont, nameRight - left), Graphics.TEXT_JUSTIFY_LEFT); + if (second.length() > 0) { + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(left, line2Y + (line2H - dc.getFontHeight(smallFont)) / 2, smallFont, + Ui.fit(dc, second, smallFont, secondRight - left), Graphics.TEXT_JUSTIFY_LEFT); + } + return complete; + } + + // Width of a code with its countdown (TOTP and Steam) in `font`. + private function codeWidth(dc as Graphics.Dc, header as Entry, display as String, font as Graphics.FontType) as Number { + var countdown = header.type == Entry.OTP_HOTP ? 0 : 2 * (dc.getFontHeight(Graphics.FONT_XTINY) / 3) + 6; + return dc.getTextWidthInPixels(display, font) + countdown; + } + + // Draws the code and countdown right-aligned at `right`, vertically + // centred in the line from y to y + lineH. Returns the left edge used. + private function drawCode(dc as Graphics.Dc, header as Entry, display as String, font as Graphics.FontType, right as Number, + y as Number, lineH as Number, now as Long) as Number { + var x = right; + if (header.type != Entry.OTP_HOTP) { + var r = dc.getFontHeight(Graphics.FONT_XTINY) / 3; + var period = header.type == Entry.OTP_STEAM ? Otp.STEAM_PERIOD : periodOf(header); + Ui.drawCountdown(dc, x - r, y + lineH / 2, r, Otp.secondsRemaining(now, period), period); + x -= 2 * r + 6; + } + dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); + dc.drawText(x, y + (lineH - dc.getFontHeight(font)) / 2, font, display, Graphics.TEXT_JUSTIFY_RIGHT); + return x - dc.getTextWidthInPixels(display, font); + } + + // Computes the codes the last draw was missing, within the per-event + // budget (onContinue, its own event: never together with drawing and + // header reading, for the watchdog). + private function computeMissing() as Void { + if (!AppState.cryptoReady) { + return; + } + var codes = AppState.theCodes(); + var now = AppState.now(); + var budget = CodeCache.BUDGET_PER_EVENT; + var n = _ids.size(); + for (var k = 0; k < _rowTops.size() && _top + k < n; k++) { + var header = headerFor(_ids[_top + k]); + if (header == null || header.kind != Entry.KIND_OTP || header.locked || codes.cached(header.id, now) != null) { + continue; + } + var cost = codes.costOf(header); + if (cost > budget) { + return; + } + budget -= cost; + try { + codes.code(header.id, now, 0); + } catch (e instanceof VaultError) { + return; + } + } + } + + // The period is encrypted with the secret; the code cache learns it when + // it computes the code. + private function periodOf(header as Entry) as Number { + return AppState.theCodes().periodOf(header.id); + } + + private function drawScrollMarks(dc as Graphics.Dc, top as Number, bottom as Number, n as Number, shown as Number) as Void { + var cx = dc.getWidth() / 2; + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + if (_top > 0) { + dc.fillPolygon([[cx - 6, top + 6], [cx + 6, top + 6], [cx, top]] as Array<Graphics.Point2D>); + } + if (_top + shown < n) { + dc.fillPolygon([[cx - 6, bottom - 6], [cx + 6, bottom - 6], [cx, bottom]] as Array<Graphics.Point2D>); + } + } + + // The cached header, reading it on first use; null when damaged. + private function headerFor(id as String) as Entry? { + var cached = _headers[id]; + if (cached != null || _damaged.hasKey(id)) { + return cached; + } + var header = AppState.theVault().header(id); + if (header == null) { + _damaged[id] = true; + } else { + _headers[id] = header; + } + return header; + } +} diff --git a/source/ui/MainMenu.mc b/source/ui/MainMenu.mc new file mode 100644 index 0000000..b827784 --- /dev/null +++ b/source/ui/MainMenu.mc @@ -0,0 +1,172 @@ +// Author: cirakg & claude ai +// Summary: The menu (README 13.1): lock now, PIN actions, settings results, entry IDs, clock status, about. +// +// A native Menu2, so scrolling, touch and buttons behave like every other +// Garmin menu. Debug builds add the items from DebugMenu. + +import Toybox.Application; +import Toybox.Lang; +import Toybox.Time; +import Toybox.Time.Gregorian; +import Toybox.Timer; +import Toybox.WatchUi; + +module MainMenu { + + function build() as WatchUi.Menu2 { + var vault = AppState.theVault(); + var menu = new WatchUi.Menu2({:title => "OTP Wallet"}); + if (vault.session.isUnlocked()) { + menu.addItem(new WatchUi.MenuItem("Lock now", null, :lock, null)); + } + menu.addItem(new WatchUi.MenuItem(vault.hasPin() ? "Change PIN" : "Set PIN", null, :pin, null)); + if (vault.hasPin()) { + menu.addItem(new WatchUi.MenuItem("Reset PIN", null, :reset, null)); // the confirmation says what it erases + } + menu.addItem(new WatchUi.MenuItem("Settings results", null, :results, null)); + menu.addItem(new WatchUi.MenuItem("Entry IDs", null, :ids, null)); + menu.addItem(new WatchUi.MenuItem("Clock status", null, :clock, null)); + menu.addItem(new WatchUi.MenuItem("About", null, :about, null)); + DebugMenu.addItems(menu); + return menu; + } + + function entryIdLines() as Array<String> { + var vault = AppState.theVault(); + var ids = vault.ids(); + var lines = [] as Array<String>; + for (var i = 0; i < ids.size(); i++) { + var header = vault.header(ids[i]); + var name = header == null ? "damaged" : (header.name.length() > 0 ? header.name : "Locked entry"); + lines.add(ids[i] + " " + name); + } + if (lines.size() == 0) { + lines.add("No entries yet."); + } + var pending = vault.pendingIds().size(); + if (pending > 0) { + lines.add(pending + " locked entries wait for your PIN."); + } + return lines; + } + + function clockLines() as Array<String> { + var lines = [] as Array<String>; + var watch = Clock.nowUnix(0); + var info = Gregorian.utcInfo(new Time.Moment(watch.toNumber()), Time.FORMAT_SHORT); + lines.add("Watch: " + info.hour.format("%02d") + ":" + info.min.format("%02d") + ":" + info.sec.format("%02d") + " UTC"); + var trusted = Clock.trustedUnix(); + if (trusted == null) { + lines.add("GPS time: not synced yet. Codes use the watch clock."); + } else { + var diff = (watch - trusted).toNumber(); + lines.add("GPS time: synced"); + lines.add("Difference: " + diff + " s" + (diff.abs() > 15 ? " (codes may be wrong)" : "")); + } + lines.add("Time offset setting: " + AppState.options.timeOffset + " s"); + return lines; + } + + function aboutLines() as Array<String> { + var vault = AppState.theVault(); + var lines = ["OTP Wallet " + AppInfo.VERSION] as Array<String>; + lines.add("Entries: " + vault.ids().size() + (vault.pendingIds().size() > 0 ? " (+" + vault.pendingIds().size() + " pending)" : "")); + lines.add("Storage: " + (storageBytes(vault) + 1023) / 1024 + " KB"); + lines.add("Self-test: " + AppState.selfTestSummary); + lines.add("No network access: your data stays on this watch."); + return lines; + } + + // Bytes the entry records take (the largest part of the store). + function storageBytes(vault as Vault) as Number { + var total = 0; + var ids = vault.ids(); + for (var i = 0; i < ids.size(); i++) { + var record = Application.Storage.getValue("e." + ids[i]); + if (record instanceof Lang.ByteArray) { + total += record.size(); + } + } + return total; + } +} + +class MainMenuDelegate extends WatchUi.Menu2InputDelegate { + + function initialize() { + Menu2InputDelegate.initialize(); + } + + function onSelect(item as WatchUi.MenuItem) as Void { + AppState.activity(); + var id = item.getId(); + var vault = AppState.theVault(); + if (id == :lock) { + AppState.lock(); + WatchUi.popView(WatchUi.SLIDE_DOWN); + } else if (id == :pin) { + if (!vault.hasPin()) { + WatchUi.switchToView(new PinView(PinView.MODE_CREATE, null), new PinDelegate(), WatchUi.SLIDE_LEFT); + } else if (vault.session.isUnlocked()) { + WatchUi.switchToView(new PinView(PinView.MODE_CHANGE, null), new PinDelegate(), WatchUi.SLIDE_LEFT); + } else { + var then = new ChangePinAfterUnlock(); + WatchUi.switchToView(new PinView(PinView.MODE_UNLOCK, then.method(:run)), new PinDelegate(), WatchUi.SLIDE_LEFT); + } + } else if (id == :reset) { + WatchUi.pushView(new WatchUi.Confirmation("Erase locked entries and set a new PIN?"), new ResetPinConfirmation(), + WatchUi.SLIDE_LEFT); + } else if (id == :results) { + WatchUi.switchToView(new InfoView("Settings results", + ["Nothing yet. Results appear here after you add or change entries in the phone settings."], "results"), + new InfoDelegate(), WatchUi.SLIDE_LEFT); + } else if (id == :ids) { + WatchUi.switchToView(new InfoView("Entry IDs", MainMenu.entryIdLines(), "ids"), new InfoDelegate(), WatchUi.SLIDE_LEFT); + } else if (id == :clock) { + WatchUi.switchToView(new InfoView("Clock status", MainMenu.clockLines(), "clock"), new InfoDelegate(), WatchUi.SLIDE_LEFT); + } else if (id == :about) { + WatchUi.switchToView(new InfoView("About", MainMenu.aboutLines(), "about"), new InfoDelegate(), WatchUi.SLIDE_LEFT); + } else { + DebugMenu.handle(id); + } + } +} + +// After unlocking from the menu's "Change PIN": ask for the new PIN. +class ChangePinAfterUnlock { + + function initialize() { + } + + function run() as Void { + WatchUi.switchToView(new PinView(PinView.MODE_CHANGE, null), new PinDelegate(), WatchUi.SLIDE_LEFT); + } +} + +// "Reset PIN" after the user confirmed. The confirmation closes itself after +// onResponse, so the PIN screen opens from a short timer, replacing the menu. +class ResetPinConfirmation extends WatchUi.ConfirmationDelegate { + + // Kept so the one-shot timer isn't collected before it fires. + var timer as Timer.Timer? = null; + + function initialize() { + ConfirmationDelegate.initialize(); + } + + function onResponse(response as WatchUi.Confirm) as Boolean { + if (response == WatchUi.CONFIRM_YES) { + AppState.theVault().resetPin(); + AppState.theCodes().clear(); + var t = new Timer.Timer(); + t.start(method(:openCreatePin), 100, false); + timer = t; + } + return true; + } + + function openCreatePin() as Void { + timer = null; + WatchUi.switchToView(new PinView(PinView.MODE_CREATE, null), new PinDelegate(), WatchUi.SLIDE_LEFT); + } +} diff --git a/source/ui/Nav.mc b/source/ui/Nav.mc new file mode 100644 index 0000000..3e823e4 --- /dev/null +++ b/source/ui/Nav.mc @@ -0,0 +1,66 @@ +// Author: cirakg & claude ai +// Summary: Navigation between screens (README 13.2): opening entries, asking for the PIN first when needed, the menu. + +import Toybox.Lang; +import Toybox.WatchUi; + +module Nav { + + // Open an entry from the list: its detail screen, or the PIN screen first + // when it is locked and the session is locked. + function openEntry(id as String) as Void { + var vault = AppState.theVault(); + var header = vault.header(id); + if (header == null) { + showInfo("Damaged entry", ["Damaged entry " + id + ". Delete it and add it again."]); + return; + } + if (header.locked && !vault.session.isUnlocked()) { + var action = new OpenAfterPin(id); + WatchUi.pushView(new PinView(PinView.MODE_UNLOCK, action.method(:run)), new PinDelegate(), WatchUi.SLIDE_LEFT); + return; + } + WatchUi.pushView(detailView(header), detailDelegate(header), WatchUi.SLIDE_LEFT); + } + + function detailView(header as Entry) as WatchUi.View { + if (header.kind == Entry.KIND_OTP) { + return new OtpView(header); + } + return new InfoView(header.name, ["Barcode display comes in a later version.", "ID " + header.id], "code"); + } + + function detailDelegate(header as Entry) as WatchUi.InputDelegate { + if (header.kind == Entry.KIND_OTP) { + return new OtpDelegate(); + } + return new InfoDelegate(); + } + + // A simple text screen. + function showInfo(title as String, lines as Array<String>) as Void { + WatchUi.pushView(new InfoView(title, lines, "info"), new InfoDelegate(), WatchUi.SLIDE_LEFT); + } + + function openMenu() as Void { + WatchUi.pushView(MainMenu.build(), new MainMenuDelegate(), WatchUi.SLIDE_UP); + } + + // Opens an entry once the PIN screen succeeded (replacing the PIN screen). + class OpenAfterPin { + private var _id as String; + + function initialize(id as String) { + _id = id; + } + + function run() as Void { + var header = AppState.theVault().header(_id); + if (header == null) { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return; + } + WatchUi.switchToView(detailView(header), detailDelegate(header), WatchUi.SLIDE_LEFT); + } + } +} diff --git a/source/ui/OtpView.mc b/source/ui/OtpView.mc new file mode 100644 index 0000000..faf94d0 --- /dev/null +++ b/source/ui/OtpView.mc @@ -0,0 +1,305 @@ +// Author: cirakg & claude ai +// Summary: OTP detail: issuer and account, the code in large grouped digits, the countdown ring or bar, next code, HOTP next. +// +// README 13.1: TOTP and Steam show the seconds left as a ring along the bezel +// (a bar on non-round screens), turning red and showing the next code in the +// last 5 seconds. HOTP shows its counter and how to get the next code. The +// entry ID is in small print at the bottom. +// +// The code font follows the code-size option and steps down until the code +// fits the screen. A locked entry closes back to the list when auto-lock +// fires while it is open. + +import Toybox.Graphics; +import Toybox.Lang; +import Toybox.System; +import Toybox.Timer; +import Toybox.WatchUi; + +class OtpView extends WatchUi.View { + + static const NEXT_CODE_SECONDS = 5; + static const RING_WIDTH = 8; + + var header as Entry; + + private var _ticker as Timer.Timer? = null; + private var _compute as Timer.Timer? = null; + private var _shownLogged as Boolean = false; + + function initialize(entryHeader as Entry) { + View.initialize(); + header = entryHeader; + } + + function onShow() as Void { + _shownLogged = false; + var ticker = new Timer.Timer(); + ticker.start(method(:onTick), 1000, true); + _ticker = ticker; + } + + function onHide() as Void { + var ticker = _ticker; + if (ticker != null) { + ticker.stop(); + _ticker = null; + } + } + + function onTick() as Void { + if (AppState.checkAutoLock() && header.locked) { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return; + } + WatchUi.requestUpdate(); + } + + // Re-read the header (the HOTP counter changed). + function refresh() as Void { + var fresh = AppState.theVault().header(header.id); + if (fresh != null) { + header = fresh; + } + _shownLogged = false; + WatchUi.requestUpdate(); + } + + function isHotp() as Boolean { + return header.type == Entry.OTP_HOTP; + } + + function onUpdate(dc as Graphics.Dc) as Void { + var frameStart = System.getTimer(); + Ui.clear(dc); + var w = dc.getWidth(); + var h = dc.getHeight(); + var cx = w / 2; + var now = AppState.now(); + var codes = AppState.theCodes(); + // Codes are computed in their own timer events (computeNext), one + // per event: an SHA-512 code costs a large share of the watchdog's + // per-event budget on some watches. + var cached = AppState.cryptoReady ? codes.cached(header.id, now) : null; + var complete = cached != null; + var code = cached != null ? cached : "------"; + var period = header.type == Entry.OTP_STEAM ? Otp.STEAM_PERIOD : codes.periodOf(header.id); + var remaining = Otp.secondsRemaining(now, period); + var round = Ui.isRound(); + if (!isHotp()) { + drawTimeLeft(dc, remaining, period); + } + + // Layout: a vertical stack of name, account, code, two info lines and + // the ID, centred; the code font is the largest that fits both the + // width and the height left for it. + var nameFont = Graphics.FONT_SMALL; + var smallFont = Graphics.FONT_XTINY; + var infoFont = isHotp() ? smallFont : Graphics.FONT_TINY; + var nameH = dc.getFontHeight(nameFont); + var smallH = dc.getFontHeight(smallFont); + var accountH = header.account.length() > 0 ? smallH : 0; + var infoH = dc.getFontHeight(infoFont) + smallH; + var margin = round ? h / 10 : RING_WIDTH; + var fixedH = nameH + accountH + infoH + smallH + 8; + var font = chooseCodeFont(dc, code, 2 * Ui.halfWidthAt(dc, h / 2) - 2 * RING_WIDTH - 8, h - 2 * margin - fixedH); + var codeH = dc.getFontHeight(font); + var y = (h - fixedH - codeH) / 2; + + var title = header.name.length() > 0 ? header.name : "Locked entry " + header.id; + dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, nameFont, Ui.fit(dc, title, nameFont, 2 * Ui.halfWidthAt(dc, y) - 2 * RING_WIDTH), + Graphics.TEXT_JUSTIFY_CENTER); + y += nameH; + if (accountH > 0) { + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, smallFont, Ui.fit(dc, header.account, smallFont, 2 * Ui.halfWidthAt(dc, y) - 2 * RING_WIDTH), + Graphics.TEXT_JUSTIFY_CENTER); + y += accountH; + } + y += 2; + drawCode(dc, code, font, y); + y += codeH + 2; + + // Below the code: seconds left and the next code, or the HOTP hint. + if (isHotp()) { + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, smallFont, "Counter " + header.counter, Graphics.TEXT_JUSTIFY_CENTER); + y += smallH; + dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT); + var hint = Ui.isTouch() ? "Tap for the next code" : "START: next code"; + dc.drawText(cx, y, smallFont, Ui.fit(dc, hint, smallFont, 2 * Ui.halfWidthAt(dc, y)), Graphics.TEXT_JUSTIFY_CENTER); + y += smallH; + } else { + dc.setColor(remaining <= NEXT_CODE_SECONDS ? Ui.WARN_COLOR : Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, infoFont, remaining + " s", Graphics.TEXT_JUSTIFY_CENTER); + y += dc.getFontHeight(infoFont); + if (remaining <= NEXT_CODE_SECONDS && cached != null) { + var next = codes.cached(header.id + "+", now); + if (next == null) { + complete = false; + next = "······"; + } + var parts = CodeCache.split(next); + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, smallFont, "next " + (parts.size() == 2 ? parts[0] + " " + parts[1] : parts[0]), + Graphics.TEXT_JUSTIFY_CENTER); + } + y += smallH; + } + + // Entry ID in small print. + y += 4; + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, smallFont, (header.locked ? "Locked · " : "") + header.id, Graphics.TEXT_JUSTIFY_CENTER); + + Diag.overlay(dc, frameStart); + if (!complete) { + scheduleCompute(); + } else if (!_shownLogged) { + // UI scripts screenshot the screen once its codes are drawn. + _shownLogged = true; + Diag.shown(isHotp() ? "otp_hotp" : "otp"); + } + } + + private function scheduleCompute() as Void { + if (_compute == null && AppState.cryptoReady) { + var t = new Timer.Timer(); + t.start(method(:computeNext), 50, false); + _compute = t; + } + } + + // Computes the first missing code (current, then next) and redraws. + function computeNext() as Void { + _compute = null; + var codes = AppState.theCodes(); + var now = AppState.now(); + try { + if (codes.cached(header.id, now) == null) { + codes.code(header.id, now, 0); + } else { + codes.code(header.id, now, 1); + } + } catch (e instanceof VaultError) { + // Locked meanwhile: the next tick closes the screen. + return; + } + WatchUi.requestUpdate(); + } + + // The largest allowed code font whose code fits maxWidth and maxHeight + // (the code-size option gives the largest allowed). + private function chooseCodeFont(dc as Graphics.Dc, code as String, maxWidth as Number, maxHeight as Number) as Graphics.FontType { + // Number fonts only have digits: Steam codes and placeholders use text fonts. + var letters = header.type == Entry.OTP_STEAM || !Ui.isDigits(code); + var fonts = letters ? [Ui.codeFont(true), Graphics.FONT_MEDIUM, Graphics.FONT_SMALL] + : [Ui.codeFont(false), Graphics.FONT_NUMBER_MEDIUM, Graphics.FONT_NUMBER_MILD, Graphics.FONT_MEDIUM]; + for (var i = 0; i < fonts.size(); i++) { + var f = fonts[i] as Graphics.FontType; + if (codeWidth(dc, code, f) <= maxWidth && dc.getFontHeight(f) <= maxHeight) { + return f; + } + } + return fonts[fonts.size() - 1] as Graphics.FontType; + } + + // Width of the code as drawn: its groups with a gap of a quarter of the + // font height between them. + private function codeWidth(dc as Graphics.Dc, code as String, font as Graphics.FontType) as Number { + var parts = CodeCache.split(code); + var total = parts.size() == 2 ? dc.getFontHeight(font) / 4 : 0; + for (var k = 0; k < parts.size(); k++) { + total += dc.getTextWidthInPixels(parts[k], font); + } + return total; + } + + // The code, centred horizontally, with its top at y. + private function drawCode(dc as Graphics.Dc, code as String, font as Graphics.FontType, y as Number) as Void { + var parts = CodeCache.split(code); + var gap = parts.size() == 2 ? dc.getFontHeight(font) / 4 : 0; + var x = dc.getWidth() / 2 - codeWidth(dc, code, font) / 2; + dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); + for (var k = 0; k < parts.size(); k++) { + dc.drawText(x, y, font, parts[k], Graphics.TEXT_JUSTIFY_LEFT); + x += dc.getTextWidthInPixels(parts[k], font) + gap; + } + } + + // The ring along the bezel, or a bar at the top of non-round screens. + private function drawTimeLeft(dc as Graphics.Dc, remaining as Number, period as Number) as Void { + var w = dc.getWidth(); + var h = dc.getHeight(); + var color = remaining <= NEXT_CODE_SECONDS ? Ui.WARN_COLOR : Ui.accent(); + if (Ui.isRound()) { + var r = w / 2 - RING_WIDTH / 2 - 1; + dc.setPenWidth(RING_WIDTH); + dc.setColor(Ui.dark() ? 0x333333 : Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT); + dc.drawCircle(w / 2, h / 2, r); + var sweep = period > 0 ? 360 * remaining / period : 0; + dc.setColor(color, Graphics.COLOR_TRANSPARENT); + if (sweep >= 360) { + dc.drawCircle(w / 2, h / 2, r); + } else if (sweep > 0) { + dc.drawArc(w / 2, h / 2, r, Graphics.ARC_CLOCKWISE, 90, (450 - sweep) % 360); + } + dc.setPenWidth(1); + } else { + dc.setColor(Ui.dark() ? 0x333333 : Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(0, 0, w, RING_WIDTH / 2 + 2); + dc.setColor(color, Graphics.COLOR_TRANSPARENT); + dc.fillRectangle(0, 0, period > 0 ? w * remaining / period : 0, RING_WIDTH / 2 + 2); + } + } +} + +// Input on the OTP detail: START or a tap gives the next HOTP code; BACK closes. +class OtpDelegate extends WatchUi.BehaviorDelegate { + + function initialize() { + BehaviorDelegate.initialize(); + } + + private function view() as OtpView? { + var current = WatchUi.getCurrentView()[0]; + return current instanceof OtpView ? current : null; + } + + function onSelect() as Boolean { + AppState.activity(); + nextHotp(); + return true; + } + + function onTap(event as WatchUi.ClickEvent) as Boolean { + AppState.activity(); + nextHotp(); + return true; + } + + function onBack() as Boolean { + var v = view(); + if (v != null && v.header.locked && AppState.theVault().session.autoLockMs == 0) { + // Auto-lock "Immediately": leaving a locked entry locks. + AppState.lock(); + } + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return true; + } + + private function nextHotp() as Void { + var v = view(); + if (v == null || !v.isHotp()) { + return; + } + try { + AppState.theVault().setCounter(v.header.id, v.header.counter + 1); + } catch (e instanceof VaultError) { + return; + } + AppState.theCodes().invalidate(v.header.id); + v.refresh(); + } +} diff --git a/source/ui/PinView.mc b/source/ui/PinView.mc new file mode 100644 index 0000000..6eefa55 --- /dev/null +++ b/source/ui/PinView.mc @@ -0,0 +1,503 @@ +// Author: cirakg & claude ai +// Summary: PIN entry for unlocking, creating and changing the PIN: touch keypad or button digit selector, lockout display. +// +// README 13.1 and 13.4: +// * Touch screens get a 3x4 keypad (digits, delete, OK). Watches without a +// touch screen get a digit selector: UP/DOWN change the digit, START +// adds it (or confirms on "OK"), BACK deletes. +// * Entered digits show as dots. A wrong PIN vibrates and shows any wait. +// * Creating or changing the PIN asks twice and warns below 6 digits. +// * Key derivation (about 1 s) runs as a job on a timer, so the screen +// shows "Checking..." and the watchdog never trips. +// The typed digits are a ByteArray that is wiped after every attempt. + +import Toybox.Attention; +import Toybox.Graphics; +import Toybox.Lang; +import Toybox.Timer; +import Toybox.WatchUi; + +class PinView extends WatchUi.View { + + static const MODE_UNLOCK = 0; + static const MODE_CREATE = 1; + static const MODE_CHANGE = 2; + + // Selector positions 0-9 are digits; this one is "OK". + static const SELECT_OK = 10; + // Keypad keys besides the digits. + static const KEY_DELETE = -1; + static const KEY_OK = -2; + static const RECOMMENDED_DIGITS = 6; + // Calibrate the PIN derivation to about this long on this watch. + static const DERIVATION_TARGET_MS = 1000; + static const CALIBRATION_SAMPLE = 200; + + var mode as Number; + var busy as Boolean = false; + + private var _onSuccess as Method() as Void or Null; + private var _digits as ByteArray = []b; + private var _first as ByteArray? = null; // first entry when creating + private var _selector as Number = 0; + private var _message as String? = null; + private var _messageIsError as Boolean = false; + private var _job as PinJob? = null; + // The PIN copy the running derivation was given; wiped when it is done. + private var _jobPin as ByteArray? = null; + // The running derivation's timer driver (kept so it isn't collected). + var runner as Jobs.JobRunner? = null; + private var _ticker as Timer.Timer? = null; + private var _keys as Array<Array<Number>> = [] as Array<Array<Number>>; // [x, y, w, h, key] + private var _shownLogged as Boolean = false; + + // onSuccess runs after a correct or newly set PIN and must replace this + // view (switchToView); without it the view just closes. + function initialize(pinMode as Number, onSuccess as Method() as Void or Null) { + View.initialize(); + mode = pinMode; + _onSuccess = onSuccess; + } + + function onShow() as Void { + _shownLogged = false; + var ticker = new Timer.Timer(); + ticker.start(method(:onTick), 1000, true); + _ticker = ticker; + } + + function onHide() as Void { + var ticker = _ticker; + if (ticker != null) { + ticker.stop(); + _ticker = null; + } + } + + function onTick() as Void { + WatchUi.requestUpdate(); + } + + // Seconds until another attempt is allowed (unlock mode only). + function waitSeconds() as Number { + return mode == MODE_UNLOCK ? AppState.theVault().lockout.remainingSeconds() : 0; + } + + // ---- Input (called by PinDelegate) ---------------------------------- + + // A keypad key: 0-9, KEY_DELETE or KEY_OK. + function press(key as Number) as Void { + if (busy || waitSeconds() > 0) { + return; + } + if (key == KEY_DELETE) { + deleteDigit(); + } else if (key == KEY_OK) { + submit(); + } else if (_digits.size() < KeyManager.MAX_PIN_DIGITS) { + _digits.add(0x30 + key); + _message = null; + } + WatchUi.requestUpdate(); + } + + // Selector: UP/DOWN. + function cycle(delta as Number) as Void { + _selector = ((_selector + delta) % (SELECT_OK + 1) + SELECT_OK + 1) % (SELECT_OK + 1); + WatchUi.requestUpdate(); + } + + // Selector: START. + function choose() as Void { + press(_selector == SELECT_OK ? KEY_OK : _selector); + } + + // BACK: delete a digit; returns false when there was none (close). + function back() as Boolean { + if (busy) { + return true; + } + if (_digits.size() == 0) { + return false; + } + deleteDigit(); + WatchUi.requestUpdate(); + return true; + } + + // The keypad key at a touch position, or null. + function keyAt(x as Number, y as Number) as Number? { + for (var i = 0; i < _keys.size(); i++) { + var k = _keys[i]; + if (x >= k[0] && x < k[0] + k[2] && y >= k[1] && y < k[1] + k[3]) { + return k[4]; + } + } + return null; + } + + private function deleteDigit() as Void { + if (_digits.size() > 0) { + var shorter = _digits.slice(0, _digits.size() - 1); + Bytes.wipe(_digits); + _digits = shorter; + } + } + + // ---- Checking and setting the PIN ------------------------------------ + + private function submit() as Void { + if (!KeyManager.isValidPin(_digits)) { + showMessage("4 to 10 digits", true); + return; + } + if (mode == MODE_UNLOCK) { + startUnlock(); + } else if (_first == null) { + _first = _digits; + _digits = []b; + if (_first.size() < RECOMMENDED_DIGITS) { + showMessage("6+ digits are safer", false); + } + } else if (Bytes.equal(_first as ByteArray, _digits)) { + startSetPin(); + } else { + Bytes.wipe(_first); + _first = null; + clearDigits(); + showMessage("PINs differ. Try again", true); + buzz(); + } + } + + private function startUnlock() as Void { + var vault = AppState.theVault(); + _jobPin = Bytes.copy(_digits); + try { + _job = vault.beginUnlock(_jobPin as ByteArray); + } catch (e instanceof VaultError) { + Bytes.wipe(_jobPin); + _jobPin = null; + clearDigits(); + if (e.code == VaultError.NO_PIN) { + mode = MODE_CREATE; + showMessage("Set a PIN first", false); + } else if (e.code != VaultError.LOCKED_OUT) { + showMessage("PIN data damaged", true); + } + return; + } + clearDigits(); + startJob(method(:onUnlockDone)); + } + + private function startSetPin() as Void { + var iterations = TestMode.ON ? Kdf.MIN_ITERATIONS : Kdf.calibrate(DERIVATION_TARGET_MS, CALIBRATION_SAMPLE); + _jobPin = _first; + _first = null; + clearDigits(); + try { + _job = AppState.theVault().beginSetPin(_jobPin as ByteArray, iterations); + } catch (e instanceof VaultError) { + Bytes.wipe(_jobPin); + _jobPin = null; + showMessage("Unlock first", true); + return; + } + startJob(method(:onSetDone)); + } + + private function startJob(onDone as Method() as Void) as Void { + busy = true; + _message = null; + var r = new Jobs.JobRunner(_job as PinJob, Kdf.ITERATIONS_PER_STEP, onDone); + r.start(); + runner = r; + WatchUi.requestUpdate(); + } + + function onUnlockDone() as Void { + busy = false; + _shownLogged = false; + Bytes.wipe(_jobPin); + _jobPin = null; + var vault = AppState.theVault(); + if (vault.finishUnlock(_job as PinJob)) { + _job = null; + vault.promotePending(); + succeed(); + return; + } + _job = null; + buzz(); + if (!vault.hasPin()) { + // "Erase after 10 wrong PINs" just erased the locked entries. + WatchUi.switchToView(new InfoView("PIN", ["10 wrong PINs: the locked entries were erased."], "erased"), + new InfoDelegate(), WatchUi.SLIDE_IMMEDIATE); + return; + } + showMessage("Wrong PIN", true); + } + + function onSetDone() as Void { + busy = false; + Bytes.wipe(_jobPin); + _jobPin = null; + var vault = AppState.theVault(); + vault.finishSetPin(_job as PinJob); + _job = null; + vault.promotePending(); + succeed(); + } + + private function succeed() as Void { + AppState.activity(); + var next = _onSuccess; + if (next != null) { + next.invoke(); + } else { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } + } + + private function clearDigits() as Void { + Bytes.wipe(_digits); + _digits = []b; + } + + private function showMessage(text as String, isError as Boolean) as Void { + _message = text; + _messageIsError = isError; + _shownLogged = false; + WatchUi.requestUpdate(); + } + + private function buzz() as Void { + // Not in the UI-test build: the simulator shakes the drawing while + // vibrating, which spoils screenshots. + if (!TestMode.ON && Attention has :vibrate) { + Attention.vibrate([new Attention.VibeProfile(50, 300)]); + } + } + + // ---- Drawing ------------------------------------------------------------ + + function title() as String { + if (busy) { + return mode == MODE_UNLOCK ? "Checking…" : "Saving…"; + } + if (mode == MODE_UNLOCK) { + return "Enter PIN"; + } + return _first == null ? "New PIN" : "Repeat PIN"; + } + + function onUpdate(dc as Graphics.Dc) as Void { + Ui.clear(dc); + var w = dc.getWidth(); + var h = dc.getHeight(); + var cx = w / 2; + var wait = waitSeconds(); + + var y = h * 9 / 100; + dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, Graphics.FONT_SMALL, Ui.fit(dc, wait > 0 ? "PIN locked" : title(), Graphics.FONT_SMALL, + 2 * Ui.halfWidthAt(dc, y)), Graphics.TEXT_JUSTIFY_CENTER); + y += dc.getFontHeight(Graphics.FONT_SMALL) + 4; + + // Dots: entered digits filled, at least RECOMMENDED_DIGITS shown. + var slots = _digits.size() > RECOMMENDED_DIGITS ? _digits.size() : RECOMMENDED_DIGITS; + var r = h / 60 > 3 ? h / 60 : 3; + var step = r * 3; + var x = cx - (slots - 1) * step / 2; + for (var i = 0; i < slots; i++) { + dc.setColor(i < _digits.size() ? Ui.fg() : Ui.dim(), Graphics.COLOR_TRANSPARENT); + if (i < _digits.size()) { + dc.fillCircle(x, y + r, r); + } else { + dc.drawCircle(x, y + r, r); + } + x += step; + } + y += 2 * r + 4; + + var message = wait > 0 ? "Wrong PINs. Wait " + Ui.minutesSeconds(wait) : _message; + if (message != null) { + dc.setColor(_messageIsError || wait > 0 ? Ui.WARN_COLOR : Ui.accent(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, y, Graphics.FONT_XTINY, Ui.fit(dc, message, Graphics.FONT_XTINY, 2 * Ui.halfWidthAt(dc, y)), + Graphics.TEXT_JUSTIFY_CENTER); + } + y += dc.getFontHeight(Graphics.FONT_XTINY) + 2; + + if (Ui.isTouch()) { + drawKeypad(dc, y, h - h / 20, wait > 0 || busy); + } else { + drawSelector(dc, y, h - h / 10, wait > 0 || busy); + } + + if (!_shownLogged) { + _shownLogged = true; + Diag.shown(wait > 0 ? "pin_wait" : (_message != null && _messageIsError ? "pin_error" : "pin")); + } + } + + // Logs the shown state again after a state change, for UI scripts. + function markChanged() as Void { + _shownLogged = false; + } + + private function drawKeypad(dc as Graphics.Dc, top as Number, bottom as Number, disabled as Boolean) as Void { + var w = dc.getWidth(); + var keyH = (bottom - top) / 4; + var halfW = Ui.halfWidthBetween(dc, top, bottom); + if (Ui.isRound()) { + // The bottom row is narrowest; the keypad is as wide as it allows. + halfW = Ui.halfWidthAt(dc, bottom - keyH / 2) + keyH / 2; + } + var keyW = 2 * halfW / 3; + if (keyW > keyH * 2) { + keyW = keyH * 2; + } + var left = w / 2 - 3 * keyW / 2; + var labels = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "OK"]; + var values = [1, 2, 3, 4, 5, 6, 7, 8, 9, KEY_DELETE, 0, KEY_OK]; + var font = Graphics.FONT_MEDIUM; + _keys = [] as Array<Array<Number>>; + var log = ""; + for (var i = 0; i < 12; i++) { + var kx = left + (i % 3) * keyW; + var ky = top + (i / 3) * keyH; + _keys.add([kx, ky, keyW, keyH, values[i] as Number]); + var label = labels[i] as String; + var isAction = (values[i] as Number) < 0; + dc.setColor(disabled ? (Ui.dark() ? 0x444444 : Graphics.COLOR_LT_GRAY) : (isAction ? Ui.accent() : Ui.fg()), + Graphics.COLOR_TRANSPARENT); + if (values[i] == KEY_DELETE) { + drawDeleteIcon(dc, kx + keyW / 2, ky + keyH / 2, dc.getFontHeight(Graphics.FONT_SMALL) / 2); + } else { + var f = isAction ? Graphics.FONT_SMALL : font; + dc.drawText(kx + keyW / 2, ky + (keyH - dc.getFontHeight(f)) / 2, f, label, Graphics.TEXT_JUSTIFY_CENTER); + } + log += (i > 0 ? ";" : "") + (values[i] as Number) + "=" + (kx + keyW / 2) + "," + (ky + keyH / 2); + } + if (!_shownLogged) { + Diag.data("pin_keys", log); + } + } + + // A backspace key: an arrow-shaped outline with an x, `size` px high, + // centred on (cx, cy). Fonts don't all have a backspace glyph. + private function drawDeleteIcon(dc as Graphics.Dc, cx as Number, cy as Number, size as Number) as Void { + var h = size / 2; + var left = cx - size; + var right = cx + size; + dc.setPenWidth(2); + dc.drawLine(left, cy, left + h, cy - h); + dc.drawLine(left + h, cy - h, right, cy - h); + dc.drawLine(right, cy - h, right, cy + h); + dc.drawLine(right, cy + h, left + h, cy + h); + dc.drawLine(left + h, cy + h, left, cy); + var x = cx + h / 3; + var a = h / 2; + dc.drawLine(x - a, cy - a, x + a, cy + a); + dc.drawLine(x - a, cy + a, x + a, cy - a); + dc.setPenWidth(1); + } + + private function drawSelector(dc as Graphics.Dc, top as Number, bottom as Number, disabled as Boolean) as Void { + var cx = dc.getWidth() / 2; + var mid = (top + bottom) / 2; + var font = Graphics.FONT_NUMBER_MEDIUM; + var label = _selector == SELECT_OK ? "OK" : _selector.toString(); + if (_selector == SELECT_OK) { + font = Graphics.FONT_MEDIUM; + } + dc.setColor(disabled ? Ui.dim() : Ui.fg(), Graphics.COLOR_TRANSPARENT); + dc.drawText(cx, mid - dc.getFontHeight(font) / 2, font, label, Graphics.TEXT_JUSTIFY_CENTER); + dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT); + var a = dc.getFontHeight(font) / 2 + 10; + dc.fillPolygon([[cx - 8, mid - a], [cx + 8, mid - a], [cx, mid - a - 8]] as Array<Graphics.Point2D>); + dc.fillPolygon([[cx - 8, mid + a], [cx + 8, mid + a], [cx, mid + a + 8]] as Array<Graphics.Point2D>); + dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); + var hint = "START: add"; + dc.drawText(cx, bottom - dc.getFontHeight(Graphics.FONT_XTINY), Graphics.FONT_XTINY, + Ui.fit(dc, hint, Graphics.FONT_XTINY, 2 * Ui.halfWidthAt(dc, bottom)), Graphics.TEXT_JUSTIFY_CENTER); + } +} + +// Input on the PIN screen. +class PinDelegate extends WatchUi.BehaviorDelegate { + + function initialize() { + BehaviorDelegate.initialize(); + } + + private function view() as PinView? { + var current = WatchUi.getCurrentView()[0]; + return current instanceof PinView ? current : null; + } + + function onTap(event as WatchUi.ClickEvent) as Boolean { + var v = view(); + if (v == null) { + return true; + } + var xy = event.getCoordinates(); + var key = v.keyAt(xy[0], xy[1]); + if (key != null) { + v.markChanged(); + v.press(key); + } + return true; + } + + // On touch screens a tap also arrives as the select behaviour first; + // returning false lets it through to onTap (the key under the finger), + // and the START button comes through onKey. + function onSelect() as Boolean { + if (Ui.isTouch()) { + return false; + } + var v = view(); + if (v != null) { + v.markChanged(); + v.choose(); + } + return true; + } + + function onKey(event as WatchUi.KeyEvent) as Boolean { + var v = view(); + if (v != null && Ui.isTouch() && event.getKey() == WatchUi.KEY_ENTER) { + v.markChanged(); + v.press(PinView.KEY_OK); + return true; + } + return false; + } + + function onNextPage() as Boolean { + var v = view(); + if (v != null && !Ui.isTouch()) { + v.cycle(1); + } + return true; + } + + function onPreviousPage() as Boolean { + var v = view(); + if (v != null && !Ui.isTouch()) { + v.cycle(-1); + } + return true; + } + + function onBack() as Boolean { + var v = view(); + if (v != null && v.back()) { + return true; + } + WatchUi.popView(WatchUi.SLIDE_RIGHT); + return true; + } +} diff --git a/source/ui/Ui.mc b/source/ui/Ui.mc new file mode 100644 index 0000000..a8d1ec7 --- /dev/null +++ b/source/ui/Ui.mc @@ -0,0 +1,211 @@ +// Author: cirakg & claude ai +// Summary: Shared drawing helpers: screen shape, usable width on round screens, theme colours, fonts, icons, text fitting. +// +// Every view draws through these helpers, so round, semi-round, rectangular +// and semi-octagon screens and the dark and light themes are handled in one +// place. Nothing here allocates in the per-second redraw path except fit(), +// which only shortens text that doesn't fit. + +import Toybox.Graphics; +import Toybox.Lang; +import Toybox.Math; +import Toybox.System; + +module Ui { + + const ELLIPSIS = "…"; + const WARN_COLOR = 0xFF5555; + const ACCENT_COLOR = 0x00AAAA; // the teal of the launcher icon + const LIGHT_ACCENT_COLOR = 0x007777; + + // Round and semi-round screens get rings and narrower rows near the top + // and bottom; the others get bars and full-width rows. + function isRound() as Boolean { + var shape = System.getDeviceSettings().screenShape; + return shape == System.SCREEN_SHAPE_ROUND || shape == System.SCREEN_SHAPE_SEMI_ROUND; + } + + function isTouch() as Boolean { + return System.getDeviceSettings().isTouchScreen; + } + + // Half the usable width at height y: on round screens the half chord of + // the circle there (minus a margin), elsewhere half the screen width. + function halfWidthAt(dc as Graphics.Dc, y as Number) as Number { + var w = dc.getWidth(); + if (!isRound()) { + return w / 2 - 4; + } + var r = w / 2; + var dy = (y - dc.getHeight() / 2).abs(); + if (dy >= r) { + return 0; + } + return (Math.sqrt(r * r - dy * dy) as Float).toNumber() - 6; + } + + // The usable half width for a band from y0 to y1 (the narrower end). + function halfWidthBetween(dc as Graphics.Dc, y0 as Number, y1 as Number) as Number { + var a = halfWidthAt(dc, y0); + var b = halfWidthAt(dc, y1); + return a < b ? a : b; + } + + function dark() as Boolean { + return AppState.options.theme == Options.THEME_DARK; + } + + function fg() as Number { + return dark() ? Graphics.COLOR_WHITE : Graphics.COLOR_BLACK; + } + + function bg() as Number { + return dark() ? Graphics.COLOR_BLACK : Graphics.COLOR_WHITE; + } + + function dim() as Number { + return dark() ? Graphics.COLOR_LT_GRAY : Graphics.COLOR_DK_GRAY; + } + + function accent() as Number { + return dark() ? ACCENT_COLOR : LIGHT_ACCENT_COLOR; + } + + // Clear the screen in the theme's background. + function clear(dc as Graphics.Dc) as Void { + dc.setColor(fg(), bg()); + dc.clear(); + } + + // The digit font for codes on the detail screen, by the code-size option. + // Number fonts only hold digits, so Steam codes (letters) use text fonts. + function codeFont(letters as Boolean) as Graphics.FontType { + var size = AppState.options.codeSize; + if (letters) { + return size == Options.CODE_HUGE ? Graphics.FONT_LARGE : Graphics.FONT_MEDIUM; + } + if (size == Options.CODE_NORMAL) { + return Graphics.FONT_NUMBER_MILD; + } + return size == Options.CODE_HUGE ? Graphics.FONT_NUMBER_HOT : Graphics.FONT_NUMBER_MEDIUM; + } + + // Text shortened with an ellipsis to fit maxWidth pixels. + function fit(dc as Graphics.Dc, text as String, font as Graphics.FontType, maxWidth as Number) as String { + if (maxWidth <= 0) { + return ""; + } + if (dc.getTextWidthInPixels(text, font) <= maxWidth) { + return text; + } + var length = text.length(); + while (length > 0) { + length--; + var shorter = text.substring(0, length) + ELLIPSIS; + if (dc.getTextWidthInPixels(shorter, font) <= maxWidth) { + return shorter; + } + } + return ""; + } + + // A small padlock whose body is `size` pixels wide, top-left at (x, y). + function drawLock(dc as Graphics.Dc, x as Number, y as Number, size as Number, color as Number) as Void { + dc.setColor(color, Graphics.COLOR_TRANSPARENT); + var bodyTop = y + size * 2 / 5; + dc.setPenWidth(size / 6 > 1 ? size / 6 : 1); + dc.drawArc(x + size / 2, bodyTop, size * 3 / 10, Graphics.ARC_COUNTER_CLOCKWISE, 0, 180); + dc.fillRectangle(x, bodyTop, size, size * 3 / 5); + dc.setPenWidth(1); + } + + // A small barcode icon (vertical bars), `size` pixels square. + function drawBarcode(dc as Graphics.Dc, x as Number, y as Number, size as Number, color as Number) as Void { + dc.setColor(color, Graphics.COLOR_TRANSPARENT); + var widths = [2, 1, 3, 1, 1, 2, 1, 3]; + var unit = size / 16 > 0 ? size / 16 : 1; + var cx = x; + for (var i = 0; i < widths.size() && cx < x + size; i++) { + var w = (widths[i] as Number) * unit; + if (i % 2 == 0) { + dc.fillRectangle(cx, y, w, size); + } + cx += w + unit; + } + } + + // A countdown pie: a ring with the remaining fraction filled. + function drawCountdown(dc as Graphics.Dc, cx as Number, cy as Number, r as Number, remaining as Number, period as Number) as Void { + dc.setColor(dim(), Graphics.COLOR_TRANSPARENT); + dc.setPenWidth(1); + dc.drawCircle(cx, cy, r); + if (period <= 0 || remaining <= 0) { + return; + } + dc.setColor(remaining <= 5 ? WARN_COLOR : accent(), Graphics.COLOR_TRANSPARENT); + var pen = r / 2 > 1 ? r / 2 : 1; + dc.setPenWidth(pen); + var sweep = 360 * remaining / period; + if (sweep >= 360) { + dc.drawCircle(cx, cy, r - pen / 2); + } else { + dc.drawArc(cx, cy, r - pen / 2, Graphics.ARC_CLOCKWISE, 90, (450 - sweep) % 360); + } + dc.setPenWidth(1); + } + + // "m:ss" for a number of seconds. + function minutesSeconds(seconds as Number) as String { + return (seconds / 60) + ":" + (seconds % 60).format("%02d"); + } + + // Split text into lines no wider than maxWidth, at spaces where possible. + function wrap(dc as Graphics.Dc, text as String, font as Graphics.FontType, maxWidth as Number) as Array<String> { + var lines = [] as Array<String>; + var words = split(text, " "); + var line = ""; + for (var i = 0; i < words.size(); i++) { + var candidate = line.length() == 0 ? words[i] : line + " " + words[i]; + if (dc.getTextWidthInPixels(candidate, font) <= maxWidth || line.length() == 0) { + line = candidate; + } else { + lines.add(line); + line = words[i]; + } + } + if (line.length() > 0) { + lines.add(line); + } + // A single word wider than the line is shortened. + for (var i = 0; i < lines.size(); i++) { + lines[i] = fit(dc, lines[i], font, maxWidth); + } + return lines; + } + + // Split text at every occurrence of separator. + function split(text as String, separator as String) as Array<String> { + var parts = [] as Array<String>; + var rest = text; + var at = rest.find(separator); + while (at != null) { + parts.add(rest.substring(0, at) as String); + rest = rest.substring(at + separator.length(), rest.length()) as String; + at = rest.find(separator); + } + parts.add(rest); + return parts; + } + + // True when text is non-empty and only ASCII digits and spaces. + function isDigits(text as String) as Boolean { + var chars = text.toCharArray(); + for (var i = 0; i < chars.size(); i++) { + var c = chars[i]; + if ((c < '0' || c > '9') && c != ' ') { + return false; + } + } + return chars.size() > 0; + } +} diff --git a/source/util/Clock.mc b/source/util/Clock.mc index 4e2faf4..44a0e37 100644 --- a/source/util/Clock.mc +++ b/source/util/Clock.mc @@ -22,11 +22,17 @@ module Clock { // Current unix time (UTC seconds) plus the user's offset for clock skew. function nowUnix(offsetSeconds as Number) as Long { + if (TestMode.ON) { + return TestMode.NOW + offsetSeconds; + } return toUnsigned(Time.now().value()) + offsetSeconds; } // The trusted clock's unix time, or null when it isn't synced yet. function trustedUnix() as Long? { + if (TestMode.ON) { + return TestMode.NOW; + } try { return toUnsigned(Time.getCurrentTime({:currentTimeType => Time.CURRENT_TIME_RTC}).value()); } catch (e instanceof Time.RealTimeClockNotValidException) { diff --git a/source/util/Diag.mc b/source/util/Diag.mc index aea2a70..a09d3f8 100644 --- a/source/util/Diag.mc +++ b/source/util/Diag.mc @@ -1,5 +1,5 @@ // Author: cirakg & claude ai -// Summary: Debug-only diagnostics (start-up time, memory) for `make perf` and `make memtest`. +// Summary: Debug-only diagnostics: start-up time, memory, screen markers for UI scripts, and the performance overlay. // // Debug builds print one machine-readable line per event to the simulator // console: "DIAG", the event name, then key=value pairs, for example @@ -10,13 +10,21 @@ // (:release) versions instead, so a real watch running a release build never // logs anything. // +// UI scripts (docker/scripts/screenshots.sh) wait for "DIAG shown <screen>" +// lines before taking a screenshot, and read the PIN keypad's key positions +// from "DIAG pin_keys" to tap them. +// // Security rule: only timings and memory counters ever go through here, never // secrets, codes or entry names. This is the only file allowed to call // System.println outside tests (enforced by tools/lint_project.py). +import Toybox.Graphics; import Toybox.Lang; import Toybox.System; +// (:glance): the app object calls markStart() before the non-glance code is +// loaded (apps with a glance load the rest only for the full app). +(:glance) module Diag { // System.getTimer() value when the app object was created. @@ -47,4 +55,45 @@ module Diag { (:release) function firstFrame() as Void { } + + // A screen finished its first draw since it was shown. + (:debug) + function shown(screen as String) as Void { + System.println("DIAG shown " + screen); + } + + (:release) + function shown(screen as String) as Void { + } + + // Free-form key=value data for UI scripts (never secrets). + (:debug) + function data(event as String, values as String) as Void { + System.println("DIAG " + event + " " + values); + } + + (:release) + function data(event as String, values as String) as Void { + } + + // The performance overlay (toggled in the debug menu): draw time of the + // frame that started at startMs, and memory use. + (:debug) + var overlayOn as Boolean = false; + + (:debug) + function overlay(dc as Graphics.Dc, frameStartMs as Number) as Void { + if (!overlayOn) { + return; + } + var stats = System.getSystemStats(); + var text = (System.getTimer() - frameStartMs) + " ms " + (stats.usedMemory * 100 / stats.totalMemory) + "% mem"; + dc.setColor(Graphics.COLOR_YELLOW, Graphics.COLOR_BLACK); + dc.drawText(dc.getWidth() / 2, dc.getHeight() - dc.getFontHeight(Graphics.FONT_XTINY) - 2, Graphics.FONT_XTINY, + text, Graphics.TEXT_JUSTIFY_CENTER); + } + + (:release) + function overlay(dc as Graphics.Dc, frameStartMs as Number) as Void { + } } diff --git a/source/util/TestMode.mc b/source/util/TestMode.mc new file mode 100644 index 0000000..9b5c36f --- /dev/null +++ b/source/util/TestMode.mc @@ -0,0 +1,30 @@ +// Author: cirakg & claude ai +// Summary: Switch for the UI-test build: a frozen clock, a short auto-lock and a store reset to fake entries. +// +// Two versions of this module exist; each build keeps exactly one: +// * (:no_uitest), in every normal build (debug, release, unit tests): +// ON is false and nothing changes. +// * (:uitest), only with uitest.jungle (`make screenshots`): the clock is +// frozen at NOW, so codes and countdowns in screenshots never change; the +// store is wiped and filled with fake entries at every start +// (source/app/Fixtures.mc); auto-lock is short enough to test. +// The constant-condition checks on ON are resolved by the compiler, so the +// unused branch costs nothing. + +import Toybox.Lang; + +(:uitest) +module TestMode { + const ON = true; + // 2027-01-15 08:00:26 UTC: 4 s left in a 30 s period, so the detail + // screen shows the next code too. + const NOW = 1800000026l; + const AUTO_LOCK_MS = 10000; +} + +(:no_uitest) +module TestMode { + const ON = false; + const NOW = 0l; + const AUTO_LOCK_MS = 0; +} diff --git a/uitest.jungle b/uitest.jungle new file mode 100644 index 0000000..5639dea --- /dev/null +++ b/uitest.jungle @@ -0,0 +1,8 @@ +# Author: cirakg & claude ai +# Summary: UI-test build: fixed clock and fake entries, for screenshots and scripted input flows only. +# +# Used on top of monkey.jungle by `build-app.sh uitest` (make screenshots): +# it keeps the (:uitest) code and drops the (:no_uitest) alternatives, so the +# app starts with a reset store full of fake entries and a frozen clock +# (source/util/TestMode.mc). Never shipped: release builds use monkey.jungle. +base.excludeAnnotations = no_uitest |
