// 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() : 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() : 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 { // 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(); } 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; } 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) { return; } AppState.theCodes().invalidate(v.header.id); v.refresh(); } }