summaryrefslogtreecommitdiff
path: root/source/settings/AboutScan.mc
blob: 559cce980b98c80ca6c79bf81080a735cc300d48 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
// Author:  cirakg & claude ai
// Summary: Writes the read-only About fields (catalog, version, storage use), applies "Encrypt names" to existing entries and updates the glance code.
//
// 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 glance code (GlanceCode.mc): the first favourite TOTP or Steam
//     entry without the PIN is sealed for the glance, or the glance item
//     removed when there is none or the option is off.
// The scan only runs when something it reports changed: a form was
// applied, or the signature of the entry list (IDs, pending, options,
// 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;
    private var _glanceId as String? = null;

    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(vault.glanceCode);
        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)" : ""));
        if (_glanceId == null && GlanceStore.qualifies(header)) {
            _glanceId = id;
        }
        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);
        try {
            GlanceStore.update(_vault, _glanceId);
        } catch (e instanceof Lang.Exception) {
            // Damaged entry or a full store: no glance code, and the scan
            // runs again next time.
            _vault.storage().deleteValue(GlanceCode.KEY);
            _incomplete = true;
        }
        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);
        }
    }
}