// Author: cirakg & claude ai // 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. // * Linear codes use the widest horizontal band: on round screens the // band's narrower edge limits the width. The module size is the largest // 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. 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.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(); header = entryHeader; } function onShow() as Void { _shownLogged = false; encode(); boostBrightness(); } function onHide() as Void { stopJob(); _code = null; } private function encode() as Void { var vault = AppState.theVault(); try { 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; 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 { if (!AppState.options.fullBrightness || !(Attention has :backlight)) { return; } try { Attention.backlight(true); } catch (e instanceof Lang.Exception) { // BacklightOnTooLongException: the system's limit was reached. } } function onUpdate(dc as Graphics.Dc) as Void { var w = dc.getWidth(); var h = dc.getHeight(); dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_WHITE); dc.clear(); 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 { drawMessage(dc, code instanceof Codes.CodeError ? code.message : "…", smallFont, smallH); } // 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 / 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.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; } } // The bars in the widest band that fits, with text below when enabled. private function drawLinear(dc as Graphics.Dc, code as Codes.LinearCode, font as Graphics.FontType, fontH as Number) as Void { var w = dc.getWidth(); var h = dc.getHeight(); var modules = code.modules; var total = modules.size() + 2 * QUIET_MODULES; var barH = h * 30 / 100; var textH = _showText ? fontH : 0; var bandTop = (h - barH - textH) / 2; var width = 2 * Ui.halfWidthBetween(dc, bandTop, bandTop + barH + textH) + (Ui.isRound() ? 12 : 8); var px = width / total; if (px < MIN_LINEAR_PX) { drawMessage(dc, "Too large for this screen: needs " + total + " px, " + width + " px available", font, fontH); return; } var x0 = (w - modules.size() * px) / 2; dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_WHITE); var i = 0; var n = modules.size(); while (i < n) { if (modules[i] == 1) { var start = i; while (i < n && modules[i] == 1) { i++; } dc.fillRectangle(x0 + start * px, bandTop, (i - start) * px, barH); } else { i++; } } if (_showText) { 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. class CodeDelegate extends WatchUi.BehaviorDelegate { function initialize() { BehaviorDelegate.initialize(); } function onTap(event as WatchUi.ClickEvent) as Boolean { boost(); return true; } function onSelect() as Boolean { boost(); return true; } private function boost() as Void { AppState.activity(); var current = WatchUi.getCurrentView()[0]; if (current instanceof CodeView) { current.boostBrightness(); } } function onBack() as Boolean { var current = WatchUi.getCurrentView()[0]; if (current instanceof CodeView && current.header.locked && AppState.theVault().session.autoLockMs == 0) { AppState.lock(); } WatchUi.popView(WatchUi.SLIDE_RIGHT); return true; } }