aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--resources/settings/properties.xml26
-rw-r--r--resources/settings/settings.xml48
-rw-r--r--resources/strings/strings.xml19
-rw-r--r--source/app/AppInfo.mc2
-rw-r--r--source/app/AppState.mc21
-rw-r--r--source/app/Fixtures.mc37
-rw-r--r--source/app/OtpWalletApp.mc9
-rw-r--r--source/settings/Results.mc43
-rw-r--r--source/settings/SettingsProcessor.mc150
-rw-r--r--source/ui/InfoView.mc24
-rw-r--r--source/ui/ListView.mc15
-rw-r--r--source/ui/MainMenu.mc6
-rw-r--r--source/util/Diag.mc27
13 files changed, 411 insertions, 16 deletions
diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml
index 9e2da95..e6b75a4 100644
--- a/resources/settings/properties.xml
+++ b/resources/settings/properties.xml
@@ -1,15 +1,29 @@
<!--
Author: cirakg & claude ai
- Summary: App properties for the Options group (README 10.2) with their defaults; the phone form follows in M7.
+ Summary: App properties (README 10.2): the Import form (M5) and the Options group with their defaults.
-->
<!--
- Values the watch reads through source/app/Options.mc. M4 defines only the
- Options group, so the app can read them (reading an undefined property
- stops the app with a system error that can't be caught). M7 adds the
- settings.xml form that edits them on the phone, and the other groups.
- List values are indexes; see Options.mc for their meaning.
+ Every property the watch reads must be defined here: reading an undefined
+ one stops the app with a system error that can't be caught.
+ * Import (M5): read by source/settings/SettingsProcessor.mc. impText1-8
+ are joined in order; the watch clears them and turns impApply off after
+ processing, and writes the result to impStatus.
+ * Options: read by source/app/Options.mc; list values are indexes. Their
+ phone form follows in M7 with the other groups.
-->
<properties>
+ <property id="impText1" type="string"></property>
+ <property id="impText2" type="string"></property>
+ <property id="impText3" type="string"></property>
+ <property id="impText4" type="string"></property>
+ <property id="impText5" type="string"></property>
+ <property id="impText6" type="string"></property>
+ <property id="impText7" type="string"></property>
+ <property id="impText8" type="string"></property>
+ <property id="impMode" type="number">0</property>
+ <property id="impLocked" type="boolean">false</property>
+ <property id="impApply" type="boolean">false</property>
+ <property id="impStatus" type="string"></property>
<property id="optAutoLock" type="number">3</property>
<property id="optWipe" type="boolean">false</property>
<property id="optTimeOffset" type="number">0</property>
diff --git a/resources/settings/settings.xml b/resources/settings/settings.xml
new file mode 100644
index 0000000..a8c3b66
--- /dev/null
+++ b/resources/settings/settings.xml
@@ -0,0 +1,48 @@
+<!--
+ Author: cirakg & claude ai
+ Summary: The app settings form in Garmin Connect: the Import group (M5); the other groups follow in M7.
+-->
+<settings>
+ <group id="import" title="@Strings.GroupImport" description="@Strings.GroupImportHelp">
+ <setting propertyKey="@Properties.impText1" title="@Strings.ImpText1" prompt="@Strings.ImpTextPrompt">
+ <settingConfig type="alphaNumeric" />
+ </setting>
+ <setting propertyKey="@Properties.impText2" title="@Strings.ImpText2" prompt="@Strings.ImpTextPrompt">
+ <settingConfig type="alphaNumeric" />
+ </setting>
+ <setting propertyKey="@Properties.impText3" title="@Strings.ImpText3" prompt="@Strings.ImpTextPrompt">
+ <settingConfig type="alphaNumeric" />
+ </setting>
+ <setting propertyKey="@Properties.impText4" title="@Strings.ImpText4" prompt="@Strings.ImpTextPrompt">
+ <settingConfig type="alphaNumeric" />
+ </setting>
+ <setting propertyKey="@Properties.impText5" title="@Strings.ImpText5" prompt="@Strings.ImpTextPrompt">
+ <settingConfig type="alphaNumeric" />
+ </setting>
+ <setting propertyKey="@Properties.impText6" title="@Strings.ImpText6" prompt="@Strings.ImpTextPrompt">
+ <settingConfig type="alphaNumeric" />
+ </setting>
+ <setting propertyKey="@Properties.impText7" title="@Strings.ImpText7" prompt="@Strings.ImpTextPrompt">
+ <settingConfig type="alphaNumeric" />
+ </setting>
+ <setting propertyKey="@Properties.impText8" title="@Strings.ImpText8" prompt="@Strings.ImpTextPrompt">
+ <settingConfig type="alphaNumeric" />
+ </setting>
+ <setting propertyKey="@Properties.impMode" title="@Strings.ImpMode">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.ImpModeAppend</listEntry>
+ <listEntry value="1">@Strings.ImpModeReplace</listEntry>
+ <listEntry value="2">@Strings.ImpModeCancel</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.impLocked" title="@Strings.ImpLocked">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.impApply" title="@Strings.ImpApply">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.impStatus" title="@Strings.Status">
+ <settingConfig type="alphaNumeric" readonly="true" />
+ </setting>
+ </group>
+</settings>
diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml
index 7aab88a..d571554 100644
--- a/resources/strings/strings.xml
+++ b/resources/strings/strings.xml
@@ -5,4 +5,23 @@
<!-- User-visible strings. English only for now (README section 14). -->
<strings>
<string id="AppName">OTP Wallet</string>
+ <!-- Settings: Import group (README 10.2, 11) -->
+ <string id="GroupImport">Import</string>
+ <string id="GroupImportHelp">Paste otpauth:// links or an unencrypted Aegis JSON export. Long text continues in the next field. Turn on Import now and save; the result appears below and on the watch.</string>
+ <string id="ImpTextPrompt">Paste here</string>
+ <string id="ImpText1">Text, part 1</string>
+ <string id="ImpText2">Text, part 2</string>
+ <string id="ImpText3">Text, part 3</string>
+ <string id="ImpText4">Text, part 4</string>
+ <string id="ImpText5">Text, part 5</string>
+ <string id="ImpText6">Text, part 6</string>
+ <string id="ImpText7">Text, part 7</string>
+ <string id="ImpText8">Text, part 8</string>
+ <string id="ImpMode">Mode</string>
+ <string id="ImpModeAppend">Add to my entries</string>
+ <string id="ImpModeReplace">Replace imported entries</string>
+ <string id="ImpModeCancel">Cancel waiting import</string>
+ <string id="ImpLocked">Require PIN for these entries</string>
+ <string id="ImpApply">Import now</string>
+ <string id="Status">Result</string>
</strings>
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 {
+ }
}