aboutsummaryrefslogtreecommitdiff
path: root/source/ui/OtpView.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/OtpView.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/OtpView.mc')
-rw-r--r--source/ui/OtpView.mc305
1 files changed, 305 insertions, 0 deletions
diff --git a/source/ui/OtpView.mc b/source/ui/OtpView.mc
new file mode 100644
index 0000000..faf94d0
--- /dev/null
+++ b/source/ui/OtpView.mc
@@ -0,0 +1,305 @@
+// Author: cirakg & claude ai
+// Summary: OTP detail: issuer and account, the code in large grouped digits, the countdown ring or bar, next code, HOTP next.
+//
+// README 13.1: TOTP and Steam show the seconds left as a ring along the bezel
+// (a bar on non-round screens), turning red and showing the next code in the
+// last 5 seconds. HOTP shows its counter and how to get the next code. The
+// entry ID is in small print at the bottom.
+//
+// The code font follows the code-size option and steps down until the code
+// fits the screen. A locked entry closes back to the list when auto-lock
+// fires while it is open.
+
+import Toybox.Graphics;
+import Toybox.Lang;
+import Toybox.System;
+import Toybox.Timer;
+import Toybox.WatchUi;
+
+class OtpView extends WatchUi.View {
+
+ static const NEXT_CODE_SECONDS = 5;
+ static const RING_WIDTH = 8;
+
+ var header as Entry;
+
+ private var _ticker as Timer.Timer? = null;
+ private var _compute as Timer.Timer? = null;
+ private var _shownLogged as Boolean = false;
+
+ function initialize(entryHeader as Entry) {
+ View.initialize();
+ header = entryHeader;
+ }
+
+ 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 {
+ if (AppState.checkAutoLock() && header.locked) {
+ WatchUi.popView(WatchUi.SLIDE_RIGHT);
+ return;
+ }
+ WatchUi.requestUpdate();
+ }
+
+ // Re-read the header (the HOTP counter changed).
+ function refresh() as Void {
+ var fresh = AppState.theVault().header(header.id);
+ if (fresh != null) {
+ header = fresh;
+ }
+ _shownLogged = false;
+ WatchUi.requestUpdate();
+ }
+
+ function isHotp() as Boolean {
+ return header.type == Entry.OTP_HOTP;
+ }
+
+ function onUpdate(dc as Graphics.Dc) as Void {
+ var frameStart = System.getTimer();
+ Ui.clear(dc);
+ var w = dc.getWidth();
+ var h = dc.getHeight();
+ var cx = w / 2;
+ var now = AppState.now();
+ var codes = AppState.theCodes();
+ // Codes are computed in their own timer events (computeNext), one
+ // per event: an SHA-512 code costs a large share of the watchdog's
+ // per-event budget on some watches.
+ var cached = AppState.cryptoReady ? codes.cached(header.id, now) : null;
+ var complete = cached != null;
+ var code = cached != null ? cached : "------";
+ var period = header.type == Entry.OTP_STEAM ? Otp.STEAM_PERIOD : codes.periodOf(header.id);
+ var remaining = Otp.secondsRemaining(now, period);
+ var round = Ui.isRound();
+ if (!isHotp()) {
+ drawTimeLeft(dc, remaining, period);
+ }
+
+ // Layout: a vertical stack of name, account, code, two info lines and
+ // the ID, centred; the code font is the largest that fits both the
+ // width and the height left for it.
+ var nameFont = Graphics.FONT_SMALL;
+ var smallFont = Graphics.FONT_XTINY;
+ var infoFont = isHotp() ? smallFont : Graphics.FONT_TINY;
+ var nameH = dc.getFontHeight(nameFont);
+ var smallH = dc.getFontHeight(smallFont);
+ var accountH = header.account.length() > 0 ? smallH : 0;
+ var infoH = dc.getFontHeight(infoFont) + smallH;
+ var margin = round ? h / 10 : RING_WIDTH;
+ var fixedH = nameH + accountH + infoH + smallH + 8;
+ var font = chooseCodeFont(dc, code, 2 * Ui.halfWidthAt(dc, h / 2) - 2 * RING_WIDTH - 8, h - 2 * margin - fixedH);
+ var codeH = dc.getFontHeight(font);
+ var y = (h - fixedH - codeH) / 2;
+
+ var title = header.name.length() > 0 ? header.name : "Locked entry " + header.id;
+ dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT);
+ dc.drawText(cx, y, nameFont, Ui.fit(dc, title, nameFont, 2 * Ui.halfWidthAt(dc, y) - 2 * RING_WIDTH),
+ Graphics.TEXT_JUSTIFY_CENTER);
+ y += nameH;
+ if (accountH > 0) {
+ dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT);
+ dc.drawText(cx, y, smallFont, Ui.fit(dc, header.account, smallFont, 2 * Ui.halfWidthAt(dc, y) - 2 * RING_WIDTH),
+ Graphics.TEXT_JUSTIFY_CENTER);
+ y += accountH;
+ }
+ y += 2;
+ drawCode(dc, code, font, y);
+ y += codeH + 2;
+
+ // Below the code: seconds left and the next code, or the HOTP hint.
+ if (isHotp()) {
+ dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT);
+ dc.drawText(cx, y, smallFont, "Counter " + header.counter, Graphics.TEXT_JUSTIFY_CENTER);
+ y += smallH;
+ dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT);
+ var hint = Ui.isTouch() ? "Tap for the next code" : "START: next code";
+ 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.drawText(cx, y, infoFont, remaining + " s", Graphics.TEXT_JUSTIFY_CENTER);
+ y += dc.getFontHeight(infoFont);
+ if (remaining <= NEXT_CODE_SECONDS && cached != null) {
+ var next = codes.cached(header.id + "+", now);
+ if (next == null) {
+ complete = false;
+ next = "······";
+ }
+ var parts = CodeCache.split(next);
+ dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT);
+ dc.drawText(cx, y, smallFont, "next " + (parts.size() == 2 ? parts[0] + " " + parts[1] : parts[0]),
+ Graphics.TEXT_JUSTIFY_CENTER);
+ }
+ y += smallH;
+ }
+
+ // Entry ID in small print.
+ y += 4;
+ dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT);
+ dc.drawText(cx, y, smallFont, (header.locked ? "Locked · " : "") + header.id, Graphics.TEXT_JUSTIFY_CENTER);
+
+ Diag.overlay(dc, frameStart);
+ if (!complete) {
+ scheduleCompute();
+ } else if (!_shownLogged) {
+ // UI scripts screenshot the screen once its codes are drawn.
+ _shownLogged = true;
+ Diag.shown(isHotp() ? "otp_hotp" : "otp");
+ }
+ }
+
+ private function scheduleCompute() as Void {
+ if (_compute == null && AppState.cryptoReady) {
+ var t = new Timer.Timer();
+ t.start(method(:computeNext), 50, false);
+ _compute = t;
+ }
+ }
+
+ // Computes the first missing code (current, then next) and redraws.
+ function computeNext() as Void {
+ _compute = null;
+ var codes = AppState.theCodes();
+ var now = AppState.now();
+ try {
+ if (codes.cached(header.id, now) == null) {
+ codes.code(header.id, now, 0);
+ } else {
+ codes.code(header.id, now, 1);
+ }
+ } catch (e instanceof VaultError) {
+ // Locked meanwhile: the next tick closes the screen.
+ return;
+ }
+ WatchUi.requestUpdate();
+ }
+
+ // The largest allowed code font whose code fits maxWidth and maxHeight
+ // (the code-size option gives the largest allowed).
+ private function chooseCodeFont(dc as Graphics.Dc, code as String, maxWidth as Number, maxHeight as Number) as Graphics.FontType {
+ // Number fonts only have digits: Steam codes and placeholders use text fonts.
+ var letters = header.type == Entry.OTP_STEAM || !Ui.isDigits(code);
+ var fonts = letters ? [Ui.codeFont(true), Graphics.FONT_MEDIUM, Graphics.FONT_SMALL]
+ : [Ui.codeFont(false), Graphics.FONT_NUMBER_MEDIUM, Graphics.FONT_NUMBER_MILD, Graphics.FONT_MEDIUM];
+ for (var i = 0; i < fonts.size(); i++) {
+ var f = fonts[i] as Graphics.FontType;
+ if (codeWidth(dc, code, f) <= maxWidth && dc.getFontHeight(f) <= maxHeight) {
+ return f;
+ }
+ }
+ return fonts[fonts.size() - 1] as Graphics.FontType;
+ }
+
+ // Width of the code as drawn: its groups with a gap of a quarter of the
+ // font height between them.
+ private function codeWidth(dc as Graphics.Dc, code as String, font as Graphics.FontType) as Number {
+ var parts = CodeCache.split(code);
+ var total = parts.size() == 2 ? dc.getFontHeight(font) / 4 : 0;
+ for (var k = 0; k < parts.size(); k++) {
+ total += dc.getTextWidthInPixels(parts[k], font);
+ }
+ return total;
+ }
+
+ // The code, centred horizontally, with its top at y.
+ private function drawCode(dc as Graphics.Dc, code as String, font as Graphics.FontType, y as Number) as Void {
+ var parts = CodeCache.split(code);
+ var gap = parts.size() == 2 ? dc.getFontHeight(font) / 4 : 0;
+ var x = dc.getWidth() / 2 - codeWidth(dc, code, font) / 2;
+ dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT);
+ for (var k = 0; k < parts.size(); k++) {
+ dc.drawText(x, y, font, parts[k], Graphics.TEXT_JUSTIFY_LEFT);
+ x += dc.getTextWidthInPixels(parts[k], font) + gap;
+ }
+ }
+
+ // The ring along the bezel, or a bar at the top of non-round screens.
+ 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();
+ if (Ui.isRound()) {
+ var r = w / 2 - RING_WIDTH / 2 - 1;
+ dc.setPenWidth(RING_WIDTH);
+ dc.setColor(Ui.dark() ? 0x333333 : Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT);
+ dc.drawCircle(w / 2, h / 2, r);
+ var sweep = period > 0 ? 360 * remaining / period : 0;
+ dc.setColor(color, Graphics.COLOR_TRANSPARENT);
+ if (sweep >= 360) {
+ dc.drawCircle(w / 2, h / 2, r);
+ } else if (sweep > 0) {
+ dc.drawArc(w / 2, h / 2, r, Graphics.ARC_CLOCKWISE, 90, (450 - sweep) % 360);
+ }
+ dc.setPenWidth(1);
+ } else {
+ dc.setColor(Ui.dark() ? 0x333333 : Graphics.COLOR_LT_GRAY, Graphics.COLOR_TRANSPARENT);
+ dc.fillRectangle(0, 0, w, RING_WIDTH / 2 + 2);
+ dc.setColor(color, Graphics.COLOR_TRANSPARENT);
+ dc.fillRectangle(0, 0, period > 0 ? w * remaining / period : 0, RING_WIDTH / 2 + 2);
+ }
+ }
+}
+
+// Input on the OTP detail: START or a tap gives the next HOTP code; BACK closes.
+class OtpDelegate extends WatchUi.BehaviorDelegate {
+
+ function initialize() {
+ BehaviorDelegate.initialize();
+ }
+
+ private function view() as OtpView? {
+ var current = WatchUi.getCurrentView()[0];
+ return current instanceof OtpView ? current : null;
+ }
+
+ function onSelect() as Boolean {
+ AppState.activity();
+ nextHotp();
+ return true;
+ }
+
+ function onTap(event as WatchUi.ClickEvent) as Boolean {
+ AppState.activity();
+ nextHotp();
+ return true;
+ }
+
+ function onBack() as Boolean {
+ var v = view();
+ if (v != null && v.header.locked && AppState.theVault().session.autoLockMs == 0) {
+ // Auto-lock "Immediately": leaving a locked entry locks.
+ AppState.lock();
+ }
+ WatchUi.popView(WatchUi.SLIDE_RIGHT);
+ return true;
+ }
+
+ private function nextHotp() as Void {
+ var v = view();
+ if (v == null || !v.isHotp()) {
+ return;
+ }
+ try {
+ AppState.theVault().setCounter(v.header.id, v.header.counter + 1);
+ } catch (e instanceof VaultError) {
+ return;
+ }
+ AppState.theCodes().invalidate(v.header.id);
+ v.refresh();
+ }
+}