diff options
Diffstat (limited to 'source/ui/PinView.mc')
| -rw-r--r-- | source/ui/PinView.mc | 503 |
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; + } +} |
