diff options
| -rw-r--r-- | source/app/Fixtures.mc | 9 | ||||
| -rw-r--r-- | source/storage/Vault.mc | 34 | ||||
| -rw-r--r-- | source/ui/CodeView.mc | 177 |
3 files changed, 195 insertions, 25 deletions
diff --git a/source/app/Fixtures.mc b/source/app/Fixtures.mc index 114e879..0aa6ea9 100644 --- a/source/app/Fixtures.mc +++ b/source/app/Fixtures.mc @@ -52,6 +52,15 @@ module Fixtures { card.payload = Bytes.fromUtf8("590123412345"); // fake; the check digit 7 is added card.options = Codes.DEFAULT_OPTIONS; vault.add(card); + var qr = new Entry(); + qr.kind = Entry.KIND_CODE; + qr.type = Codes.QR; + qr.name = "Member QR"; + qr.group = "Personal"; + // Fake data; with the digit run in numeric mode it is a version 6 QR. + qr.payload = Bytes.fromUtf8("https://example.com/member/fake-0042?name=Alice%20Example&card=0000111122223333&valid=2027-12-31&level=gold"); + qr.options = Codes.DEFAULT_OPTIONS; + vault.add(qr); vault.lock(); if (TestMode.ON) { startImport(); diff --git a/source/storage/Vault.mc b/source/storage/Vault.mc index e77bbf2..46c8de2 100644 --- a/source/storage/Vault.mc +++ b/source/storage/Vault.mc @@ -153,6 +153,38 @@ class Vault { session.keys(); // throws LOCKED } write("e." + entry.id, seal(entry)); + _store.deleteValue("x." + entry.id); // the cached matrix may be stale + } + + // ---- Encoded barcode cache (README 12.2) ----------------------------- + + // Store an encoded matrix for a code entry, sealed with the entry's own + // key (device key, or the master key for locked entries) and bound to the + // ID. A full store just means no cache. Throws NOT_FOUND or LOCKED. + function saveCodeCache(id as String, data as ByteArray) as Void { + var keys = keysFor(isLockedRecord(id)); + try { + _store.setValue("x." + id, SealedBox.seal(keys, cacheAad(id), data)); + } catch (e instanceof Lang.Exception) { + // Storage full: the code is simply encoded again next time. + } + } + + // The cached matrix, or null when there is none or it doesn't verify. + // Throws NOT_FOUND or LOCKED. + function loadCodeCache(id as String) as ByteArray? { + var keys = keysFor(isLockedRecord(id)); + var stored = _store.getValue("x." + id); + return stored instanceof ByteArray ? SealedBox.open(keys, cacheAad(id), stored) : null; + } + + private function isLockedRecord(id as String) as Boolean { + var record = recordFor(id); + return record != null && Record.keyRef(record) == Record.KEY_MASTER; + } + + private function cacheAad(id as String) as ByteArray { + return Bytes.fromUtf8("OTPW-X-1" + id); } // Set a HOTP counter (re-seals the record, since the MAC covers it). @@ -178,6 +210,7 @@ class Vault { _ids.remove(id); saveMeta(); _store.deleteValue("e." + id); + _store.deleteValue("x." + id); } // Move an entry to position `index` (clamped to the list). Throws NOT_FOUND. @@ -316,6 +349,7 @@ class Vault { saveMeta(); for (var i = 0; i < erased.size(); i++) { _store.deleteValue("e." + erased[i]); + _store.deleteValue("x." + erased[i]); } for (var i = 0; i < pending.size(); i++) { _store.deleteValue("p." + pending[i]); diff --git a/source/ui/CodeView.mc b/source/ui/CodeView.mc index fc1c889..2687d6c 100644 --- a/source/ui/CodeView.mc +++ b/source/ui/CodeView.mc @@ -1,5 +1,5 @@ // Author: cirakg & claude ai -// Summary: Shows a barcode entry: black modules on a white screen at the largest whole module size that fits, with text. +// Summary: Shows a barcode entry: black modules on white at the largest whole module size that fits, linear or 2D. // // README 12.2: // * Always black on white (whatever the theme), quiet zone included. @@ -8,26 +8,38 @@ // whole number of pixels that fits the code plus 10 quiet modules on // each side; 1 px is allowed for linear codes (EAN-13 on 218 px screens // needs it, and the screenshot decode test covers it). +// * 2D codes use the largest centred square (round screens) or the +// screen's shorter side, at least 2 px per module. +// * 2D codes are encoded once, as a job on a timer ("Encoding... 40%"), +// and cached sealed with the entry's key (Vault.saveCodeCache), so the +// next opening is instant. // * Human-readable text under linear codes when enabled and there's room. // * Brightness: the backlight is turned on when shown and on every tap. // * Too big: a message with the size needed. -// The payload is decrypted when the screen opens and wiped once encoded; -// the module row stays in memory only while the screen is shown. +// The payload is decrypted when the screen opens and wiped once encoded. import Toybox.Attention; import Toybox.Graphics; import Toybox.Lang; +import Toybox.System; +import Toybox.Timer; import Toybox.WatchUi; class CodeView extends WatchUi.View { static const QUIET_MODULES = 10; static const MIN_LINEAR_PX = 1; + static const MIN_MATRIX_PX = 2; + static const QR_QUIET = 4; var header as Entry; - private var _code as Codes.LinearCode or Codes.CodeError or Null = null; + private var _code as Codes.LinearCode or Codes.Matrix or Codes.CodeError or Null = null; private var _showText as Boolean = true; private var _shownLogged as Boolean = false; + private var _job as Qr.QrJob? = null; + private var _runner as Jobs.JobRunner? = null; + private var _ticker as Timer.Timer? = null; + private var _startedAt as Number = 0; function initialize(entryHeader as Entry) { View.initialize(); @@ -41,21 +53,90 @@ class CodeView extends WatchUi.View { } function onHide() as Void { + stopJob(); _code = null; } private function encode() as Void { + var vault = AppState.theVault(); try { - var entry = AppState.theVault().open(header.id); + if (Codes.isMatrix(header.type)) { + var cached = vault.loadCodeCache(header.id); + var matrix = cached != null ? Codes.Matrix.fromBytes(cached) : null; + if (matrix != null) { + _code = matrix; + return; + } + } + var entry = vault.open(header.id); var payload = Bytes.toUtf8(entry.payload as ByteArray); _showText = (entry.options & Codes.OPT_SHOW_TEXT) != 0; - _code = payload != null ? Codes.encode(entry.type, payload, entry.options) : new Codes.CodeError("damaged text"); + if (payload == null) { + _code = new Codes.CodeError("damaged text"); + } else if (entry.type == Codes.QR) { + startQr(payload, Codes.ecLevel(entry.options)); + } else { + _code = Codes.encode(entry.type, payload, entry.options); + } entry.wipeSecrets(); } catch (e instanceof VaultError) { _code = new Codes.CodeError(e.code == VaultError.LOCKED ? "locked" : "damaged entry"); } } + private function startQr(payload as String, ec as Number) as Void { + _startedAt = System.getTimer(); + var job = new Qr.QrJob(payload, ec, 0, -1); + _job = job; + var runner = new Jobs.JobRunner(job, 1, method(:onEncoded)); + _runner = runner; + runner.start(); + var ticker = new Timer.Timer(); + ticker.start(method(:onTick), 250, true); + _ticker = ticker; + } + + function onTick() as Void { + WatchUi.requestUpdate(); + } + + function onEncoded() as Void { + var job = _job; + stopJob(); + if (job == null) { + return; + } + var err = job.error; + var result = job.result; + if (err != null || result == null) { + _code = new Codes.CodeError(err != null ? err : "could not encode"); + } else { + Diag.data("qr_encoded", "version=" + result.version + " ms=" + (System.getTimer() - _startedAt)); + var matrix = new Codes.Matrix(result.size, result.size, QR_QUIET, result.modules); + _code = matrix; + try { + AppState.theVault().saveCodeCache(header.id, matrix.toBytes()); + } catch (e instanceof VaultError) { + // Locked meanwhile: no cache this time. + } + } + WatchUi.requestUpdate(); + } + + private function stopJob() as Void { + var runner = _runner; + if (runner != null) { + runner.cancel(); + } + var ticker = _ticker; + if (ticker != null) { + ticker.stop(); + } + _runner = null; + _ticker = null; + _job = null; + } + // Full backlight while a code is shown (AMOLED watches limit how long; // the system then dims it, and a tap asks again). function boostBrightness() as Void { @@ -77,27 +158,39 @@ class CodeView extends WatchUi.View { var smallFont = Graphics.FONT_XTINY; var smallH = dc.getFontHeight(smallFont); var code = _code; + var job = _job; if (code instanceof Codes.LinearCode) { drawLinear(dc, code, smallFont, smallH); + } else if (code instanceof Codes.Matrix) { + drawMatrix(dc, code, smallFont, smallH); + } else if (job != null) { + dc.drawText(w / 2, h / 2 - smallH / 2, smallFont, "Encoding… " + job.percent() + "%", Graphics.TEXT_JUSTIFY_CENTER); } else { - var message = code instanceof Codes.CodeError ? code.message : "…"; - var lines = Ui.wrap(dc, message, smallFont, 2 * Ui.halfWidthAt(dc, h / 2) - 16); - var y = h / 2 - lines.size() * smallH / 2; - for (var i = 0; i < lines.size(); i++) { - dc.drawText(w / 2, y, smallFont, lines[i], Graphics.TEXT_JUSTIFY_CENTER); - y += smallH; - } + drawMessage(dc, code instanceof Codes.CodeError ? code.message : "…", smallFont, smallH); } - // Name at the top and ID at the bottom, in small print. - var top = Ui.isRound() ? h / 8 : 4; + // Name at the top and type and ID at the bottom, in small print. + var top = Ui.isRound() ? h / 10 : 2; dc.setColor(Graphics.COLOR_DK_GRAY, Graphics.COLOR_TRANSPARENT); dc.drawText(w / 2, top, smallFont, Ui.fit(dc, header.name, smallFont, 2 * Ui.halfWidthAt(dc, top + smallH)), Graphics.TEXT_JUSTIFY_CENTER); - var bottom = Ui.isRound() ? h - h / 8 - smallH : h - smallH - 4; - dc.drawText(w / 2, bottom, smallFont, Codes.name(header.type) + " · " + header.id, Graphics.TEXT_JUSTIFY_CENTER); + var bottom = Ui.isRound() ? h - h / 10 - smallH : h - smallH - 2; + dc.drawText(w / 2, bottom, smallFont, Ui.fit(dc, Codes.name(header.type) + " · " + header.id, smallFont, + 2 * Ui.halfWidthAt(dc, bottom)), Graphics.TEXT_JUSTIFY_CENTER); if (!_shownLogged && code != null) { _shownLogged = true; - Diag.shown(code instanceof Codes.LinearCode ? "code" : "code_error"); + Diag.shown(code instanceof Codes.CodeError ? "code_error" : "code"); + } + } + + private function drawMessage(dc as Graphics.Dc, message as String, font as Graphics.FontType, fontH as Number) as Void { + var w = dc.getWidth(); + var h = dc.getHeight(); + dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT); + var lines = Ui.wrap(dc, message, font, 2 * Ui.halfWidthAt(dc, h / 2) - 16); + var y = h / 2 - lines.size() * fontH / 2; + for (var i = 0; i < lines.size(); i++) { + dc.drawText(w / 2, y, font, lines[i], Graphics.TEXT_JUSTIFY_CENTER); + y += fontH; } } @@ -113,13 +206,7 @@ class CodeView extends WatchUi.View { var width = 2 * Ui.halfWidthBetween(dc, bandTop, bandTop + barH + textH) + (Ui.isRound() ? 12 : 8); var px = width / total; if (px < MIN_LINEAR_PX) { - var lines = Ui.wrap(dc, "Too large for this screen: needs " + total + " px, " + width + " px available", - font, 2 * Ui.halfWidthAt(dc, h / 2) - 16); - var y = h / 2 - lines.size() * fontH / 2; - for (var i = 0; i < lines.size(); i++) { - dc.drawText(w / 2, y, font, lines[i], Graphics.TEXT_JUSTIFY_CENTER); - y += fontH; - } + drawMessage(dc, "Too large for this screen: needs " + total + " px, " + width + " px available", font, fontH); return; } var x0 = (w - modules.size() * px) / 2; @@ -141,6 +228,46 @@ class CodeView extends WatchUi.View { dc.drawText(w / 2, bandTop + barH + 2, font, code.text, Graphics.TEXT_JUSTIFY_CENTER); } } + + // A 2D code in the largest centred square (or rectangle) that fits. + private function drawMatrix(dc as Graphics.Dc, code as Codes.Matrix, font as Graphics.FontType, fontH as Number) as Void { + var w = dc.getWidth(); + var h = dc.getHeight(); + // Round screens: the largest square inside the circle (side w/sqrt 2); + // others: the shorter side, minus room for the name and ID lines. + var areaW = Ui.isRound() ? w * 707 / 1000 : w - 4; + var areaH = Ui.isRound() ? h * 707 / 1000 : h - 2 * fontH - 8; + var totalW = code.width + 2 * code.quiet; + var totalH = code.height + 2 * code.quiet; + var px = areaW / totalW; + if (areaH / totalH < px) { + px = areaH / totalH; + } + if (px < MIN_MATRIX_PX) { + drawMessage(dc, "Too large for this screen: needs " + MIN_MATRIX_PX + " px per module, a " + + totalW * MIN_MATRIX_PX + " px square", font, fontH); + return; + } + var x0 = (w - code.width * px) / 2; + var y0 = (h - code.height * px) / 2; + dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_WHITE); + var m = code.modules; + for (var y = 0; y < code.height; y++) { + var row = y * code.width; + var x = 0; + while (x < code.width) { + if (m[row + x] == 1) { + var start = x; + while (x < code.width && m[row + x] == 1) { + x++; + } + dc.fillRectangle(x0 + start * px, y0 + y * px, (x - start) * px, px); + } else { + x++; + } + } + } + } } // Input on a code: a tap asks for full brightness again; BACK closes. |
