// 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 = {} as Dictionary; 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 = [] as Array; 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 = [] as Array; 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; } } 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 : [] as Array; log.add(hash); if (log.size() > LOG_SIZE) { log = log.slice(log.size() - LOG_SIZE, null) as Array; } 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)); } }