// 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: // * 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, 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. // * A legacy PIN (4 to 10 digits, set before the 4-digit rule) is entered // the old way, with an OK key (and START on touch screens, "OK" in the // selector); after it unlocks, the screen asks for a new 4-digit PIN // and then continues to what was opened. // * Entered digits show as dots. A wrong PIN vibrates and shows any wait. // * Creating or changing the PIN asks twice. // * Key derivation (about 1 s) runs as a job on a timer, so the screen // shows "Checking..." and the watchdog never trips. So does the work // after a correct or new PIN (LockJob: pending entries are promoted, // waiting "Require PIN" changes applied); its lines go to the results. // 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" (legacy PINs only). static const SELECT_OK = 10; // 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; var mode as Number; var busy as Boolean = false; // Unlocking a legacy PIN: 4 to 10 digits, submitted with OK. var legacy 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 _lockJob as LockJob? = null; // A LockJob ran for this screen's PIN (a legacy PIN's new PIN runs a // second one, which adds to the first one's lines). private var _lockJobRan as Boolean = false; private var _ticker as Timer.Timer? = null; private var _keys as Array> = [] as Array>; // [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; legacy = pinMode == MODE_UNLOCK && AppState.theVault().isLegacyPin(); } function onShow() as Void { _shownLogged = false; var ticker = new Timer.Timer(); ticker.start(method(:onTick), 1000, true); _ticker = ticker; } function onHide() as Void { if (_lockJobRan && _lockJob == null) { // Closed (or replaced): a "Confirm all" that follows has taken // the lock lines already; else they are in the results. ChangeQueue.takeLockLines(); } 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 (legacy PINs only). The // PIN_DIGITS-th digit of a 4-digit PIN submits it. function press(key as Number) as Void { if (busy || waitSeconds() > 0) { return; } if (key == KEY_DELETE) { deleteDigit(); } else if (key == KEY_OK) { if (legacy) { submit(); } } else if (_digits.size() < (legacy ? KeyManager.MAX_PIN_DIGITS : KeyManager.PIN_DIGITS)) { _digits.add(0x30 + key); _message = null; if (!legacy && _digits.size() == KeyManager.PIN_DIGITS) { submit(); } } WatchUi.requestUpdate(); } // Selector: UP/DOWN, over the digits (and "OK" for a legacy PIN). function cycle(delta as Number) as Void { var positions = legacy ? SELECT_OK + 1 : SELECT_OK; _selector = ((_selector + delta) % positions + positions) % positions; 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 ------------------------------------ // A complete entry: check it (unlock) or keep it for the repeat (new PIN). private function submit() as Void { if (!(legacy ? KeyManager.isValidPin(_digits) : KeyManager.isValidNewPin(_digits))) { return; // a legacy OK with fewer than 4 digits } if (mode == MODE_UNLOCK) { startUnlock(); } else if (_first == null) { _first = _digits; _digits = []b; } 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; legacy = false; 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(e.code == VaultError.LOCKED ? "Unlock first" : KeyManager.PIN_DIGITS + " digits", 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 { Bytes.wipe(_jobPin); _jobPin = null; var vault = AppState.theVault(); if (vault.finishUnlock(_job as PinJob)) { _job = null; startLockJob(); return; } busy = false; _shownLogged = false; _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 { Bytes.wipe(_jobPin); _jobPin = null; AppState.theVault().finishSetPin(_job as PinJob); _job = null; startLockJob(); } // Unlocked: promote the pending entries and apply waiting "Require // PIN" changes in steps, still showing the busy state (BACK waits). private function startLockJob() as Void { var job = new LockJob(AppState.theVault(), !_lockJobRan); _lockJobRan = true; _lockJob = job; var r = new Jobs.JobRunner(job, 1, method(:onLockJobDone)); r.start(); runner = r; } function onLockJobDone() as Void { busy = false; _shownLogged = false; var job = _lockJob as LockJob; _lockJob = null; if (job.lines.size() > 0) { Results.add(job.lines); // the list says "Results ready (menu)" } // "Encrypt names" can now reach locked entries, and a lock just // applied changes what the glance may show. AppState.processSettings(); if (legacy) { askForNewPin(); return; } succeed(); } // After a legacy PIN unlocked: the same screen asks for a new 4-digit // PIN, and onSuccess runs once that is set. BACK leaves it unlocked // with the old PIN, which asks again at the next unlock. private function askForNewPin() as Void { AppState.activity(); legacy = false; mode = MODE_CHANGE; if (_selector == SELECT_OK) { _selector = 0; } showMessage("Set a new " + KeyManager.PIN_DIGITS + "-digit PIN", false); } 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, PIN_DIGITS slots (more for a long legacy PIN). var slots = _digits.size() > KeyManager.PIN_DIGITS ? _digits.size() : KeyManager.PIN_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() : 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; // 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")); } } // 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; // 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>; var log = ""; for (var i = 0; i < 12; i++) { 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 = 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); 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 += (log.length() > 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; // 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) { 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); dc.fillPolygon([[cx - 8, mid + a], [cx + 8, mid + a], [cx, mid + a + 8]] as Array); dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); var hint = "START: add"; dc.drawText(cx, hintTop, 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; } // START on a touch screen does nothing, in every mode: one tap can arrive // both here and in onTap, and submitting the digits typed so far was a // counted wrong PIN. A legacy PIN is submitted with the tappable OK key. function onKey(event as WatchUi.KeyEvent) as Boolean { if (Ui.isTouch() && event.getKey() == WatchUi.KEY_ENTER) { 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; } }