diff options
Diffstat (limited to 'source')
| -rw-r--r-- | source/app/AppInfo.mc | 2 | ||||
| -rw-r--r-- | source/app/Fixtures.mc | 56 | ||||
| -rw-r--r-- | source/settings/ChangeQueue.mc | 29 | ||||
| -rw-r--r-- | source/ui/CodeView.mc | 19 | ||||
| -rw-r--r-- | source/ui/DebugMenu.mc | 9 | ||||
| -rw-r--r-- | source/ui/GlanceView.mc | 23 | ||||
| -rw-r--r-- | source/ui/InfoView.mc | 4 | ||||
| -rw-r--r-- | source/ui/ListView.mc | 18 | ||||
| -rw-r--r-- | source/ui/MainMenu.mc | 113 | ||||
| -rw-r--r-- | source/ui/OtpView.mc | 4 | ||||
| -rw-r--r-- | source/ui/PinView.mc | 33 | ||||
| -rw-r--r-- | source/ui/Ui.mc | 8 | ||||
| -rw-r--r-- | source/util/TestMode.mc | 31 |
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; } |
