// 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 { var lines = [] as Array; 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 { var vault = AppState.theVault(); var lines = ["OTP Wallet " + AppInfo.VERSION] as Array; 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) { WatchUi.switchToView(QueueMenu.build(), new QueueMenuDelegate(), WatchUi.SLIDE_LEFT); } 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 = [] as Array; private var _ids as Array; 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, "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); } }