aboutsummaryrefslogtreecommitdiff
path: root/source
diff options
context:
space:
mode:
Diffstat (limited to 'source')
-rw-r--r--source/app/AppInfo.mc2
-rw-r--r--source/app/AppState.mc114
-rw-r--r--source/app/Fixtures.mc83
-rw-r--r--source/app/Options.mc78
-rw-r--r--source/app/OtpWalletApp.mc47
-rw-r--r--source/storage/Vault.mc14
-rw-r--r--source/ui/CodeCache.mc127
-rw-r--r--source/ui/DebugMenu.mc110
-rw-r--r--source/ui/GlanceView.mc37
-rw-r--r--source/ui/InfoView.mc122
-rw-r--r--source/ui/ListDelegate.mc88
-rw-r--r--source/ui/ListView.mc391
-rw-r--r--source/ui/MainMenu.mc172
-rw-r--r--source/ui/Nav.mc66
-rw-r--r--source/ui/OtpView.mc305
-rw-r--r--source/ui/PinView.mc503
-rw-r--r--source/ui/Ui.mc211
-rw-r--r--source/util/Clock.mc6
-rw-r--r--source/util/Diag.mc51
-rw-r--r--source/util/TestMode.mc30
20 files changed, 2541 insertions, 16 deletions
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;
+}