// 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 module size at which the code fits inside the // circle (round screens) or the screen (Codes.fits): at least 2 px for // square codes, 1 px for PDF417 (a stacked linear code). // * 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; 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 Codes.MatrixJob? = null; private var _runner as Jobs.JobRunner? = null; private var _ticker as Timer.Timer? = null; private var _startedAt as Number = 0; // The cache-saving one-shot timer (kept so it is not collected). var saveTimer as Timer.Timer? = null; 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 (Codes.isMatrix(entry.type)) { var screen = System.getDeviceSettings(); var job = Codes.matrixJob(entry.type, payload, entry.options, screen.screenWidth, screen.screenHeight, Ui.isRound()); if (job != null) { startJob(job); } else { _code = new Codes.CodeError(Codes.name(entry.type) + " is not supported yet"); } } 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 startJob(job as Codes.MatrixJob) as Void { _startedAt = System.getTimer(); _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 matrix = job.matrix(); if (err != null || matrix == null) { _code = new Codes.CodeError(err != null ? err : "could not encode"); } else { Diag.data("matrix_encoded", "type=" + header.type + " size=" + matrix.width + "x" + matrix.height + " ms=" + (System.getTimer() - _startedAt)); _code = matrix; // Sealing and storing the cache gets its own timer event: the // job's last step already used much of this event's budget. var t = new Timer.Timer(); t.start(method(:saveCache), 50, false); saveTimer = t; } WatchUi.requestUpdate(); } // Store the encoded matrix sealed in the vault (one-shot timer). function saveCache() as Void { saveTimer = null; var code = _code; if (!(code instanceof Codes.Matrix)) { return; } try { AppState.theVault().saveCodeCache(header.id, code.toBytes()); } catch (e instanceof VaultError) { // Locked meanwhile: no cache this time. } } 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 at the largest module size that fits (Codes.fits: inside the // circle on round screens), centred. 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(); var totalW = code.width + 2 * code.quiet; var totalH = code.drawnHeight() + 2 * code.quiet; var minPx = Codes.minModulePx(header.type); var px = 0; for (var p = 16; p >= minPx && px == 0; p--) { if (Codes.fits(totalW, totalH, p, w, h, Ui.isRound())) { px = p; } } if (px == 0) { drawMessage(dc, "Too large for this screen: needs " + minPx + " px per module, " + totalW * minPx + " x " + totalH * minPx + " px", font, fontH); return; } var x0 = (w - code.width * px) / 2; var y0 = (h - code.drawnHeight() * px) / 2; var rowPx = code.rowHeight * px; 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 * rowPx, (x - start) * px, rowPx); } 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; } }