diff options
Diffstat (limited to 'source')
| -rw-r--r-- | source/app/AppInfo.mc | 2 | ||||
| -rw-r--r-- | source/app/AppState.mc | 21 | ||||
| -rw-r--r-- | source/app/Fixtures.mc | 37 | ||||
| -rw-r--r-- | source/app/OtpWalletApp.mc | 9 | ||||
| -rw-r--r-- | source/settings/Results.mc | 43 | ||||
| -rw-r--r-- | source/settings/SettingsProcessor.mc | 150 | ||||
| -rw-r--r-- | source/ui/InfoView.mc | 24 | ||||
| -rw-r--r-- | source/ui/ListView.mc | 15 | ||||
| -rw-r--r-- | source/ui/MainMenu.mc | 6 | ||||
| -rw-r--r-- | source/util/Diag.mc | 27 |
10 files changed, 324 insertions, 10 deletions
diff --git a/source/app/AppInfo.mc b/source/app/AppInfo.mc index ab5ceb9..1f40fee 100644 --- a/source/app/AppInfo.mc +++ b/source/app/AppInfo.mc @@ -12,5 +12,5 @@ import Toybox.Lang; module AppInfo { // Semantic version of the app: MAJOR.MINOR.PATCH. - const VERSION = "0.4.0"; + const VERSION = "0.5.0"; } diff --git a/source/app/AppState.mc b/source/app/AppState.mc index 05656e2..b900943 100644 --- a/source/app/AppState.mc +++ b/source/app/AppState.mc @@ -16,6 +16,7 @@ module AppState { var options as Options = new Options(); var vault as Vault? = null; var codes as CodeCache? = null; + var processor as SettingsProcessor? = null; // A message that replaces the whole app (newer data, damaged metadata, // failed self-test), or null. var startError as String? = null; @@ -36,6 +37,7 @@ module AppState { v.session.autoLockMs = TestMode.ON ? TestMode.AUTO_LOCK_MS : options.autoLockMs; vault = v; codes = new CodeCache(v); + processor = new SettingsProcessor(); if (TestMode.ON) { Fixtures.load(v); } @@ -81,6 +83,25 @@ module AppState { return true; } + // The phone changed the settings: reload the options and apply forms. + function settingsChanged() as Void { + options.load(); + var v = vault; + if (v != null) { + v.eraseAfterFailures = options.eraseAfterFailures; + v.session.autoLockMs = TestMode.ON ? TestMode.AUTO_LOCK_MS : options.autoLockMs; + processSettings(); + } + } + + // Apply any settings form that asks for it (after start-up and on change). + function processSettings() as Void { + var p = processor; + if (p != null && cryptoReady) { + p.start(); + } + } + // Note user activity (restarts the auto-lock timeout). function activity() as Void { var v = vault; diff --git a/source/app/Fixtures.mc b/source/app/Fixtures.mc index 0b641ee..8ed35c9 100644 --- a/source/app/Fixtures.mc +++ b/source/app/Fixtures.mc @@ -1,5 +1,5 @@ // Author: cirakg & claude ai -// Summary: Debug-only fake entries for the simulator and screenshots; release builds get empty stubs. +// Summary: Debug-only fake entries (and, in UI tests, a fake import via the settings form); release builds get empty stubs. // // All data is fabricated: the secrets are the RFC 4226/6238 test keys and the // names are made up. The UI-test build (source/util/TestMode.mc) wipes the @@ -52,6 +52,41 @@ module Fixtures { card.payload = Bytes.fromUtf8("0000-1111-2222"); vault.add(card); vault.lock(); + if (TestMode.ON) { + startImport(); + } + } + + // UI-test build: an Aegis import of 100 entries through the settings + // form, the same path as the phone: 97 new, one duplicate of GitHub, one + // error and one skipped type. The results screen opens when it's done. + function startImport() as Void { + var rfcKey = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; // lint: allow-literal (RFC 4226 test key, = GitHub's) + var proton = "KRSXG5BAKNSWG4TFOQQEWZLZEBEGK4TF"; // lint: allow-literal (fake: Base32 of "Test Secret Key Here") + var json = "{\"version\": 1, \"header\": {\"slots\": null, \"params\": null}, \"db\": {\"version\": 3, \"entries\": [" + + aegisEntry("totp", "Proton", "zoe@example.com", proton, 6) + "," + + aegisEntry("totp", "GitHub copy", "alice@example.com", rfcKey, 6) + "," + + aegisEntry("totp", "Broken", "x", proton, 9) + "," + + aegisEntry("motp", "Legacy", "y", rfcKey, 6); + // 96 more, so the flow imports 100 entries through the paced job. + // Fake 16-character Base32 secrets built from alphabet letters (cheap: + // Base32-encoding 96 secrets in this one event would trip the watchdog + // on the Forerunner 255S). + var abc = Base32.ALPHABET; + for (var i = 0; i < 96; i++) { + var secret = "MFRGGZDF" + abc.substring(i / 32, i / 32 + 1) + abc.substring(i % 32, i % 32 + 1) + "MZTWQ2LK"; + json += "," + aegisEntry("totp", "Site " + i, "user" + i + "@example.com", secret, 6); + } + json += "], \"groups\": []}}"; + Application.Properties.setValue("impText1", json); + Application.Properties.setValue("impMode", 0); + Application.Properties.setValue("impLocked", false); + Application.Properties.setValue("impApply", true); + } + + function aegisEntry(type as String, issuer as String, name as String, secret as String, digits as Number) as String { + return "{\"type\": \"" + type + "\", \"name\": \"" + name + "\", \"issuer\": \"" + issuer + + "\", \"info\": {\"secret\": \"" + secret + "\", \"algo\": \"SHA1\", \"digits\": " + digits + ", \"period\": 30}}"; } function otp(name as String, account as String, group as String, type as Number, algorithm as Number, diff --git a/source/app/OtpWalletApp.mc b/source/app/OtpWalletApp.mc index 7419433..596ccc2 100644 --- a/source/app/OtpWalletApp.mc +++ b/source/app/OtpWalletApp.mc @@ -54,6 +54,15 @@ class OtpWalletApp extends Application.AppBase { return [list, new ListDelegate(list)]; } + // The phone sent new settings (only while the full app runs). + (:typecheck(disableGlanceCheck)) + function onSettingsChanged() as Void { + if (_started) { + AppState.settingsChanged(); + WatchUi.requestUpdate(); + } + } + (:glance) function getGlanceView() as [WatchUi.GlanceView] or [WatchUi.GlanceView, WatchUi.GlanceViewDelegate] or Null { return [new OtpGlanceView()]; diff --git a/source/settings/Results.mc b/source/settings/Results.mc new file mode 100644 index 0000000..37c932f --- /dev/null +++ b/source/settings/Results.mc @@ -0,0 +1,43 @@ +// Author: cirakg & claude ai +// Summary: The latest settings results (README 10.3): kept in storage for the results screen, with an unseen flag for the list. + +import Toybox.Application; +import Toybox.Lang; + +module Results { + + const KEY = "res"; + const MAX_LINES = 60; + + // Replace the stored results with these lines (marked unseen). + function save(lines as Array<String>) as Void { + var kept = lines.size() > MAX_LINES ? lines.slice(0, MAX_LINES) as Array<String> : lines; + Application.Storage.setValue(KEY, {"l" => kept, "u" => true} as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>); + } + + // The stored lines, or a short explanation when there are none. + function lines() as Array<String> { + var stored = Application.Storage.getValue(KEY); + if (stored instanceof Dictionary && stored["l"] instanceof Array) { + return stored["l"] as Array<String>; + } + return ["Nothing yet. Results appear here after you import or change entries in the phone settings."]; + } + + function unseen() as Boolean { + var stored = Application.Storage.getValue(KEY); + return stored instanceof Dictionary && isTrue(stored["u"]); + } + + function markSeen() as Void { + var stored = Application.Storage.getValue(KEY); + if (stored instanceof Dictionary && isTrue(stored["u"])) { + stored["u"] = false; + Application.Storage.setValue(KEY, stored as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>); + } + } + + function isTrue(value as Application.Storage.ValueType) as Boolean { + return value instanceof Boolean && value; + } +} diff --git a/source/settings/SettingsProcessor.mc b/source/settings/SettingsProcessor.mc new file mode 100644 index 0000000..4c0a429 --- /dev/null +++ b/source/settings/SettingsProcessor.mc @@ -0,0 +1,150 @@ +// Author: cirakg & claude ai +// Summary: Applies the phone's settings forms on the watch (README 10.3); in M5 the Import form. +// +// Rules (README 10.3): +// * Runs from onSettingsChanged and at every start, after the first frame. +// * A form is applied only when its switch ("Import now") is on. Afterwards +// the watch turns the switch off and clears the text fields (they hold +// secrets) and writes a short result to the status field. +// * Idempotency: a hash of every applied form is remembered (the last 32), +// so the same form sent again does nothing. +// * The import runs as a job on a timer; when it ends, the results are +// stored and the results screen opens. +// Only the full app runs this, never the glance. + +import Toybox.Application; +import Toybox.Lang; +import Toybox.System; +import Toybox.WatchUi; + +class SettingsProcessor { + + static const TEXT_FIELDS = 8; + static const FORM_LOG_KEY = "fh"; + static const FORM_LOG_SIZE = 32; + static const STATUS_MAX_CHARS = 200; + + private var _job as Importer? = null; + // The job's timer driver (kept so it isn't collected while it runs). + var runner as Jobs.JobRunner? = null; + private var _formHash as String = ""; + private var _startedAt as Number = 0; + + function initialize() { + } + + function isBusy() as Boolean { + return _job != null; + } + + // "Importing… 40%" while an import runs, else null. + function busyText() as String? { + var job = _job; + return job != null ? "Importing… " + job.percent() + "%" : null; + } + + // Look at the forms and start whatever they ask for. + function start() as Void { + if (_job != null || AppState.vault == null) { + return; + } + if (!isTrue(prop("impApply"))) { + return; + } + var mode = prop("impMode"); + var locked = isTrue(prop("impLocked")); + var importMode = mode instanceof Number && mode >= Importer.MODE_APPEND && mode <= Importer.MODE_CANCEL ? mode : Importer.MODE_APPEND; + var vault = AppState.theVault(); + if (importMode == Importer.MODE_CANCEL) { + ImportBuffer.clear(vault.storage()); + finish(["Waiting import cancelled."], "Waiting import cancelled", false); + return; + } + var text = ""; + for (var i = 1; i <= TEXT_FIELDS; i++) { + var part = prop("impText" + i); + if (part instanceof String) { + text += part; + } + } + _formHash = formHash(importMode + "|" + locked + "|" + text); + if (alreadyApplied(_formHash)) { + finish(["This import was already applied. Change the text to import again."], "Already imported", false); + return; + } + _startedAt = System.getTimer(); + var job = new Importer(vault, text, importMode, locked, Clock.nowUnix(0)); + _job = job; + var r = new Jobs.JobRunner(job, 1, method(:onImportDone)); + runner = r; + r.start(); + WatchUi.requestUpdate(); + } + + function onImportDone() as Void { + var job = _job as Importer; + _job = null; + runner = null; + var report = job.report; + Diag.importDone(report.imported, System.getTimer() - _startedAt); + if (report.failure == null && report.waitingChars < 0) { + rememberApplied(_formHash); + } + AppState.theCodes().clear(); + finish(report.lines(), report.summary(), true); + } + + // Clear the form, write the status, store and show the results. + private function finish(lines as Array<String>, status as String, clearText as Boolean) as Void { + if (clearText) { + for (var i = 1; i <= TEXT_FIELDS; i++) { + setProp("impText" + i, ""); + } + } + setProp("impApply", false); + setProp("impStatus", Text.clip(status, STATUS_MAX_CHARS)); + Results.save(lines); + WatchUi.pushView(new InfoView("Results", lines, "results"), new InfoDelegate(), WatchUi.SLIDE_UP); + Results.markSeen(); + } + + private function isTrue(value as Application.Properties.ValueType or Null) as Boolean { + return value instanceof Boolean && value; + } + + private function prop(key as String) as Application.Properties.ValueType or Null { + try { + return Application.Properties.getValue(key); + } catch (e instanceof Lang.Exception) { + return null; + } + } + + private function setProp(key as String, value as Application.Properties.ValueType) as Void { + try { + Application.Properties.setValue(key, value); + } catch (e instanceof Lang.Exception) { + // A property the phone hasn't got yet: nothing to clear. + } + } + + // Short hash of a form's content (SHA-256, first 8 bytes, hex). + static function formHash(content as String) as String { + return Bytes.toHex(Hashes.digest(Hashes.SHA256, Bytes.fromUtf8(content)).slice(0, 8)); + } + + private function alreadyApplied(hash as String) as Boolean { + var log = Application.Storage.getValue(FORM_LOG_KEY); + return log instanceof Array && log.indexOf(hash) >= 0; + } + + private function rememberApplied(hash as String) as Void { + var stored = Application.Storage.getValue(FORM_LOG_KEY); + var log = stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>; + log.add(hash); + if (log.size() > FORM_LOG_SIZE) { + log = log.slice(log.size() - FORM_LOG_SIZE, null) as Array<Application.Storage.ValueType>; + } + Application.Storage.setValue(FORM_LOG_KEY, log); + } +} diff --git a/source/ui/InfoView.mc b/source/ui/InfoView.mc index 3b75764..cab4b52 100644 --- a/source/ui/InfoView.mc +++ b/source/ui/InfoView.mc @@ -11,6 +11,8 @@ class InfoView extends WatchUi.View { private var _text as Array<String>; private var _screen as String; private var _lines as Array<String>? = null; // wrapped, on first draw + // For each wrapped line: true when it starts one of the text items. + private var _starts as Array<Boolean> = [] as Array<Boolean>; private var _first as Number = 0; private var _visible as Number = 1; private var _shownLogged as Boolean = false; @@ -62,17 +64,31 @@ class InfoView extends WatchUi.View { var width = 2 * Ui.halfWidthBetween(dc, y, bottom) - 8; lines = [] as Array<String>; for (var i = 0; i < _text.size(); i++) { - lines.addAll(Ui.wrap(dc, _text[i], font, width)); + var wrapped = Ui.wrap(dc, _text[i], font, width); + for (var k = 0; k < wrapped.size(); k++) { + _starts.add(k == 0); + } + lines.addAll(wrapped); } _lines = lines; } - _visible = (bottom - y) / lineH; dc.setColor(Ui.fg(), Graphics.COLOR_TRANSPARENT); - for (var i = _first; i < lines.size() && i < _first + _visible; i++) { + // A third of a line between items, so wrapped items stay apart. + var gap = lineH / 3; + var shown = 0; + for (var i = _first; i < lines.size() && y + lineH <= bottom; i++) { + if (_starts[i] && i != _first) { + y += gap; + if (y + lineH > bottom) { + break; + } + } dc.drawText(w / 2, y, font, lines[i], Graphics.TEXT_JUSTIFY_CENTER); y += lineH; + shown++; } - if (_first + _visible < lines.size()) { + _visible = shown > 0 ? shown : 1; + if (_first + shown < lines.size()) { var cx = w / 2; dc.setColor(Ui.dim(), Graphics.COLOR_TRANSPARENT); dc.fillPolygon([[cx - 6, bottom], [cx + 6, bottom], [cx, bottom + 6]] as Array<Graphics.Point2D>); diff --git a/source/ui/ListView.mc b/source/ui/ListView.mc index ab0e594..cc4e5f3 100644 --- a/source/ui/ListView.mc +++ b/source/ui/ListView.mc @@ -119,6 +119,8 @@ class ListView extends WatchUi.View { var mode = vault.hasPin() ? PinView.MODE_UNLOCK : PinView.MODE_CREATE; WatchUi.pushView(new PinView(mode, null), new PinDelegate(), WatchUi.SLIDE_LEFT); } + // Settings forms are applied after the first frame (README 10.3). + AppState.processSettings(); } // One-shot timer: start-up checks after the first frame, and further @@ -149,7 +151,7 @@ class ListView extends WatchUi.View { var titleFont = Graphics.FONT_XTINY; var titleY = Ui.isRound() ? h / 12 : 4; dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT); - dc.drawText(w / 2, titleY, titleFont, "OTP Wallet", Graphics.TEXT_JUSTIFY_CENTER); + dc.drawText(w / 2, titleY, titleFont, title(), Graphics.TEXT_JUSTIFY_CENTER); var listTop = titleY + dc.getFontHeight(titleFont) + 2; var listBottom = Ui.isRound() ? h - h / 12 : h - 4; @@ -176,6 +178,17 @@ class ListView extends WatchUi.View { } } + // "Importing… 40%" while an import runs, "Results ready" when settings + // results haven't been looked at, else the app name. + private function title() as String { + var p = AppState.processor; + var busy = p != null ? p.busyText() : null; + if (busy != null) { + return busy; + } + return Results.unseen() ? "Results ready (menu)" : "OTP Wallet"; + } + private function drawEmpty(dc as Graphics.Dc, top as Number, bottom as Number) as Void { var w = dc.getWidth(); var y = (top + bottom) / 2 - dc.getFontHeight(Graphics.FONT_SMALL); diff --git a/source/ui/MainMenu.mc b/source/ui/MainMenu.mc index b827784..3d13180 100644 --- a/source/ui/MainMenu.mc +++ b/source/ui/MainMenu.mc @@ -117,9 +117,9 @@ class MainMenuDelegate extends WatchUi.Menu2InputDelegate { WatchUi.pushView(new WatchUi.Confirmation("Erase locked entries and set a new PIN?"), new ResetPinConfirmation(), WatchUi.SLIDE_LEFT); } else if (id == :results) { - WatchUi.switchToView(new InfoView("Settings results", - ["Nothing yet. Results appear here after you add or change entries in the phone settings."], "results"), - new InfoDelegate(), WatchUi.SLIDE_LEFT); + WatchUi.switchToView(new InfoView("Results", Results.lines(), "results"), new InfoDelegate(), + WatchUi.SLIDE_LEFT); + Results.markSeen(); } else if (id == :ids) { WatchUi.switchToView(new InfoView("Entry IDs", MainMenu.entryIdLines(), "ids"), new InfoDelegate(), WatchUi.SLIDE_LEFT); } else if (id == :clock) { diff --git a/source/util/Diag.mc b/source/util/Diag.mc index a09d3f8..0ab121f 100644 --- a/source/util/Diag.mc +++ b/source/util/Diag.mc @@ -96,4 +96,31 @@ module Diag { (:release) function overlay(dc as Graphics.Dc, frameStartMs as Number) as Void { } + + // Peak memory while long work runs (sampled by the import job). + (:debug) + var peakMemory as Number = 0; + + (:debug) + function sampleMemory() as Void { + var used = System.getSystemStats().usedMemory; + if (used > peakMemory) { + peakMemory = used; + } + } + + (:release) + function sampleMemory() as Void { + } + + // Reports an import's size, duration and peak memory for UI scripts. + (:debug) + function importDone(entries as Number, wallMs as Number) as Void { + System.println("DIAG import entries=" + entries + " wall_ms=" + wallMs + " mem_peak=" + peakMemory + + " mem_total=" + System.getSystemStats().totalMemory); + } + + (:release) + function importDone(entries as Number, wallMs as Number) as Void { + } } |
