Files
MeshCore-Solo/examples/companion_radio/ui-new/ChannelsView.h
Jakub 453dc5e570 feat(admin): typed Radio/Routing field editors; pre-release audit fixes
Tools > Admin: split the Radio tab's single "f,bw,sf,cr" comma-string
field (free-text keyboard) into 4 independent, type-appropriate rows --
Frequency (same digit-cursor DigitEditor Settings/Repeater use locally),
Bandwidth/Spreading factor/Coding rate (discrete-set LEFT/RIGHT stepping),
plus TX power and the 3 Routing numeric fields as number steppers and
Repeat as an ON/OFF toggle. Edits happen locally (zero mesh traffic per
keystroke); only Enter sends one combined `set`, Cancel sends nothing.

Full pre-release audit of all 16 commits since v1.22 (5 parallel focus
areas: display/font, keyboard/messages, Nodes/Admin, Bot/channels,
UITask+NodePrefs schema) turned up and fixed:

- Admin: fetched FK_NUMBER values weren't clamped to the field's range,
  so a value already out-of-range could get stuck unreachable; fixed
  commit-time float format noise (%.6f -> %.3f); Cancel/failed-login
  always returned to the Nodes picker even when Admin was opened
  directly from a node's Hold-Enter action -- now returns to wherever
  it was actually opened from (AdminScreen::_from_picker).
- Keyboard: one-shot Shift was consumed after the *first* T9 multi-tap,
  so cycling to the 2nd/3rd candidate always came out lowercase --
  fixed by caching the cycle's caps state (t9_caps).
- Messages: history is numbered newest-first, so a message arriving
  while scrolled up to an older one silently relabeled the view onto a
  different message -- selection now shifts with the insert.
- Channels: onChannelRemoved() didn't clear ch_notif_override/
  ch_notif_muted/ch_fav_bitmask despite its own contract comment
  requiring it (now far more reachable via the on-device Delete);
  an all-zero hex secret silently self-deleted the channel it was
  just saved into (collides with the empty-slot sentinel) -- rejected.
- Room login: isRoomLoggedIn() indexed the login-tracking ring
  directly instead of via its head offset, silently wrong once the
  ring wraps (8+ rooms/session) -- the new on-device Logout depends
  on this being right.
- Font/display: removed the now fully-inert Settings > Display > Font
  toggle and the dead LemonFont.h (retired by the earlier misc-fixed
  font unification, zero remaining includes); fixed a copy-pasted
  "5x7" comment (font is 6x9), two meaningless dead ternaries, and an
  OLED/e-ink inconsistency in the undefined-glyph fallback box offset.
- AdminField's `kind`/bounds fields no longer rely on default member
  initializers inside aggregate-init: this toolchain's actual nRF52
  build (unlike env:native) has no explicit -std= override, so it
  predates C++14's aggregate-with-default-member-initializer rule.
  Given an explicit constructor instead -- portable regardless of
  standard, all existing field-table literals unchanged.

Docs updated to match: tools_screen.md (Diagnostics as a 3-tab
carousel, was documented as one flat screen), message_screen.md
(chat bubbles, newest-at-bottom, cursor mode, secret validation),
settings_screen.md (dropped the dead Font row), solo_ui_framework.md
(header menu_hint signatures, icon priority-drop, KeyboardWidget's
T9/alphabets/cursor-mode). release-notes.md gains the v1.23 section
covering all of the above plus the other 15 commits since v1.22.

Build-verified on WioTrackerL1_companion_solo_dual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 19:38:51 +02:00

168 lines
6.9 KiB
C++

