aboutsummaryrefslogtreecommitdiff
path: root/source/ui/PinView.mc
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/ui/PinView.mc
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/ui/PinView.mc')
-rw-r--r--source/ui/PinView.mc503
1 files changed, 503 insertions, 0 deletions
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;
+ }
+}