1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
|
// 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 {
// 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();
}
}
|