summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
commit151bfda5d4c567ff909a61dc9c90bf8eece1db37 (patch)
treef8487b91cf6a9ce59dba02e95e67250fc84d8e80
parent25910f3a7a7fb28c4a58367b6e56dd2468b60933 (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>
-rw-r--r--resources/settings/properties.xml57
-rw-r--r--resources/settings/settings.xml206
-rw-r--r--resources/strings/strings.xml118
-rw-r--r--source/app/AppState.mc16
-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
-rw-r--r--source/storage/KeyManager.mc24
-rw-r--r--source/storage/Vault.mc19
-rw-r--r--source/ui/ListView.mc9
-rw-r--r--source/ui/MainMenu.mc8
-rw-r--r--source/ui/PinView.mc2
-rw-r--r--source/ui/QueueMenu.mc91
18 files changed, 2369 insertions, 120 deletions
diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml
index e6b75a4..bcd31b6 100644
--- a/resources/settings/properties.xml
+++ b/resources/settings/properties.xml
@@ -1,17 +1,57 @@
<!--
Author: cirakg & claude ai
- Summary: App properties (README 10.2): the Import form (M5) and the Options group with their defaults.
+ Summary: App properties (README 10.2): every settings form field with its default, the options and the read-only About fields.
-->
<!--
Every property the watch reads must be defined here: reading an undefined
one stops the app with a system error that can't be caught.
- * Import (M5): read by source/settings/SettingsProcessor.mc. impText1-8
- are joined in order; the watch clears them and turns impApply off after
- processing, and writes the result to impStatus.
- * Options: read by source/app/Options.mc; list values are indexes. Their
- phone form follows in M7 with the other groups.
+ * Forms (Add OTP, Add code, Manage entry, Import): read by
+ source/settings/SettingsJob.mc when their switch (otpAdd, codeAdd,
+ mgApply, impApply) is on. Afterwards the watch turns the switch off,
+ clears the form (secret fields first) and writes *Status. The defaults
+ here are the values a cleared form returns to (OtpForm/CodeForm/
+ ManageForm.defaults() must match them).
+ * Options: read by source/app/Options.mc; list values are indexes.
+ * About (catalog, version, storageUse): written by the watch only
+ (source/settings/AboutScan.mc).
-->
<properties>
+ <!-- Add OTP -->
+ <property id="otpType" type="number">0</property>
+ <property id="otpIssuer" type="string"></property>
+ <property id="otpAccount" type="string"></property>
+ <property id="otpSecret" type="string"></property>
+ <property id="otpAlgorithm" type="number">0</property>
+ <property id="otpDigits" type="number">6</property>
+ <property id="otpPeriod" type="number">30</property>
+ <property id="otpCounter" type="long">0</property>
+ <property id="otpGroup" type="string"></property>
+ <property id="otpLocked" type="boolean">false</property>
+ <property id="otpAdd" type="boolean">false</property>
+ <property id="otpStatus" type="string"></property>
+ <!-- Add code -->
+ <property id="codeName" type="string"></property>
+ <property id="codeSymbology" type="number">0</property>
+ <property id="codePayload" type="string"></property>
+ <property id="codeEcLevel" type="number">1</property>
+ <property id="codeCheckDigit" type="number">0</property>
+ <property id="codeShowText" type="boolean">true</property>
+ <property id="codeGroup" type="string"></property>
+ <property id="codeLocked" type="boolean">false</property>
+ <property id="codeAdd" type="boolean">false</property>
+ <property id="codeStatus" type="string"></property>
+ <!-- Manage entry -->
+ <property id="mgId" type="string"></property>
+ <property id="mgAction" type="number">0</property>
+ <property id="mgName" type="string"></property>
+ <property id="mgAccount" type="string"></property>
+ <property id="mgGroup" type="string"></property>
+ <property id="mgPosition" type="number">1</property>
+ <property id="mgSecret" type="string"></property>
+ <property id="mgCounter" type="long">0</property>
+ <property id="mgApply" type="boolean">false</property>
+ <property id="mgStatus" type="string"></property>
+ <!-- Import -->
<property id="impText1" type="string"></property>
<property id="impText2" type="string"></property>
<property id="impText3" type="string"></property>
@@ -24,6 +64,7 @@
<property id="impLocked" type="boolean">false</property>
<property id="impApply" type="boolean">false</property>
<property id="impStatus" type="string"></property>
+ <!-- Options -->
<property id="optAutoLock" type="number">3</property>
<property id="optWipe" type="boolean">false</property>
<property id="optTimeOffset" type="number">0</property>
@@ -33,4 +74,8 @@
<property id="optEncryptNames" type="boolean">false</property>
<property id="optGlanceCode" type="boolean">false</property>
<property id="optBrightness" type="boolean">true</property>
+ <!-- About (written by the watch) -->
+ <property id="catalog" type="string"></property>
+ <property id="version" type="string"></property>
+ <property id="storageUse" type="string"></property>
</properties>
diff --git a/resources/settings/settings.xml b/resources/settings/settings.xml
index a8c3b66..d6ae008 100644
--- a/resources/settings/settings.xml
+++ b/resources/settings/settings.xml
@@ -1,8 +1,158 @@
<!--
Author: cirakg & claude ai
- Summary: The app settings form in Garmin Connect: the Import group (M5); the other groups follow in M7.
+ Summary: The app settings form in Garmin Connect (README 10.2): Add OTP, Add code, Manage entry, Import, Options and About.
+-->
+<!--
+ The forms are requests to the watch: fill one in, turn its switch on and
+ save; the watch applies it, clears it and writes the result into its
+ Result field (README 10.1, 10.3). List values are indexes the watch maps
+ (source/settings/*Form.mc, source/app/Options.mc).
-->
<settings>
+ <group id="addOtp" title="@Strings.GroupAddOtp" description="@Strings.GroupAddOtpHelp">
+ <setting propertyKey="@Properties.otpType" title="@Strings.OtpType">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.TypeTotp</listEntry>
+ <listEntry value="1">@Strings.TypeHotp</listEntry>
+ <listEntry value="2">@Strings.TypeSteam</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.otpIssuer" title="@Strings.OtpIssuer">
+ <settingConfig type="alphaNumeric" maxLength="40" />
+ </setting>
+ <setting propertyKey="@Properties.otpAccount" title="@Strings.OtpAccount">
+ <settingConfig type="alphaNumeric" maxLength="64" />
+ </setting>
+ <setting propertyKey="@Properties.otpSecret" title="@Strings.OtpSecret" prompt="@Strings.OtpSecretPrompt">
+ <settingConfig type="password" />
+ </setting>
+ <setting propertyKey="@Properties.otpAlgorithm" title="@Strings.OtpAlgorithm">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.AlgSha1</listEntry>
+ <listEntry value="1">@Strings.AlgSha256</listEntry>
+ <listEntry value="2">@Strings.AlgSha512</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.otpDigits" title="@Strings.OtpDigits">
+ <settingConfig type="list">
+ <listEntry value="6">@Strings.Digits6</listEntry>
+ <listEntry value="7">@Strings.Digits7</listEntry>
+ <listEntry value="8">@Strings.Digits8</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.otpPeriod" title="@Strings.OtpPeriod">
+ <settingConfig type="numeric" min="1" max="300" errorMessage="@Strings.OtpPeriodError" />
+ </setting>
+ <setting propertyKey="@Properties.otpCounter" title="@Strings.OtpCounter">
+ <settingConfig type="numeric" min="0" errorMessage="@Strings.CounterError" />
+ </setting>
+ <setting propertyKey="@Properties.otpGroup" title="@Strings.Group">
+ <settingConfig type="alphaNumeric" maxLength="32" />
+ </setting>
+ <setting propertyKey="@Properties.otpLocked" title="@Strings.RequirePin">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.otpAdd" title="@Strings.AddThisEntry">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.otpStatus" title="@Strings.Status">
+ <settingConfig type="alphaNumeric" readonly="true" />
+ </setting>
+ </group>
+ <group id="addCode" title="@Strings.GroupAddCode" description="@Strings.GroupAddCodeHelp">
+ <setting propertyKey="@Properties.codeName" title="@Strings.CodeName">
+ <settingConfig type="alphaNumeric" maxLength="40" />
+ </setting>
+ <setting propertyKey="@Properties.codeSymbology" title="@Strings.CodeSymbology">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.SymQr</listEntry>
+ <listEntry value="1">@Strings.SymCode128</listEntry>
+ <listEntry value="2">@Strings.SymCode39</listEntry>
+ <listEntry value="3">@Strings.SymEan13</listEntry>
+ <listEntry value="4">@Strings.SymEan8</listEntry>
+ <listEntry value="5">@Strings.SymUpcA</listEntry>
+ <listEntry value="6">@Strings.SymUpcE</listEntry>
+ <listEntry value="7">@Strings.SymItf</listEntry>
+ <listEntry value="8">@Strings.SymCodabar</listEntry>
+ <listEntry value="9">@Strings.SymDataMatrix</listEntry>
+ <listEntry value="10">@Strings.SymPdf417</listEntry>
+ <listEntry value="11">@Strings.SymAztec</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.codePayload" title="@Strings.CodePayload" prompt="@Strings.CodePayloadPrompt">
+ <settingConfig type="alphaNumeric" />
+ </setting>
+ <setting propertyKey="@Properties.codeEcLevel" title="@Strings.CodeEcLevel">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.EcLow</listEntry>
+ <listEntry value="1">@Strings.EcMedium</listEntry>
+ <listEntry value="2">@Strings.EcQuartile</listEntry>
+ <listEntry value="3">@Strings.EcHigh</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.codeCheckDigit" title="@Strings.CodeCheckDigit">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.CheckAddVerify</listEntry>
+ <listEntry value="1">@Strings.CheckFix</listEntry>
+ <listEntry value="2">@Strings.CheckOff</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.codeShowText" title="@Strings.CodeShowText">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.codeGroup" title="@Strings.Group">
+ <settingConfig type="alphaNumeric" maxLength="32" />
+ </setting>
+ <setting propertyKey="@Properties.codeLocked" title="@Strings.RequirePin">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.codeAdd" title="@Strings.AddThisEntry">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.codeStatus" title="@Strings.Status">
+ <settingConfig type="alphaNumeric" readonly="true" />
+ </setting>
+ </group>
+ <group id="manage" title="@Strings.GroupManage" description="@Strings.GroupManageHelp">
+ <setting propertyKey="@Properties.mgId" title="@Strings.MgId" prompt="@Strings.MgIdPrompt">
+ <settingConfig type="alphaNumeric" maxLength="4" />
+ </setting>
+ <setting propertyKey="@Properties.mgAction" title="@Strings.MgAction">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.ActEdit</listEntry>
+ <listEntry value="1">@Strings.ActMove</listEntry>
+ <listEntry value="2">@Strings.ActLock</listEntry>
+ <listEntry value="3">@Strings.ActUnlock</listEntry>
+ <listEntry value="4">@Strings.ActReplace</listEntry>
+ <listEntry value="5">@Strings.ActCounter</listEntry>
+ <listEntry value="6">@Strings.ActDelete</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.mgName" title="@Strings.MgName">
+ <settingConfig type="alphaNumeric" maxLength="64" />
+ </setting>
+ <setting propertyKey="@Properties.mgAccount" title="@Strings.MgAccount">
+ <settingConfig type="alphaNumeric" maxLength="64" />
+ </setting>
+ <setting propertyKey="@Properties.mgGroup" title="@Strings.MgGroup">
+ <settingConfig type="alphaNumeric" maxLength="32" />
+ </setting>
+ <setting propertyKey="@Properties.mgPosition" title="@Strings.MgPosition">
+ <settingConfig type="numeric" min="1" max="150" errorMessage="@Strings.PositionError" />
+ </setting>
+ <setting propertyKey="@Properties.mgSecret" title="@Strings.MgSecret">
+ <settingConfig type="password" />
+ </setting>
+ <setting propertyKey="@Properties.mgCounter" title="@Strings.MgCounter">
+ <settingConfig type="numeric" min="0" errorMessage="@Strings.CounterError" />
+ </setting>
+ <setting propertyKey="@Properties.mgApply" title="@Strings.MgApply">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.mgStatus" title="@Strings.Status">
+ <settingConfig type="alphaNumeric" readonly="true" />
+ </setting>
+ </group>
<group id="import" title="@Strings.GroupImport" description="@Strings.GroupImportHelp">
<setting propertyKey="@Properties.impText1" title="@Strings.ImpText1" prompt="@Strings.ImpTextPrompt">
<settingConfig type="alphaNumeric" />
@@ -45,4 +195,58 @@
<settingConfig type="alphaNumeric" readonly="true" />
</setting>
</group>
+ <group id="options" title="@Strings.GroupOptions" description="@Strings.GroupOptionsHelp">
+ <setting propertyKey="@Properties.optAutoLock" title="@Strings.OptAutoLock">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.LockNow</listEntry>
+ <listEntry value="1">@Strings.Lock15s</listEntry>
+ <listEntry value="2">@Strings.Lock30s</listEntry>
+ <listEntry value="3">@Strings.Lock1m</listEntry>
+ <listEntry value="4">@Strings.Lock2m</listEntry>
+ <listEntry value="5">@Strings.Lock5m</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.optWipe" title="@Strings.OptWipe">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.optTimeOffset" title="@Strings.OptTimeOffset">
+ <settingConfig type="numeric" min="-120" max="120" errorMessage="@Strings.OffsetError" />
+ </setting>
+ <setting propertyKey="@Properties.optCodeSize" title="@Strings.OptCodeSize">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.SizeNormal</listEntry>
+ <listEntry value="1">@Strings.SizeLarge</listEntry>
+ <listEntry value="2">@Strings.SizeHuge</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.optTheme" title="@Strings.OptTheme">
+ <settingConfig type="list">
+ <listEntry value="0">@Strings.ThemeDark</listEntry>
+ <listEntry value="1">@Strings.ThemeLight</listEntry>
+ </settingConfig>
+ </setting>
+ <setting propertyKey="@Properties.optGroups" title="@Strings.OptGroups">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.optEncryptNames" title="@Strings.OptEncryptNames">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.optGlanceCode" title="@Strings.OptGlanceCode">
+ <settingConfig type="boolean" />
+ </setting>
+ <setting propertyKey="@Properties.optBrightness" title="@Strings.OptBrightness">
+ <settingConfig type="boolean" />
+ </setting>
+ </group>
+ <group id="about" title="@Strings.GroupAbout" description="@Strings.GroupAboutHelp">
+ <setting propertyKey="@Properties.catalog" title="@Strings.Catalog">
+ <settingConfig type="alphaNumeric" readonly="true" />
+ </setting>
+ <setting propertyKey="@Properties.version" title="@Strings.Version">
+ <settingConfig type="alphaNumeric" readonly="true" />
+ </setting>
+ <setting propertyKey="@Properties.storageUse" title="@Strings.StorageUse">
+ <settingConfig type="alphaNumeric" readonly="true" />
+ </setting>
+ </group>
</settings>
diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml
index d571554..32f6cc6 100644
--- a/resources/strings/strings.xml
+++ b/resources/strings/strings.xml
@@ -1,10 +1,93 @@
<!--
Author: cirakg & claude ai
- Summary: User-visible strings of the app (English).
+ Summary: User-visible strings of the app (English): the app name and the phone settings form.
-->
<!-- User-visible strings. English only for now (README section 14). -->
<strings>
<string id="AppName">OTP Wallet</string>
+
+ <!-- Settings: shared -->
+ <string id="Status">Result</string>
+ <string id="Group">Group (optional)</string>
+ <string id="RequirePin">Require PIN to display</string>
+ <string id="AddThisEntry">Add this entry</string>
+ <string id="CounterError">A whole number, 0 or more</string>
+
+ <!-- Settings: Add OTP (README 10.2) -->
+ <string id="GroupAddOtp">Add OTP</string>
+ <string id="GroupAddOtpHelp">Fill in one account, turn on Add this entry and save. The watch adds it, clears this form and writes the result below. The secret is the Base32 text the service shows next to its QR code.</string>
+ <string id="OtpType">Type</string>
+ <string id="TypeTotp">TOTP (time-based)</string>
+ <string id="TypeHotp">HOTP (counter-based)</string>
+ <string id="TypeSteam">Steam Guard</string>
+ <string id="OtpIssuer">Issuer (service)</string>
+ <string id="OtpAccount">Account</string>
+ <string id="OtpSecret">Secret (Base32)</string>
+ <string id="OtpSecretPrompt">Letters A-Z and digits 2-7; spaces are ignored</string>
+ <string id="OtpAlgorithm">Algorithm</string>
+ <string id="AlgSha1">SHA1 (most services)</string>
+ <string id="AlgSha256">SHA256</string>
+ <string id="AlgSha512">SHA512</string>
+ <string id="OtpDigits">Digits</string>
+ <string id="Digits6">6</string>
+ <string id="Digits7">7</string>
+ <string id="Digits8">8</string>
+ <string id="OtpPeriod">Period in seconds (TOTP)</string>
+ <string id="OtpPeriodError">1 to 300 seconds</string>
+ <string id="OtpCounter">Starting counter (HOTP)</string>
+
+ <!-- Settings: Add code -->
+ <string id="GroupAddCode">Add code</string>
+ <string id="GroupAddCodeHelp">A loyalty card, ticket or other barcode. Fill in the name and contents, turn on Add this entry and save. The watch checks that the contents are valid for the type.</string>
+ <string id="CodeName">Card name</string>
+ <string id="CodeSymbology">Barcode type</string>
+ <string id="SymQr">QR code</string>
+ <string id="SymCode128">Code 128</string>
+ <string id="SymCode39">Code 39</string>
+ <string id="SymEan13">EAN-13</string>
+ <string id="SymEan8">EAN-8</string>
+ <string id="SymUpcA">UPC-A</string>
+ <string id="SymUpcE">UPC-E</string>
+ <string id="SymItf">ITF (Interleaved 2 of 5)</string>
+ <string id="SymCodabar">Codabar</string>
+ <string id="SymDataMatrix">Data Matrix</string>
+ <string id="SymPdf417">PDF417</string>
+ <string id="SymAztec">Aztec</string>
+ <string id="CodePayload">Contents</string>
+ <string id="CodePayloadPrompt">The digits or text of the code</string>
+ <string id="CodeEcLevel">Error correction (2D codes)</string>
+ <string id="EcLow">Low</string>
+ <string id="EcMedium">Medium</string>
+ <string id="EcQuartile">Quartile</string>
+ <string id="EcHigh">High</string>
+ <string id="CodeCheckDigit">Check digit</string>
+ <string id="CheckAddVerify">Add or verify</string>
+ <string id="CheckFix">Fix if wrong</string>
+ <string id="CheckOff">Off</string>
+ <string id="CodeShowText">Show the text under the bars</string>
+
+ <!-- Settings: Manage entry -->
+ <string id="GroupManage">Manage entry</string>
+ <string id="GroupManageHelp">Change one entry by its ID (shown on the watch and under About). Turn on Apply and save. Changes to entries that require the PIN wait until you confirm them with the PIN on the watch.</string>
+ <string id="MgId">Entry ID</string>
+ <string id="MgIdPrompt">For example K7F2</string>
+ <string id="MgAction">Action</string>
+ <string id="ActEdit">Edit details</string>
+ <string id="ActMove">Move to position</string>
+ <string id="ActLock">Require PIN</string>
+ <string id="ActUnlock">Don't require PIN</string>
+ <string id="ActReplace">Replace secret or contents</string>
+ <string id="ActCounter">Set HOTP counter</string>
+ <string id="ActDelete">Delete</string>
+ <string id="MgName">New name (blank: unchanged)</string>
+ <string id="MgAccount">New account (blank: unchanged, - clears)</string>
+ <string id="MgGroup">New group (blank: unchanged, - clears)</string>
+ <string id="MgPosition">New position (1 = top)</string>
+ <string id="PositionError">1 to 150</string>
+ <string id="MgSecret">New secret or contents</string>
+ <string id="MgCounter">New HOTP counter</string>
+ <string id="MgApply">Apply</string>
+
<!-- Settings: Import group (README 10.2, 11) -->
<string id="GroupImport">Import</string>
<string id="GroupImportHelp">Paste otpauth:// links or an unencrypted Aegis JSON export. Long text continues in the next field. Turn on Import now and save; the result appears below and on the watch.</string>
@@ -23,5 +106,36 @@
<string id="ImpModeCancel">Cancel waiting import</string>
<string id="ImpLocked">Require PIN for these entries</string>
<string id="ImpApply">Import now</string>
- <string id="Status">Result</string>
+
+ <!-- Settings: Options -->
+ <string id="GroupOptions">Options</string>
+ <string id="GroupOptionsHelp">Applied as soon as you save.</string>
+ <string id="OptAutoLock">Lock after</string>
+ <string id="LockNow">Immediately</string>
+ <string id="Lock15s">15 seconds</string>
+ <string id="Lock30s">30 seconds</string>
+ <string id="Lock1m">1 minute</string>
+ <string id="Lock2m">2 minutes</string>
+ <string id="Lock5m">5 minutes</string>
+ <string id="OptWipe">Erase locked entries after 10 wrong PINs</string>
+ <string id="OptTimeOffset">Time offset in seconds</string>
+ <string id="OffsetError">-120 to 120 seconds</string>
+ <string id="OptCodeSize">Code size</string>
+ <string id="SizeNormal">Normal</string>
+ <string id="SizeLarge">Large</string>
+ <string id="SizeHuge">Huge</string>
+ <string id="OptTheme">Theme</string>
+ <string id="ThemeDark">Dark</string>
+ <string id="ThemeLight">Light</string>
+ <string id="OptGroups">Show groups</string>
+ <string id="OptEncryptNames">Encrypt names on the watch</string>
+ <string id="OptGlanceCode">Show the favourite code in the glance</string>
+ <string id="OptBrightness">Full brightness while showing a barcode</string>
+
+ <!-- Settings: About (written by the watch) -->
+ <string id="GroupAbout">About</string>
+ <string id="GroupAboutHelp">Written by the watch. Entry IDs for Manage entry are in the list below.</string>
+ <string id="Catalog">Entries</string>
+ <string id="Version">Version</string>
+ <string id="StorageUse">Storage</string>
</strings>
diff --git a/source/app/AppState.mc b/source/app/AppState.mc
index b900943..fb56689 100644
--- a/source/app/AppState.mc
+++ b/source/app/AppState.mc
@@ -7,6 +7,7 @@
import Toybox.Application;
import Toybox.Lang;
import Toybox.Time;
+import Toybox.WatchUi;
module AppState {
@@ -33,8 +34,7 @@ module AppState {
}
try {
var v = new Vault(new Persist.AppStorageBackend(), new TimeSource());
- v.eraseAfterFailures = options.eraseAfterFailures;
- v.session.autoLockMs = TestMode.ON ? TestMode.AUTO_LOCK_MS : options.autoLockMs;
+ applyOptions(v);
vault = v;
codes = new CodeCache(v);
processor = new SettingsProcessor();
@@ -88,10 +88,18 @@ module AppState {
options.load();
var v = vault;
if (v != null) {
- v.eraseAfterFailures = options.eraseAfterFailures;
- v.session.autoLockMs = TestMode.ON ? TestMode.AUTO_LOCK_MS : options.autoLockMs;
+ applyOptions(v);
processSettings();
}
+ // Theme, code size and groups are read at every draw.
+ WatchUi.requestUpdate();
+ }
+
+ // The options the vault itself uses.
+ function applyOptions(v as Vault) as Void {
+ v.eraseAfterFailures = options.eraseAfterFailures;
+ v.encryptNames = options.encryptNames;
+ v.session.autoLockMs = TestMode.ON ? TestMode.AUTO_LOCK_MS : options.autoLockMs;
}
// Apply any settings form that asks for it (after start-up and on change).
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);
}
}
diff --git a/source/storage/KeyManager.mc b/source/storage/KeyManager.mc
index 567288f..47dbd59 100644
--- a/source/storage/KeyManager.mc
+++ b/source/storage/KeyManager.mc
@@ -84,6 +84,30 @@ class KeyManager {
return out;
}
+ // A keyed hash (16 hex characters) of a settings form's content, for
+ // the applied-forms log (README 10.3). Keyed like the fingerprints, with
+ // its own label, because the content includes secrets. The parts are
+ // fed to the native HMAC one by one, each preceded by `separator`, so a
+ // large import form costs no byte loop here (the watchdog).
+ function formHash(parts as Array<ByteArray>, separator as Number) as String {
+ deviceKeys();
+ var mac = new Cryptography.HashBasedMessageAuthenticationCode({
+ :algorithm => Cryptography.HASH_SHA256,
+ :key => _fingerprintKey as ByteArray
+ });
+ mac.update(Bytes.fromUtf8("OTPW-FH-1"));
+ for (var i = 0; i < parts.size(); i++) {
+ mac.update([separator]b);
+ if (parts[i].size() > 0) {
+ mac.update(parts[i]);
+ }
+ }
+ var digest = mac.digest();
+ var out = Bytes.toHex(digest.slice(0, FINGERPRINT_BYTES));
+ Bytes.wipe(digest);
+ return out;
+ }
+
// True when a PIN has been set.
function hasPin() as Boolean {
return _store.getValue(WRAP_KEY) != null;
diff --git a/source/storage/Vault.mc b/source/storage/Vault.mc
index 46c8de2..88054e0 100644
--- a/source/storage/Vault.mc
+++ b/source/storage/Vault.mc
@@ -31,6 +31,9 @@ class Vault {
var lockout as Lockout;
// The "Erase locked entries after 10 wrong PINs" setting (off by default).
var eraseAfterFailures as Boolean = false;
+ // The "Encrypt names" setting: new entries keep their names in the
+ // encrypted part (existing ones are converted by AboutScan).
+ var encryptNames as Boolean = false;
private var _store as Persist.Backend;
private var _meta as Dictionary<Application.Storage.KeyType, Application.Storage.ValueType>;
@@ -126,6 +129,7 @@ class Vault {
throw new VaultError(VaultError.LIMIT, "at most " + MAX_ENTRIES + " entries");
}
entry.id = newId();
+ entry.namesEncrypted = encryptNames;
entry.fingerprint = keyManager.fingerprint(entry);
if (entry.created == 0) {
entry.created = Clock.nowUnix(0);
@@ -156,6 +160,21 @@ class Vault {
_store.deleteValue("x." + entry.id); // the cached matrix may be stale
}
+ // Re-seal an entry with or without encrypted names ("Encrypt names").
+ // Nothing else changes, so the barcode cache stays. Throws NOT_FOUND,
+ // LOCKED, DAMAGED or FULL.
+ function setNamesEncrypted(id as String, encrypted as Boolean) as Void {
+ var entry = open(id);
+ entry.namesEncrypted = encrypted;
+ try {
+ write("e." + id, seal(entry));
+ } catch (e instanceof VaultError) {
+ entry.wipeSecrets();
+ throw e;
+ }
+ entry.wipeSecrets();
+ }
+
// ---- Encoded barcode cache (README 12.2) -----------------------------
// Store an encoded matrix for a code entry, sealed with the entry's own
diff --git a/source/ui/ListView.mc b/source/ui/ListView.mc
index cc4e5f3..94b8c99 100644
--- a/source/ui/ListView.mc
+++ b/source/ui/ListView.mc
@@ -179,14 +179,19 @@ class ListView extends WatchUi.View {
}
// "Importing… 40%" while an import runs, "Results ready" when settings
- // results haven't been looked at, else the app name.
+ // results haven't been looked at, "Confirm in menu" while phone changes
+ // wait for the PIN, else the app name.
private function title() as String {
var p = AppState.processor;
var busy = p != null ? p.busyText() : null;
if (busy != null) {
return busy;
}
- return Results.unseen() ? "Results ready (menu)" : "OTP Wallet";
+ if (Results.unseen()) {
+ return "Results ready (menu)";
+ }
+ var v = AppState.vault;
+ return v != null && ChangeQueue.count(v) > 0 ? "Confirm in menu" : "OTP Wallet";
}
private function drawEmpty(dc as Graphics.Dc, top as Number, bottom as Number) as Void {
diff --git a/source/ui/MainMenu.mc b/source/ui/MainMenu.mc
index 3d13180..8863c6d 100644
--- a/source/ui/MainMenu.mc
+++ b/source/ui/MainMenu.mc
@@ -1,5 +1,5 @@
// Author: cirakg & claude ai
-// Summary: The menu (README 13.1): lock now, PIN actions, settings results, entry IDs, clock status, about.
+// Summary: The menu (README 13.1): phone changes to confirm, lock now, PIN actions, settings results, entry IDs, clock status, about.
//
// A native Menu2, so scrolling, touch and buttons behave like every other
// Garmin menu. Debug builds add the items from DebugMenu.
@@ -16,6 +16,10 @@ module MainMenu {
function build() as WatchUi.Menu2 {
var vault = AppState.theVault();
var menu = new WatchUi.Menu2({:title => "OTP Wallet"});
+ var waiting = ChangeQueue.count(vault);
+ if (waiting > 0) {
+ menu.addItem(new WatchUi.MenuItem("Phone changes", waiting + " to confirm", :queue, null));
+ }
if (vault.session.isUnlocked()) {
menu.addItem(new WatchUi.MenuItem("Lock now", null, :lock, null));
}
@@ -120,6 +124,8 @@ class MainMenuDelegate extends WatchUi.Menu2InputDelegate {
WatchUi.switchToView(new InfoView("Results", Results.lines(), "results"), new InfoDelegate(),
WatchUi.SLIDE_LEFT);
Results.markSeen();
+ } else if (id == :queue) {
+ WatchUi.switchToView(QueueMenu.build(), new QueueMenuDelegate(), WatchUi.SLIDE_LEFT);
} else if (id == :ids) {
WatchUi.switchToView(new InfoView("Entry IDs", MainMenu.entryIdLines(), "ids"), new InfoDelegate(), WatchUi.SLIDE_LEFT);
} else if (id == :clock) {
diff --git a/source/ui/PinView.mc b/source/ui/PinView.mc
index 6eefa55..4502919 100644
--- a/source/ui/PinView.mc
+++ b/source/ui/PinView.mc
@@ -225,6 +225,8 @@ class PinView extends WatchUi.View {
if (vault.finishUnlock(_job as PinJob)) {
_job = null;
vault.promotePending();
+ // Unlocked: "Encrypt names" can now reach locked entries.
+ AppState.processSettings();
succeed();
return;
}
diff --git a/source/ui/QueueMenu.mc b/source/ui/QueueMenu.mc
new file mode 100644
index 0000000..880ca7c
--- /dev/null
+++ b/source/ui/QueueMenu.mc
@@ -0,0 +1,91 @@
+// Author: cirakg & claude ai
+// Summary: "Phone changes": lists the changes to locked entries waiting for the PIN, and confirms or rejects them (README 7.9).
+//
+// Confirming always asks for the PIN, even while the watch is unlocked:
+// the point is that a person at the watch agrees, not that a key is
+// available. (Before any PIN exists only "Require PIN" can wait here, and
+// confirming creates the PIN.) The changes are then applied by a
+// ChangeQueueJob on a timer and the results screen shows what happened.
+
+import Toybox.Lang;
+import Toybox.WatchUi;
+
+module QueueMenu {
+
+ function build() as WatchUi.Menu2 {
+ var vault = AppState.theVault();
+ var menu = new WatchUi.Menu2({:title => "Phone changes"});
+ menu.addItem(new WatchUi.MenuItem("Confirm all", vault.hasPin() ? "Asks for your PIN" : "Sets a PIN first", :confirm, null));
+ menu.addItem(new WatchUi.MenuItem("Reject all", null, :reject, null));
+ var items = ChangeQueue.descriptions(vault);
+ for (var i = 0; i < items.size(); i++) {
+ menu.addItem(new WatchUi.MenuItem(items[i], null, i, null));
+ }
+ return menu;
+ }
+}
+
+class QueueMenuDelegate extends WatchUi.Menu2InputDelegate {
+
+ function initialize() {
+ Menu2InputDelegate.initialize();
+ }
+
+ function onSelect(item as WatchUi.MenuItem) as Void {
+ AppState.activity();
+ var id = item.getId();
+ var vault = AppState.theVault();
+ if (id == :confirm) {
+ var then = new ApplyQueueAfterPin();
+ var mode = vault.hasPin() ? PinView.MODE_UNLOCK : PinView.MODE_CREATE;
+ WatchUi.switchToView(new PinView(mode, then.method(:run)), new PinDelegate(), WatchUi.SLIDE_LEFT);
+ } else if (id == :reject) {
+ var lines = ["Rejected these changes from the phone:"] as Array<String>;
+ lines.addAll(ChangeQueue.descriptions(vault));
+ ChangeQueue.clear(vault);
+ Results.save(lines);
+ WatchUi.switchToView(new InfoView("Results", lines, "results"), new InfoDelegate(), WatchUi.SLIDE_LEFT);
+ Results.markSeen();
+ AppState.processSettings(); // the About fields mention waiting changes
+ } else if (id instanceof Number) {
+ // The full text of one change (menu labels are cut short).
+ var items = ChangeQueue.descriptions(vault);
+ if (id < items.size()) {
+ WatchUi.pushView(new InfoView("Change", [items[id]] as Array<String>, "change"), new InfoDelegate(),
+ WatchUi.SLIDE_LEFT);
+ }
+ }
+ }
+}
+
+// After the PIN: apply the waiting changes, then show what happened.
+class ApplyQueueAfterPin {
+
+ // The job's timer driver (kept so it isn't collected while it runs).
+ var runner as Jobs.JobRunner? = null;
+ private var _job as ChangeQueueJob? = null;
+
+ function initialize() {
+ }
+
+ function run() as Void {
+ var job = new ChangeQueueJob(AppState.theVault());
+ _job = job;
+ WatchUi.switchToView(new InfoView("Results", ["Applying the changes…"] as Array<String>, "applying"), new InfoDelegate(),
+ WatchUi.SLIDE_LEFT);
+ var r = new Jobs.JobRunner(job, 1, method(:onDone));
+ runner = r;
+ r.start();
+ }
+
+ function onDone() as Void {
+ var job = _job as ChangeQueueJob;
+ _job = null;
+ runner = null;
+ AppState.theCodes().clear();
+ Results.save(job.lines);
+ WatchUi.switchToView(new InfoView("Results", job.lines, "results"), new InfoDelegate(), WatchUi.SLIDE_IMMEDIATE);
+ Results.markSeen();
+ AppState.processSettings(); // refresh the catalog on the phone
+ }
+}