#pragma once
// On-device channel Add/Edit form (Messages > Channels > "+ Add channel" / Edit).
// Owned by MessagesScreen, which delegates render()/handleInput() to it while
// active() — the same relationship WaypointsView has with TrailScreen.
//
// The Secret field toggles between two entry modes with LEFT/RIGHT:
// - Passphrase (default): any text, SHA-256'd down to 16 bytes — the same
// primitive BaseChatMesh::addChannel()/setChannel() already use to derive
// a channel's routing hash, just applied here to a typed phrase instead of
// a raw key. Easiest to agree on verbally, like a room password.
// - Hex key: the exact 32-hex-char (128-bit) secret, e.g. from a channel QR
// (see docs/qr_codes.md) — for joining a channel whose precise secret you
// were given rather than agreeing on a new passphrase.
// Either way only a 16-byte (128-bit) secret is produced, matching the
// existing CMD_GET_CHANNEL comment ("NOTE: only 128-bit supported").
#include <Utils.h>
class ChannelsView {
UITask* _task;
enum Mode : uint8_t { OFF, ADD, EDIT };
uint8_t _mode = OFF;
int _idx = -1; // channel slot being added/edited
char _name[32] = "";
bool _hex_mode = false; // false = passphrase, true = 32-hex-char raw key
char _secret_text[33] = ""; // passphrase or hex string typed so far
int _sel = 0; // 0=Name 1=Secret 2=[Save]
bool _kb_active = false;
int _kb_field = -1; // 0=Name, 1=Secret
KeyboardWidget& kb() { return _task->keyboard(); }
void openKb(const char* initial, int max) {
kb().begin(initial, max);
kb().clearPlaceholders(); // literal field — {loc}/{time} make no sense here
_kb_active = true;
}
// Turn the typed Secret field into a 16-byte channel secret. Returns false
// (and leaves out[] untouched) if the field can't be parsed as configured.
bool deriveSecret(uint8_t out[32]) const {
if (_hex_mode) {
if (strlen(_secret_text) != 32) return false;
uint8_t tmp[16];
for (int i = 0; i < 16; i++) {
char byte_str[3] = { _secret_text[i * 2], _secret_text[i * 2 + 1], 0 };
char* end = nullptr;
long v = strtol(byte_str, &end, 16);
if (*end != 0) return false;
tmp[i] = (uint8_t)v;
}
// An all-zero 16-byte secret is the sentinel setChannelLocal() reads as
// "slot deleted" (see isAllZero() in MyMesh.cpp) -- saving one here would
// pass, then immediately trigger onChannelRemoved() on this very slot,
// silently discarding the channel and its bot/notif/favourite state.
// Reject it up front rather than let that self-delete happen invisibly.
bool all_zero = true;
for (int i = 0; i < 16 && all_zero; i++) if (tmp[i]) all_zero = false;
if (all_zero) return false;
memset(out, 0, 32);
memcpy(out, tmp, 16);
return true;
}
if (_secret_text[0] == '\0') return false; // blank passphrase not allowed
uint8_t digest[32];
mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)_secret_text, (int)strlen(_secret_text));
memset(out, 0, 32);
memcpy(out, digest, 16);
return true;
}
void commit() {
if (_name[0] == '\0') { _task->showAlert("Name required", 1200); return; }
uint8_t secret[32];
if (!deriveSecret(secret)) {
_task->showAlert(_hex_mode ? "Invalid secret" : "Secret required", 1400);
return;
}
ChannelDetails ch;
memset(&ch, 0, sizeof(ch));
strncpy(ch.name, _name, sizeof(ch.name) - 1);
memcpy(ch.channel.secret, secret, sizeof(ch.channel.secret));
if (the_mesh.setChannelLocal((uint8_t)_idx, ch)) {
_task->showAlert(_mode == ADD ? "Channel added" : "Channel updated", 1000);
_mode = OFF;
} else {
_task->showAlert("Save failed", 1200);
}
}
public:
explicit ChannelsView(UITask* task) : _task(task) {}
bool active() const { return _mode != OFF || _kb_active; }
void reset() { _mode = OFF; _kb_active = false; _kb_field = -1; }
// idx = the slot to write on Save (first free slot for Add, existing slot
// for Edit). Edit pre-fills Name; the Secret can't be redisplayed (only the
// derived key is stored), so editing it means typing a new passphrase/hex —
// same as re-logging into a room with a new password.
void openAdd(int idx) {
_mode = ADD; _idx = idx;
_name[0] = '\0'; _secret_text[0] = '\0'; _hex_mode = false; _sel = 0;
}
void openEdit(int idx, const char* existing_name) {
_mode = EDIT; _idx = idx;
strncpy(_name, existing_name, sizeof(_name) - 1); _name[sizeof(_name) - 1] = '\0';
_secret_text[0] = '\0'; _hex_mode = false; _sel = 0;
}
int render(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
if (_kb_active) return kb().render(display);
display.drawCenteredHeader(_mode == ADD ? "ADD CHANNEL" : "EDIT CHANNEL");
const int top = display.listStart();
const int step = display.lineStep();
for (int i = 0; i < 3; i++) {
int y = top + i * step;
bool sel = (i == _sel);
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
char row[40];
if (i == 0) snprintf(row, sizeof(row), "Name: %s", _name[0] ? _name : "(none)");
else if (i == 1) snprintf(row, sizeof(row), "Secret (%s): %s",
_hex_mode ? "hex" : "phrase", _secret_text[0] ? _secret_text : "(none)");
else snprintf(row, sizeof(row), "[Save]");
// Ellipsize rather than print() directly -- a long name/secret must not
// wrap onto the next row's line (print() wraps by default).
display.drawTextEllipsized(2, y, display.width() - 4, row);
display.setColor(DisplayDriver::LIGHT);
}
return 1000;
}
bool handleInput(char c) {
if (_kb_active) {
auto r = kb().handleInput(c);
if (r == KeyboardWidget::DONE) {
if (_kb_field == 0) { strncpy(_name, kb().buf, sizeof(_name) - 1); _name[sizeof(_name) - 1] = '\0'; }
else { strncpy(_secret_text, kb().buf, sizeof(_secret_text) - 1); _secret_text[sizeof(_secret_text) - 1] = '\0'; }
_kb_active = false; _kb_field = -1;
} else if (r == KeyboardWidget::CANCELLED) {
_kb_active = false; _kb_field = -1;
}
return true;
}
if (c == KEY_CANCEL) { _mode = OFF; return true; }
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; }
if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; }
if ((keyIsPrev(c) || keyIsNext(c)) && _sel == 1) {
_hex_mode = !_hex_mode;
_secret_text[0] = '\0'; // switching mode invalidates whatever was typed
return true;
}
if (c == KEY_ENTER) {
if (_sel == 0) { _kb_field = 0; openKb(_name, sizeof(_name) - 1); }
else if (_sel == 1) { _kb_field = 1; openKb(_secret_text, _hex_mode ? 32 : (int)sizeof(_secret_text) - 1); }
else commit();
return true;
}
return true;
}
};