feat(ui-core,ui-lvgl): channel management in the Core, L2 channel screens

ChannelControl (add Public/Hashtag/Private, rename, delete, notif/fav/scope)
and Favourites slots move into ui-core; ui-new uses them. Contact / channel
pref cleanup moves from ui-new's UITask into UiCore so it runs on every
frontend (L2 had none). L2: channel options popup (hold a row or the gear
in a channel) and the Add / Edit channel form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-25 19:42:44 +02:00
co-authored by Claude Opus 5.5
parent 84d8a4df2a
commit 79fd5a50fb
13 changed files with 782 additions and 289 deletions
+15 -91
View File
@@ -29,7 +29,7 @@
// 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>
#include "../ui-core/ChannelControl.h"
class ChannelsView {
UITask* _task;
@@ -57,107 +57,31 @@ class ChannelsView {
_kb_active = true;
}
// Parses exactly 32 hex chars into a 16-byte secret (zero-padded to 32 for
// ChannelDetails.secret's full field width). Rejects malformed hex and the
// all-zero sentinel setChannelLocal() reads as "slot deleted" (see
// isAllZero() in MyMesh.cpp) -- saving one would pass, then immediately
// trigger onChannelRemoved() on this very slot, silently discarding the
// channel and its bot/notif/favourite state. Shared by deriveSecret()'s
// hex-mode branch and the Public quick-add (a fixed, known-good constant,
// but parsed through the same path rather than duplicated).
static bool hexToSecret(const char* hex32, uint8_t out[32]) {
if (strlen(hex32) != 32) return false;
uint8_t tmp[16];
for (int i = 0; i < 16; i++) {
char byte_str[3] = { hex32[i * 2], hex32[i * 2 + 1], 0 };
char* end = nullptr;
long v = strtol(byte_str, &end, 16);
if (*end != 0) return false;
tmp[i] = (uint8_t)v;
}
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;
// Secret parsing, duplicate check and the save itself live in the Core
// (ui-core/ChannelControl.h), shared with ui-lvgl.
void report(chanctl::Result r, const char* ok_text) {
if (r == chanctl::OK) { _task->showAlert(ok_text, 1000); _mode = OFF; }
else if (r == chanctl::BAD_SECRET) _task->showAlert("Invalid secret", 1400);
else _task->showAlert(chanctl::resultText(r), 1400);
}
// 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) return hexToSecret(_secret_text, out);
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;
}
// True if a *different* slot already holds this secret. The secret is the
// channel's identity on the air (the name is only a label), so a second slot
// with the same key would be the same channel listed twice -- with split
// history/unread state. Edit skips its own slot (_idx) so re-saving a channel
// under a new name still works.
bool secretInUse(const uint8_t secret[32]) const {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
if (i == _idx) continue;
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
if (memcmp(ch.channel.secret, secret, 16) == 0) return true;
}
return false;
}
bool saveChannel(const char* name, const uint8_t secret[32]) {
ChannelDetails ch;
memset(&ch, 0, sizeof(ch));
strncpy(ch.name, name, sizeof(ch.name) - 1);
memcpy(ch.channel.secret, secret, sizeof(ch.channel.secret));
return the_mesh.setChannelLocal((uint8_t)_idx, ch);
}
// Type picker's Public row: no fields to fill in, so this commits directly
// instead of dropping into the Name+Secret form -- the well-known channel's
// name and secret are both fixed. Hex confirmed via
// `python3 -c "import base64; print(base64.b64decode('izOH6cXN6mrJ5e26oRXNcg==').hex())"`
// to match MyMesh.cpp's PUBLIC_GROUP_PSK (base64) and
// docs/companion_protocol.md's documented public channel key (hex).
// Type picker's Public row: no fields to fill in -- the well-known
// channel's name and secret are both fixed.
void commitPublic() {
static const char PUBLIC_SECRET_HEX[] = "8b3387e9c5cdea6ac9e5edbaa115cd72";
uint8_t secret[32];
hexToSecret(PUBLIC_SECRET_HEX, secret); // fixed, known-good constant -- can't fail
if (secretInUse(secret)) { _task->showAlert("Already added", 1200); _mode = OFF; return; }
if (saveChannel("Public", secret)) _task->showAlert("Channel added", 1000);
else _task->showAlert("Save failed", 1200);
chanctl::Result r = chanctl::addPublic();
if (r == chanctl::EXISTS) { _task->showAlert("Already added", 1200); _mode = OFF; return; }
report(r, "Channel added");
_mode = OFF;
}
void commit() {
if (_mode == ADD_HASHTAG) {
if (_topic[0] == '\0') { _task->showAlert("Topic required", 1200); return; }
// "#topic" as both the channel's display name and the passphrase fed to
// sha256 -- the exact "Hashtag Channels" convention in
// docs/companion_protocol.md, just synthesized instead of hand-typed.
snprintf(_name, sizeof(_name), "#%s", _topic);
strncpy(_secret_text, _name, sizeof(_secret_text) - 1);
_secret_text[sizeof(_secret_text) - 1] = '\0';
_hex_mode = false;
}
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);
report(chanctl::addHashtag(_topic), "Channel added");
return;
}
if (secretInUse(secret)) { _task->showAlert("Channel already exists", 1400); return; }
if (saveChannel(_name, secret)) {
_task->showAlert((_mode == ADD || _mode == ADD_HASHTAG) ? "Channel added" : "Channel updated", 1000);
_mode = OFF;
} else {
_task->showAlert("Save failed", 1200);
}
report(chanctl::savePrivate(_idx, _name, _secret_text, _hex_mode),
_mode == ADD ? "Channel added" : "Channel updated");
}
public:
@@ -886,15 +886,9 @@ class MessagesScreen : public UIScreen {
memcpy(tbl[0].prefix, pub_key, 4); tbl[0].*val = v; // table full — overwrite slot 0
}
// Channel notif: state 1 = muted (variant bit set), 2 = force-on (variant clear).
uint8_t chNotifState(uint8_t ch_idx) const {
NodePrefs* p = _task->getNodePrefs();
return p ? maskPairGet(p->ch_notif_override, p->ch_notif_muted, ch_idx, 1, 2) : 0;
}
void setChNotifState(uint8_t ch_idx, uint8_t state) {
NodePrefs* p = _task->getNodePrefs();
if (p) maskPairSet(p->ch_notif_override, p->ch_notif_muted, ch_idx, state, 1);
}
// Channel notif (ui-core/ChannelControl.h): 0 = default, 1 = muted, 2 = force-on.
uint8_t chNotifState(uint8_t ch_idx) const { return chanctl::notif(_task->getNodePrefs(), ch_idx); }
void setChNotifState(uint8_t ch_idx, uint8_t state) { chanctl::setNotif(_task->getNodePrefs(), ch_idx, state); }
uint8_t dmNotifState(const uint8_t* pub_key) const {
NodePrefs* p = _task->getNodePrefs();
@@ -955,13 +949,7 @@ public:
bool navActive() const { return _nav_active; }
// First free channel slot (existing config or blank name), or -1 if full.
int findFreeChannelSlot() const {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') return i;
}
return -1;
}
int findFreeChannelSlot() const { return chanctl::freeSlot(); }
// CHANNEL_PICK row count including the synthetic "+ Add channel" row
// (suppressed while picking a channel for the bot).
@@ -2198,9 +2186,7 @@ public:
if (_ch_delete_confirm_active) {
// Delete/Cancel sub-menu, defaults to Cancel (see where it's opened).
if (res == PopupMenu::SELECTED && _ctx_menu.selectedIndex() == 0) { // "Delete"
ChannelDetails ch;
memset(&ch, 0, sizeof(ch));
the_mesh.setChannelLocal(_ctx_ch_idx, ch);
chanctl::remove(_ctx_ch_idx);
_task->showAlert("Channel deleted", 1000);
}
if (res != PopupMenu::NONE) {
@@ -2893,104 +2893,6 @@ void UITask::setTargetNow(uint8_t kind, const uint8_t* key, int32_t lat, int32_t
void UITask::clearTarget() { _core->locator.clearTarget(); }
void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) { _core->locator.clearTargetIfWaypoint(lat_1e6, lon_1e6); }
// CONTRACT: every NodePrefs field that keys on a contact pubkey/prefix is
// cleared here, so a removed contact can't leave a dangling reference. If you
// add such a field, add its cleanup below (and mark the field in NodePrefs.h).
// Currently covered: favourite_contacts, locator_key, loc_share_dm_prefix,
// dm_notif[], dm_melody[]. Also clears the DM unread table (RAM-only, not a
// NodePrefs field, so no savePrefs() needed for it) -- same 4-byte-prefix
// shape and same 16-slot starvation risk as dm_notif/dm_melody above. Called
// for both explicit removal and silent auto-eviction (see MyMesh
// CMD_REMOVE_CONTACT / onContactOverwrite).
void UITask::onContactRemoved(const uint8_t* pub_key) {
if (!_node_prefs || !pub_key) return;
bool changed = false;
clearDMUnread(pub_key);
int slot = findFavouriteSlot(pub_key);
if (slot >= 0) { clearFavouriteSlot(slot); changed = true; }
if (_core->locator.onContactRemoved(pub_key)) changed = true;
// Fail closed rather than guess a new recipient: a contact target that's
// gone just turns auto-share off, it doesn't fall back to some other target.
if (_node_prefs->loc_share_target_type == 1
&& memcmp(_node_prefs->loc_share_dm_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
_node_prefs->loc_share_enabled = 0;
changed = true;
}
// Same fail-closed rule for the room bot's target: the room contact is
// gone, disable it rather than risk a re-added contact silently inheriting
// the old bot config.
if (_node_prefs->bot_room_enabled
&& memcmp(_node_prefs->bot_room_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
_node_prefs->bot_room_enabled = 0;
changed = true;
}
// Per-contact mute/melody overrides — only 16 slots shared across every
// contact, so an orphaned entry isn't just stale, it can eventually starve
// new overrides for contacts that still exist. Keyed by a 4-byte prefix
// (narrower than the 6-byte one above), so compare only that many bytes.
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) {
if (_node_prefs->dm_notif[i].state && memcmp(_node_prefs->dm_notif[i].prefix, pub_key, 4) == 0) {
memset(&_node_prefs->dm_notif[i], 0, sizeof(_node_prefs->dm_notif[i]));
changed = true;
}
}
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) {
if (_node_prefs->dm_melody[i].slot && memcmp(_node_prefs->dm_melody[i].prefix, pub_key, 4) == 0) {
memset(&_node_prefs->dm_melody[i], 0, sizeof(_node_prefs->dm_melody[i]));
changed = true;
}
}
if (changed) the_mesh.savePrefs();
}
// CONTRACT: every NodePrefs field that keys on a channel index is cleared here,
// so a channel re-added at a freed slot can't inherit the old one's settings.
// If you add such a field, add its cleanup below (and mark it in NodePrefs.h).
// Currently covered: bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*,
// ch_notif_override/ch_notif_muted, ch_fav_bitmask, favourite_contacts/_kinds,
// ch_scope_idx.
void UITask::onChannelRemoved(uint8_t channel_idx) {
if (!_node_prefs) return;
bool changed = false;
if (_node_prefs->bot_channel_enabled && _node_prefs->bot_channel_idx == channel_idx) {
_node_prefs->bot_channel_enabled = 0;
changed = true;
}
// Fail closed, same policy as onContactRemoved()'s Live Share case.
if (_node_prefs->loc_share_target_type == 0 && _node_prefs->loc_share_channel_idx == channel_idx) {
_node_prefs->loc_share_enabled = 0;
changed = true;
}
uint64_t mask = 1ULL << channel_idx;
if (_node_prefs->ch_notif_melody_set & mask) {
_node_prefs->ch_notif_melody_set &= ~mask;
_node_prefs->ch_notif_melody_2 &= ~mask;
changed = true;
}
if (_node_prefs->ch_notif_override & mask) {
_node_prefs->ch_notif_override &= ~mask;
_node_prefs->ch_notif_muted &= ~mask;
changed = true;
}
if (_node_prefs->ch_fav_bitmask & mask) {
_node_prefs->ch_fav_bitmask &= ~mask;
changed = true;
}
if (channel_idx < NodePrefs::MAX_SCOPED_CHANNELS && _node_prefs->ch_scope_idx[channel_idx]) {
_node_prefs->ch_scope_idx[channel_idx] = 0; // back to "*", same as a never-configured channel
changed = true;
}
int fav_slot = findFavouriteChannelSlot(channel_idx);
if (fav_slot >= 0) { clearFavouriteSlot(fav_slot); changed = true; }
if (changed) the_mesh.savePrefs();
}
// Homing beeper: while armed with a target and inside the radius, emit a short
// tick whose interval shrinks linearly with distance — slow at the edge, rapid
// near the centre. Polls distance a few times a second; silent outside the
+10 -61
View File
@@ -42,6 +42,7 @@
#include "../LiveTrack.h"
#include "KeyboardWidget.h"
#include "../ui-core/UiCoreHost.h"
#include "../ui-core/Favourites.h"
class UiCore;
struct UiEvent;
@@ -298,17 +299,8 @@ public:
// from waypoint deletion so the Locator can't keep pointing at a spot
// that no longer exists.
void clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6);
// A contact was removed (companion app / CLI): drop any UI reference to its
// pubkey that would otherwise dangle — a pinned favourite slot, the Locator
// target if it was this contact, the Live Share target if it was this
// contact (auto-share turns off rather than guessing a new recipient), and
// any per-contact mute/melody override (those tables have only 16 shared
// slots, so an orphan isn't just stale — it can starve other contacts).
void onContactRemoved(const uint8_t* pub_key) override;
// A channel slot was cleared: turn off anything armed against it by index
// (the bot's channel, Live Share's channel target) and drop its per-channel
// melody bit, so a future channel re-added at the same slot starts clean.
void onChannelRemoved(uint8_t channel_idx) override;
// Contact / channel removal cleanup lives in the Core (UiCore::onContactRemoved,
// chanctl::onRemoved).
// Resolve a person target (6-byte pubkey prefix) to a current position:
// prefers an active [LOC] live share, falls back to their last-advertised
// GPS fix. Returns false when neither is known. Optional live/ts report
@@ -397,56 +389,13 @@ public:
// Favourites dial helpers. Slot index 0..FAVOURITES_COUNT-1. A slot holds
// either a contact/room (pubkey prefix) or a channel (index), per
// favourite_kinds[] — see NodePrefs.
uint8_t favouriteSlotKind(int slot) const {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return NodePrefs::FAV_KIND_CONTACT;
return _node_prefs->favourite_kinds[slot];
}
int findFavouriteSlot(const uint8_t* pub_key) const {
if (!_node_prefs || !pub_key) return -1;
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
if (_node_prefs->favourite_kinds[i] != NodePrefs::FAV_KIND_CONTACT) continue;
if (memcmp(_node_prefs->favourite_contacts[i], pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
// All-zero prefix is "empty" — never matches a real key.
bool any = false;
for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
if (_node_prefs->favourite_contacts[i][b]) { any = true; break; }
if (any) return i;
}
}
return -1;
}
int findFavouriteChannelSlot(uint8_t ch_idx) const {
if (!_node_prefs) return -1;
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
if (_node_prefs->favourite_kinds[i] == NodePrefs::FAV_KIND_CHANNEL &&
_node_prefs->favourite_contacts[i][0] == ch_idx) return i;
}
return -1;
}
bool isFavouriteSlotEmpty(int slot) const {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return true;
// A channel slot is never empty: channel 0's prefix is all zeroes.
if (_node_prefs->favourite_kinds[slot] == NodePrefs::FAV_KIND_CHANNEL) return false;
for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
if (_node_prefs->favourite_contacts[slot][b]) return false;
return true;
}
void setFavouriteSlot(int slot, const uint8_t* pub_key) {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT || !pub_key) return;
memcpy(_node_prefs->favourite_contacts[slot], pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
_node_prefs->favourite_kinds[slot] = NodePrefs::FAV_KIND_CONTACT;
}
void setFavouriteChannelSlot(int slot, uint8_t ch_idx) {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return;
memset(_node_prefs->favourite_contacts[slot], 0, NodePrefs::FAVOURITE_PREFIX_LEN);
_node_prefs->favourite_contacts[slot][0] = ch_idx;
_node_prefs->favourite_kinds[slot] = NodePrefs::FAV_KIND_CHANNEL;
}
void clearFavouriteSlot(int slot) {
if (!_node_prefs || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return;
memset(_node_prefs->favourite_contacts[slot], 0, NodePrefs::FAVOURITE_PREFIX_LEN);
_node_prefs->favourite_kinds[slot] = NodePrefs::FAV_KIND_CONTACT;
}
uint8_t favouriteSlotKind(int slot) const { return favslots::kind(_node_prefs, slot); }
int findFavouriteSlot(const uint8_t* pub_key) const { return favslots::findContact(_node_prefs, pub_key); }
int findFavouriteChannelSlot(uint8_t ch_idx) const { return favslots::findChannel(_node_prefs, ch_idx); }
bool isFavouriteSlotEmpty(int slot) const { return favslots::isEmpty(_node_prefs, slot); }
void setFavouriteSlot(int slot, const uint8_t* pub_key) { favslots::setContact(_node_prefs, slot, pub_key); }
void setFavouriteChannelSlot(int slot, uint8_t ch_idx) { favslots::setChannel(_node_prefs, slot, ch_idx); }
void clearFavouriteSlot(int slot) { favslots::clear(_node_prefs, slot); }
bool isButtonPressed() const;
// Lock-screen support: the HomeScreen LOCK page draws the unlock hint from