aboutsummaryrefslogtreecommitdiff
path: root/source/app
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-19 22:28:09 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-19 22:28:09 +0200
commit35042ff04ffc50e55ff59a385849442be4429d6b (patch)
tree3c142dd9bfb995b7aacdf39128e591dd3f9370de /source/app
parent871aba901501302586895d266fe4569665d9fe70 (diff)
feat(ui): add the list, OTP detail, PIN entry, menu, info screens and glance
List with live codes and countdowns, adaptive rows and lazy headers; OTP detail with ring or bar, next code and HOTP next; PIN keypad or button selector with create, unlock, change, lockout display and pending promotion; native menu; glance with the entry count; self-test after the first frame; option properties with defaults. Codes are computed one per timer event, apart from drawing, for the watchdog. A UI-test build (uitest.jungle) starts with fake entries, PIN 123456 and a frozen clock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/app')
-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
5 files changed, 310 insertions, 14 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()];
}
}