aboutsummaryrefslogtreecommitdiff
path: root/source/ui
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-25 12:46:42 +0100
committercirakg <cirakg@cirakg.xyz>2026-10-25 12:46:42 +0100
commit096b5a4d17c85fa5f87db3e72364e9c3506d1e64 (patch)
tree9846544fe7d76d849df4a1ee2058f4847f674e7e /source/ui
parente0384274bbeab999279bdc35bdddbd8a3a451aeb (diff)
feat(pin): exactly 4 digits, entered on the 4th digit
The keypad has no OK key; the 4th digit submits, so a tap that also arrives as START can no longer submit a partial PIN (the likely cause of "Wrong PIN" before the code opened). PINs set by earlier versions still unlock and are then replaced by a 4-digit one. Opening an entry ignores a second request from the same tap. A Require PIN sent from the phone while the watch is locked now applies at the next PIN instead of waiting for Confirm all. The linter skips .claude. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/ui')
-rw-r--r--source/ui/Nav.mc20
-rw-r--r--source/ui/OtpView.mc11
-rw-r--r--source/ui/PinView.mc87
-rw-r--r--source/ui/QueueMenu.mc3
4 files changed, 96 insertions, 25 deletions
diff --git a/source/ui/Nav.mc b/source/ui/Nav.mc
index 222386a..af08d4a 100644
--- a/source/ui/Nav.mc
+++ b/source/ui/Nav.mc
@@ -2,13 +2,31 @@
// Summary: Navigation between screens (README 13.2): opening entries, asking for the PIN first when needed, the menu.
import Toybox.Lang;
+import Toybox.System;
import Toybox.WatchUi;
module Nav {
+ // A second open request within this time is the same tap delivered
+ // twice (as START in onKey and as onTap) and is ignored.
+ const OPEN_DEBOUNCE_MS = 500;
+ var _lastOpenMs as Number? = null;
+
// Open an entry from the list: its detail screen, or the PIN screen first
- // when it is locked and the session is locked.
+ // when it is locked and the session is locked. Ignored while a PIN or
+ // detail screen is already on top, or right after another open, so one
+ // tap can't push two screens.
function openEntry(id as String) as Void {
+ var top = WatchUi.getCurrentView()[0];
+ if (top instanceof PinView || top instanceof OtpView || top instanceof CodeView) {
+ return;
+ }
+ var now = System.getTimer();
+ var last = _lastOpenMs;
+ if (last != null && now - last >= 0 && now - last < OPEN_DEBOUNCE_MS) {
+ return;
+ }
+ _lastOpenMs = now;
var vault = AppState.theVault();
var header = vault.header(id);
if (header == null) {
diff --git a/source/ui/OtpView.mc b/source/ui/OtpView.mc
index faf94d0..1c0c0bd 100644
--- a/source/ui/OtpView.mc
+++ b/source/ui/OtpView.mc
@@ -258,6 +258,11 @@ class OtpView extends WatchUi.View {
// Input on the OTP detail: START or a tap gives the next HOTP code; BACK closes.
class OtpDelegate extends WatchUi.BehaviorDelegate {
+ // One tap can arrive twice (as START and as a tap); a second "next
+ // code" within this time is ignored so the counter moves only once.
+ static const NEXT_DEBOUNCE_MS = 500;
+ private var _lastNextMs as Number? = null;
+
function initialize() {
BehaviorDelegate.initialize();
}
@@ -294,6 +299,12 @@ class OtpDelegate extends WatchUi.BehaviorDelegate {
if (v == null || !v.isHotp()) {
return;
}
+ var now = System.getTimer();
+ var last = _lastNextMs;
+ if (last != null && now - last >= 0 && now - last < NEXT_DEBOUNCE_MS) {
+ return;
+ }
+ _lastNextMs = now;
try {
AppState.theVault().setCounter(v.header.id, v.header.counter + 1);
} catch (e instanceof VaultError) {
diff --git a/source/ui/PinView.mc b/source/ui/PinView.mc
index 4502919..fa58bfe 100644
--- a/source/ui/PinView.mc
+++ b/source/ui/PinView.mc
@@ -2,11 +2,18 @@
// 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.
+// * 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
+// 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 and warns below 6 digits.
+// * 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.
// The typed digits are a ByteArray that is wiped after every attempt.
@@ -23,18 +30,19 @@ class PinView extends WatchUi.View {
static const MODE_CREATE = 1;
static const MODE_CHANGE = 2;
- // Selector positions 0-9 are digits; this one is "OK".
+ // Selector positions 0-9 are digits; this one is "OK" (legacy PINs only).
static const SELECT_OK = 10;
- // Keypad keys besides the digits.
+ // Keypad keys besides the digits (OK for legacy PINs only).
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;
+ // 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;
@@ -57,6 +65,7 @@ class PinView extends WatchUi.View {
View.initialize();
mode = pinMode;
_onSuccess = onSuccess;
+ legacy = pinMode == MODE_UNLOCK && AppState.theVault().isLegacyPin();
}
function onShow() as Void {
@@ -85,7 +94,8 @@ class PinView extends WatchUi.View {
// ---- Input (called by PinDelegate) ----------------------------------
- // A keypad key: 0-9, KEY_DELETE or KEY_OK.
+ // 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;
@@ -93,17 +103,23 @@ class PinView extends WatchUi.View {
if (key == KEY_DELETE) {
deleteDigit();
} else if (key == KEY_OK) {
- submit();
- } else if (_digits.size() < KeyManager.MAX_PIN_DIGITS) {
+ 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.
+ // Selector: UP/DOWN, over the digits (and "OK" for a legacy PIN).
function cycle(delta as Number) as Void {
- _selector = ((_selector + delta) % (SELECT_OK + 1) + SELECT_OK + 1) % (SELECT_OK + 1);
+ var positions = legacy ? SELECT_OK + 1 : SELECT_OK;
+ _selector = ((_selector + delta) % positions + positions) % positions;
WatchUi.requestUpdate();
}
@@ -146,19 +162,16 @@ class PinView extends WatchUi.View {
// ---- 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 (!KeyManager.isValidPin(_digits)) {
- showMessage("4 to 10 digits", true);
- return;
+ 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;
- if (_first.size() < RECOMMENDED_DIGITS) {
- showMessage("6+ digits are safer", false);
- }
} else if (Bytes.equal(_first as ByteArray, _digits)) {
startSetPin();
} else {
@@ -181,6 +194,7 @@ class PinView extends WatchUi.View {
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);
@@ -201,7 +215,7 @@ class PinView extends WatchUi.View {
} catch (e instanceof VaultError) {
Bytes.wipe(_jobPin);
_jobPin = null;
- showMessage("Unlock first", true);
+ showMessage(e.code == VaultError.LOCKED ? "Unlock first" : KeyManager.PIN_DIGITS + " digits", true);
return;
}
startJob(method(:onSetDone));
@@ -225,8 +239,13 @@ class PinView extends WatchUi.View {
if (vault.finishUnlock(_job as PinJob)) {
_job = null;
vault.promotePending();
+ ChangeQueue.applyLocks(vault);
// Unlocked: "Encrypt names" can now reach locked entries.
AppState.processSettings();
+ if (legacy) {
+ askForNewPin();
+ return;
+ }
succeed();
return;
}
@@ -249,9 +268,23 @@ class PinView extends WatchUi.View {
vault.finishSetPin(_job as PinJob);
_job = null;
vault.promotePending();
+ ChangeQueue.applyLocks(vault);
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;
@@ -307,8 +340,8 @@ class PinView extends WatchUi.View {
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;
+ // 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;
@@ -367,6 +400,9 @@ class PinView extends WatchUi.View {
_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
+ }
var kx = left + (i % 3) * keyW;
var ky = top + (i / 3) * keyH;
_keys.add([kx, ky, keyW, keyH, values[i] as Number]);
@@ -468,11 +504,16 @@ class PinDelegate extends WatchUi.BehaviorDelegate {
return true;
}
+ // START on a touch screen submits only a legacy PIN. Otherwise it does
+ // nothing: one tap can arrive both here and in onTap, and submitting
+ // the digits typed so far was a wrong PIN.
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);
+ if (v.legacy) {
+ v.markChanged();
+ v.press(PinView.KEY_OK);
+ }
return true;
}
return false;
diff --git a/source/ui/QueueMenu.mc b/source/ui/QueueMenu.mc
index 880ca7c..3f9161d 100644
--- a/source/ui/QueueMenu.mc
+++ b/source/ui/QueueMenu.mc
@@ -4,7 +4,8 @@
// Confirming always asks for the PIN, even while the watch is unlocked:
// the point is that a person at the watch agrees, not that a key is
// available. (Before any PIN exists only "Require PIN" can wait here, and
-// confirming creates the PIN.) The changes are then applied by a
+// confirming creates the PIN. "Require PIN" items are applied by the PIN
+// screen itself, ChangeQueue.applyLocks, and listed in the results.) The changes are then applied by a
// ChangeQueueJob on a timer and the results screen shows what happened.
import Toybox.Lang;