aboutsummaryrefslogtreecommitdiff
path: root/source/settings
diff options
context:
space:
mode:
Diffstat (limited to 'source/settings')
-rw-r--r--source/settings/AboutScan.mc174
-rw-r--r--source/settings/ChangeQueue.mc143
-rw-r--r--source/settings/CodeForm.mc214
-rw-r--r--source/settings/Forms.mc255
-rw-r--r--source/settings/ManageForm.mc509
-rw-r--r--source/settings/OtpForm.mc152
-rw-r--r--source/settings/SettingsJob.mc351
-rw-r--r--source/settings/SettingsProcessor.mc141
8 files changed, 1835 insertions, 104 deletions
diff --git a/source/settings/AboutScan.mc b/source/settings/AboutScan.mc
new file mode 100644
index 0000000..8fc898a
--- /dev/null
+++ b/source/settings/AboutScan.mc
@@ -0,0 +1,174 @@
+// Author: cirakg & claude ai
+// Summary: Writes the read-only About fields (catalog, version, storage use) and applies "Encrypt names" to existing entries.
+//
+// One pass over every entry header, a few per step:
+// * catalog: "K7F2 GitHub (alice) · 9QXR Gym card · ...", so the Manage
+// form's IDs can be looked up on the phone. With "Encrypt names" on it
+// lists only IDs and types: names stay off the phone too.
+// * storageUse: entries, waiting items and the bytes their records take.
+// * version: the app version.
+// * Entries whose names are stored the other way from the current
+// "Encrypt names" option are re-sealed, one per step. Locked ones need
+// the session; if one can't be done now, the next scan retries (after
+// the next PIN entry, which runs the processor again).
+// The scan only runs when something it reports changed: a form was
+// applied, or the signature of the entry list (IDs, pending, option,
+// version) differs from the last complete scan.
+
+import Toybox.Lang;
+
+class AboutScan extends Jobs.Job {
+
+ static const SIGNATURE_KEY = "cs";
+ static const HEADERS_PER_STEP = 4;
+ static const CATALOG_MAX_CHARS = 800; // Garmin Express caps a text field at 800 (FEASIBILITY 3d)
+ static const SEPARATOR = " · ";
+ // README 6.2: the stricter of Garmin's two documented totals.
+ static const STORE_KB = 100;
+
+ private var _vault as Vault;
+ private var _store as Forms.Store;
+ private var _ids as Array<String>;
+ private var _pending as Array<String>;
+ private var _index as Number = 0;
+ private var _catalog as String = "";
+ private var _left as Number = 0;
+ private var _bytes as Number = 0;
+ private var _incomplete as Boolean = false;
+
+ function initialize(vault as Vault, store as Forms.Store) {
+ Job.initialize();
+ _vault = vault;
+ _store = store;
+ _ids = vault.ids();
+ _pending = vault.pendingIds();
+ }
+
+ // True when the About fields may be out of date.
+ static function needed(vault as Vault) as Boolean {
+ var stored = vault.storage().getValue(SIGNATURE_KEY);
+ return !(stored instanceof String) || !stored.equals(signature(vault));
+ }
+
+ static function signature(vault as Vault) as String {
+ var content = new Forms.Content("about");
+ content.add(vault.ids().toString());
+ content.add(vault.pendingIds().toString());
+ content.add(vault.encryptNames);
+ content.add(ChangeQueue.count(vault));
+ content.add(AppInfo.VERSION);
+ return Forms.hash(vault, content);
+ }
+
+ function step(budget as Number) as Boolean {
+ var total = _ids.size() + _pending.size();
+ for (var n = 0; n < HEADERS_PER_STEP && _index < total; n++) {
+ var converted = _index < _ids.size() ? visit(_ids[_index]) : visitPending(_pending[_index - _ids.size()]);
+ _index++;
+ if (converted) {
+ return false; // one re-seal per step
+ }
+ }
+ if (_index < total) {
+ return false;
+ }
+ writeFields();
+ return true;
+ }
+
+ // Catalog and size of a listed entry; re-seals it when its names are
+ // stored the other way. True when it re-sealed.
+ private function visit(id as String) as Boolean {
+ var header = _vault.anyHeader(id);
+ addBytes("e." + id);
+ addBytes("x." + id);
+ if (header == null) {
+ addItem(id + " damaged");
+ return false;
+ }
+ var converted = false;
+ if (header.namesEncrypted != _vault.encryptNames) {
+ if (!header.locked || _vault.session.isUnlocked()) {
+ try {
+ _vault.setNamesEncrypted(id, _vault.encryptNames);
+ converted = true;
+ var after = _vault.anyHeader(id); // names readable again after decrypting
+ header = after != null ? after : header;
+ } catch (e instanceof VaultError) {
+ _incomplete = true;
+ }
+ } else {
+ _incomplete = true;
+ }
+ }
+ addItem(id + " " + describe(header) + (header.locked ? " (PIN)" : ""));
+ return converted;
+ }
+
+ private function visitPending(id as String) as Boolean {
+ var header = _vault.anyHeader(id);
+ addBytes("p." + id);
+ addItem(id + " " + (header != null ? describe(header) : "entry") + " (waits for the PIN)");
+ return false;
+ }
+
+ // The names, or the type when names are hidden or encrypted.
+ private function describe(header as Entry) as String {
+ var label = Forms.label(header.name, header.account);
+ if (!_vault.encryptNames && !header.namesEncrypted && label.length() > 0) {
+ return label;
+ }
+ if (header.kind == Entry.KIND_CODE) {
+ return Codes.name(header.type);
+ }
+ return header.type == Entry.OTP_HOTP ? "HOTP" : (header.type == Entry.OTP_STEAM ? "Steam" : "TOTP");
+ }
+
+ private function addItem(item as String) as Void {
+ if (_left > 0 || _catalog.length() + SEPARATOR.length() + item.length() > CATALOG_MAX_CHARS) {
+ _left++;
+ return;
+ }
+ _catalog += (_catalog.length() > 0 ? SEPARATOR : "") + item;
+ }
+
+ private function addBytes(key as String) as Void {
+ var value = _vault.storage().getValue(key);
+ if (value instanceof ByteArray) {
+ _bytes += value.size();
+ }
+ }
+
+ private function writeFields() as Void {
+ var catalog = _catalog.length() > 0 ? _catalog : "No entries yet";
+ if (_left > 0) {
+ catalog += SEPARATOR + "and " + _left + " more (see Entry IDs on the watch)";
+ }
+ var use = _ids.size() + " of " + Vault.MAX_ENTRIES + " entries";
+ if (_pending.size() > 0) {
+ use += " (+" + _pending.size() + " waiting for the PIN)";
+ }
+ use += ", " + (_bytes + 1023) / 1024 + " KB of about " + STORE_KB + " KB";
+ var waiting = ChangeQueue.count(_vault);
+ if (waiting > 0) {
+ use += ". " + waiting + (waiting == 1 ? " change waits" : " changes wait") + " for the PIN on the watch";
+ }
+ setIfChanged("catalog", catalog);
+ setIfChanged("storageUse", use);
+ setIfChanged("version", AppInfo.VERSION);
+ if (!_incomplete) {
+ try {
+ _vault.storage().setValue(SIGNATURE_KEY, signature(_vault));
+ } catch (e instanceof Lang.Exception) {
+ // A full store: the scan just runs again next time.
+ }
+ }
+ }
+
+ private function setIfChanged(key as String, value as String) as Void {
+ var old = _store.get(key);
+ if (!(old instanceof String) || !old.equals(value)) {
+ _store.set(key, value);
+ }
+ }
+}
diff --git a/source/settings/ChangeQueue.mc b/source/settings/ChangeQueue.mc
new file mode 100644
index 0000000..12f88f7
--- /dev/null
+++ b/source/settings/ChangeQueue.mc
@@ -0,0 +1,143 @@
+// Author: cirakg & claude ai
+// Summary: Changes from the phone that wait for the PIN on the watch (README 7.9), sealed with the device key under "q".
+//
+// Anyone who controls the phone can send settings, so edits, new secrets,
+// "Don't require PIN" and deletes of locked entries are not applied when
+// they arrive: they wait here until someone confirms them with the PIN on
+// the watch (or rejects them). Each item is one ManageAction sealed on its
+// own with the device key (aad "OTPW-Q-1"), so a damaged item costs only
+// itself. A new secret in an item is no more exposed than it was in the
+// settings it came from, as with pending entries (README 7.7).
+
+import Toybox.Application;
+import Toybox.Lang;
+
+module ChangeQueue {
+
+ const KEY = "q";
+ const MAX_ITEMS = 16;
+ const AAD = "OTPW-Q-1";
+
+ function count(vault as Vault) as Number {
+ var stored = vault.storage().getValue(KEY);
+ return stored instanceof Array ? stored.size() : 0;
+ }
+
+ // Append an action. Throws VaultError LIMIT (too many waiting) or FULL.
+ function add(vault as Vault, action as ManageAction) as Void {
+ var stored = vault.storage().getValue(KEY);
+ var items = stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>;
+ if (items.size() >= MAX_ITEMS) {
+ throw new VaultError(VaultError.LIMIT, MAX_ITEMS + " changes already wait for your PIN. Confirm or reject them on the watch first");
+ }
+ var plain = action.toBytes();
+ items.add(SealedBox.seal(vault.keyManager.deviceKeys(), Bytes.fromUtf8(AAD), plain));
+ Bytes.wipe(plain);
+ try {
+ vault.storage().setValue(KEY, items);
+ } catch (e instanceof Lang.Exception) {
+ throw new VaultError(VaultError.FULL, "storage full");
+ }
+ }
+
+ // The waiting actions in arrival order; items that don't verify are
+ // left out (and dropped by the next clear()).
+ function load(vault as Vault) as Array<ManageAction> {
+ var out = [] as Array<ManageAction>;
+ var stored = vault.storage().getValue(KEY);
+ if (!(stored instanceof Array)) {
+ return out;
+ }
+ var keys = vault.keyManager.deviceKeys();
+ var aad = Bytes.fromUtf8(AAD);
+ for (var i = 0; i < stored.size(); i++) {
+ var box = stored[i];
+ var plain = box instanceof ByteArray ? SealedBox.open(keys, aad, box) : null;
+ if (plain != null) {
+ var action = ManageAction.fromBytes(plain);
+ Bytes.wipe(plain);
+ if (action != null) {
+ out.add(action);
+ }
+ }
+ }
+ return out;
+ }
+
+ // Short descriptions for the confirmation screen ("Delete: K7F2 Bank").
+ function descriptions(vault as Vault) as Array<String> {
+ var actions = load(vault);
+ var out = [] as Array<String>;
+ for (var i = 0; i < actions.size(); i++) {
+ out.add(actions[i].describe());
+ actions[i].wipe();
+ }
+ return out;
+ }
+
+ function clear(vault as Vault) as Void {
+ vault.storage().deleteValue(KEY);
+ }
+
+ // Remove the first n items (the ones just applied), keeping any that
+ // arrived from the phone meanwhile.
+ function drop(vault as Vault, n as Number) as Void {
+ var stored = vault.storage().getValue(KEY);
+ if (!(stored instanceof Array) || stored.size() <= n) {
+ clear(vault);
+ return;
+ }
+ try {
+ vault.storage().setValue(KEY, stored.slice(n, null) as Array<Application.Storage.ValueType>);
+ } catch (e instanceof Lang.Exception) {
+ clear(vault); // can't happen when shrinking; never re-apply
+ }
+ }
+}
+
+// Applies every waiting change after the PIN was entered, one per step
+// (each re-seals a record; a code may need its CodeCheck). The applied
+// items leave the queue only at the end, so a crash midway loses nothing;
+// applying one again is harmless (a deleted entry is then just missing).
+class ChangeQueueJob extends Jobs.Job {
+
+ var lines as Array<String> = [] as Array<String>;
+
+ private var _vault as Vault;
+ private var _actions as Array<ManageAction>;
+ private var _loaded as Number = 0;
+ private var _index as Number = 0;
+ private var _run as ManageRun? = null;
+
+ function initialize(vault as Vault) {
+ Job.initialize();
+ _vault = vault;
+ var stored = vault.storage().getValue(ChangeQueue.KEY);
+ _loaded = stored instanceof Array ? stored.size() : 0;
+ _actions = ChangeQueue.load(vault);
+ }
+
+ function step(budget as Number) as Boolean {
+ var run = _run;
+ if (run == null) {
+ if (_index >= _actions.size()) {
+ ChangeQueue.drop(_vault, _loaded);
+ if (lines.size() == 0) {
+ lines.add("No changes were waiting.");
+ }
+ return true;
+ }
+ run = new ManageRun(_vault, _actions[_index], true);
+ _run = run;
+ }
+ if (run.step(budget)) {
+ var problem = run.failed ? "Not applied: " + _actions[_index].describe() + ". " : "";
+ lines.add(problem + run.message);
+ lines.addAll(run.notes);
+ _actions[_index].wipe();
+ _index++;
+ _run = null;
+ }
+ return false;
+ }
+}
diff --git a/source/settings/CodeForm.mc b/source/settings/CodeForm.mc
new file mode 100644
index 0000000..89d519d
--- /dev/null
+++ b/source/settings/CodeForm.mc
@@ -0,0 +1,214 @@
+// Author: cirakg & claude ai
+// Summary: The Add code form (README 10.2): reads the code* fields, and CodeCheck, the job that proves a code encodes and fits.
+//
+// read() checks the fields that need no encoding (name, symbology, length).
+// Whether the contents are valid for the symbology is decided by the real
+// encoder (README 12.1), in CodeCheck:
+// * Linear codes encode at once; the encoder's message ("EAN-13: check
+// digit should be 7, got 3. Choose 'Fix if wrong' to correct it") becomes
+// the status, and its note ("check digit 7 added") a remark.
+// * 2D codes run their MatrixJob step by step (the watchdog), then the
+// matrix is packed for the encrypted cache in a step of its own, so the
+// first opening on the watch is instant.
+// * A code that encodes but doesn't fit this screen is still added, with
+// the "Too large for this screen" warning (README 12.2).
+
+import Toybox.Lang;
+import Toybox.System;
+
+module CodeForm {
+
+ const SWITCH = "codeAdd";
+ const STATUS = "codeStatus";
+ const PAYLOAD = "codePayload";
+ const NAME_MAX_BYTES = Entry.MAX_NAME_BYTES;
+
+ const FIELDS = ["codeName", "codeSymbology", "codePayload", "codeEcLevel", "codeCheckDigit", "codeShowText", "codeGroup",
+ "codeLocked"];
+
+ // Field values after a reset (resources/settings/properties.xml);
+ // the contents first.
+ function defaults() as Dictionary<String, String or Number or Boolean> {
+ return {
+ PAYLOAD => "",
+ "codeName" => "",
+ "codeSymbology" => Codes.QR,
+ "codeEcLevel" => 1,
+ "codeCheckDigit" => Codes.CHECK_ADD_VERIFY,
+ "codeShowText" => true,
+ "codeGroup" => "",
+ "codeLocked" => false
+ } as Dictionary<String, String or Number or Boolean>;
+ }
+
+ // The entry described by the form (not yet checked by the encoder).
+ function read(store as Forms.Store) as Forms.Parsed {
+ var parsed = new Forms.Parsed("code");
+ for (var i = 0; i < FIELDS.size(); i++) {
+ parsed.content.add(store.get(FIELDS[i] as String));
+ }
+ var e = new Entry();
+ e.kind = Entry.KIND_CODE;
+ e.source = Entry.SOURCE_MANUAL;
+ e.name = Forms.text(store, "codeName");
+ e.group = Forms.text(store, "codeGroup");
+ e.locked = Forms.flag(store, "codeLocked");
+ if (e.name.length() == 0) {
+ return parsed.fail("Name: enter a name for the card");
+ }
+ var problem = Forms.checkName("Name", e.name, NAME_MAX_BYTES);
+ if (problem == null) {
+ problem = Forms.checkName("Group", e.group, Entry.MAX_GROUP_BYTES);
+ }
+ if (problem != null) {
+ return parsed.fail(problem);
+ }
+ var symbology = Forms.number(store, "codeSymbology");
+ if (symbology == null || symbology < Codes.QR || symbology > Codes.AZTEC) {
+ return parsed.fail("Type: choose one of the 12 barcode types");
+ }
+ e.type = symbology;
+ var ec = Forms.number(store, "codeEcLevel");
+ var check = Forms.number(store, "codeCheckDigit");
+ if (ec == null || ec < 0 || ec > Codes.OPT_EC_MASK) {
+ ec = 1;
+ }
+ if (check == null || check < Codes.CHECK_ADD_VERIFY || check > Codes.CHECK_OFF) {
+ check = Codes.CHECK_ADD_VERIFY;
+ }
+ e.options = ec | (check << Codes.OPT_CHECK_SHIFT) | (Forms.flag(store, "codeShowText") ? Codes.OPT_SHOW_TEXT : 0);
+ var payload = contents(Forms.rawText(store, PAYLOAD), symbology);
+ if (payload instanceof String) {
+ return parsed.fail(payload);
+ }
+ e.payload = payload;
+ parsed.entry = e;
+ return parsed;
+ }
+
+ // The contents as stored (trimmed for linear codes, whose digits are
+ // often typed with spaces around), or the problem.
+ function contents(text as String, symbology as Number) as ByteArray or String {
+ var value = Codes.isLinear(symbology) ? Text.trim(text) : text;
+ if (Text.trim(value).length() == 0) {
+ return "Contents: missing";
+ }
+ var bytes = Bytes.fromUtf8(value);
+ if (bytes.size() > Entry.MAX_PAYLOAD_BYTES) {
+ return "Contents: too long (" + bytes.size() + " bytes, at most " + Entry.MAX_PAYLOAD_BYTES + ")";
+ }
+ return bytes;
+ }
+
+ function reset(store as Forms.Store, everything as Boolean) as Void {
+ store.set(PAYLOAD, "");
+ if (everything) {
+ var d = defaults();
+ var keys = d.keys();
+ for (var i = 0; i < keys.size(); i++) {
+ store.set(keys[i], d[keys[i]] as String or Number or Boolean);
+ }
+ }
+ store.set(SWITCH, false);
+ }
+}
+
+// Encodes a code once to prove it is valid and to check that it fits this
+// screen. When step() returns true: error is set, or the code is fine
+// (notes may hold remarks) and, for 2D codes, cache holds Matrix.toBytes().
+class CodeCheck extends Jobs.Job {
+
+ // Linear codes draw with 10 quiet modules on each side (CodeView).
+ static const LINEAR_QUIET = 10;
+
+ var error as String? = null;
+ var notes as Array<String> = [] as Array<String>;
+ var cache as ByteArray? = null;
+
+ private var _symbology as Number;
+ private var _payload as String;
+ private var _options as Number;
+ private var _job as Codes.MatrixJob? = null;
+ private var _matrix as Codes.Matrix? = null;
+ private var _started as Boolean = false;
+ private var _w as Number;
+ private var _h as Number;
+ private var _round as Boolean;
+
+ function initialize(symbology as Number, payload as String, options as Number) {
+ Job.initialize();
+ _symbology = symbology;
+ _payload = payload;
+ _options = options;
+ var screen = System.getDeviceSettings();
+ _w = screen.screenWidth;
+ _h = screen.screenHeight;
+ _round = screen.screenShape == System.SCREEN_SHAPE_ROUND;
+ }
+
+ function step(budget as Number) as Boolean {
+ if (!_started) {
+ _started = true;
+ if (!Codes.isMatrix(_symbology)) {
+ checkLinear();
+ return true;
+ }
+ _job = Codes.matrixJob(_symbology, _payload, _options, _w, _h, _round);
+ _payload = "";
+ return false;
+ }
+ var matrix = _matrix;
+ if (matrix != null) {
+ // Packing gets a step of its own (it runs over every module).
+ cache = matrix.toBytes();
+ _matrix = null;
+ return true;
+ }
+ var job = _job as Codes.MatrixJob;
+ if (!job.step(budget)) {
+ return false;
+ }
+ _job = null;
+ var m = job.matrix();
+ var err = job.error;
+ if (err != null || m == null) {
+ error = withName(err != null ? err : "could not encode");
+ return true;
+ }
+ var totalW = m.width + 2 * m.quiet;
+ var totalH = m.drawnHeight() + 2 * m.quiet;
+ var px = Codes.minModulePx(_symbology);
+ if (!Codes.fits(totalW, totalH, px, _w, _h, _round)) {
+ notes.add("Too large for this screen: needs " + px + " px per module, " + totalW * px + " x " + totalH * px + " px");
+ }
+ _matrix = m;
+ return false;
+ }
+
+ private function checkLinear() as Void {
+ var result = Codes.encode(_symbology, _payload, _options);
+ _payload = "";
+ if (result instanceof Codes.CodeError) {
+ error = withName(result.message);
+ return;
+ }
+ var note = result.note;
+ if (note != null) {
+ notes.add(note);
+ }
+ // The widest band is a little under the screen width on round
+ // screens (CodeView measures it exactly when drawing).
+ var available = _round ? _w * 96 / 100 : _w - 4;
+ var needed = result.modules.size() + 2 * LINEAR_QUIET;
+ if (needed > available) {
+ notes.add("Too large for this screen: needs " + needed + " px, " + available + " px available");
+ }
+ }
+
+ // "too long for QR..." becomes "QR: too long for QR...", but a message
+ // that already names the symbology is kept as it is.
+ private function withName(message as String) as String {
+ var name = Codes.name(_symbology);
+ return message.find(name) == 0 ? message : name + ": " + message;
+ }
+}
diff --git a/source/settings/Forms.mc b/source/settings/Forms.mc
new file mode 100644
index 0000000..d26cb7a
--- /dev/null
+++ b/source/settings/Forms.mc
@@ -0,0 +1,255 @@
+// Author: cirakg & claude ai
+// Summary: Shared pieces of the settings forms: where field values come from, typed reads, text checks, the applied-forms log.
+//
+// The processor never calls Application.Properties directly: it goes through
+// a Forms.Store, so the unit tests can run every form against a dictionary
+// (MemoryStore) while the app uses the real properties (PropertyStore).
+//
+// Values from the phone are untrusted: a field can be missing (an app update
+// added it but the phone hasn't synced), of an unexpected type, or out of
+// range. The typed reads below never throw; they return a default or null
+// and the form validation turns that into a message.
+
+import Toybox.Application;
+import Toybox.Lang;
+
+module Forms {
+
+ // Form hashes remembered, most recent last (README 10.3, rule 3).
+ const LOG_KEY = "fh";
+ const LOG_SIZE = 32;
+ // Status fields hold at most this many characters.
+ const STATUS_MAX_CHARS = 200;
+ // Field separator inside the hashed form content (not typeable on the phone).
+ const SEPARATOR = 0x1F;
+
+ // Where field values come from and go to.
+ class Store {
+ function initialize() {
+ }
+
+ // The value, or null when it doesn't exist.
+ function get(key as String) as Application.Properties.ValueType or Null {
+ return null;
+ }
+
+ function set(key as String, value as Application.Properties.ValueType) as Void {
+ }
+ }
+
+ // The app's properties (resources/settings/properties.xml). Reading a
+ // property that isn't defined there stops the app with an error that
+ // can't be caught, so only defined keys may be passed in.
+ class PropertyStore extends Store {
+ function initialize() {
+ Store.initialize();
+ }
+
+ function get(key as String) as Application.Properties.ValueType or Null {
+ try {
+ return Application.Properties.getValue(key);
+ } catch (e instanceof Lang.Exception) {
+ return null;
+ }
+ }
+
+ function set(key as String, value as Application.Properties.ValueType) as Void {
+ try {
+ Application.Properties.setValue(key, value);
+ } catch (e instanceof Lang.Exception) {
+ // Nothing to clear or report into.
+ }
+ }
+ }
+
+ // A dictionary of fields, for tests (and nothing else).
+ class MemoryStore extends Store {
+ var values as Dictionary<String, Application.Properties.ValueType> = {} as Dictionary<String, Application.Properties.ValueType>;
+
+ function initialize() {
+ Store.initialize();
+ }
+
+ function get(key as String) as Application.Properties.ValueType or Null {
+ return values[key];
+ }
+
+ function set(key as String, value as Application.Properties.ValueType) as Void {
+ values[key] = value;
+ }
+ }
+
+ // ---- Typed reads ----------------------------------------------------
+
+ // A text field, trimmed; "" when missing or not text.
+ function text(store as Store, key as String) as String {
+ var v = store.get(key);
+ return v instanceof String ? Text.trim(v) : "";
+ }
+
+ // A text field exactly as sent (secrets, barcode contents).
+ function rawText(store as Store, key as String) as String {
+ var v = store.get(key);
+ return v instanceof String ? v : "";
+ }
+
+ function flag(store as Store, key as String) as Boolean {
+ var v = store.get(key);
+ return v instanceof Boolean && v;
+ }
+
+ // A whole number (list index, numeric field), or null. Longs that fit,
+ // whole floats and decimal text are accepted too: the phone's numeric
+ // fields don't promise a type.
+ function number(store as Store, key as String) as Number? {
+ var v = long(store, key);
+ return v != null && v >= -2147483648l && v <= 2147483647l ? v.toNumber() : null;
+ }
+
+ // A whole number as a Long, or null (also null for negative text).
+ function long(store as Store, key as String) as Long? {
+ var v = store.get(key);
+ if (v instanceof Number || v instanceof Long) {
+ return v.toLong();
+ }
+ if (v instanceof Float || v instanceof Double) {
+ var whole = v.toLong();
+ return whole.toDouble() == v.toDouble() ? whole : null;
+ }
+ if (v instanceof String) {
+ var t = Text.trim(v);
+ if (t.find("-") == 0) {
+ var magnitude = Text.parseLong(t.substring(1, t.length()) as String);
+ return magnitude != null ? -magnitude : null;
+ }
+ return Text.parseLong(t);
+ }
+ return null;
+ }
+
+ // ---- Text checks ----------------------------------------------------
+
+ // The 1-based position of the first control character, or 0.
+ function controlCharAt(value as String) as Number {
+ var chars = value.toCharArray();
+ for (var i = 0; i < chars.size(); i++) {
+ var c = chars[i].toNumber();
+ if (c < 32 || c == 127) {
+ return i + 1;
+ }
+ }
+ return 0;
+ }
+
+ // null when a name-like field is acceptable, else the message.
+ function checkName(label as String, value as String, maxBytes as Number) as String? {
+ var at = controlCharAt(value);
+ if (at > 0) {
+ return label + ": control character at position " + at;
+ }
+ var size = Bytes.fromUtf8(value).size();
+ if (size > maxBytes) {
+ return label + ": too long (" + size + " bytes, at most " + maxBytes + ")";
+ }
+ return null;
+ }
+
+ // "GitHub (alice)", "GitHub", "alice" or "" for messages.
+ function label(name as String, account as String) as String {
+ if (name.length() > 0 && account.length() > 0) {
+ return name + " (" + account + ")";
+ }
+ return name.length() > 0 ? name : account;
+ }
+
+ // What reading a form gave: an entry to add, or the first problem, plus
+ // remarks that don't stop it (a short secret, a check digit added).
+ class Parsed {
+ var entry as Entry? = null;
+ var error as String? = null;
+ var notes as Array<String> = [] as Array<String>;
+ var content as Content;
+
+ function initialize(formName as String) {
+ content = new Content(formName);
+ }
+
+ function fail(message as String) as Parsed {
+ error = message;
+ var e = entry;
+ if (e != null) {
+ e.wipeSecrets();
+ entry = null;
+ }
+ return self;
+ }
+ }
+
+ // ---- Content hash and the applied-forms log --------------------------
+
+ // Collects the content of a form for its hash: every field in a fixed
+ // order, each as its own UTF-8 bytes (converted natively, never copied
+ // into one buffer: an import form holds kilobytes). The parts hold
+ // secrets: hash() wipes them, or call wipe().
+ class Content {
+ var parts as Array<ByteArray> = [] as Array<ByteArray>;
+
+ function initialize(formName as String) {
+ parts.add(Bytes.fromUtf8(formName));
+ }
+
+ function add(value as Application.Properties.ValueType or Null) as Void {
+ if (value instanceof String) {
+ parts.add(Bytes.fromUtf8(value));
+ } else {
+ parts.add(value != null ? Bytes.fromUtf8(value.toString()) : []b);
+ }
+ }
+
+ // Zero the short parts (fields, secrets) and drop all of them.
+ // Long parts (import text) are dropped unwiped: zeroing kilobytes
+ // byte by byte would trip the watchdog, and the same text stays in
+ // memory as the property's String anyway (README 7.8 limits).
+ function wipe() as Void {
+ for (var i = 0; i < parts.size(); i++) {
+ if (parts[i].size() <= WIPE_MAX_BYTES) {
+ Bytes.wipe(parts[i]);
+ }
+ }
+ parts = [] as Array<ByteArray>;
+ }
+ }
+
+ const WIPE_MAX_BYTES = 512;
+
+ // Keyed hash of a form's content (KeyManager.formHash), wiping the content.
+ function hash(vault as Vault, content as Content) as String {
+ var h = vault.keyManager.formHash(content.parts, SEPARATOR);
+ content.wipe();
+ return h;
+ }
+
+ function alreadyApplied(vault as Vault, hash as String) as Boolean {
+ var log = vault.storage().getValue(LOG_KEY);
+ return log instanceof Array && log.indexOf(hash) >= 0;
+ }
+
+ function rememberApplied(vault as Vault, hash as String) as Void {
+ var stored = vault.storage().getValue(LOG_KEY);
+ var log = stored instanceof Array ? stored as Array<Application.Storage.ValueType> : [] as Array<Application.Storage.ValueType>;
+ log.add(hash);
+ if (log.size() > LOG_SIZE) {
+ log = log.slice(log.size() - LOG_SIZE, null) as Array<Application.Storage.ValueType>;
+ }
+ try {
+ vault.storage().setValue(LOG_KEY, log);
+ } catch (e instanceof Lang.Exception) {
+ // A full store: the form may be applied again if re-sent.
+ }
+ }
+
+ // Write a form's status field (shortened to fit).
+ function status(store as Store, key as String, message as String) as Void {
+ store.set(key, Text.clip(message, STATUS_MAX_CHARS));
+ }
+}
diff --git a/source/settings/ManageForm.mc b/source/settings/ManageForm.mc
new file mode 100644
index 0000000..b92c40d
--- /dev/null
+++ b/source/settings/ManageForm.mc
@@ -0,0 +1,509 @@
+// Author: cirakg & claude ai
+// Summary: The Manage entry form (README 10.2): one action on an entry by its ID, applied now or queued for the PIN (README 7.9).
+//
+// Three parts:
+// * ManageAction: one parsed request, and its byte form for the queue.
+// * ManageForm.read(): the mg* fields to a ManageAction or a problem.
+// * ManageRun: a job that checks the action against the entry and then
+// applies it, or queues it (ChangeQueue) when it touches a locked
+// entry: edit, replace, counter, "Don't require PIN" and delete wait
+// until the PIN is entered on the watch. "Require PIN" waits too while
+// the watch is locked (it needs the master key). Moving never waits:
+// the order isn't protected and a position is absolute, so applying a
+// move twice can't hit another entry.
+// Replacing a code's contents runs CodeCheck with the entry's own options
+// (a sub-job, so a large 2D code can't trip the watchdog). For a locked
+// entry the options are encrypted, so that check runs when the PIN confirms.
+
+import Toybox.Lang;
+
+class ManageAction {
+
+ // mgAction list values (resources/settings/settings.xml). Stored in the
+ // queue: never renumber.
+ static const EDIT = 0;
+ static const MOVE = 1;
+ static const LOCK = 2;
+ static const UNLOCK = 3;
+ static const REPLACE = 4;
+ static const COUNTER = 5;
+ static const DELETE = 6;
+
+ static const NAMES = ["Edit details", "Move", "Require PIN", "Don't require PIN", "Replace contents", "Set counter", "Delete"];
+
+ // Codec tags of the queued form.
+ static const T_ID = 1;
+ static const T_ACTION = 2;
+ static const T_NAME = 3;
+ static const T_ACCOUNT = 4;
+ static const T_GROUP = 5;
+ static const T_POSITION = 6;
+ static const T_MATERIAL = 7;
+ static const T_COUNTER = 8;
+ static const T_LABEL = 9;
+ static const T_DECODED = 10;
+
+ // In "Edit details", this value clears a field; empty keeps it.
+ static const CLEAR = "-";
+
+ var id as String = "";
+ var action as Number = EDIT;
+ var name as String = "";
+ var account as String = "";
+ var group as String = "";
+ var position as Number = 0;
+ // REPLACE: the decoded OTP secret or the new code contents, once
+ // checked against the entry (raw text before). Wiped after use.
+ var material as ByteArray? = null;
+ var counter as Long = 0l;
+ // True once an OTP secret in `material` is decoded from Base32.
+ var decoded as Boolean = false;
+ // "Bank (carol)", kept so the queue can be shown while locked.
+ var label as String = "";
+
+ function initialize() {
+ }
+
+ // "Delete K7F2 Bank (carol)".
+ function describe() as String {
+ var verb = NAMES[action] as String;
+ var target = label.length() > 0 ? id + " " + label : id;
+ if (action == MOVE) {
+ return "Move " + target + " to position " + position;
+ }
+ if (action == COUNTER) {
+ return "Set the counter of " + target + " to " + counter;
+ }
+ return verb + ": " + target;
+ }
+
+ function wipe() as Void {
+ Bytes.wipe(material);
+ material = null;
+ }
+
+ function toBytes() as ByteArray {
+ var w = new Codec.Writer();
+ w.addText(T_ID, id);
+ w.addInt(T_ACTION, action);
+ w.addText(T_NAME, name);
+ w.addText(T_ACCOUNT, account);
+ w.addText(T_GROUP, group);
+ w.addInt(T_POSITION, position);
+ var m = material;
+ if (m != null) {
+ w.addBytes(T_MATERIAL, m);
+ }
+ w.addInt(T_COUNTER, counter);
+ w.addText(T_LABEL, label);
+ w.addBool(T_DECODED, decoded);
+ return w.bytes;
+ }
+
+ // The action, or null when the bytes are malformed.
+ static function fromBytes(data as ByteArray) as ManageAction? {
+ var map = Codec.decode(data);
+ if (map == null) {
+ return null;
+ }
+ var a = new ManageAction();
+ var id = map[T_ID];
+ var action = map[T_ACTION];
+ var position = map[T_POSITION];
+ var counter = map[T_COUNTER];
+ if (!(id instanceof String) || !(action instanceof Long) || !(position instanceof Long) || !(counter instanceof Long)) {
+ return null;
+ }
+ if (action < EDIT || action > DELETE || !Entry.isValidId(id)) {
+ return null;
+ }
+ a.id = id;
+ a.action = action.toNumber();
+ a.position = position.toNumber();
+ a.counter = counter;
+ a.name = textOf(map[T_NAME]);
+ a.account = textOf(map[T_ACCOUNT]);
+ a.group = textOf(map[T_GROUP]);
+ a.label = textOf(map[T_LABEL]);
+ var m = map[T_MATERIAL];
+ a.material = m instanceof ByteArray ? m : null;
+ var decoded = map[T_DECODED];
+ a.decoded = decoded instanceof Boolean && decoded;
+ return a;
+ }
+
+ static function textOf(value as ByteArray or String or Long or Boolean or Null) as String {
+ return value instanceof String ? value : "";
+ }
+}
+
+module ManageForm {
+
+ const SWITCH = "mgApply";
+ const STATUS = "mgStatus";
+ const SECRET = "mgSecret";
+
+ const FIELDS = ["mgId", "mgAction", "mgName", "mgAccount", "mgGroup", "mgPosition", "mgSecret", "mgCounter"];
+
+ function defaults() as Dictionary<String, String or Number or Long> {
+ return {
+ SECRET => "",
+ "mgId" => "",
+ "mgAction" => ManageAction.EDIT,
+ "mgName" => "",
+ "mgAccount" => "",
+ "mgGroup" => "",
+ "mgPosition" => 1,
+ "mgCounter" => 0l
+ } as Dictionary<String, String or Number or Long>;
+ }
+
+ // The requested action (not yet checked against the entry), or the
+ // problem. The form content goes into `content` for the form hash.
+ function read(store as Forms.Store, content as Forms.Content) as ManageAction or String {
+ for (var i = 0; i < FIELDS.size(); i++) {
+ content.add(store.get(FIELDS[i] as String));
+ }
+ var a = new ManageAction();
+ a.id = Forms.text(store, "mgId").toUpper();
+ if (a.id.length() == 0) {
+ return "Entry ID: missing. The IDs are under Entry IDs in the watch menu";
+ }
+ if (!Entry.isValidId(a.id)) {
+ return "Entry ID: '" + Text.clip(a.id, 8) + "' is not an ID. IDs have 4 characters, like K7F2";
+ }
+ var action = Forms.number(store, "mgAction");
+ if (action == null || action < ManageAction.EDIT || action > ManageAction.DELETE) {
+ return "Action: choose one from the list";
+ }
+ a.action = action;
+ if (action == ManageAction.EDIT) {
+ a.name = Forms.text(store, "mgName");
+ a.account = Forms.text(store, "mgAccount");
+ a.group = Forms.text(store, "mgGroup");
+ if (a.name.length() == 0 && a.account.length() == 0 && a.group.length() == 0) {
+ return "Edit details: fill in a new name, account or group ('-' clears one)";
+ }
+ var problem = Forms.checkName("Name", a.name, Entry.MAX_NAME_BYTES);
+ if (problem == null) {
+ problem = Forms.checkName("Account", a.account, Entry.MAX_NAME_BYTES);
+ }
+ if (problem == null) {
+ problem = Forms.checkName("Group", a.group, Entry.MAX_GROUP_BYTES);
+ }
+ return problem != null ? problem : a;
+ }
+ if (action == ManageAction.MOVE) {
+ var position = Forms.number(store, "mgPosition");
+ if (position == null || position < 1) {
+ return "Position: 1 or more";
+ }
+ a.position = position;
+ } else if (action == ManageAction.REPLACE) {
+ var secret = Forms.rawText(store, SECRET);
+ if (Text.trim(secret).length() == 0) {
+ return "Replace: enter the new secret or contents";
+ }
+ a.material = Bytes.fromUtf8(secret);
+ } else if (action == ManageAction.COUNTER) {
+ var counter = Forms.long(store, "mgCounter");
+ if (counter == null || counter < 0) {
+ return "Counter: a whole number, 0 or more";
+ }
+ a.counter = counter;
+ }
+ return a;
+ }
+
+ function reset(store as Forms.Store, everything as Boolean) as Void {
+ store.set(SECRET, "");
+ if (everything) {
+ var d = defaults();
+ var keys = d.keys();
+ for (var i = 0; i < keys.size(); i++) {
+ store.set(keys[i], d[keys[i]] as String or Number or Long);
+ }
+ }
+ store.set(SWITCH, false);
+ }
+}
+
+// Checks one action against its entry and applies or queues it. When step()
+// returns true, `message` says what happened; `failed` is true when nothing
+// changed because of a problem, `queued` when it waits for the PIN.
+class ManageRun extends Jobs.Job {
+
+ var message as String = "";
+ var failed as Boolean = false;
+ var queued as Boolean = false;
+ // Remarks (a check digit added, too large for the screen).
+ var notes as Array<String> = [] as Array<String>;
+
+ private var _vault as Vault;
+ private var _a as ManageAction;
+ private var _confirmed as Boolean;
+ private var _header as Entry? = null;
+ private var _check as CodeCheck? = null;
+ private var _cache as ByteArray? = null;
+
+ // confirmed: the PIN was just entered for this action (from the queue),
+ // so it is applied, never queued again.
+ function initialize(vault as Vault, action as ManageAction, confirmed as Boolean) {
+ Job.initialize();
+ _vault = vault;
+ _a = action;
+ _confirmed = confirmed;
+ }
+
+ function step(budget as Number) as Boolean {
+ var check = _check;
+ if (check != null) {
+ if (!check.step(budget)) {
+ return false;
+ }
+ _check = null;
+ var err = check.error;
+ if (err != null) {
+ return fail(err);
+ }
+ notes.addAll(check.notes);
+ // Applying gets the next step: packing a large matrix used
+ // much of this event's budget.
+ _cache = check.cache;
+ return false;
+ }
+ if (_header == null) {
+ try {
+ return resolve();
+ } catch (e instanceof VaultError) {
+ return fail(problemText(e));
+ }
+ }
+ return finish(_cache);
+ }
+
+ // Find the entry and check the action against it. Returns true when
+ // the run ends here (a problem); false to continue with the next step.
+ private function resolve() as Boolean {
+ var a = _a;
+ if (_vault.pendingIds().indexOf(a.id) >= 0) {
+ return fail(a.id + " waits for your PIN on the watch. Enter the PIN there first");
+ }
+ if (_vault.ids().indexOf(a.id) < 0) {
+ return fail("No entry " + a.id + ". The IDs are under Entry IDs in the watch menu");
+ }
+ var header = _vault.header(a.id);
+ if (header == null) {
+ if (a.action == ManageAction.DELETE) {
+ _vault.remove(a.id); // a damaged entry can always be deleted
+ message = "Deleted damaged entry " + a.id;
+ return true;
+ }
+ return fail(a.id + " is damaged. Delete it and add it again");
+ }
+ _header = header;
+ if (a.label.length() == 0) {
+ a.label = Forms.label(header.name, header.account);
+ }
+ var problem = checkAgainst(header);
+ if (problem != null) {
+ return fail(problem);
+ }
+ if (mustWait(header)) {
+ return queue();
+ }
+ if (a.action == ManageAction.REPLACE && header.kind == Entry.KIND_CODE) {
+ return startCodeCheck(header);
+ }
+ return false;
+ }
+
+ // Problems that don't depend on the PIN.
+ private function checkAgainst(header as Entry) as String? {
+ var a = _a;
+ var isOtp = header.kind == Entry.KIND_OTP;
+ if (a.action == ManageAction.COUNTER && !(isOtp && header.type == Entry.OTP_HOTP)) {
+ return "Set counter: " + a.id + " is not a HOTP entry";
+ }
+ if (a.action == ManageAction.EDIT && !isOtp && a.account.length() > 0) {
+ return "Account: " + a.id + " is a barcode, which has no account";
+ }
+ if (a.action == ManageAction.LOCK && header.locked) {
+ return a.id + " already requires the PIN";
+ }
+ if (a.action == ManageAction.UNLOCK && !header.locked) {
+ return a.id + " doesn't require the PIN";
+ }
+ if (a.action == ManageAction.REPLACE && isOtp && !a.decoded) {
+ var raw = a.material as ByteArray;
+ var text = Bytes.toUtf8(raw);
+ Bytes.wipe(raw);
+ a.material = null;
+ var decoded = OtpForm.decodeSecret(text != null ? text : "?");
+ if (decoded instanceof String) {
+ return decoded;
+ }
+ a.material = decoded;
+ a.decoded = true;
+ if (decoded.size() < OtpForm.WEAK_SECRET_BYTES) {
+ notes.add("Warning: the secret is only " + decoded.size() * 8 + " bits (80 or more recommended)");
+ }
+ }
+ return null;
+ }
+
+ // Locked entries' sensitive actions wait for the PIN (README 7.9).
+ private function mustWait(header as Entry) as Boolean {
+ if (_confirmed) {
+ return false;
+ }
+ var action = _a.action;
+ if (action == ManageAction.MOVE) {
+ return false;
+ }
+ if (action == ManageAction.LOCK) {
+ return !_vault.session.isUnlocked();
+ }
+ return header.locked;
+ }
+
+ private function queue() as Boolean {
+ try {
+ ChangeQueue.add(_vault, _a);
+ } catch (e instanceof VaultError) {
+ return fail(e.code == VaultError.LIMIT ? e.getErrorMessage() as String : "storage full");
+ }
+ queued = true;
+ message = "Waiting for your PIN on the watch: " + _a.describe();
+ _a.wipe();
+ return true;
+ }
+
+ // Replacing a code's contents: validate them with the entry's options.
+ private function startCodeCheck(header as Entry) as Boolean {
+ var a = _a;
+ var raw = a.material as ByteArray;
+ var text = Bytes.toUtf8(raw);
+ Bytes.wipe(raw);
+ a.material = null;
+ var contents = CodeForm.contents(text != null ? text : "", header.type);
+ if (contents instanceof String) {
+ return fail(contents);
+ }
+ a.material = contents;
+ var options = Codes.DEFAULT_OPTIONS;
+ try {
+ var entry = _vault.open(a.id);
+ options = entry.options;
+ entry.wipeSecrets();
+ } catch (e instanceof VaultError) {
+ return fail(problemText(e));
+ }
+ var payload = Bytes.toUtf8(contents);
+ _check = new CodeCheck(header.type, payload != null ? payload : "", options);
+ return false;
+ }
+
+ // Apply the action; cache: a checked code's packed matrix, or null.
+ private function finish(cache as ByteArray?) as Boolean {
+ var a = _a;
+ var target = a.id + (a.label.length() > 0 ? " " + a.label : "");
+ try {
+ if (a.action == ManageAction.MOVE) {
+ _vault.move(a.id, a.position - 1);
+ message = "Moved " + target + " to position " + (_vault.ids().indexOf(a.id) + 1);
+ } else if (a.action == ManageAction.COUNTER) {
+ _vault.setCounter(a.id, a.counter);
+ message = "Set the counter of " + target + " to " + a.counter;
+ } else if (a.action == ManageAction.DELETE) {
+ _vault.remove(a.id);
+ message = "Deleted " + target;
+ } else {
+ var problem = changeEntry();
+ if (problem != null) {
+ return fail(problem);
+ }
+ if (cache != null) {
+ _vault.saveCodeCache(a.id, cache);
+ }
+ }
+ } catch (e instanceof VaultError) {
+ a.wipe();
+ return fail(problemText(e));
+ }
+ a.wipe();
+ return true;
+ }
+
+ // Edit, lock, unlock or replace: open, change, re-seal. Null or the problem.
+ private function changeEntry() as String? {
+ var a = _a;
+ var target = a.id + (a.label.length() > 0 ? " " + a.label : "");
+ var entry = _vault.open(a.id);
+ if (a.action == ManageAction.EDIT) {
+ entry.name = edited(entry.name, a.name);
+ entry.account = edited(entry.account, a.account);
+ entry.group = edited(entry.group, a.group);
+ if (entry.name.length() == 0 && (entry.kind == Entry.KIND_CODE || entry.account.length() == 0)) {
+ entry.wipeSecrets();
+ return entry.kind == Entry.KIND_CODE ? "Name: a barcode needs a name" : "An OTP entry needs an issuer or an account";
+ }
+ message = "Edited " + a.id + ": " + Forms.label(entry.name, entry.account)
+ + (entry.group.length() > 0 ? ", group " + entry.group : "");
+ } else if (a.action == ManageAction.LOCK || a.action == ManageAction.UNLOCK) {
+ entry.locked = a.action == ManageAction.LOCK;
+ message = target + (entry.locked ? " now requires the PIN" : " no longer requires the PIN");
+ } else {
+ var material = a.material as ByteArray;
+ if (entry.kind == Entry.KIND_OTP) {
+ Bytes.wipe(entry.secret);
+ entry.secret = Bytes.copy(material);
+ message = "Replaced the secret of " + target;
+ } else {
+ Bytes.wipe(entry.payload);
+ entry.payload = Bytes.copy(material);
+ message = "Replaced the contents of " + target;
+ }
+ }
+ try {
+ _vault.update(entry);
+ } catch (e instanceof VaultError) {
+ entry.wipeSecrets();
+ throw e;
+ }
+ entry.wipeSecrets();
+ return null;
+ }
+
+ // Blank keeps the old value, "-" clears it, anything else replaces it.
+ private function edited(old as String, requested as String) as String {
+ if (requested.length() == 0) {
+ return old;
+ }
+ return requested.equals(ManageAction.CLEAR) ? "" : requested;
+ }
+
+ private function fail(problem as String) as Boolean {
+ failed = true;
+ message = problem;
+ _a.wipe();
+ return true;
+ }
+
+ static function problemText(e as VaultError) as String {
+ if (e.code == VaultError.LOCKED) {
+ return "the watch is locked; try again after entering the PIN";
+ }
+ if (e.code == VaultError.FULL) {
+ return "storage full";
+ }
+ if (e.code == VaultError.NOT_FOUND) {
+ return "the entry no longer exists";
+ }
+ if (e.code == VaultError.DAMAGED) {
+ return "the entry is damaged";
+ }
+ var m = e.getErrorMessage();
+ return m != null ? m : "not possible";
+ }
+}
diff --git a/source/settings/OtpForm.mc b/source/settings/OtpForm.mc
new file mode 100644
index 0000000..40c766f
--- /dev/null
+++ b/source/settings/OtpForm.mc
@@ -0,0 +1,152 @@
+// Author: cirakg & claude ai
+// Summary: The Add OTP form (README 10.2): reads the otp* fields, validates them (README 10.4) and resets the form.
+//
+// read() turns the fields into a new Entry or the first problem, in the
+// words of README 10.3 ("Secret: '1' is not a Base32 character (position
+// 5)"). Messages never quote the secret. Steam ignores the algorithm, digits
+// and period fields (always SHA-1, 5 characters, 30 s); TOTP ignores the
+// counter and HOTP the period.
+
+import Toybox.Lang;
+
+module OtpForm {
+
+ const SWITCH = "otpAdd";
+ const STATUS = "otpStatus";
+ const SECRET = "otpSecret";
+ // Below this many bytes (80 bits) the secret gets a warning (RFC 4226 4).
+ const WEAK_SECRET_BYTES = 10;
+
+ // Every field and its value after a reset (the form's defaults in
+ // resources/settings/properties.xml). The secret comes first: it is
+ // cleared before anything else.
+ function defaults() as Dictionary<String, String or Number or Long or Boolean> {
+ return {
+ SECRET => "",
+ "otpType" => Entry.OTP_TOTP,
+ "otpIssuer" => "",
+ "otpAccount" => "",
+ "otpAlgorithm" => Hashes.SHA1,
+ "otpDigits" => 6,
+ "otpPeriod" => 30,
+ "otpCounter" => 0l,
+ "otpGroup" => "",
+ "otpLocked" => false
+ } as Dictionary<String, String or Number or Long or Boolean>;
+ }
+
+ // The form's fields in their hash order.
+ const FIELDS = ["otpType", "otpIssuer", "otpAccount", "otpSecret", "otpAlgorithm", "otpDigits", "otpPeriod", "otpCounter",
+ "otpGroup", "otpLocked"];
+
+ function read(store as Forms.Store) as Forms.Parsed {
+ var parsed = new Forms.Parsed("otp");
+ for (var i = 0; i < FIELDS.size(); i++) {
+ parsed.content.add(store.get(FIELDS[i] as String));
+ }
+ var type = Forms.number(store, "otpType");
+ if (type == null || type < Entry.OTP_TOTP || type > Entry.OTP_STEAM) {
+ return parsed.fail("Type: choose TOTP, HOTP or Steam");
+ }
+ var e = new Entry();
+ e.kind = Entry.KIND_OTP;
+ e.type = type;
+ e.source = Entry.SOURCE_MANUAL;
+ e.name = Forms.text(store, "otpIssuer");
+ e.account = Forms.text(store, "otpAccount");
+ e.group = Forms.text(store, "otpGroup");
+ e.locked = Forms.flag(store, "otpLocked");
+ if (e.name.length() == 0 && e.account.length() == 0) {
+ return parsed.fail("Fill in the issuer or the account");
+ }
+ var problem = Forms.checkName("Issuer", e.name, Entry.MAX_NAME_BYTES);
+ if (problem == null) {
+ problem = Forms.checkName("Account", e.account, Entry.MAX_NAME_BYTES);
+ }
+ if (problem == null) {
+ problem = Forms.checkName("Group", e.group, Entry.MAX_GROUP_BYTES);
+ }
+ if (problem != null) {
+ return parsed.fail(problem);
+ }
+ problem = readParameters(store, e);
+ if (problem != null) {
+ return parsed.fail(problem);
+ }
+ var secret = Forms.rawText(store, SECRET);
+ var decoded = decodeSecret(secret);
+ if (decoded instanceof String) {
+ return parsed.fail(decoded);
+ }
+ e.secret = decoded;
+ if (decoded.size() < WEAK_SECRET_BYTES) {
+ parsed.notes.add("Warning: the secret is only " + decoded.size() * 8 + " bits (80 or more recommended)");
+ }
+ parsed.entry = e;
+ return parsed;
+ }
+
+ // Algorithm, digits, period and counter by type; null or the problem.
+ function readParameters(store as Forms.Store, e as Entry) as String? {
+ if (e.type == Entry.OTP_STEAM) {
+ e.algorithm = Hashes.SHA1;
+ e.digits = Otp.MIN_DIGITS;
+ e.period = Otp.STEAM_PERIOD;
+ return null;
+ }
+ var algorithm = Forms.number(store, "otpAlgorithm");
+ if (algorithm == null || !Hashes.isValid(algorithm)) {
+ return "Algorithm: choose SHA1, SHA256 or SHA512";
+ }
+ e.algorithm = algorithm as Hashes.Algorithm;
+ var digits = Forms.number(store, "otpDigits");
+ if (digits == null || digits < Otp.MIN_DIGITS || digits > Otp.MAX_DIGITS) {
+ return "Digits: choose 6, 7 or 8";
+ }
+ e.digits = digits;
+ if (e.type == Entry.OTP_TOTP) {
+ var period = Forms.number(store, "otpPeriod");
+ if (period == null || period < Otp.MIN_PERIOD || period > Otp.MAX_PERIOD) {
+ return "Period: 1 to " + Otp.MAX_PERIOD + " seconds";
+ }
+ e.period = period;
+ } else {
+ var counter = Forms.long(store, "otpCounter");
+ if (counter == null || counter < 0) {
+ return "Counter: a whole number, 0 or more";
+ }
+ e.counter = counter;
+ }
+ return null;
+ }
+
+ // The secret's bytes, or the message for the status field.
+ function decodeSecret(text as String) as ByteArray or String {
+ if (Text.trim(text).length() == 0) {
+ return "Secret: missing";
+ }
+ var decoded = Base32.decode(text);
+ if (decoded instanceof Base32.DecodeError) {
+ return "Secret: " + decoded.message + (decoded.position > 0 ? " (position " + decoded.position + ")" : "");
+ }
+ if (decoded.size() == 0 || decoded.size() > Entry.MAX_SECRET_BYTES) {
+ Bytes.wipe(decoded);
+ return "Secret: must decode to 1 to " + Entry.MAX_SECRET_BYTES + " bytes";
+ }
+ return decoded;
+ }
+
+ // Turn the switch off and clear the form: everything after it was
+ // applied, only the secret after a problem (so the rest can be fixed).
+ function reset(store as Forms.Store, everything as Boolean) as Void {
+ store.set(SECRET, "");
+ if (everything) {
+ var d = defaults();
+ var keys = d.keys();
+ for (var i = 0; i < keys.size(); i++) {
+ store.set(keys[i], d[keys[i]] as String or Number or Long or Boolean);
+ }
+ }
+ store.set(SWITCH, false);
+ }
+}
diff --git a/source/settings/SettingsJob.mc b/source/settings/SettingsJob.mc
new file mode 100644
index 0000000..e6e7bcf
--- /dev/null
+++ b/source/settings/SettingsJob.mc
@@ -0,0 +1,351 @@
+// Author: cirakg & claude ai
+// Summary: Applies every settings form that asks for it, in README 10.3 order, as one resumable job.
+//
+// Order (README 10.3, rule 6): Import, Add OTP, Add code, Manage entry, then
+// the About fields (the Options group needs no step: AppState reloads the
+// options before this job starts). For each form whose switch is on:
+// 1. Read and validate the fields; hash their content (keyed, see
+// KeyManager.formHash).
+// 2. A hash in the applied-forms log means the phone sent the same form
+// again: nothing is applied (rule 3).
+// 3. Apply it. On success the form is cleared (secret fields first) and
+// its hash remembered; after a problem only the secret field is
+// cleared, so the rest can be corrected. The switch always goes off.
+// 4. The status field and the results lines say what happened.
+// Each phase runs in its own steps (one timer event each), so the watchdog
+// never sees a whole import or a large 2D code at once.
+
+import Toybox.Lang;
+import Toybox.System;
+
+class SettingsJob extends Jobs.Job {
+
+ static const P_IMPORT = 0;
+ static const P_OTP = 1;
+ static const P_CODE = 2;
+ static const P_MANAGE = 3;
+ static const P_SCAN = 4;
+ static const P_DONE = 5;
+
+ static const IMPORT_TEXT_FIELDS = 8;
+
+ var phase as Number = P_IMPORT;
+ // Lines for the results screen.
+ var lines as Array<String> = [] as Array<String>;
+ // Forms looked at (applied, refused or repeated).
+ var forms as Number = 0;
+ // Entries were added, changed or removed (cached codes are stale).
+ var changed as Boolean = false;
+
+ private var _vault as Vault;
+ private var _store as Forms.Store;
+ private var _now as Long;
+ private var _hash as String = "";
+ private var _importer as Importer? = null;
+ private var _importStart as Number = 0;
+ private var _code as Forms.Parsed? = null;
+ private var _check as CodeCheck? = null;
+ private var _manage as ManageRun? = null;
+ private var _scan as AboutScan? = null;
+
+ function initialize(vault as Vault, store as Forms.Store, now as Long) {
+ Job.initialize();
+ _vault = vault;
+ _store = store;
+ _now = now;
+ }
+
+ // True when a form's switch is on or the About fields are out of date.
+ static function hasWork(vault as Vault, store as Forms.Store) as Boolean {
+ return Forms.flag(store, "impApply") || Forms.flag(store, OtpForm.SWITCH) || Forms.flag(store, CodeForm.SWITCH)
+ || Forms.flag(store, ManageForm.SWITCH) || AboutScan.needed(vault);
+ }
+
+ // "Importing… 40%" while an import runs, else a general text.
+ function busyText() as String {
+ var importer = _importer;
+ return importer != null ? "Importing… " + importer.percent() + "%" : "Applying settings…";
+ }
+
+ function step(budget as Number) as Boolean {
+ if (phase == P_IMPORT) {
+ stepImport(budget);
+ } else if (phase == P_OTP) {
+ stepOtp();
+ } else if (phase == P_CODE) {
+ stepCode(budget);
+ } else if (phase == P_MANAGE) {
+ stepManage(budget);
+ } else if (phase == P_SCAN) {
+ stepScan(budget);
+ }
+ return phase == P_DONE;
+ }
+
+ // ---- Import (README 11) ---------------------------------------------
+
+ private function stepImport(budget as Number) as Void {
+ var importer = _importer;
+ if (importer != null) {
+ if (importer.step(budget)) {
+ finishImport(importer);
+ }
+ return;
+ }
+ if (!Forms.flag(_store, "impApply")) {
+ phase = P_OTP;
+ return;
+ }
+ forms++;
+ var mode = Forms.number(_store, "impMode");
+ var locked = Forms.flag(_store, "impLocked");
+ var importMode = mode != null && mode >= Importer.MODE_APPEND && mode <= Importer.MODE_CANCEL ? mode : Importer.MODE_APPEND;
+ if (importMode == Importer.MODE_CANCEL) {
+ ImportBuffer.clear(_vault.storage());
+ endImport(["Import: waiting import cancelled."] as Array<String>, "Waiting import cancelled", false);
+ return;
+ }
+ var content = new Forms.Content("import");
+ content.add(importMode);
+ content.add(locked);
+ var text = "";
+ for (var i = 1; i <= IMPORT_TEXT_FIELDS; i++) {
+ var part = Forms.rawText(_store, "impText" + i);
+ content.add(part);
+ text += part;
+ }
+ _hash = Forms.hash(_vault, content);
+ if (Forms.alreadyApplied(_vault, _hash)) {
+ endImport(["Import: already applied. Change the text to import again."] as Array<String>, "Already imported", true);
+ return;
+ }
+ _importStart = System.getTimer();
+ _importer = new Importer(_vault, text, importMode, locked, _now);
+ }
+
+ private function finishImport(importer as Importer) as Void {
+ _importer = null;
+ var report = importer.report;
+ Diag.importDone(report.imported, System.getTimer() - _importStart);
+ if (report.failure == null && report.waitingChars < 0) {
+ Forms.rememberApplied(_vault, _hash);
+ }
+ if (report.imported > 0 || report.removed > 0) {
+ changed = true;
+ }
+ var out = report.lines();
+ out[0] = "Import: " + out[0];
+ endImport(out, report.summary(), true);
+ }
+
+ // The text fields are cleared whenever their content was used (they
+ // hold secrets); a cancel leaves them.
+ private function endImport(out as Array<String>, status as String, clearText as Boolean) as Void {
+ if (clearText) {
+ for (var i = 1; i <= IMPORT_TEXT_FIELDS; i++) {
+ _store.set("impText" + i, "");
+ }
+ }
+ _store.set("impApply", false);
+ Forms.status(_store, "impStatus", status);
+ lines.addAll(out);
+ phase = P_OTP;
+ }
+
+ // ---- Add OTP ---------------------------------------------------------
+
+ private function stepOtp() as Void {
+ phase = P_CODE;
+ if (!Forms.flag(_store, OtpForm.SWITCH)) {
+ return;
+ }
+ forms++;
+ var parsed = OtpForm.read(_store);
+ var problem = parsed.error;
+ if (problem != null) {
+ parsed.content.wipe();
+ OtpForm.reset(_store, false);
+ report("Add OTP", OtpForm.STATUS, "Not added: " + problem, [] as Array<String>);
+ return;
+ }
+ var hash = Forms.hash(_vault, parsed.content);
+ var entry = parsed.entry as Entry;
+ if (Forms.alreadyApplied(_vault, hash)) {
+ entry.wipeSecrets();
+ OtpForm.reset(_store, true);
+ report("Add OTP", OtpForm.STATUS, "Already added. Change a field to add another entry", [] as Array<String>);
+ return;
+ }
+ addEntry(entry, hash, "Add OTP", OtpForm.STATUS, parsed.notes, null);
+ if (entry.id.length() > 0) {
+ OtpForm.reset(_store, true);
+ } else {
+ OtpForm.reset(_store, false);
+ }
+ }
+
+ // Store a checked entry, write the status; on success remember the hash
+ // (entry.id is set). cache: a code's packed matrix, or null.
+ private function addEntry(entry as Entry, hash as String, form as String, statusKey as String, notes as Array<String>,
+ cache as ByteArray?) as Void {
+ var id = "";
+ try {
+ id = _vault.add(entry);
+ } catch (e instanceof VaultError) {
+ entry.wipeSecrets();
+ entry.id = "";
+ var why = e.code == VaultError.LIMIT ? e.getErrorMessage() as String : ManageRun.problemText(e);
+ report(form, statusKey, "Not added: " + why, [] as Array<String>);
+ return;
+ }
+ entry.wipeSecrets();
+ changed = true;
+ Forms.rememberApplied(_vault, hash);
+ var pending = _vault.pendingIds().indexOf(id) >= 0;
+ if (cache != null && !pending) {
+ try {
+ _vault.saveCodeCache(id, cache);
+ } catch (e instanceof VaultError) {
+ // Locked meanwhile: the code is encoded when first opened.
+ }
+ }
+ var label = Forms.label(entry.name, entry.account);
+ var text = "Added " + id + " " + label + (pending ? ". It waits for your PIN on the watch" : "");
+ report(form, statusKey, text, notes);
+ }
+
+ // Status field (with the remarks) and results lines.
+ private function report(form as String, statusKey as String, text as String, notes as Array<String>) as Void {
+ var status = text;
+ for (var i = 0; i < notes.size(); i++) {
+ status += "; " + notes[i];
+ }
+ Forms.status(_store, statusKey, status);
+ lines.add(form + ": " + text);
+ lines.addAll(notes);
+ }
+
+ // ---- Add code --------------------------------------------------------
+
+ private function stepCode(budget as Number) as Void {
+ var check = _check;
+ var parsed = _code;
+ if (check != null && parsed != null) {
+ if (check.step(budget)) {
+ _check = null;
+ _code = null;
+ finishCode(parsed, check);
+ }
+ return;
+ }
+ if (!Forms.flag(_store, CodeForm.SWITCH)) {
+ phase = P_MANAGE;
+ return;
+ }
+ forms++;
+ parsed = CodeForm.read(_store);
+ var problem = parsed.error;
+ if (problem != null) {
+ parsed.content.wipe();
+ CodeForm.reset(_store, false);
+ report("Add code", CodeForm.STATUS, "Not added: " + problem, [] as Array<String>);
+ phase = P_MANAGE;
+ return;
+ }
+ _hash = Forms.hash(_vault, parsed.content);
+ var entry = parsed.entry as Entry;
+ if (Forms.alreadyApplied(_vault, _hash)) {
+ entry.wipeSecrets();
+ CodeForm.reset(_store, true);
+ report("Add code", CodeForm.STATUS, "Already added. Change a field to add another card", [] as Array<String>);
+ phase = P_MANAGE;
+ return;
+ }
+ var payload = Bytes.toUtf8(entry.payload as ByteArray);
+ _code = parsed;
+ _check = new CodeCheck(entry.type, payload != null ? payload : "", entry.options);
+ }
+
+ private function finishCode(parsed as Forms.Parsed, check as CodeCheck) as Void {
+ phase = P_MANAGE;
+ var entry = parsed.entry as Entry;
+ var problem = check.error;
+ if (problem != null) {
+ entry.wipeSecrets();
+ CodeForm.reset(_store, false);
+ report("Add code", CodeForm.STATUS, "Not added: " + problem, [] as Array<String>);
+ return;
+ }
+ var notes = parsed.notes;
+ notes.addAll(check.notes);
+ addEntry(entry, _hash, "Add code", CodeForm.STATUS, notes, check.cache);
+ CodeForm.reset(_store, entry.id.length() > 0);
+ }
+
+ // ---- Manage entry ----------------------------------------------------
+
+ private function stepManage(budget as Number) as Void {
+ var run = _manage;
+ if (run != null) {
+ if (run.step(budget)) {
+ _manage = null;
+ finishManage(run);
+ }
+ return;
+ }
+ if (!Forms.flag(_store, ManageForm.SWITCH)) {
+ phase = P_SCAN;
+ return;
+ }
+ forms++;
+ var content = new Forms.Content("manage");
+ var action = ManageForm.read(_store, content);
+ if (action instanceof String) {
+ content.wipe();
+ ManageForm.reset(_store, false);
+ report("Manage", ManageForm.STATUS, "Not applied: " + action, [] as Array<String>);
+ phase = P_SCAN;
+ return;
+ }
+ _hash = Forms.hash(_vault, content);
+ if (Forms.alreadyApplied(_vault, _hash)) {
+ action.wipe();
+ ManageForm.reset(_store, true);
+ report("Manage", ManageForm.STATUS, "Already applied. Change a field to send another action", [] as Array<String>);
+ phase = P_SCAN;
+ return;
+ }
+ _manage = new ManageRun(_vault, action, false);
+ }
+
+ private function finishManage(run as ManageRun) as Void {
+ phase = P_SCAN;
+ if (run.failed) {
+ ManageForm.reset(_store, false);
+ report("Manage", ManageForm.STATUS, "Not applied: " + run.message, [] as Array<String>);
+ return;
+ }
+ changed = changed || !run.queued;
+ Forms.rememberApplied(_vault, _hash);
+ ManageForm.reset(_store, true);
+ report("Manage", ManageForm.STATUS, run.message, run.notes);
+ }
+
+ // ---- About fields ----------------------------------------------------
+
+ private function stepScan(budget as Number) as Void {
+ var scan = _scan;
+ if (scan == null) {
+ if (forms == 0 && !AboutScan.needed(_vault)) {
+ phase = P_DONE;
+ return;
+ }
+ scan = new AboutScan(_vault, _store);
+ _scan = scan;
+ }
+ if (scan.step(budget)) {
+ _scan = null;
+ phase = P_DONE;
+ }
+ }
+}
diff --git a/source/settings/SettingsProcessor.mc b/source/settings/SettingsProcessor.mc
index 4c0a429..cf7684b 100644
--- a/source/settings/SettingsProcessor.mc
+++ b/source/settings/SettingsProcessor.mc
@@ -1,34 +1,25 @@
// Author: cirakg & claude ai
-// Summary: Applies the phone's settings forms on the watch (README 10.3); in M5 the Import form.
+// Summary: Runs the settings forms (SettingsJob) on a timer from the app, then stores and shows the results (README 10.3).
//
// 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.
+// * Runs from onSettingsChanged, at every start (after the first frame)
+// and after the PIN is entered (which may let "Encrypt names" finish).
+// * The work itself is SettingsJob; this class only drives it on a timer
+// (one step per tick), and when forms were looked at, stores the
+// results lines and opens the results screen.
+// * New settings arriving while a run is going start another run when it
+// ends, so nothing is missed.
// 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;
+ private var _job as SettingsJob? = 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;
+ private var _again as Boolean = false;
function initialize() {
}
@@ -37,114 +28,56 @@ class SettingsProcessor {
return _job != null;
}
- // "Importing… 40%" while an import runs, else null.
+ // "Importing… 40%" or "Applying settings…" while a run goes, else null.
function busyText() as String? {
var job = _job;
- return job != null ? "Importing… " + job.percent() + "%" : null;
+ return job != null && job.forms > 0 ? job.busyText() : null;
}
- // Look at the forms and start whatever they ask for.
+ // Start a run if any form asks for one (or the About fields are stale).
function start() as Void {
- if (_job != null || AppState.vault == null) {
+ if (AppState.vault == null) {
return;
}
- if (!isTrue(prop("impApply"))) {
+ if (_job != null) {
+ _again = true;
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);
+ var store = new Forms.PropertyStore();
+ if (!SettingsJob.hasWork(vault, store)) {
return;
}
- _startedAt = System.getTimer();
- var job = new Importer(vault, text, importMode, locked, Clock.nowUnix(0));
+ var job = new SettingsJob(vault, store, Clock.nowUnix(0));
_job = job;
- var r = new Jobs.JobRunner(job, 1, method(:onImportDone));
+ var r = new Jobs.JobRunner(job, 1, method(:onDone));
runner = r;
r.start();
WatchUi.requestUpdate();
}
- function onImportDone() as Void {
- var job = _job as Importer;
+ function onDone() as Void {
+ var job = _job as SettingsJob;
_job = null;
runner = null;
- var report = job.report;
- Diag.importDone(report.imported, System.getTimer() - _startedAt);
- if (report.failure == null && report.waitingChars < 0) {
- rememberApplied(_formHash);
+ if (job.changed) {
+ AppState.theCodes().clear();
}
- 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, "");
+ if (job.forms > 0) {
+ var lines = job.lines;
+ var waiting = ChangeQueue.count(AppState.theVault());
+ if (waiting > 0) {
+ lines.add(waiting + (waiting == 1 ? " change waits" : " changes wait")
+ + " for your PIN: menu, Phone changes.");
}
+ Results.save(lines);
+ WatchUi.pushView(new InfoView("Results", lines, "results"), new InfoDelegate(), WatchUi.SLIDE_UP);
+ Results.markSeen();
}
- 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>;
+ WatchUi.requestUpdate();
+ if (_again) {
+ _again = false;
+ start();
}
- Application.Storage.setValue(FORM_LOG_KEY, log);
}
}