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
|
// Author: cirakg & claude ai
// Summary: The menu (README 13.1): phone changes to confirm, lock now, PIN actions, settings results, entry IDs, clock status, about.
//
// A native Menu2, so scrolling, touch and buttons behave like every other
// Garmin menu. Debug builds add the items from DebugMenu.
import Toybox.Application;
import Toybox.Lang;
import Toybox.Time;
import Toybox.Time.Gregorian;
import Toybox.Timer;
import Toybox.WatchUi;
module MainMenu {
function build() as WatchUi.Menu2 {
var vault = AppState.theVault();
var menu = new WatchUi.Menu2({:title => "OTP Wallet"});
var waiting = ChangeQueue.count(vault);
if (waiting > 0) {
// Only "Require PIN" waiting: it applies at the next PIN anyway.
var toConfirm = ChangeQueue.countToConfirm(vault);
menu.addItem(new WatchUi.MenuItem("Phone changes", toConfirm > 0 ? toConfirm + " to confirm" : "Apply at next PIN",
:queue, null));
}
if (vault.session.isUnlocked()) {
menu.addItem(new WatchUi.MenuItem("Lock now", null, :lock, null));
}
menu.addItem(new WatchUi.MenuItem(vault.hasPin() ? "Change PIN" : "Set PIN", null, :pin, null));
if (vault.hasPin()) {
menu.addItem(new WatchUi.MenuItem("Reset PIN", null, :reset, null)); // the confirmation says what it erases
}
menu.addItem(new WatchUi.MenuItem("Settings results", null, :results, null));
menu.addItem(new WatchUi.MenuItem("Entry IDs", null, :ids, null));
menu.addItem(new WatchUi.MenuItem("Clock status", null, :clock, null));
menu.addItem(new WatchUi.MenuItem("About", null, :about, null));
DebugMenu.addItems(menu);
logItems(menu);
return menu;
}
// Debug builds: the item labels in order, so UI scripts can find an
// item on touch-only watches, whose menus have no focus to move.
(:debug)
function logItems(menu as WatchUi.Menu2) as Void {
var labels = "";
for (var i = 0; menu.getItem(i) != null; i++) {
labels += (i > 0 ? "|" : "") + (menu.getItem(i) as WatchUi.MenuItem).getLabel();
}
Diag.data("menu", "items=" + labels);
}
(:release)
function logItems(menu as WatchUi.Menu2) as Void {
}
// The Entry IDs screen: "Reading…" while EntryIdsJob reads the
// headers on a timer, then the list (kept so the runner isn't collected).
var idsScreen as EntryIdsScreen? = null;
function clockLines() as Array<String> {
var lines = [] as Array<String>;
var watch = Clock.nowUnix(0);
var info = Gregorian.utcInfo(new Time.Moment(watch.toNumber()), Time.FORMAT_SHORT);
lines.add("Watch: " + info.hour.format("%02d") + ":" + info.min.format("%02d") + ":" + info.sec.format("%02d") + " UTC");
var trusted = Clock.trustedUnix();
if (trusted == null) {
lines.add("GPS time: not synced yet. Codes use the watch clock.");
} else {
var diff = (watch - trusted).toNumber();
lines.add("GPS time: synced");
lines.add("Difference: " + diff + " s" + (diff.abs() > 15 ? " (codes may be wrong)" : ""));
}
lines.add("Time offset setting: " + AppState.options.timeOffset + " s");
return lines;
}
function aboutLines() as Array<String> {
var vault = AppState.theVault();
var lines = ["OTP Wallet " + AppInfo.VERSION] as Array<String>;
lines.add("Entries: " + vault.ids().size() + (vault.pendingIds().size() > 0 ? " (+" + vault.pendingIds().size() + " pending)" : ""));
lines.add("Storage: " + (storageBytes(vault) + 1023) / 1024 + " KB");
lines.add("Self-test: " + AppState.selfTestSummary);
lines.add("No network access: your data stays on this watch.");
return lines;
}
// Bytes the entry records take (the largest part of the store).
function storageBytes(vault as Vault) as Number {
var total = 0;
var ids = vault.ids();
for (var i = 0; i < ids.size(); i++) {
var record = Application.Storage.getValue("e." + ids[i]);
if (record instanceof Lang.ByteArray) {
total += record.size();
}
}
return total;
}
}
class MainMenuDelegate extends WatchUi.Menu2InputDelegate {
function initialize() {
Menu2InputDelegate.initialize();
}
function onSelect(item as WatchUi.MenuItem) as Void {
AppState.activity();
var id = item.getId();
var vault = AppState.theVault();
if (id == :lock) {
AppState.lock();
WatchUi.popView(WatchUi.SLIDE_DOWN);
} else if (id == :pin) {
if (!vault.hasPin()) {
WatchUi.switchToView(new PinView(PinView.MODE_CREATE, null), new PinDelegate(), WatchUi.SLIDE_LEFT);
} else if (vault.session.isUnlocked()) {
WatchUi.switchToView(new PinView(PinView.MODE_CHANGE, null), new PinDelegate(), WatchUi.SLIDE_LEFT);
} else {
// A legacy PIN is followed by the forced new-PIN screen, which
// is already the change: no second one after it.
var then = vault.isLegacyPin() ? null : new ChangePinAfterUnlock().method(:run);
WatchUi.switchToView(new PinView(PinView.MODE_UNLOCK, then), new PinDelegate(), WatchUi.SLIDE_LEFT);
}
} else if (id == :reset) {
// Two short lines (the item said "Reset PIN"): the native dialog shows
// two lines and cuts longer text mid-word.
WatchUi.pushView(new WatchUi.Confirmation("Erase locked\nentries?"), new ResetPinConfirmation(),
WatchUi.SLIDE_LEFT);
} else if (id == :results) {
WatchUi.switchToView(new InfoView("Results", Results.lines(), "results"), new InfoDelegate(),
WatchUi.SLIDE_LEFT);
Results.markSeen();
} else if (id == :queue) {
QueueMenu.open();
} else if (id == :ids) {
var screen = new EntryIdsScreen();
MainMenu.idsScreen = screen;
screen.start();
} else if (id == :clock) {
WatchUi.switchToView(new InfoView("Clock status", MainMenu.clockLines(), "clock"), new InfoDelegate(), WatchUi.SLIDE_LEFT);
} else if (id == :about) {
WatchUi.switchToView(new InfoView("About", MainMenu.aboutLines(), "about"), new InfoDelegate(), WatchUi.SLIDE_LEFT);
} else {
DebugMenu.handle(id);
}
}
}
// Reads one header per entry, a few per timer step: reading 110 in one
// event tripped the watchdog. Ends with the lines of the Entry IDs screen.
class EntryIdsJob extends Jobs.Job {
static const HEADERS_PER_STEP = 10;
var lines as Array<String> = [] as Array<String>;
private var _ids as Array<String>;
private var _at as Number = 0;
function initialize() {
Job.initialize();
_ids = AppState.theVault().ids();
}
function step(budget as Number) as Boolean {
var vault = AppState.theVault();
var stop = _at + HEADERS_PER_STEP * budget;
stop = stop < _ids.size() ? stop : _ids.size();
for (; _at < stop; _at++) {
var id = _ids[_at];
var header = vault.header(id);
var name = header == null ? "damaged" : (header.name.length() > 0 ? header.name : "Locked entry");
lines.add(id + " " + name);
}
if (_at < _ids.size()) {
return false;
}
if (lines.size() == 0) {
lines.add("No entries yet.");
}
var pending = vault.pendingIds().size();
if (pending > 0) {
lines.add(pending + " locked entries wait for your PIN.");
}
return true;
}
}
// Shows "Reading…" (replacing the menu), runs EntryIdsJob, then replaces
// that screen with the IDs, unless the user left it meanwhile.
class EntryIdsScreen {
private var _job as EntryIdsJob = new EntryIdsJob();
// The timer driver (kept so it isn't collected).
var runner as Jobs.JobRunner? = null;
private var _reading as InfoView = new InfoView("Entry IDs", ["Reading…"] as Array<String>, "ids_reading");
function initialize() {
}
function start() as Void {
WatchUi.switchToView(_reading, new InfoDelegate(), WatchUi.SLIDE_LEFT);
var r = new Jobs.JobRunner(_job, 1, method(:done));
runner = r;
r.start();
}
function done() as Void {
runner = null;
MainMenu.idsScreen = null;
if (WatchUi.getCurrentView()[0] == _reading) {
WatchUi.switchToView(new InfoView("Entry IDs", _job.lines, "ids"), new InfoDelegate(), WatchUi.SLIDE_IMMEDIATE);
}
}
}
// After unlocking from the menu's "Change PIN": ask for the new PIN.
class ChangePinAfterUnlock {
function initialize() {
}
function run() as Void {
WatchUi.switchToView(new PinView(PinView.MODE_CHANGE, null), new PinDelegate(), WatchUi.SLIDE_LEFT);
}
}
// "Reset PIN" after the user confirmed. The confirmation closes itself after
// onResponse, so the PIN screen opens from a short timer, replacing the menu.
class ResetPinConfirmation extends WatchUi.ConfirmationDelegate {
// Kept so the one-shot timer isn't collected before it fires.
var timer as Timer.Timer? = null;
function initialize() {
ConfirmationDelegate.initialize();
}
function onResponse(response as WatchUi.Confirm) as Boolean {
if (response == WatchUi.CONFIRM_YES) {
AppState.theVault().resetPin();
AppState.theCodes().clear();
var t = new Timer.Timer();
t.start(method(:openCreatePin), 100, false);
timer = t;
}
return true;
}
function openCreatePin() as Void {
timer = null;
WatchUi.switchToView(new PinView(PinView.MODE_CREATE, null), new PinDelegate(), WatchUi.SLIDE_LEFT);
}
}
|