From 417a52caa1e5e7f32bd00354966b621c62784486 Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Tue, 25 Aug 2026 15:52:26 -0400 Subject: [PATCH 1/2] Fix Wardrive UTF-8 text handling Signed-off-by: Michael A. Cojocari --- deploy/apps/apps.json | 2 +- deploy/apps/wardrive/1.1/wardrive.json | 1 + deploy/apps/wardrive/1.1/wardrive.lua | 216 +++++++++++++++++++++++++ scripts/lua-harness/README.md | 2 +- scripts/lua-harness/harness.lua | 65 +++++++- src/ui-touch/LuaAppHost.cpp | 70 ++++++-- src/ui-touch/UITask.cpp | 61 +++---- src/ui-touch/Utf8Text.h | 78 +++++++++ src/ui-touch/lua_builtin.h | 32 +++- test/test_utf8_text.cpp | 32 ++++ 10 files changed, 495 insertions(+), 64 deletions(-) create mode 100644 deploy/apps/wardrive/1.1/wardrive.json create mode 100644 deploy/apps/wardrive/1.1/wardrive.lua create mode 100644 src/ui-touch/Utf8Text.h create mode 100644 test/test_utf8_text.cpp diff --git a/deploy/apps/apps.json b/deploy/apps/apps.json index ee2da03..138d7f2 100644 --- a/deploy/apps/apps.json +++ b/deploy/apps/apps.json @@ -33,7 +33,7 @@ { "id": "wardrive", "name": "Wardrive", - "ver": "1.0", + "ver": "1.1", "desc": "LoRa coverage survey. Probes every 20s and logs each reply with GPS position, altitude and BOTH link directions (how well you heard them, how well they heard you) to a CSV. Reference app for wada.mesh.discover." }, { diff --git a/deploy/apps/wardrive/1.1/wardrive.json b/deploy/apps/wardrive/1.1/wardrive.json new file mode 100644 index 0000000..d467e18 --- /dev/null +++ b/deploy/apps/wardrive/1.1/wardrive.json @@ -0,0 +1 @@ +{"id":"wardrive","name":"Wardrive","ver":"1.1","desc":"LoRa coverage survey. Probes every 20s, logs each reply with GPS position, altitude and BOTH link directions to a CSV you can pull off the device. Reference app for wada.mesh.discover."} \ No newline at end of file diff --git a/deploy/apps/wardrive/1.1/wardrive.lua b/deploy/apps/wardrive/1.1/wardrive.lua new file mode 100644 index 0000000..a2f8377 --- /dev/null +++ b/deploy/apps/wardrive/1.1/wardrive.lua @@ -0,0 +1,216 @@ +-- Wardrive (Lua) — a LoRa coverage survey that logs to a CSV you can pull off +-- the device afterwards. +-- +-- Reference app for the discovery half of the SDK. It works the way a survey +-- has to work: it PROBES rather than listens. A probe is a zero-hop request +-- that every node in earshot answers, so a reply proves the link works from +-- exactly where you are standing. Listening only ever tells you what happened +-- to transmit while you were there, which is a different and much weaker claim. +-- +-- Each reply carries both directions of the link: how well we heard them, and +-- how well they heard us. They are rarely equal, and the asymmetry is the point +-- -- "I can hear the repeater but it cannot hear me" is not the same fact as +-- "no coverage", and only a probe reveals it. +local ui, sys, mesh, fs, timer = wada.ui, wada.sys, wada.mesh, wada.fs, wada.timer +local C = ui.colors +local app = {} + +local SWEEP_MS = 20000 -- above the 15 s floor the firmware enforces on probes +local HARVEST_MS = 4000 -- replies land over the few seconds after a probe +local TYPE = { [1]="chat", [2]="repeater", [3]="room", [4]="sensor" } + +local run, running, samples, sweeps, last_err = "run", false, 0, 0, nil +local node_count = 0 +local phase, phase_at = "idle", 0 +local hdr, gps_lbl, stat_lbl, rows = nil, nil, nil, {} +local nodes = {} -- pubkey -> { name, type, best, worst, seen } +local pending = {} -- lines waiting on the 1 write/sec limit + +local function logname() return run .. ".csv" end + +-- Return at most max_bytes without splitting a UTF-8 sequence. The row's +-- fixed-width columns are byte-budgeted, not character-budgeted; a raw +-- string.sub(1, 14) cut Ouderkerk + sun + VS16 inside the final codepoint and +-- left LVGL unable to advance through the label (#323, same class as #223). +local function utf8_prefix_bytes(text, max_bytes) + local offset, last, length = 1, 0, #text + while offset <= length and offset <= max_bytes do + local first = text:byte(offset) + local width = first <= 0x7F and 1 + or (first >= 0xC2 and first <= 0xDF and 2) + or (first >= 0xE0 and first <= 0xEF and 3) + or (first >= 0xF0 and first <= 0xF4 and 4) or 0 + if width == 0 or offset + width - 1 > length or offset + width - 1 > max_bytes then break end + local valid = true + for i = 2, width do + local byte = text:byte(offset + i - 1) + if byte < 0x80 or byte > 0xBF then valid = false; break end + end + local second = width > 1 and text:byte(offset + 1) or 0 + if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or + (first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then valid = false end + if not valid then break end + last = offset + width - 1 + offset = last + 1 + end + return text:sub(1, last) +end + +-- Lua here is built with 32-bit floats, so fix.lat is good to about a metre and +-- no better. fix.lat_e6 is the same reading as an exact integer in +-- micro-degrees, which is what belongs in a log: a survey you plot months later +-- should not carry rounding the device never had. +local HEADER = "epoch,lat_e6,lon_e6,alt_m,pubkey,name,type,rssi,snr,their_snr,hops" +local wrote_header = false + +-- The filesystem allows one write a second. Sweeps produce a burst of rows, so +-- they queue here and drain a chunk per tick instead of being dropped. +local function flush() + if #pending == 0 then return end + local chunk = table.concat(pending, "\n") .. "\n" + if not wrote_header then chunk = HEADER .. "\n" .. chunk end + local ok = fs.append(logname(), chunk) + if ok then pending, wrote_header = {}, true end +end + +local function record(fix, hit) + local key = hit.pubkey + local n = nodes[key] + if not n then + n = { name = hit.name or key, type = hit.type, best = hit.snr, worst = hit.snr, seen = 0 } + nodes[key] = n + node_count = node_count + 1 + end + if hit.snr > n.best then n.best = hit.snr end + if hit.snr < n.worst then n.worst = hit.snr end + n.seen = n.seen + 1 + if hit.name then n.name = hit.name end + + samples = samples + 1 + pending[#pending + 1] = string.format("%d,%d,%d,%d,%s,%s,%d,%d,%.2f,%.2f,%d", + fix.time or sys.epoch(), fix.lat_e6, fix.lon_e6, fix.alt_m or 0, + key, (hit.name or ""):gsub(",", " "), hit.type, + hit.rssi, hit.snr, hit.their_snr, hit.hops) +end + +local function sweep() + local tag, err = mesh.discover() -- every node type + if not tag then last_err = err; return false end + last_err = nil + sweeps = sweeps + 1 + return true +end + +local function harvest() + local fix = sys.gps() + if not fix then + -- No fix means the sample cannot be placed, so it is discarded rather than + -- logged at 0,0. A survey file with phantom points at Null Island is worse + -- than a shorter one. + mesh.discover_clear() + return + end + for _, hit in ipairs(mesh.discovered()) do record(fix, hit) end + mesh.discover_clear() -- next sample must not inherit this one +end + +local function redraw() + local fix = sys.gps() + if fix then + gps_lbl:set(string.format("%.5f, %.5f %dm %d sats", fix.lat, fix.lon, fix.alt_m or 0, fix.sats)) + gps_lbl:color(C.good) + else + gps_lbl:set("waiting for a GPS fix - samples are discarded until then") + gps_lbl:color(C.bad) + end + + local state = running and (phase == "probe" and "listening..." or "sweeping") or "stopped" + stat_lbl:set(string.format("%s | %s | %d sweeps, %d samples, %d nodes%s", + run, state, sweeps, samples, node_count, + last_err and (" [" .. last_err .. "]") or "")) + stat_lbl:color(last_err and C.bad or C.accent) + + local list = {} + for key, n in pairs(nodes) do list[#list + 1] = { key = key, n = n } end + table.sort(list, function(a, b) return a.n.best > b.n.best end) + for i = 1, #rows do + local e = list[i] + if e then + rows[i]:set(string.format("%-14s %-8s best %5.1f worst %5.1f x%d", + utf8_prefix_bytes(e.n.name, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen)) + rows[i]:color(e.n.best > 0 and C.good or C.text) + else + rows[i]:set(i == 1 and "nothing has answered a probe yet" or "") + rows[i]:color(C.sub) + end + end +end + +function app.on_open(w, h) + if not sys.caps().discover then + ui.label("This board does not carry the extended SDK,", 6, 8, 12, C.bad) + ui.label("so it cannot send discovery probes.", 6, 26, 12, C.bad) + return + end + ui.scroll(true) + local LH = ui.text_h(12) + local y = 4 + + hdr = ui.label("LoRa coverage survey", 4, y, 12, C.accent); hdr:width(w - 10); y = y + LH + 3 + gps_lbl = ui.label("", 4, y, 12, C.sub); gps_lbl:width(w - 10); y = y + LH + 3 + stat_lbl = ui.label("", 4, y, 12, C.text); stat_lbl:width(w - 10); y = y + LH * 2 + 5 + + local bw = math.min(96, (w - 20) // 3) + ui.button("Start", 4, y, bw, 32, function() + running = not running + if running then phase, phase_at = "idle", 0 end + sys.toast(running and "Survey running" or "Survey stopped", 1200) + end) + ui.button("Name", 8 + bw, y, bw, 32, function() + ui.input("Name this run", run, function(text) + if text then + run = text:gsub("[^%w%-_]", "_") + wrote_header = false -- a new file needs its own header row + sys.toast("Logging to " .. logname(), 1500) + end + end) + end) + ui.button("Reset", 12 + bw * 2, y, bw, 32, function() + nodes, samples, sweeps, pending, node_count = {}, 0, 0, {}, 0 + mesh.discover_clear() + fs.remove(logname()) + wrote_header = false + sys.toast("Cleared " .. logname(), 1200) + end) + y = y + 38 + + ui.label("strongest first", 4, y, 12, C.sub); y = y + LH + 2 + for i = 1, 12 do + rows[i] = ui.label("", 4, y, 12, C.text); rows[i]:width(w - 10); y = y + LH + 2 + end + + redraw() + timer.every(1000) +end + +function app.on_tick() + if running then + local now = sys.millis() + if phase == "idle" or (phase == "wait" and now - phase_at >= SWEEP_MS) then + -- A refused or rate-limited probe backs off a full sweep interval. Retrying + -- every tick would re-enter the permission path once a second for nothing. + phase, phase_at = sweep() and "probe" or "wait", now + elseif phase == "probe" and now - phase_at >= HARVEST_MS then + harvest() + phase = "wait" -- phase_at stays at the probe time, so sweeps stay on cadence + end + end + flush() + redraw() +end + +function app.on_close() + flush() -- one last drain; anything queued would otherwise be lost +end + +return app diff --git a/scripts/lua-harness/README.md b/scripts/lua-harness/README.md index 08cfa7b..771b411 100644 --- a/scripts/lua-harness/README.md +++ b/scripts/lua-harness/README.md @@ -5,7 +5,7 @@ it ever touches a device. Not part of the firmware build. ```sh scripts/lua-harness/run.sh # gpscompass, all scenarios -scripts/lua-harness/run.sh deploy/apps/wardrive/1.0/wardrive.lua +scripts/lua-harness/run.sh deploy/apps/wardrive/1.1/wardrive.lua SCENARIO=declination scripts/lua-harness/run.sh # one scenario ``` diff --git a/scripts/lua-harness/harness.lua b/scripts/lua-harness/harness.lua index dc7adc2..4aa09eb 100644 --- a/scripts/lua-harness/harness.lua +++ b/scripts/lua-harness/harness.lua @@ -10,6 +10,27 @@ local function checkint(v, what) assert(math.tointeger(v) ~= nil, what .. ": not local function checkcol(v, what) if v ~= nil then checkint(v, what .. " color") end end local function checkstr(v, what) assert(type(v) == "string" or type(v) == "number", what .. ": not a string") end +local function valid_utf8(text) + local i, n = 1, #text + while i <= n do + local first = text:byte(i) + local width = first <= 0x7F and 1 + or (first >= 0xC2 and first <= 0xDF and 2) + or (first >= 0xE0 and first <= 0xEF and 3) + or (first >= 0xF0 and first <= 0xF4 and 4) or 0 + if width == 0 or i + width - 1 > n then return false end + for j = 2, width do + local byte = text:byte(i + j - 1) + if byte < 0x80 or byte > 0xBF then return false end + end + local second = width > 1 and text:byte(i + 1) or 0 + if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or + (first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then return false end + i = i + width + end + return true +end + -- instruction budget like guardedCall(): error() out of the hook, pcall catches local function guarded(budget, fn, ...) local count = 0 @@ -77,7 +98,7 @@ local function mkbutton(text, x, y, w, h, fn) end local function build_wada() - local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {} } + local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {}, fs = {} } wada.ui.colors = { accent = 0x15B6A6, text = 0xE6E9ED, sub = 0x7A7F87, bg = 0x000000, panel = 0x15181B, bad = 0xD7574E, good = 0x53C06B } wada.ui.canvas = mkcanvas wada.ui.label = mklabel @@ -102,7 +123,7 @@ local function build_wada() wada.sys.beep = function() return false end wada.sys.caps = function() return { sdk_ext = cfg.caps.sdk_ext, keyboard = cfg.caps.keyboard, touch = cfg.caps.touch, sd = true, compass = cfg.caps.compass, - accel = cfg.caps.accel } end + accel = cfg.caps.accel, discover = cfg.caps.discover } end if cfg.caps.sdk_ext then wada.sys.battery = function() return { mv = 3900, pct = 70, charging = false } end wada.sys.gps = function() return cfg.gps and cfg.gps() or nil end @@ -118,6 +139,12 @@ local function build_wada() wada.mesh.self = function() return { name = "me", lat = cfg.self_lat or 0, lon = cfg.self_lon or 0 } end wada.mesh.stats = function() return {} end wada.mesh.rx_log = function() return {} end + wada.mesh.discover = function() return "probe-1" end + wada.mesh.discovered = function() return cfg.discovered or {} end + wada.mesh.discover_clear = function() cfg.discovered = {} end + + wada.fs.append = function(name, data) checkstr(name, "fs.append name"); checkstr(data, "fs.append data"); return true end + wada.fs.remove = function(name) checkstr(name, "fs.remove name"); return true end wada.store.get = function(k, d) assert(type(k) == "string"); local v = storekv[k]; if v == nil then return d end return v end wada.store.set = function(k, v) assert(type(k) == "string", "store key must be a string") @@ -204,6 +231,36 @@ local contacts_fixture = { local scenarios = {} +scenarios.wardrive_utf8 = function() + cfg = { + w = 320, h = 196, + caps = { sdk_ext = true, keyboard = true, touch = false, compass = false, accel = false, discover = true }, + discovered = { + { pubkey = "01020304", name = "Ouderkerk☀️", type = 2, + rssi = -72, snr = 7.25, their_snr = 6.5, hops = 0 } + } + } + cfg.gps = function() + return { lat = 52.295, lon = 4.907, lat_e6 = 52295000, lon_e6 = 4907000, + sats = 9, alt_m = 3, time = 1787620000 } + end + storekv = {} + wada = build_wada() + local app = load_app() + assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h)) + assert(buttons[1] and buttons[1].fn, "Wardrive Start button missing") + assert(guarded(BUDGET, buttons[1].fn)) + tick(app, 1, 100) + tick(app, 1, 4000) + local found = false + for _, label in ipairs(labels) do + assert(valid_utf8(label.text), "Wardrive rendered invalid UTF-8") + if label.text:find("Ouderkerk☀", 1, true) then found = true end + end + assert(found, "emoji-bearing repeater name was not rendered") + if app.on_close then guarded(BUDGET, app.on_close) end +end + -- The declination model, as it actually ships. Rather than testing a copy in -- out/wmm, this pulls the do-block straight out of the app file that gets -- sideloaded, so an inlining mistake fails here instead of on the device. @@ -711,7 +768,9 @@ scenarios.cost = function() assert(worst < BUDGET / 4, "tick too expensive") end -local order = { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "cost" } +local order = APP_PATH:find("/wardrive/", 1, true) + and { "wardrive_utf8" } + or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "cost" } for _, name in ipairs(order) do if SCENARIO == "all" or SCENARIO == name then print("== " .. name) diff --git a/src/ui-touch/LuaAppHost.cpp b/src/ui-touch/LuaAppHost.cpp index dd51781..38f11f2 100644 --- a/src/ui-touch/LuaAppHost.cpp +++ b/src/ui-touch/LuaAppHost.cpp @@ -18,6 +18,7 @@ #include "AppPage.h" #include "device_caps.h" #include "i18n.h" // wada.sys.tr: apps can use the same translation table the UI does +#include "Utf8Text.h" #include "helpers/esp32/WdtHeavyGuard.h" // wada.fs writes can trigger SPIFFS GC extern "C" { @@ -246,6 +247,19 @@ uint32_t argColor(lua_State* L, int idx, uint32_t def = 0xFFFFFF) { return (uint32_t)luaL_optinteger(L, idx, (lua_Integer)def) & 0xFFFFFF; } +// LVGL assumes structurally valid UTF-8 and can stop advancing when an app +// hands it a partial codepoint. Keep one reusable repair buffer: valid strings +// stay zero-copy, while malformed runs become one ASCII '?'. Every call below +// is synchronous and LVGL copies label text before this buffer can be reused. +const char* safeUiText(const char* text, size_t length, size_t* safe_length = nullptr, + unsigned scratch_slot = 0) { + static std::string scratch_slots[2]; + std::string& scratch = scratch_slots[scratch_slot & 1U]; + const char* safe = Utf8Text::sanitize(text, length, scratch); + if (safe_length) *safe_length = safe == text ? length : scratch.size(); + return safe; +} + // ---- canvas userdata ---- struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; }; @@ -298,11 +312,14 @@ int cvCircle(lua_State* L) { int cvText(lua_State* L) { CanvasUd* c = checkCanvas(L); if (!c->obj) return 0; + const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2); + const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3); + size_t text_length = 0; + const char* text = luaL_checklstring(L, 4, &text_length); lv_draw_label_dsc_t d; lv_draw_label_dsc_init(&d); d.color = lv_color_hex(argColor(L, 5)); d.font = luaHostFontForSize((int)luaL_optinteger(L, 6, 14)); - lv_canvas_draw_text(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3), - c->w, &d, luaL_checkstring(L, 4)); + lv_canvas_draw_text(c->obj, x, y, c->w, &d, safeUiText(text, text_length)); return 0; } int cvPos(lua_State* L) { @@ -357,7 +374,9 @@ WidgetUd* checkLabel(lua_State* L) { return (WidgetUd*)luaL_checkudata(L, 1, "wa int lbSet(lua_State* L) { WidgetUd* u = checkLabel(L); - if (u->obj) lv_label_set_text(u->obj, luaL_checkstring(L, 2)); + size_t length = 0; + const char* text = luaL_checklstring(L, 2, &length); + if (u->obj) lv_label_set_text(u->obj, safeUiText(text, length)); return 0; } int lbPos(lua_State* L) { @@ -390,11 +409,17 @@ int lbWidth(lua_State* L) { // label:width(px [, "left"|"center"|"right"]) — int uiLabel(lua_State* L) { if (!s_h || !s_h->body) return luaL_error(L, "no app body"); + size_t length = 0; + const char* text = luaL_checklstring(L, 1, &length); + const lv_coord_t x = (lv_coord_t)luaL_optinteger(L, 2, 0); + const lv_coord_t y = (lv_coord_t)luaL_optinteger(L, 3, 0); + const lv_font_t* font = luaHostFontForSize((int)luaL_optinteger(L, 4, 14)); + const uint32_t color = argColor(L, 5, 0xE6E9ED); lv_obj_t* l = lv_label_create(s_h->body); - lv_label_set_text(l, luaL_checkstring(L, 1)); - lv_obj_set_pos(l, (lv_coord_t)luaL_optinteger(L, 2, 0), (lv_coord_t)luaL_optinteger(L, 3, 0)); - lv_obj_set_style_text_font(l, luaHostFontForSize((int)luaL_optinteger(L, 4, 14)), LV_PART_MAIN); - lv_obj_set_style_text_color(l, lv_color_hex(argColor(L, 5, 0xE6E9ED)), LV_PART_MAIN); + lv_label_set_text(l, safeUiText(text, length)); + lv_obj_set_pos(l, x, y); + lv_obj_set_style_text_font(l, font, LV_PART_MAIN); + lv_obj_set_style_text_color(l, lv_color_hex(color), LV_PART_MAIN); WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0); ud->obj = l; luaL_setmetatable(L, "wada.label"); @@ -497,12 +522,17 @@ void btnEventCb(lv_event_t* e) { int uiButton(lua_State* L) { if (!s_h || !s_h->body) return luaL_error(L, "no app body"); - const char* txt = luaL_checkstring(L, 1); + size_t text_length = 0; + const char* text = luaL_checklstring(L, 1, &text_length); + const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2); + const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3); + const lv_coord_t w = (lv_coord_t)luaL_optinteger(L, 4, 90); + const lv_coord_t h = (lv_coord_t)luaL_optinteger(L, 5, 34); lv_obj_t* b = lv_btn_create(s_h->body); - lv_obj_set_pos(b, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3)); - lv_obj_set_size(b, (lv_coord_t)luaL_optinteger(L, 4, 90), (lv_coord_t)luaL_optinteger(L, 5, 34)); + lv_obj_set_pos(b, x, y); + lv_obj_set_size(b, w, h); lv_obj_t* bl = lv_label_create(b); - lv_label_set_text(bl, txt); + lv_label_set_text(bl, safeUiText(text, text_length)); lv_obj_center(bl); if (lua_isfunction(L, 6)) { lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb); @@ -554,6 +584,7 @@ int uiTextW(lua_State* L) { size_t len = 0; const char* txt = luaL_checklstring(L, 1, &len); const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 2, 14)); + txt = safeUiText(txt, len, &len); lua_pushinteger(L, (lua_Integer)lv_txt_get_width(txt, (uint32_t)len, f, 0, LV_TEXT_FLAG_NONE)); return 1; } @@ -566,6 +597,7 @@ int uiTextLines(lua_State* L) { const char* txt = luaL_checklstring(L, 1, &len); const int w = (int)luaL_checkinteger(L, 2); const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 3, 14)); + txt = safeUiText(txt, len, &len); if (w <= 0) { lua_pushinteger(L, 1); return 1; } int lines = 0; const char* p = txt; @@ -605,7 +637,8 @@ int uiList(lua_State* L) { // list:add(text [, fn]) -> index of the new row int lsAdd(lua_State* L) { ListUd* u = checkList(L); - const char* txt = luaL_checkstring(L, 2); + size_t text_length = 0; + const char* text = luaL_checklstring(L, 2, &text_length); if (!u->obj || !s_h) return 0; lv_obj_t* row = lv_btn_create(u->obj); lv_obj_set_width(row, LV_PCT(100)); @@ -614,7 +647,7 @@ int lsAdd(lua_State* L) { lv_obj_set_style_pad_all(row, 6, LV_PART_MAIN); listPaintRow(row, false); lv_obj_t* lb = lv_label_create(row); - lv_label_set_text(lb, txt); + lv_label_set_text(lb, safeUiText(text, text_length)); lv_label_set_long_mode(lb, LV_LABEL_LONG_DOT); // a long name truncates instead of reflowing the row lv_obj_set_width(lb, LV_PCT(100)); lv_obj_set_style_text_font(lb, luaHostFontForSize(12), LV_PART_MAIN); @@ -633,9 +666,11 @@ int lsAdd(lua_State* L) { int lsSet(lua_State* L) { ListUd* u = checkList(L); lv_obj_t* row = listRowAt(u, (int)luaL_checkinteger(L, 2)); + size_t text_length = 0; + const char* text = luaL_checklstring(L, 3, &text_length); if (!row) return 0; lv_obj_t* lb = lv_obj_get_child(row, 0); - if (lb) lv_label_set_text(lb, luaL_checkstring(L, 3)); + if (lb) lv_label_set_text(lb, safeUiText(text, text_length)); return 0; } int lsColor(lua_State* L) { @@ -1939,11 +1974,14 @@ void promptDeliver(const char* text) { } int uiInput(lua_State* L) { - const char* title = luaL_optstring(L, 1, ""); - const char* initial = luaL_optstring(L, 2, ""); + size_t title_length = 0, initial_length = 0; + const char* title_raw = luaL_optlstring(L, 1, "", &title_length); + const char* initial_raw = luaL_optlstring(L, 2, "", &initial_length); luaL_checktype(L, 3, LUA_TFUNCTION); if (!s_h) return luaL_error(L, "no app"); if (s_h->prompt_cb != LUA_NOREF) return luaL_error(L, "an input prompt is already open"); + const char* title = safeUiText(title_raw, title_length, nullptr, 0); + const char* initial = safeUiText(initial_raw, initial_length, nullptr, 1); lua_pushvalue(L, 3); s_h->prompt_cb = luaL_ref(L, LUA_REGISTRYINDEX); luaHostTextPrompt(title, initial, promptDeliver); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..1e6e455 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -119,7 +119,7 @@ #include #endif #if defined(HAS_TDECK_KEYBOARD) - #include + #include "../helpers/input/TDeckKeyboard.h" #elif defined(HAS_M9_KEYBOARD) #include #endif @@ -130,7 +130,7 @@ #include // wada.sys.accel() source (luaHostAccel) #endif #if defined(HAS_PAGER_KEYBOARD) - #include + #include "../helpers/input/PagerKeyboard.h" #endif #if defined(HAS_PAGER_ENCODER) #include @@ -36172,30 +36172,6 @@ static void updatePagerBackspaceHold(unsigned long now) { s_was_held = held; } -// Orange/Alt key, tapped alone (not held as a symbol-layer modifier or for the -// encoder's Alt+turn). In an open chat it takes over the old Backspace role: -// jump to the latest message (when scrolled up in history) and drop focus in -// the composer, ready to type — the natural "done reading, reply now" motion, -// and the deliberate way OUT of the message list now that plain encoder turns -// hold focus inside it while history loads (pagerEncoderChatEdgeScroll). -// Everywhere else it stays the same "next field" as one rotary NEXT detent. -// Call once per loop tick while the screen is on; screen-off handling discards -// any pending tap instead (see loop()'s HAS_PAGER_KEYBOARD branch) so a stray -// tap picked up while idle-dimmed can't fire the moment the screen wakes. -static void updatePagerAltTapNext() { - if (!pagerKeyboardConsumeAltTap()) return; - LvChatPanel* cp = navOpenChatPanel(); - if (cp && cp->msgs && cp->composer_ta && lv_obj_is_valid(cp->composer_ta)) { - if (chatVirtAwayFromBottom(cp)) chatVirtJumpToLatest(cp); - lv_group_focus_obj(cp->composer_ta); - s_nav_show = true; - if (g_lv.task) g_lv.task->noteUserInput(); - return; - } - navPushTap(LV_KEY_NEXT); - if (g_lv.task) g_lv.task->noteUserInput(); -} - // Alt(Fn)+Shift chord (PagerKeyboard.cpp only reports it, since the driver has // no UI visibility): toggles Caps Lock while actually editing a text field // (the field is where "Caps Lock" means anything) and is a deliberate no-op @@ -36410,9 +36386,7 @@ static bool pagerEncoderScrollOversizedFocused(bool up) { // the list instead (same navScrollFocused the Alt+turn branch uses): the // scroll fires the virtualization render, the neighbor bubble joins the nav // group via the tree-signature rebuild, and the next detent walks onto it. -// Focus only leaves the list at the TRUE oldest/newest message. The orange-key -// solo tap (updatePagerAltTapNext) pushes LV_KEY_NEXT directly and never comes -// through here, so it stays the deliberate "leave the messages now" exit. +// Focus only leaves the list at the TRUE oldest/newest message. // Returns true when the detent was consumed as a scroll. static bool pagerEncoderChatEdgeScroll(bool up) { LvChatPanel* cp = navOpenChatPanel(); @@ -36583,8 +36557,8 @@ static void updatePagerEncoder(unsigned long now) { if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput(); if (delta != 0 || held) noteKbActivity(); // same activity counts for the keyboard-backlight auto mode - // Alt+turn is a modifier combo, not a solo Alt tap -- mark it used so a - // release right after this doesn't ALSO fire updatePagerAltTapNext()'s NEXT. + // Alt+turn is a modifier combo, not a solo tap -- mark it used so releasing + // Alt afterward does not also arm the one-shot symbol layer. if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed(); if (s_mentionnav_active) { @@ -38430,9 +38404,9 @@ if (g_lv.task && g_lv.task->isManualLock()) { // below the "NEW ----" unread divider) and focus it, so the user reads the // new messages chronologically with plain encoder turns from there; with // no divider (nothing unread) it jumps to the NEWEST message instead. - // (The old Backspace role -- jump to latest + focus the composer -- moved - // to the solo orange/Alt tap, see updatePagerAltTapNext().) The divider - // jump reuses the exact scroll recipe refreshChatDetail's open-to-divider + // The former solo-Alt shortcut now latches the symbol layer; at the true + // newest message, the next encoder detent leaves the list for the composer. + // The divider jump reuses the exact scroll recipe refreshChatDetail's open-to-divider // path uses; both cases use the pending-focus re-aim so the recreated row // for the target message ends up focused after the virtualization render. // Outside a chat, Backspace keeps its normal no-op / hold-to-back @@ -52834,6 +52808,8 @@ void UITask::loop() { // started above. bool con_activity = false; #if defined(HAS_TDECK_KEYBOARD) + if (_screen_off || _manual_lock) tdeckKeyboardDiscardModifiers(); + else tdeckKeyboardAllowModifiers(); for (int kbi = 0; kbi < 12; ++kbi) { int key = tdeckKeyboardReadKey(); if (key <= 0) break; @@ -52842,7 +52818,7 @@ void UITask::loop() { // A key pressed on a dark screen WAKES rather than types, the same as a // touch does in the UI. Otherwise the character you used to see the screen // ends up in the command you are typing. - if (_screen_off) { wakeScreen(); continue; } + if (_screen_off) { tdeckKeyboardDiscardModifiers(); wakeScreen(); continue; } consoleKey(key); } #endif @@ -53445,12 +53421,15 @@ void UITask::loop() { updatePagerEncoder(now); #endif #if defined(HAS_TDECK_KEYBOARD) + if (_screen_off || _manual_lock || s_remote_mode) tdeckKeyboardDiscardModifiers(); + else tdeckKeyboardAllowModifiers(); // Drain physical-keyboard presses buffered by the touch task into the field. for (int kbi = 0; kbi < 12; ++kbi) { int key = tdeckKeyboardReadKey(); if (key <= 0) break; if (!_screen_off) s_kb_last_key_ms = now; // a keypress while locked must not light the kb - if (s_remote_mode) { remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit + if (s_remote_mode) { tdeckKeyboardDiscardModifiers(); remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit + if (_screen_off) tdeckKeyboardDiscardModifiers(); handleHwKey(key); } // Focusing a text field (cursor starts blinking — tapped or auto-focused) @@ -53492,6 +53471,7 @@ void UITask::loop() { // working while the screen is dark. updatePagerKbBacklight() follows the // same rule. Remote Mode pauses both because it owns the lit placeholder. pagerKeyboardPoll(); + if (g_lv.task && g_lv.task->isManualLock()) pagerKeyboardDiscardAlt(); // Remote Mode keeps the placeholder lit and reserves all keys for its local // escape path, so normal lock/backlight state machines stay paused there. if (!s_remote_mode) { @@ -53507,7 +53487,7 @@ void UITask::loop() { if (key <= 0) break; remotePhysicalKey(key); } - pagerKeyboardConsumeAltTap(); + pagerKeyboardDiscardAlt(); pagerKeyboardConsumeAltShiftChord(); pagerKeyboardConsumeAltBackspaceChord(); } else if (g_lv.task && g_lv.task->isScreenOff()) { @@ -53526,10 +53506,10 @@ void UITask::loop() { any = true; if (k == 0x08) saw_backspace = true; } - // Discard any Alt tap / Alt+Shift / Alt+Backspace chord picked up while - // idle-dimmed -- none of them may fire (NEXT / Caps toggle / jump Home) + // Discard any Alt latch / Alt+Shift / Alt+Backspace chord picked up while + // idle-dimmed -- none of them may fire (symbol layer / Caps / jump Home) // the instant the screen wakes. - pagerKeyboardConsumeAltTap(); + pagerKeyboardDiscardAlt(); pagerKeyboardConsumeAltShiftChord(); pagerKeyboardConsumeAltBackspaceChord(); // Hard-locked: an ordinary keypress must NOT wake/unlock -- only holding @@ -53554,7 +53534,6 @@ void UITask::loop() { if (key <= 0) break; handleHwKey(key); } - updatePagerAltTapNext(); updatePagerAltShiftChord(); updatePagerAltBackspaceChord(); updatePagerBackspaceHold(now); diff --git a/src/ui-touch/Utf8Text.h b/src/ui-touch/Utf8Text.h new file mode 100644 index 0000000..90571fe --- /dev/null +++ b/src/ui-touch/Utf8Text.h @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include +#include + +namespace Utf8Text { + +inline bool continuation(uint8_t byte) { + return (byte & 0xC0U) == 0x80U; +} + +inline size_t sequenceLength(const uint8_t* text, size_t remaining) { + if (!remaining) return 0; + const uint8_t first = text[0]; + if (first <= 0x7F) return 1; + if (first >= 0xC2 && first <= 0xDF) { + return remaining >= 2 && continuation(text[1]) ? 2 : 0; + } + if (first == 0xE0) { + return remaining >= 3 && text[1] >= 0xA0 && text[1] <= 0xBF && continuation(text[2]) ? 3 : 0; + } + if ((first >= 0xE1 && first <= 0xEC) || (first >= 0xEE && first <= 0xEF)) { + return remaining >= 3 && continuation(text[1]) && continuation(text[2]) ? 3 : 0; + } + if (first == 0xED) { + return remaining >= 3 && text[1] >= 0x80 && text[1] <= 0x9F && continuation(text[2]) ? 3 : 0; + } + if (first == 0xF0) { + return remaining >= 4 && text[1] >= 0x90 && text[1] <= 0xBF && + continuation(text[2]) && continuation(text[3]) ? 4 : 0; + } + if (first >= 0xF1 && first <= 0xF3) { + return remaining >= 4 && continuation(text[1]) && continuation(text[2]) && continuation(text[3]) ? 4 : 0; + } + if (first == 0xF4) { + return remaining >= 4 && text[1] >= 0x80 && text[1] <= 0x8F && + continuation(text[2]) && continuation(text[3]) ? 4 : 0; + } + return 0; +} + +inline bool valid(const char* text, size_t length) { + const uint8_t* bytes = reinterpret_cast(text); + size_t offset = 0; + while (offset < length) { + const size_t sequence = sequenceLength(bytes + offset, length - offset); + if (!sequence) return false; + offset += sequence; + } + return true; +} + +// Returns the original pointer when it is already valid. Malformed runs are +// collapsed to one ASCII '?' in scratch so LVGL never receives a partial codepoint. +inline const char* sanitize(const char* text, size_t length, std::string& scratch) { + scratch.clear(); + if (valid(text, length)) return text; + + scratch.reserve(length); + const uint8_t* bytes = reinterpret_cast(text); + size_t offset = 0; + while (offset < length) { + const size_t sequence = sequenceLength(bytes + offset, length - offset); + if (sequence) { + scratch.append(text + offset, sequence); + offset += sequence; + continue; + } + scratch.push_back('?'); + ++offset; + while (offset < length && continuation(bytes[offset])) ++offset; + } + return scratch.c_str(); +} + +} // namespace Utf8Text \ No newline at end of file diff --git a/src/ui-touch/lua_builtin.h b/src/ui-touch/lua_builtin.h index ecbcc82..4a43f4d 100644 --- a/src/ui-touch/lua_builtin.h +++ b/src/ui-touch/lua_builtin.h @@ -1473,6 +1473,34 @@ local pending = {} -- lines waiting on the 1 write/sec limit local function logname() return run .. ".csv" end +-- Return at most max_bytes without splitting a UTF-8 sequence. The row's +-- fixed-width columns are byte-budgeted, not character-budgeted; a raw +-- string.sub(1, 14) cut Ouderkerk + sun + VS16 inside the final codepoint and +-- left LVGL unable to advance through the label (#323, same class as #223). +local function utf8_prefix_bytes(text, max_bytes) + local offset, last, length = 1, 0, #text + while offset <= length and offset <= max_bytes do + local first = text:byte(offset) + local width = first <= 0x7F and 1 + or (first >= 0xC2 and first <= 0xDF and 2) + or (first >= 0xE0 and first <= 0xEF and 3) + or (first >= 0xF0 and first <= 0xF4 and 4) or 0 + if width == 0 or offset + width - 1 > length or offset + width - 1 > max_bytes then break end + local valid = true + for i = 2, width do + local byte = text:byte(offset + i - 1) + if byte < 0x80 or byte > 0xBF then valid = false; break end + end + local second = width > 1 and text:byte(offset + 1) or 0 + if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or + (first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then valid = false end + if not valid then break end + last = offset + width - 1 + offset = last + 1 + end + return text:sub(1, last) +end + -- Lua here is built with 32-bit floats, so fix.lat is good to about a metre and -- no better. fix.lat_e6 is the same reading as an exact integer in -- micro-degrees, which is what belongs in a log: a survey you plot months later @@ -1554,7 +1582,7 @@ local function redraw() local e = list[i] if e then rows[i]:set(string.format("%-14s %-8s best %5.1f worst %5.1f x%d", - e.n.name:sub(1, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen)) + utf8_prefix_bytes(e.n.name, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen)) rows[i]:color(e.n.best > 0 and C.good or C.text) else rows[i]:set(i == 1 and "nothing has answered a probe yet" or "") @@ -1777,7 +1805,7 @@ static const LuaBuiltinApp kLuaBuiltin[] = { { "snake", "Snake", "1.0", kLuaSrc_snake }, { "sdktest", "SDK Test", "1.6", kLuaSrc_sdktest }, { "2048", "2048", "1.2", kLuaSrc_2048 }, - { "wardrive", "Wardrive", "1.0", kLuaSrc_wardrive }, + { "wardrive", "Wardrive", "1.1", kLuaSrc_wardrive }, { "nearby", "Nearby", "1.0", kLuaSrc_nearby }, }; static const int kLuaBuiltinCount = (int)(sizeof(kLuaBuiltin)/sizeof(kLuaBuiltin[0])); diff --git a/test/test_utf8_text.cpp b/test/test_utf8_text.cpp new file mode 100644 index 0000000..3ccecf0 --- /dev/null +++ b/test/test_utf8_text.cpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include +#include + +#include "ui-touch/Utf8Text.h" + +int main() { + const char full[] = "Ouderkerk\xE2\x98\x80\xEF\xB8\x8F"; + assert(strlen(full) == 15); + assert(Utf8Text::valid(full, strlen(full))); + + std::string broken(full, 14); // Wardrive's former name:sub(1, 14) + assert(!Utf8Text::valid(broken.data(), broken.size())); + + std::string scratch; + const char* clean = Utf8Text::sanitize(broken.data(), broken.size(), scratch); + assert(scratch == "Ouderkerk\xE2\x98\x80?"); + assert(Utf8Text::valid(clean, scratch.size())); + + scratch = "allocated"; + const char* unchanged = Utf8Text::sanitize(full, strlen(full), scratch); + assert(unchanged == full); + assert(scratch.empty()); + + const char overlong[] = { (char)0xC0, (char)0xAF }; + assert(!Utf8Text::valid(overlong, sizeof overlong)); + Utf8Text::sanitize(overlong, sizeof overlong, scratch); + assert(scratch == "?"); + return 0; +} \ No newline at end of file From 979123806663159d0ac8ab8ff27df5a3537ce05d Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Tue, 25 Aug 2026 16:12:37 -0400 Subject: [PATCH 2/2] Add keyboard modifier transition hooks Signed-off-by: Michael A. Cojocari --- src/helpers/input/PagerKeyboard.cpp | 8 ++++++++ src/helpers/input/PagerKeyboard.h | 4 ++++ src/helpers/input/TDeckKeyboard.cpp | 4 ++++ src/helpers/input/TDeckKeyboard.h | 6 ++++++ 4 files changed, 22 insertions(+) diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index e357368..e1cc6fb 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -183,6 +183,14 @@ bool pagerKeyboardAltHeld() { return s_alt; } void pagerKeyboardMarkAltUsed() { s_alt_used = true; } +void pagerKeyboardDiscardAlt() { + s_alt = false; + s_alt_used = true; + s_alt_tap_pending = false; + s_alt_shift_chord_pending = false; + s_alt_backspace_chord_pending = false; +} + bool pagerKeyboardConsumeAltTap() { if (!s_alt_tap_pending) return false; s_alt_tap_pending = false; diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h index 774e552..4a42040 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -50,6 +50,10 @@ bool pagerKeyboardAltHeld(); * pagerKeyboardConsumeAltTap(). */ void pagerKeyboardMarkAltUsed(); +/** Clear held/tapped Alt and pending Alt chords when keyboard input is + * intentionally discarded during a UI mode transition. */ +void pagerKeyboardDiscardAlt(); + /** One-shot: true exactly once if Alt was pressed and released without being * used as a modifier for anything else in between (no key typed, no * pagerKeyboardMarkAltUsed() call) — a "solo tap", distinct from a diff --git a/src/helpers/input/TDeckKeyboard.cpp b/src/helpers/input/TDeckKeyboard.cpp index 091b6b3..a82ee14 100644 --- a/src/helpers/input/TDeckKeyboard.cpp +++ b/src/helpers/input/TDeckKeyboard.cpp @@ -66,4 +66,8 @@ int tdeckKeyboardReadKey() { return key; } +void tdeckKeyboardDiscardModifiers() {} + +void tdeckKeyboardAllowModifiers() {} + #endif diff --git a/src/helpers/input/TDeckKeyboard.h b/src/helpers/input/TDeckKeyboard.h index cf64910..8e352bb 100644 --- a/src/helpers/input/TDeckKeyboard.h +++ b/src/helpers/input/TDeckKeyboard.h @@ -23,6 +23,12 @@ void tdeckKeyboardPoll(); /** Pop the next buffered key (ASCII), or 0 if none. Safe from the UI thread. */ int tdeckKeyboardReadKey(); +/** Compatibility hooks for UI mode transitions. Modifier state belongs to + * the keyboard's C3 firmware in this legacy ASCII driver, so there is no + * host-side state to clear or re-enable. */ +void tdeckKeyboardDiscardModifiers(); +void tdeckKeyboardAllowModifiers(); + /** Request a keyboard-backlight level (0 = off, 0xFF = on). Safe from the UI * thread — the I2C write happens inside tdeckKeyboardPoll() (core 0), which * owns the bus. Only re-sent when the value changes. */