summaryrefslogtreecommitdiff
path: root/source
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-25 23:51:52 +0100
committercirakg <cirakg@cirakg.xyz>2026-10-25 23:51:52 +0100
commitb8addc87f804789368612bf8e1be31a2bf28efe1 (patch)
treebece4051eb6f009eba05ffdd585420232741a7d7 /source
parent140a6660f63f88d05ba9aae0bf1c4b401bb6574d (diff)
test(ui): second UI scenario, and the fixes it found
A second UI-test build (uitest-extra.jungle) covers the legacy PIN and the forced new PIN, Change and Reset PIN, a queued Require PIN, UPC-A, Code 39, the code error screen, the encoding progress, auto-lock on a barcode, the light theme and the glance without a code; the main flow adds the Steam and Library details and the menu's text screens. Fixed from what it found: Entry IDs tripped the watchdog with 110 entries; auto-lock didn't close a locked barcode; a queued Require PIN showed "Confirm in menu"; locked barcodes lacked the padlock; the PIN keypad's bottom row, the Reset PIN question, text-screen bottoms, the glance title and the light-theme warning colour. Goldens approved on all seven watches; only the Forerunner 70 was re-verified after the version bump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source')
-rw-r--r--source/app/AppInfo.mc2
-rw-r--r--source/app/Fixtures.mc56
-rw-r--r--source/settings/ChangeQueue.mc29
-rw-r--r--source/ui/CodeView.mc19
-rw-r--r--source/ui/DebugMenu.mc9
-rw-r--r--source/ui/GlanceView.mc23
-rw-r--r--source/ui/InfoView.mc4
-rw-r--r--source/ui/ListView.mc18
-rw-r--r--source/ui/MainMenu.mc113
-rw-r--r--source/ui/OtpView.mc4
-rw-r--r--source/ui/PinView.mc33
-rw-r--r--source/ui/Ui.mc8
-rw-r--r--source/util/TestMode.mc31
13 files changed, 292 insertions, 57 deletions
diff --git a/source/app/AppInfo.mc b/source/app/AppInfo.mc
index 163486c..1e6fe3c 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.8.0";
+ const VERSION = "0.9.0";
}
diff --git a/source/app/Fixtures.mc b/source/app/Fixtures.mc
index 01b15a8..08a51a1 100644
--- a/source/app/Fixtures.mc
+++ b/source/app/Fixtures.mc
@@ -12,6 +12,11 @@
// result, with the 4-digit PIN marker (KeyManager.PIN_LENGTH_KEY).
// Unlocking it with the app's own code also cross-checks the wrap format
// against the Python implementation (test/storage/PinTest.mc).
+//
+// The extra UI-test scenario (TestMode.SCENARIO_EXTRA, uitest-extra.jungle)
+// loads loadExtra() instead: FIXTURE_LEGACY_WRAP, the same master key under
+// the 6-digit PIN FIXTURE_LEGACY_PIN without the marker (a PIN from before
+// the 4-digit rule), and the entries and forms its flow needs.
import Toybox.Application;
import Toybox.Lang;
@@ -22,6 +27,8 @@ module Fixtures {
const FIXTURE_PIN = "1234";
const FIXTURE_WRAP = "101112131415161718191a1b1c1d1e1f000007d001202122232425262728292a2b2c2d2e2f133549536635b7a7ae3add45a59c42953d4c666538390a99305164dba9c99961e7148e1c561b6cfc70cfde7f9e3e59179464ce65bf91dd08807ff8a52027d77c90c2b692ca22fe4cbd3fb20ffb440137"; // lint: allow-literal (fake fixture)
const FIXTURE_MASTER_KEY = "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"; // lint: allow-literal (fake fixture)
+ const FIXTURE_LEGACY_PIN = "123456";
+ const FIXTURE_LEGACY_WRAP = "101112131415161718191a1b1c1d1e1f000007d001202122232425262728292a2b2c2d2e2f857b961d4854e06740f602627bfb952efaac5383e800768221bf3a7b292ee881e2ce2f76b9dd744b2ac32322473df351929bc6dfcbfc2941527ae8b90614a8a6a6bd0f72d33161bafd16b1039caa670c"; // lint: allow-literal (fake fixture)
// Erase all app storage (UI-test build only).
function resetStore() as Void {
@@ -32,6 +39,18 @@ module Fixtures {
// 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 {
+ var extra = TestMode.ON && TestMode.SCENARIO == TestMode.SCENARIO_EXTRA;
+ if (TestMode.ON) {
+ // The extra scenario's screens use the light theme, the main
+ // one's the dark theme. Properties outlive a launch in the
+ // simulator, so both set it.
+ Application.Properties.setValue("optTheme", extra ? Options.THEME_LIGHT : Options.THEME_DARK);
+ AppState.options.load();
+ }
+ if (extra) {
+ loadExtra(vault);
+ return;
+ }
if (TestMode.ON && !vault.hasPin()) {
Application.Storage.setValue(KeyManager.WRAP_KEY, Bytes.fromHex(FIXTURE_WRAP));
Application.Storage.setValue(KeyManager.PIN_LENGTH_KEY, KeyManager.PIN_DIGITS);
@@ -75,6 +94,43 @@ module Fixtures {
}
}
+ // The extra UI-test scenario (README 17.4; tools/ui_flow.py
+ // run_extra_flow): the legacy PIN, a locked TOTP, the symbologies the
+ // main fixture lacks (UPC-A, Code 39), an EAN-13 whose check digit is
+ // wrong (the code error screen) and a QR large enough that its encoding
+ // progress shows for a while. The Manage form asks for "Require PIN" on
+ // the UPC-A card while the watch is locked, so it waits for the next
+ // PIN; the glance option is off, so the glance shows the entry count.
+ // Its screens use the light theme (set in load()).
+ function loadExtra(vault as Vault) as Void {
+ Application.Storage.setValue(KeyManager.WRAP_KEY, Bytes.fromHex(FIXTURE_LEGACY_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("Bank", "carol", "Finance", Entry.OTP_TOTP, Hashes.SHA512, 6, 30,
+ "1234567890123456789012345678901234567890123456789012345678901234", true)); // lint: allow-literal (RFC 6238 test key)
+ vault.add(code("Shop card", Codes.UPCA, "036000291452")); // fake; a valid UPC-A check digit
+ vault.add(code("Badge", Codes.CODE39, "FAKE-39"));
+ vault.add(code("Broken card", Codes.EAN13, "5901234123458")); // the check digit should be 7
+ // Fake text of 199 characters: a version 10 QR in byte mode, which
+ // still fits the smallest screen at 2 px per module.
+ var text = "Fake event ticket 0042 for the UI test.";
+ while (text.length() < 160) {
+ text += " Row 12, seat 34, gate b, valid all day.";
+ }
+ vault.add(code("Big QR", Codes.QR, text));
+ vault.lock();
+ Application.Properties.setValue("optGlanceCode", false);
+ vault.glanceCode = false;
+ Application.Properties.setValue("impApply", false);
+ Application.Properties.setValue("otpAdd", false);
+ Application.Properties.setValue("codeAdd", false);
+ var ids = vault.ids();
+ Application.Properties.setValue("mgId", ids[2]); // Shop card
+ Application.Properties.setValue("mgAction", ManageAction.LOCK);
+ Application.Properties.setValue("mgApply", true);
+ }
+
// UI-test build: the glance option, and the other forms, filled in as
// the phone would (README 16, M7 end-to-end): Add OTP (a TOTP whose code the screenshot shows),
// Add code (a Code 128 card whose screenshot must decode) and Manage
diff --git a/source/settings/ChangeQueue.mc b/source/settings/ChangeQueue.mc
index c5919f6..5de74e0 100644
--- a/source/settings/ChangeQueue.mc
+++ b/source/settings/ChangeQueue.mc
@@ -27,6 +27,10 @@ module ChangeQueue {
// all" that follows it (ChangeQueueJob). Replaced at every PIN.
var _lockLines as Array<String> = [] as Array<String>;
+ // countToConfirm()'s cached result and the vault it is for.
+ var _toConfirm as Number? = null;
+ var _toConfirmFor as Vault? = null;
+
function count(vault as Vault) as Number {
var stored = vault.storage().getValue(KEY);
return stored instanceof Array ? stored.size() : 0;
@@ -42,6 +46,7 @@ module ChangeQueue {
var plain = action.toBytes();
items.add(SealedBox.seal(vault.keyManager.deviceKeys(), Bytes.fromUtf8(AAD), plain));
Bytes.wipe(plain);
+ changed();
try {
vault.storage().setValue(KEY, items);
} catch (e instanceof Lang.Exception) {
@@ -84,6 +89,26 @@ module ChangeQueue {
return out;
}
+ // The number of waiting changes that need "Confirm all": all but
+ // "Require PIN", which applies at the next PIN by itself. For the list's
+ // title and the menu; cached until the queue changes, because the list
+ // asks at every draw and counting opens every item.
+ function countToConfirm(vault as Vault) as Number {
+ var n = _toConfirm;
+ if (n == null || _toConfirmFor != vault) {
+ var total = count(vault);
+ n = total > 0 ? total - countLocks(vault) : 0;
+ _toConfirm = n;
+ _toConfirmFor = vault;
+ }
+ return n;
+ }
+
+ // The queue changed: count again next time.
+ function changed() as Void {
+ _toConfirm = null;
+ }
+
// The number of waiting "Require PIN" actions (applied at the next PIN).
function countLocks(vault as Vault) as Number {
var actions = load(vault);
@@ -104,6 +129,7 @@ module ChangeQueue {
// already locked) is dropped too.
function applyLocks(vault as Vault) as Void {
_lockLines = [] as Array<String>;
+ changed();
var stored = vault.storage().getValue(KEY);
if (!(stored instanceof Array) || !vault.session.isUnlocked()) {
return;
@@ -127,6 +153,7 @@ module ChangeQueue {
kept.add(box as Application.Storage.ValueType); // damaged items are dropped by the next clear()
}
}
+ Diag.data("locks_applied", "count=" + _lockLines.size()); // for UI scripts: no names
if (kept.size() == stored.size()) {
return;
}
@@ -150,12 +177,14 @@ module ChangeQueue {
}
function clear(vault as Vault) as Void {
+ changed();
vault.storage().deleteValue(KEY);
}
// Remove the first n items (the ones just applied), keeping any that
// arrived from the phone meanwhile.
function drop(vault as Vault, n as Number) as Void {
+ changed();
var stored = vault.storage().getValue(KEY);
if (!(stored instanceof Array) || stored.size() <= n) {
clear(vault);
diff --git a/source/ui/CodeView.mc b/source/ui/CodeView.mc
index efa23d2..03f1fa7 100644
--- a/source/ui/CodeView.mc
+++ b/source/ui/CodeView.mc
@@ -27,6 +27,9 @@
// ticker timer does the renewing (and redraws the progress while
// encoding), so a code screen never needs more timers.
// * Too big: a message with the size needed.
+// * A locked code closes back to the list when auto-lock fires while it
+// is open (README 7.5), like a locked OTP entry: the ticker keeps
+// running for it and checks the session.
// The payload is decrypted when the screen opens and wiped once encoded.
import Toybox.Attention;
@@ -70,6 +73,9 @@ class CodeView extends WatchUi.View {
_shownLogged = false;
encode();
boostBrightness();
+ if (header.locked) {
+ startTicker();
+ }
}
function onHide() as Void {
@@ -143,8 +149,13 @@ class CodeView extends WatchUi.View {
}
// Progress redraws while a job runs; brightness renewals during the
- // minute. The ticker stops when neither is needed.
+ // minute; auto-lock for a locked code. The ticker stops when none of
+ // them is needed.
function onTick() as Void {
+ if (header.locked && AppState.checkAutoLock()) {
+ WatchUi.popView(WatchUi.SLIDE_RIGHT);
+ return;
+ }
if (_job != null) {
WatchUi.requestUpdate();
}
@@ -157,7 +168,7 @@ class CodeView extends WatchUi.View {
light();
}
}
- if (_job == null && _boostStart < 0) {
+ if (_job == null && _boostStart < 0 && !header.locked) {
stopTicker();
}
}
@@ -266,8 +277,10 @@ class CodeView extends WatchUi.View {
} else if (code instanceof RunsPainter) {
drawMatrix(dc, code, smallFont, smallH);
} else if (job != null) {
+ var percent = job.percent();
dc.drawText(w / 2, h / 2 - smallH / 2, smallFont, (job instanceof RunsPainter ? "Drawing… " : "Encoding… ")
- + job.percent() + "%", Graphics.TEXT_JUSTIFY_CENTER);
+ + percent + "%", Graphics.TEXT_JUSTIFY_CENTER);
+ Diag.data("code_progress", "percent=" + percent); // for UI scripts
} else {
drawMessage(dc, code instanceof Codes.CodeError ? code.message : "…", smallFont, smallH);
}
diff --git a/source/ui/DebugMenu.mc b/source/ui/DebugMenu.mc
index f1ab407..452cb1d 100644
--- a/source/ui/DebugMenu.mc
+++ b/source/ui/DebugMenu.mc
@@ -40,6 +40,8 @@ module DebugMenu {
}
}
+ const BENCH_HEADERS = 20;
+
// The running benchmark (kept so its timer isn't collected).
var bench as BenchRunner? = null;
@@ -74,12 +76,15 @@ module DebugMenu {
} else if (_step == 2) {
_lines.add("PIN derivation: " + Kdf.calibrate(1000, Kdf.ITERATIONS_PER_STEP) + " iterations per second");
} else if (_step == 3) {
+ // At most BENCH_HEADERS: all 110 of the UI test's entries in
+ // one event tripped the watchdog.
var vault = AppState.theVault();
var ids = vault.ids();
- for (var i = 0; i < ids.size(); i++) {
+ var n = ids.size() < BENCH_HEADERS ? ids.size() : BENCH_HEADERS;
+ for (var i = 0; i < n; i++) {
vault.header(ids[i]);
}
- _lines.add("Headers: " + ids.size() + " in " + (System.getTimer() - t) + " ms");
+ _lines.add("Headers: " + n + " in " + (System.getTimer() - t) + " ms");
} else if (_step == 4) {
SelfTest.run();
_lines.add("Self-test: " + (System.getTimer() - t) + " ms");
diff --git a/source/ui/GlanceView.mc b/source/ui/GlanceView.mc
index 4540f2a..6be18f3 100644
--- a/source/ui/GlanceView.mc
+++ b/source/ui/GlanceView.mc
@@ -89,8 +89,15 @@ class OtpGlanceView extends WatchUi.GlanceView {
var h = dc.getHeight();
var top = h / 2 - dc.getFontHeight(Graphics.FONT_GLANCE);
var x = inset(dc, top);
+ // In capitals the name reaches the round screen's edge on the
+ // Forerunner 70 and 965, which cuts the last letter; the narrower
+ // mixed case fits there.
+ var title = "OTP WALLET";
+ if (x + dc.getTextWidthInPixels(title, Graphics.FONT_GLANCE) > dc.getWidth() - edgeCut(top)) {
+ title = "OTP Wallet";
+ }
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
- dc.drawText(x, top, Graphics.FONT_GLANCE, "OTP WALLET", Graphics.TEXT_JUSTIFY_LEFT);
+ dc.drawText(x, top, Graphics.FONT_GLANCE, title, Graphics.TEXT_JUSTIFY_LEFT);
dc.setColor(Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT);
dc.drawText(x, h / 2, Graphics.FONT_GLANCE, count == 1 ? "1 entry" : count + " entries",
Graphics.TEXT_JUSTIFY_LEFT);
@@ -103,15 +110,23 @@ class OtpGlanceView extends WatchUi.GlanceView {
// screen's top-left curve; on most round watches this is 0 or a few
// pixels).
private function inset(dc as Graphics.Dc, y as Number) as Number {
+ var left = (System.getDeviceSettings().screenWidth - dc.getWidth()) / 2;
+ var edge = edgeCut(y);
+ return edge > left ? edge - left : 0;
+ }
+
+ // How far a round screen's edge reaches in from each side at height y
+ // (0 on other shapes), with the same assumption: the glance starts at
+ // the top of the screen. The right end of a glance reaches the screen's
+ // edge, so this is also what the curve cuts off there.
+ private function edgeCut(y as Number) as Number {
var screen = System.getDeviceSettings();
if (screen.screenShape != System.SCREEN_SHAPE_ROUND) {
return 0;
}
var r = screen.screenWidth / 2;
var dy = r - y;
- var edge = r - Math.sqrt(r * r - dy * dy).toNumber();
- var left = (screen.screenWidth - dc.getWidth()) / 2;
- return edge > left ? edge - left : 0;
+ return r - Math.sqrt(r * r - dy * dy).toNumber();
}
// The label, the code and a bar for the seconds left in the period.
diff --git a/source/ui/InfoView.mc b/source/ui/InfoView.mc
index a965713..877ca51 100644
--- a/source/ui/InfoView.mc
+++ b/source/ui/InfoView.mc
@@ -65,7 +65,9 @@ class InfoView extends WatchUi.View {
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;
+ // The same margin on rectangles: the Venu X1's rounded corners cut
+ // off a last line drawn at the very bottom, and the scroll arrow.
+ var bottom = h - h / 10;
var lines = _lines;
if (lines == null) {
// Wrap to the narrowest width the text area has.
diff --git a/source/ui/ListView.mc b/source/ui/ListView.mc
index de73cce..36df798 100644
--- a/source/ui/ListView.mc
+++ b/source/ui/ListView.mc
@@ -3,7 +3,8 @@
//
// 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.
+// OTP entries, a padlock for locked entries (codes too: opening them asks
+// for the PIN) and a barcode icon for other 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.
@@ -186,7 +187,8 @@ class ListView extends WatchUi.View {
// "Importing… 40%" while an import runs, "Results ready" when settings
// results haven't been looked at, "Confirm in menu" while phone changes
- // wait for the PIN, else the app name.
+ // wait to be confirmed with the PIN (a waiting "Require PIN" applies at
+ // the next PIN by itself), else the app name.
private function title() as String {
var p = AppState.processor;
var busy = p != null ? p.busyText() : null;
@@ -197,7 +199,7 @@ class ListView extends WatchUi.View {
return "Results ready (menu)";
}
var v = AppState.vault;
- return v != null && ChangeQueue.count(v) > 0 ? "Confirm in menu" : "OTP Wallet";
+ return v != null && ChangeQueue.countToConfirm(v) > 0 ? "Confirm in menu" : "OTP Wallet";
}
private function drawEmpty(dc as Graphics.Dc, top as Number, bottom as Number) as Void {
@@ -283,7 +285,7 @@ class ListView extends WatchUi.View {
var right = x1 - 8;
if (header == null) {
- dc.setColor(Ui.WARN_COLOR, Graphics.COLOR_TRANSPARENT);
+ dc.setColor(Ui.warn(), Graphics.COLOR_TRANSPARENT);
dc.drawText(left, lineY, nameFont, Ui.fit(dc, "Damaged " + id, nameFont, right - left), Graphics.TEXT_JUSTIFY_LEFT);
return true;
}
@@ -297,12 +299,12 @@ class ListView extends WatchUi.View {
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) {
+ if (header.locked) {
Ui.drawLock(dc, right - iconSize, lineY + (nameH - iconSize) / 2, iconSize, Ui.dim());
nameRight -= iconSize + 6;
+ } else if (header.kind == Entry.KIND_CODE) {
+ Ui.drawBarcode(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;
diff --git a/source/ui/MainMenu.mc b/source/ui/MainMenu.mc
index 8863c6d..db59f4a 100644
--- a/source/ui/MainMenu.mc
+++ b/source/ui/MainMenu.mc
@@ -18,7 +18,10 @@ module MainMenu {
var menu = new WatchUi.Menu2({:title => "OTP Wallet"});
var waiting = ChangeQueue.count(vault);
if (waiting > 0) {
- menu.addItem(new WatchUi.MenuItem("Phone changes", waiting + " to confirm", :queue, null));
+ // Only "Require PIN" waiting: it applies at the next PIN anyway.
+ var toConfirm = ChangeQueue.countToConfirm(vault);
+ menu.addItem(new WatchUi.MenuItem("Phone changes", toConfirm > 0 ? toConfirm + " to confirm" : "Apply at next PIN",
+ :queue, null));
}
if (vault.session.isUnlocked()) {
menu.addItem(new WatchUi.MenuItem("Lock now", null, :lock, null));
@@ -32,28 +35,29 @@ module MainMenu {
menu.addItem(new WatchUi.MenuItem("Clock status", null, :clock, null));
menu.addItem(new WatchUi.MenuItem("About", null, :about, null));
DebugMenu.addItems(menu);
+ logItems(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.");
+ // Debug builds: the item labels in order, so UI scripts can find an
+ // item on touch-only watches, whose menus have no focus to move.
+ (:debug)
+ function logItems(menu as WatchUi.Menu2) as Void {
+ var labels = "";
+ for (var i = 0; menu.getItem(i) != null; i++) {
+ labels += (i > 0 ? "|" : "") + (menu.getItem(i) as WatchUi.MenuItem).getLabel();
}
- return lines;
+ Diag.data("menu", "items=" + labels);
+ }
+
+ (:release)
+ function logItems(menu as WatchUi.Menu2) as Void {
}
+ // The Entry IDs screen: "Reading…" while EntryIdsJob reads the
+ // headers on a timer, then the list (kept so the runner isn't collected).
+ var idsScreen as EntryIdsScreen? = null;
+
function clockLines() as Array<String> {
var lines = [] as Array<String>;
var watch = Clock.nowUnix(0);
@@ -118,7 +122,9 @@ class MainMenuDelegate extends WatchUi.Menu2InputDelegate {
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(),
+ // Two short lines (the item said "Reset PIN"): the native dialog shows
+ // two lines and cuts longer text mid-word.
+ WatchUi.pushView(new WatchUi.Confirmation("Erase locked\nentries?"), new ResetPinConfirmation(),
WatchUi.SLIDE_LEFT);
} else if (id == :results) {
WatchUi.switchToView(new InfoView("Results", Results.lines(), "results"), new InfoDelegate(),
@@ -127,7 +133,9 @@ class MainMenuDelegate extends WatchUi.Menu2InputDelegate {
} else if (id == :queue) {
WatchUi.switchToView(QueueMenu.build(), new QueueMenuDelegate(), WatchUi.SLIDE_LEFT);
} else if (id == :ids) {
- WatchUi.switchToView(new InfoView("Entry IDs", MainMenu.entryIdLines(), "ids"), new InfoDelegate(), WatchUi.SLIDE_LEFT);
+ var screen = new EntryIdsScreen();
+ MainMenu.idsScreen = screen;
+ screen.start();
} else if (id == :clock) {
WatchUi.switchToView(new InfoView("Clock status", MainMenu.clockLines(), "clock"), new InfoDelegate(), WatchUi.SLIDE_LEFT);
} else if (id == :about) {
@@ -138,6 +146,73 @@ class MainMenuDelegate extends WatchUi.Menu2InputDelegate {
}
}
+// Reads one header per entry, a few per timer step: reading 110 in one
+// event tripped the watchdog. Ends with the lines of the Entry IDs screen.
+class EntryIdsJob extends Jobs.Job {
+
+ static const HEADERS_PER_STEP = 10;
+
+ var lines as Array<String> = [] as Array<String>;
+ private var _ids as Array<String>;
+ private var _at as Number = 0;
+
+ function initialize() {
+ Job.initialize();
+ _ids = AppState.theVault().ids();
+ }
+
+ function step(budget as Number) as Boolean {
+ var vault = AppState.theVault();
+ var stop = _at + HEADERS_PER_STEP * budget;
+ stop = stop < _ids.size() ? stop : _ids.size();
+ for (; _at < stop; _at++) {
+ var id = _ids[_at];
+ var header = vault.header(id);
+ var name = header == null ? "damaged" : (header.name.length() > 0 ? header.name : "Locked entry");
+ lines.add(id + " " + name);
+ }
+ if (_at < _ids.size()) {
+ return false;
+ }
+ 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 true;
+ }
+}
+
+// Shows "Reading…" (replacing the menu), runs EntryIdsJob, then replaces
+// that screen with the IDs, unless the user left it meanwhile.
+class EntryIdsScreen {
+
+ private var _job as EntryIdsJob = new EntryIdsJob();
+ // The timer driver (kept so it isn't collected).
+ var runner as Jobs.JobRunner? = null;
+ private var _reading as InfoView = new InfoView("Entry IDs", ["Reading…"] as Array<String>, "ids_reading");
+
+ function initialize() {
+ }
+
+ function start() as Void {
+ WatchUi.switchToView(_reading, new InfoDelegate(), WatchUi.SLIDE_LEFT);
+ var r = new Jobs.JobRunner(_job, 1, method(:done));
+ runner = r;
+ r.start();
+ }
+
+ function done() as Void {
+ runner = null;
+ MainMenu.idsScreen = null;
+ if (WatchUi.getCurrentView()[0] == _reading) {
+ WatchUi.switchToView(new InfoView("Entry IDs", _job.lines, "ids"), new InfoDelegate(), WatchUi.SLIDE_IMMEDIATE);
+ }
+ }
+}
+
// After unlocking from the menu's "Change PIN": ask for the new PIN.
class ChangePinAfterUnlock {
diff --git a/source/ui/OtpView.mc b/source/ui/OtpView.mc
index 1c0c0bd..d35e103 100644
--- a/source/ui/OtpView.mc
+++ b/source/ui/OtpView.mc
@@ -131,7 +131,7 @@ class OtpView extends WatchUi.View {
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.setColor(remaining <= NEXT_CODE_SECONDS ? Ui.warn() : 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) {
@@ -232,7 +232,7 @@ class OtpView extends WatchUi.View {
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();
+ var color = remaining <= NEXT_CODE_SECONDS ? Ui.warn() : Ui.accent();
if (Ui.isRound()) {
var r = w / 2 - RING_WIDTH / 2 - 1;
dc.setPenWidth(RING_WIDTH);
diff --git a/source/ui/PinView.mc b/source/ui/PinView.mc
index fa58bfe..47b12fd 100644
--- a/source/ui/PinView.mc
+++ b/source/ui/PinView.mc
@@ -4,7 +4,8 @@
// README 13.1 and 13.4:
// * A PIN has exactly 4 digits (KeyManager.PIN_DIGITS) and is submitted
// as soon as the 4th is entered; there is no OK key.
-// * Touch screens get a keypad (1-9, delete, 0). The START button does
+// * Touch screens get a keypad (1-9, then 0 and delete in the bottom row,
+// delete on the right as on phone keypads). The START button does
// nothing there: a tap can also arrive as START, which used to submit a
// PIN early. Watches without a touch screen get a digit selector:
// UP/DOWN change the digit, START adds it, BACK deletes.
@@ -32,9 +33,11 @@ class PinView extends WatchUi.View {
// Selector positions 0-9 are digits; this one is "OK" (legacy PINs only).
static const SELECT_OK = 10;
- // Keypad keys besides the digits (OK for legacy PINs only).
+ // Keypad keys besides the digits (OK for legacy PINs only), and an
+ // empty slot.
static const KEY_DELETE = -1;
static const KEY_OK = -2;
+ static const KEY_NONE = -3;
// Calibrate the PIN derivation to about this long on this watch.
static const DERIVATION_TARGET_MS = 1000;
static const CALIBRATION_SAMPLE = 200;
@@ -358,7 +361,7 @@ class PinView extends WatchUi.View {
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.setColor(_messageIsError || wait > 0 ? Ui.warn() : 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);
}
@@ -372,6 +375,9 @@ class PinView extends WatchUi.View {
if (!_shownLogged) {
_shownLogged = true;
+ // For UI scripts: the state the dots and the title show (never digits).
+ Diag.data("pin_state", "mode=" + mode + " digits=" + _digits.size() + " repeat=" + (_first != null ? 1 : 0)
+ + " busy=" + (busy ? 1 : 0));
Diag.shown(wait > 0 ? "pin_wait" : (_message != null && _messageIsError ? "pin_error" : "pin"));
}
}
@@ -394,19 +400,21 @@ class PinView extends WatchUi.View {
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];
+ // A legacy PIN needs OK: delete, 0, OK. Otherwise the 4th digit
+ // submits, and the bottom row is an empty slot, 0 and delete.
+ var values = legacy ? [1, 2, 3, 4, 5, 6, 7, 8, 9, KEY_DELETE, 0, KEY_OK]
+ : [1, 2, 3, 4, 5, 6, 7, 8, 9, KEY_NONE, 0, KEY_DELETE];
var font = Graphics.FONT_MEDIUM;
_keys = [] as Array<Array<Number>>;
var log = "";
for (var i = 0; i < 12; i++) {
- if (values[i] == KEY_OK && !legacy) {
- continue; // no OK key: the 4th digit submits; the slot stays empty
+ if (values[i] == KEY_NONE) {
+ continue;
}
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 label = values[i] == KEY_OK ? "OK" : (values[i] as Number).toString();
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);
@@ -416,7 +424,7 @@ class PinView extends WatchUi.View {
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);
+ log += (log.length() > 0 ? ";" : "") + (values[i] as Number) + "=" + (kx + keyW / 2) + "," + (ky + keyH / 2);
}
if (!_shownLogged) {
Diag.data("pin_keys", log);
@@ -444,7 +452,10 @@ class PinView extends WatchUi.View {
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;
+ // Centred above the hint, so the lower arrow never touches it (it
+ // did on the Forerunner 255S).
+ var hintTop = bottom - dc.getFontHeight(Graphics.FONT_XTINY);
+ var mid = (top + hintTop) / 2;
var font = Graphics.FONT_NUMBER_MEDIUM;
var label = _selector == SELECT_OK ? "OK" : _selector.toString();
if (_selector == SELECT_OK) {
@@ -458,7 +469,7 @@ class PinView extends WatchUi.View {
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,
+ dc.drawText(cx, hintTop, Graphics.FONT_XTINY,
Ui.fit(dc, hint, Graphics.FONT_XTINY, 2 * Ui.halfWidthAt(dc, bottom)), Graphics.TEXT_JUSTIFY_CENTER);
}
}
diff --git a/source/ui/Ui.mc b/source/ui/Ui.mc
index a8d1ec7..bf52520 100644
--- a/source/ui/Ui.mc
+++ b/source/ui/Ui.mc
@@ -15,6 +15,7 @@ module Ui {
const ELLIPSIS = "…";
const WARN_COLOR = 0xFF5555;
+ const LIGHT_WARN_COLOR = 0xCC0000; // 0xFF5555 is too faint on white for small text
const ACCENT_COLOR = 0x00AAAA; // the teal of the launcher icon
const LIGHT_ACCENT_COLOR = 0x007777;
@@ -71,6 +72,11 @@ module Ui {
return dark() ? ACCENT_COLOR : LIGHT_ACCENT_COLOR;
}
+ // Warnings (wrong PIN, the last seconds of a code) in the theme.
+ function warn() as Number {
+ return dark() ? WARN_COLOR : LIGHT_WARN_COLOR;
+ }
+
// Clear the screen in the theme's background.
function clear(dc as Graphics.Dc) as Void {
dc.setColor(fg(), bg());
@@ -142,7 +148,7 @@ module Ui {
if (period <= 0 || remaining <= 0) {
return;
}
- dc.setColor(remaining <= 5 ? WARN_COLOR : accent(), Graphics.COLOR_TRANSPARENT);
+ dc.setColor(remaining <= 5 ? warn() : accent(), Graphics.COLOR_TRANSPARENT);
var pen = r / 2 > 1 ? r / 2 : 1;
dc.setPenWidth(pen);
var sweep = 360 * remaining / period;
diff --git a/source/util/TestMode.mc b/source/util/TestMode.mc
index e583ecf..6439be1 100644
--- a/source/util/TestMode.mc
+++ b/source/util/TestMode.mc
@@ -1,25 +1,43 @@
// 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:
+// Three 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
+// * (:uitest), only in the UI-test builds (`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.
+// SCENARIO picks the fixture: SCENARIO_MAIN (uitest.jungle: the import,
+// the forms and the demo entries) or SCENARIO_EXTRA
+// (uitest-extra.jungle: a legacy PIN, a queued "Require PIN", more
+// symbologies and an invalid code), each in its own build, since a
+// launch can't pass arguments to the app.
// The constant-condition checks on ON are resolved by the compiler, so the
// unused branch costs nothing.
import Toybox.Lang;
-(:uitest, :glance)
+(:uitest, :uitest_main, :glance)
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;
+ const SCENARIO_MAIN = 0;
+ const SCENARIO_EXTRA = 1;
+ const SCENARIO = SCENARIO_MAIN;
+}
+
+(:uitest, :uitest_extra, :glance)
+module TestMode {
+ const ON = true;
+ const NOW = 1800000026l;
+ const AUTO_LOCK_MS = 10000;
+ const SCENARIO_MAIN = 0;
+ const SCENARIO_EXTRA = 1;
+ const SCENARIO = SCENARIO_EXTRA;
}
(:no_uitest, :glance)
@@ -27,4 +45,7 @@ module TestMode {
const ON = false;
const NOW = 0l;
const AUTO_LOCK_MS = 0;
+ const SCENARIO_MAIN = 0;
+ const SCENARIO_EXTRA = 1;
+ const SCENARIO = SCENARIO_MAIN;
}