summaryrefslogtreecommitdiff
path: root/source/settings/Forms.mc
diff options
context:
space:
mode:
Diffstat (limited to 'source/settings/Forms.mc')
-rw-r--r--source/settings/Forms.mc255
1 files changed, 255 insertions, 0 deletions
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));
+ }
+}