diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-20 18:36:54 +0200 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-20 18:36:54 +0200 |
| commit | 151bfda5d4c567ff909a61dc9c90bf8eece1db37 (patch) | |
| tree | f8487b91cf6a9ce59dba02e95e67250fc84d8e80 /source/settings/SettingsJob.mc | |
| parent | 25910f3a7a7fb28c4a58367b6e56dd2468b60933 (diff) | |
feat(settings): add the Add OTP, Add code and Manage entry forms with a PIN queue for locked entries
The full phone settings form (README 10.2) and its processing (10.3) as one
resumable job: Import, Add OTP, Add code, Manage, then the About fields.
Forms are applied only when their switch is on, cleared afterwards (secret
first; only the secret after a problem), deduplicated by keyed hash, and
report into their Result field and the results screen.
- Add OTP validates per README 10.4; Add code runs the real encoder
(CodeCheck, a job for 2D codes) and caches 2D codes on the way in.
- Manage entry: edit, move, require/drop PIN, replace, counter, delete.
Changes to locked entries are sealed under "q" and applied only after
the PIN is entered under "Phone changes" in the menu.
- About: catalog (800 characters max), version, storage use.
- "Encrypt names" now applies to new and existing entries.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'source/settings/SettingsJob.mc')
| -rw-r--r-- | source/settings/SettingsJob.mc | 351 |
1 files changed, 351 insertions, 0 deletions
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; + } + } +} |
