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
@@ -0,0 +1,211 @@
#pragma once
// On-device channel management shared by the frontends: add (Public /
// Hashtag / Private, the phone app's channel types -- docs/companion_protocol.md
// "Channel Types"), edit, delete, and the per-channel prefs the channel
// options show (notifications, favourite, scope). Also the cleanup of every
// channel-keyed pref when a channel goes away (from here or from the app).
//
// Secrets: only a 16-byte (128-bit) secret is produced, matching the
// CMD_GET_CHANNEL note ("only 128-bit supported"). Two ways to give one:
// - passphrase: any text, SHA-256'd down to 16 bytes -- easy to agree on
// verbally, like a room password;
// - hex key: the exact 32 hex chars, e.g. from a channel QR (docs/qr_codes.md).
// A hashtag channel "#topic" uses "#topic" itself as the passphrase.
#include <Utils.h>
#include "Favourites.h"
namespace chanctl {
enum Result : uint8_t { OK, FULL, NO_NAME, NO_SECRET, BAD_SECRET, EXISTS, FAILED };
static const char* resultText(Result r) {
switch (r) {
case OK: return "Saved";
case FULL: return "Channels full";
case NO_NAME: return "Name required";
case NO_SECRET: return "Secret required";
case BAD_SECRET: return "Secret must be 32 hex characters";
case EXISTS: return "Channel already exists";
default: return "Save failed";
}
}
// First free slot (never configured, or blank name), or -1.
static int freeSlot() {
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;
}
static bool exists(int idx) {
ChannelDetails ch;
return idx >= 0 && idx < MAX_GROUP_CHANNELS && the_mesh.getChannel(idx, ch) && ch.name[0];
}
// Exactly 32 hex chars -> 16-byte secret (zero-padded to ChannelDetails'
// 32-byte field). Rejects malformed hex and the all-zero key, which
// setChannelLocal() reads as "slot deleted" (isAllZero() in MyMesh.cpp):
// saving one would drop the channel and its prefs on the spot.
static bool hexToSecret(const char* hex32, uint8_t out[32]) {
if (!hex32 || strlen(hex32) != 32) return false;
uint8_t tmp[16];
bool any = false;
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;
if (tmp[i]) any = true;
}
if (!any) return false;
memset(out, 0, 32);
memcpy(out, tmp, 16);
return true;
}
static bool phraseToSecret(const char* phrase, uint8_t out[32]) {
if (!phrase || !phrase[0]) return false;
uint8_t digest[32];
mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)phrase, (int)strlen(phrase));
memset(out, 0, 32);
memcpy(out, digest, 16);
return true;
}
// Another 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 it
// would be the same channel twice, with split history / unread.
static bool secretInUse(const uint8_t secret[32], int except_idx) {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
if (i == except_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;
}
static Result save(int idx, const char* name, const uint8_t secret[32]) {
if (idx < 0) return FULL;
if (!name || !name[0]) return NO_NAME;
if (secretInUse(secret, idx)) return EXISTS;
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) ? OK : FAILED;
}
// The well-known public channel (MyMesh.cpp's PUBLIC_GROUP_PSK, as hex).
static Result addPublic() {
static const char PUBLIC_SECRET_HEX[] = "8b3387e9c5cdea6ac9e5edbaa115cd72";
uint8_t secret[32];
hexToSecret(PUBLIC_SECRET_HEX, secret);
return save(freeSlot(), "Public", secret);
}
// "#topic": name and passphrase both "#topic" (a leading '#' typed is kept once).
static Result addHashtag(const char* topic) {
if (topic && topic[0] == '#') topic++;
if (!topic || !topic[0]) return NO_NAME;
char name[32];
snprintf(name, sizeof(name), "#%s", topic);
uint8_t secret[32];
phraseToSecret(name, secret);
return save(freeSlot(), name, secret);
}
// Private channel, or an edit of slot `idx` (-1 = add to a free slot).
static Result savePrivate(int idx, const char* name, const char* secret_text, bool hex) {
if (!name || !name[0]) return NO_NAME;
uint8_t secret[32];
if (hex) { if (!hexToSecret(secret_text, secret)) return BAD_SECRET; }
else if (!phraseToSecret(secret_text, secret)) return NO_SECRET;
return save(idx < 0 ? freeSlot() : idx, name, secret);
}
// Edit: new name, secret kept.
static Result rename(int idx, const char* name) {
ChannelDetails ch;
if (!exists(idx) || !the_mesh.getChannel(idx, ch)) return FAILED;
if (!name || !name[0]) return NO_NAME;
return save(idx, name, ch.channel.secret);
}
// The secret as 32 hex chars (to show / share it).
static bool secretHex(int idx, char out[33]) {
ChannelDetails ch;
if (!exists(idx) || !the_mesh.getChannel(idx, ch)) return false;
for (int i = 0; i < 16; i++) snprintf(out + i * 2, 3, "%02x", ch.channel.secret[i]);
return true;
}
// Delete: an all-zero slot; setChannelLocal() then runs onChannelRemoved().
static bool remove(int idx) {
if (idx < 0 || idx >= MAX_GROUP_CHANNELS) return false;
ChannelDetails ch;
memset(&ch, 0, sizeof(ch));
return the_mesh.setChannelLocal((uint8_t)idx, ch);
}
// ── Per-channel prefs ───────────────────────────────────────────────────────
// Notifications: 0 = follow the global setting, 1 = muted, 2 = always.
enum Notif : uint8_t { NOTIF_DEFAULT, NOTIF_MUTED, NOTIF_ALWAYS };
static uint8_t notif(const NodePrefs* p, uint8_t idx) {
uint64_t m = 1ULL << idx;
if (!p || !(p->ch_notif_override & m)) return NOTIF_DEFAULT;
return (p->ch_notif_muted & m) ? NOTIF_MUTED : NOTIF_ALWAYS;
}
static void setNotif(NodePrefs* p, uint8_t idx, uint8_t state) {
if (!p) return;
uint64_t m = 1ULL << idx;
if (state == NOTIF_DEFAULT) { p->ch_notif_override &= ~m; p->ch_notif_muted &= ~m; return; }
p->ch_notif_override |= m;
if (state == NOTIF_MUTED) p->ch_notif_muted |= m; else p->ch_notif_muted &= ~m;
}
static bool favourite(const NodePrefs* p, uint8_t idx) { return p && (p->ch_fav_bitmask & (1ULL << idx)); }
static void setFavourite(NodePrefs* p, uint8_t idx, bool on) {
if (!p) return;
if (on) p->ch_fav_bitmask |= 1ULL << idx; else p->ch_fav_bitmask &= ~(1ULL << idx);
}
// Scope: index into the_mesh.scopeList() (0 = "*", unscoped).
static uint8_t scope(const NodePrefs* p, uint8_t idx) {
return (p && idx < NodePrefs::MAX_SCOPED_CHANNELS) ? p->ch_scope_idx[idx] : 0;
}
// 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. Adding such a field: clear it below (and mark it in NodePrefs.h).
// Covers bot_channel_idx, loc_share_channel_idx, ch_notif_melody_*,
// ch_notif_override / ch_notif_muted, ch_fav_bitmask, favourite dial slots,
// ch_scope_idx. True when something changed (caller saves).
static bool onRemoved(NodePrefs* p, uint8_t idx) {
if (!p) return false;
bool changed = false;
if (p->bot_channel_enabled && p->bot_channel_idx == idx) { p->bot_channel_enabled = 0; changed = true; }
// Fail closed: a share whose channel is gone stops rather than picking another.
if (p->loc_share_target_type == 0 && p->loc_share_channel_idx == idx) { p->loc_share_enabled = 0; changed = true; }
uint64_t m = 1ULL << idx;
if (p->ch_notif_melody_set & m) { p->ch_notif_melody_set &= ~m; p->ch_notif_melody_2 &= ~m; changed = true; }
if (p->ch_notif_override & m) { p->ch_notif_override &= ~m; p->ch_notif_muted &= ~m; changed = true; }
if (p->ch_fav_bitmask & m) { p->ch_fav_bitmask &= ~m; changed = true; }
if (idx < NodePrefs::MAX_SCOPED_CHANNELS && p->ch_scope_idx[idx]) {
p->ch_scope_idx[idx] = 0; // back to "*", same as a never-configured channel
changed = true;
}
int slot = favslots::findChannel(p, idx);
if (slot >= 0) { favslots::clear(p, slot); changed = true; }
return changed;
}
} // namespace chanctl
@@ -0,0 +1,61 @@
#pragma once
// Favourites dial slots (NodePrefs::favourite_contacts / favourite_kinds).
// Slot index 0..FAVOURITES_COUNT-1; a slot holds either a contact / room
// (pubkey prefix) or a channel (index in byte 0), per favourite_kinds[].
namespace favslots {
inline uint8_t kind(const NodePrefs* p, int slot) {
if (!p || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return NodePrefs::FAV_KIND_CONTACT;
return p->favourite_kinds[slot];
}
inline int findContact(const NodePrefs* p, const uint8_t* pub_key) {
if (!p || !pub_key) return -1;
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++) {
if (p->favourite_kinds[i] != NodePrefs::FAV_KIND_CONTACT) continue;
if (memcmp(p->favourite_contacts[i], pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
// All-zero prefix is "empty" — never matches a real key.
for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
if (p->favourite_contacts[i][b]) return i;
}
}
return -1;
}
inline int findChannel(const NodePrefs* p, uint8_t ch_idx) {
if (!p) return -1;
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT; i++)
if (p->favourite_kinds[i] == NodePrefs::FAV_KIND_CHANNEL && p->favourite_contacts[i][0] == ch_idx) return i;
return -1;
}
inline bool isEmpty(const NodePrefs* p, int slot) {
if (!p || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return true;
// A channel slot is never empty: channel 0's prefix is all zeroes.
if (p->favourite_kinds[slot] == NodePrefs::FAV_KIND_CHANNEL) return false;
for (uint8_t b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++)
if (p->favourite_contacts[slot][b]) return false;
return true;
}
inline void setContact(NodePrefs* p, int slot, const uint8_t* pub_key) {
if (!p || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT || !pub_key) return;
memcpy(p->favourite_contacts[slot], pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
p->favourite_kinds[slot] = NodePrefs::FAV_KIND_CONTACT;
}
inline void setChannel(NodePrefs* p, int slot, uint8_t ch_idx) {
if (!p || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return;
memset(p->favourite_contacts[slot], 0, NodePrefs::FAVOURITE_PREFIX_LEN);
p->favourite_contacts[slot][0] = ch_idx;
p->favourite_kinds[slot] = NodePrefs::FAV_KIND_CHANNEL;
}
inline void clear(NodePrefs* p, int slot) {
if (!p || slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return;
memset(p->favourite_contacts[slot], 0, NodePrefs::FAVOURITE_PREFIX_LEN);
p->favourite_kinds[slot] = NodePrefs::FAV_KIND_CONTACT;
}
} // namespace favslots
+51 -2
View File
@@ -23,6 +23,7 @@
#include "LocatorEngine.h"
#include "TrailEngine.h"
#include "WaypointModel.h"
#include "ChannelControl.h"
class UiCore : public MyMesh::Listener {
public:
@@ -240,8 +241,19 @@ public:
_host->onRoomLoginResult(pub_key, success, permissions);
}
void onAdminReply(const uint8_t* pub_key, const char* text) override { _host->onAdminReply(pub_key, text); }
void onContactRemoved(const uint8_t* pub_key) override { _host->onContactRemoved(pub_key); }
void onChannelRemoved(uint8_t channel_idx) override { _host->onChannelRemoved(channel_idx); }
// A contact / channel went away (deleted here, from the app, or evicted):
// drop every pref keyed on it, then let the frontend refresh.
void onContactRemoved(const uint8_t* pub_key) override {
if (pub_key) {
clearDMUnread(pub_key);
if (cleanupContactPrefs(pub_key)) the_mesh.savePrefs();
}
_host->onContactRemoved(pub_key);
}
void onChannelRemoved(uint8_t channel_idx) override {
if (chanctl::onRemoved(_prefs, channel_idx)) the_mesh.savePrefs();
_host->onChannelRemoved(channel_idx);
}
void botSetGPS(bool on) override { _host->botSetGPS(on); }
void botBuzz(int seconds) override { _host->botBuzz(seconds); }
bool botSetGPIO(int idx, bool on) override { return _host->botSetGPIO(idx, on); }
@@ -263,6 +275,43 @@ private:
return -1;
}
// CONTRACT: every NodePrefs field keyed on a contact pubkey / prefix is
// cleared here (adding one: clear it below, mark it in NodePrefs.h). Runs
// for explicit removal and silent eviction (CMD_REMOVE_CONTACT /
// onContactOverwrite). Covers the favourite dial slot, locator
// target, live share / room bot targets (fail closed: turned off rather than
// inherited by a re-added contact), and the 16-slot notif / melody override
// tables, where an orphan would eventually starve live contacts. dm_notif /
// dm_melody key on 4 bytes, the others on FAVOURITE_PREFIX_LEN.
bool cleanupContactPrefs(const uint8_t* pub_key) {
NodePrefs* p = _prefs;
if (!p) return false;
bool changed = false;
int slot = favslots::findContact(p, pub_key);
if (slot >= 0) { favslots::clear(p, slot); changed = true; }
if (locator.onContactRemoved(pub_key)) changed = true;
if (p->loc_share_target_type == 1 &&
memcmp(p->loc_share_dm_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
p->loc_share_enabled = 0;
changed = true;
}
if (p->bot_room_enabled && memcmp(p->bot_room_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
p->bot_room_enabled = 0;
changed = true;
}
for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++)
if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) {
memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i]));
changed = true;
}
for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++)
if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) {
memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i]));
changed = true;
}
return changed;
}
UiCoreHost* _host = nullptr;
NodePrefs* _prefs = nullptr;
SensorManager* _sensors = nullptr;
@@ -0,0 +1,362 @@
#pragma once
// Channel management -- ui-new's Messages > Channels options and Add / Edit
// form. Options popup (hold a channel row, or the ⚙ in an open channel):
// notifications, scope, favourite, edit, delete. Add channel: Public (the
// well-known channel), Hashtag ("#topic", secret derived from the name) or
// Private (name + passphrase or 32-hex key). Edit: new name, secret kept unless
// a new one is typed. The logic is ui-core/ChannelControl.h, shared with ui-new.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after RadioScreen.h.
namespace chanview {
enum : uint8_t { A_FAV, A_EDIT, A_DELETE, A_READ };
enum : uint8_t { T_PUBLIC, T_HASHTAG, T_PRIVATE };
enum : uint8_t { C_NOTIF, C_SCOPE };
static int s_idx = -1; // channel the popup / form is about (-1: adding)
static uint8_t s_type = T_HASHTAG;
static bool s_hex = false;
static uint32_t s_del_armed_ms = 0;
static lv_obj_t* s_del_lbl = nullptr;
static lv_obj_t* s_fav_btn = nullptr;
// Add / Edit form
static lv_obj_t* s_types = nullptr;
static lv_obj_t* s_name = nullptr;
static lv_obj_t* s_secret = nullptr;
static lv_obj_t* s_hex_sw = nullptr;
static lv_obj_t* s_hint = nullptr;
static lv_obj_t* s_status = nullptr;
static lv_obj_t* s_kb = nullptr;
static char s_scope_opts[256];
static void clearForm() {
s_types = s_name = s_secret = s_hex_sw = s_hint = s_status = s_kb = nullptr;
}
} // namespace chanview
static void onChanAction(lv_event_t* e) { s_ui->channelAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onChanDropdown(lv_event_t* e) {
s_ui->channelSet((uint8_t)(uintptr_t)lv_event_get_user_data(e),
(int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e)));
}
static void onChanNotif(lv_event_t* e) {
s_ui->channelSet(chanview::C_NOTIF, (int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onChanRowHold(lv_event_t* e) {
lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens the channel
s_ui->channelMenu((int)(uintptr_t)lv_event_get_user_data(e));
}
static void onChanThreadMenu(lv_event_t* e) { (void)e; s_ui->channelMenu(-1); }
static void onChanAdd(lv_event_t* e) { (void)e; s_ui->showChannelEdit(-1); }
static void onChanType(lv_event_t* e) {
s_ui->channelEditType((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onChanHex(lv_event_t* e) {
s_ui->channelEditHex(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
static void onChanField(lv_event_t* e) { s_ui->channelEditField((lv_obj_t*)lv_event_get_target(e)); }
static void onChanSave(lv_event_t* e) { (void)e; s_ui->channelEditSave(); }
static void onChanKb(lv_event_t* e) {
if (lv_event_get_code(e) == LV_EVENT_READY) s_ui->channelEditSave();
else s_ui->channelEditKbHide();
}
// ── Options popup ─────────────────────────────────────────────────────────────
// idx -1: the channel open in the thread.
void UITask::channelMenu(int idx) {
using namespace chanview;
if (idx < 0) idx = _thread_channel;
ChannelDetails ch;
if (!chanctl::exists(idx) || !the_mesh.getChannel(idx, ch)) return;
s_idx = idx;
s_del_armed_ms = 0;
s_del_lbl = s_fav_btn = nullptr;
char title[40];
snprintf(title, sizeof(title), "%s%s", ch.name[0] == '#' ? "" : "# ", ch.name); // "#topic" has its own
lv_obj_t* panel = navPopupPanel(title, false);
// Notifications: three segments in the row (a dropdown's list ran off the
// bottom of the screen from a popup this low).
static const char* NOTIF[] = { "Default", "Muted", "Always", "" };
lv_obj_t* row = radioview::settingRow(panel, "Alerts", NULL);
lv_obj_t* seg = lv_buttonmatrix_create(row);
lv_buttonmatrix_set_map(seg, NOTIF);
lv_buttonmatrix_set_button_ctrl_all(seg, LV_BUTTONMATRIX_CTRL_CHECKABLE);
lv_buttonmatrix_set_one_checked(seg, true);
lv_buttonmatrix_set_button_ctrl(seg, chanctl::notif(_prefs, idx), LV_BUTTONMATRIX_CTRL_CHECKED);
lv_obj_set_size(seg, 200, 36);
lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_set_style_pad_all(seg, 0, 0);
lv_obj_set_style_pad_column(seg, 3, 0);
lv_obj_set_style_bg_opa(seg, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(seg, 0, 0);
lv_obj_set_style_bg_color(seg, lv_color_hex(theme::SURFACE_2), LV_PART_ITEMS);
lv_obj_set_style_bg_color(seg, lv_color_hex(theme::ACCENT_DIM), LV_PART_ITEMS | LV_STATE_CHECKED);
lv_obj_set_style_text_color(seg, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
lv_obj_set_style_text_font(seg, THEME_FONT_SMALL, LV_PART_ITEMS);
lv_obj_set_style_shadow_width(seg, 0, LV_PART_ITEMS);
lv_obj_set_style_radius(seg, 8, LV_PART_ITEMS);
lv_obj_add_event_cb(seg, onChanNotif, LV_EVENT_VALUE_CHANGED, NULL);
const ScopeList& sl = the_mesh.scopeList();
if (sl.count > 0) { // only once regions are set up (Settings in the app)
row = radioview::settingRow(panel, "Scope", "Region it's sent in");
int o = 0;
for (uint8_t i = 0; i <= sl.count && o < (int)sizeof(s_scope_opts) - 24; i++)
o += snprintf(s_scope_opts + o, sizeof(s_scope_opts) - o, "%s%s", i ? "\n" : "", sl.name(i));
lv_obj_t* dd = radioview::rowDropdown(row, s_scope_opts, chanctl::scope(_prefs, idx), 112, onChanDropdown, C_SCOPE);
lv_obj_set_style_max_height(lv_dropdown_get_list(dd), 100, 0); // stays on screen; scrolls if long
}
lv_obj_t* acts = lv_obj_create(panel);
styleSurface(acts, theme::BG);
lv_obj_remove_flag(acts, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(acts, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_ROW);
lv_obj_set_style_pad_column(acts, theme::GAP, 0);
bool unread = _core->history.chUnread(idx) > 0;
struct { const char* text; uint8_t act; } btns[] = {
{ UI_SYMBOL_STAR " Fav", A_FAV },
{ LV_SYMBOL_OK " Read", A_READ },
{ LV_SYMBOL_EDIT " Edit", A_EDIT },
{ LV_SYMBOL_TRASH, A_DELETE },
};
for (auto& b : btns) {
if (b.act == A_READ && !unread) continue;
lv_obj_t* bt = lv_button_create(acts);
lv_obj_set_height(bt, 40);
lv_obj_set_flex_grow(bt, 1);
lv_obj_set_style_pad_hor(bt, 4, 0);
lv_obj_set_style_radius(bt, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(bt, 0, 0);
lv_obj_set_style_bg_color(bt, lv_color_hex(theme::SURFACE), 0);
lv_obj_add_event_cb(bt, onChanAction, LV_EVENT_CLICKED, (void*)(uintptr_t)b.act);
lv_obj_t* l = label(bt, b.text, THEME_FONT_SMALL, theme::TEXT);
lv_obj_center(l);
if (b.act == A_DELETE) s_del_lbl = l;
if (b.act == A_FAV) s_fav_btn = bt;
}
if (s_fav_btn && chanctl::favourite(_prefs, idx))
lv_obj_set_style_bg_color(s_fav_btn, lv_color_hex(theme::ACCENT_DIM), 0);
}
void UITask::channelSet(uint8_t which, int v) {
using namespace chanview;
if (!chanctl::exists(s_idx) || !_prefs) return;
if (which == C_NOTIF) chanctl::setNotif(_prefs, s_idx, (uint8_t)v);
else if (which == C_SCOPE) the_mesh.setChannelScope(s_idx, (uint8_t)v);
the_mesh.savePrefs();
}
void UITask::channelAction(uint8_t act) {
using namespace chanview;
int idx = s_idx;
if (!chanctl::exists(idx)) { navClosePopup(); return; }
switch (act) {
case A_FAV: {
bool on = !chanctl::favourite(_prefs, idx);
chanctl::setFavourite(_prefs, idx, on);
the_mesh.savePrefs();
if (s_fav_btn) lv_obj_set_style_bg_color(s_fav_btn, lv_color_hex(on ? theme::ACCENT_DIM : theme::SURFACE), 0);
showToast(on ? "Added to favourites" : "Removed from favourites", 1200);
if (_screen == SCR_CHATS) { buildChats(); channelMenu(idx); } // list order / star
break;
}
case A_READ:
_core->history.setChUnread(idx, 0);
navClosePopup();
if (_screen == SCR_CHATS) buildChats();
break;
case A_EDIT:
navClosePopup();
showChannelEdit(idx);
break;
case A_DELETE:
if (!s_del_armed_ms || millis() - s_del_armed_ms > 3000) { // second tap within 3 s confirms
s_del_armed_ms = millis() | 1;
if (s_del_lbl) lv_label_set_text(s_del_lbl, "Delete?");
break;
}
chanctl::remove(idx);
navClosePopup();
showToast("Channel deleted");
showChats();
break;
}
}
// ── Add / Edit ────────────────────────────────────────────────────────────────
static lv_obj_t* chanField(lv_obj_t* parent, const char* placeholder) {
lv_obj_t* ta = wifiField(parent, placeholder, false);
lv_obj_remove_event_cb(ta, onWifiField);
lv_obj_add_event_cb(ta, onChanField, LV_EVENT_CLICKED, NULL);
return ta;
}
void UITask::showChannelEdit(int idx) {
_screen = SCR_CHANNEL_EDIT;
chanview::s_idx = chanctl::exists(idx) ? idx : -1;
chanview::s_hex = false;
buildChannelEdit();
}
void UITask::buildChannelEdit() {
using namespace chanview;
bool edit = s_idx >= 0;
lv_obj_t* body = newScreen(edit ? "Edit channel" : "Add channel", true);
lv_obj_set_style_pad_row(body, 4, 0);
clearForm();
if (!edit) {
static const char* TYPES[] = { "Public", "Hashtag", "Private", "" };
s_types = lv_buttonmatrix_create(body);
lv_buttonmatrix_set_map(s_types, TYPES);
lv_buttonmatrix_set_button_ctrl_all(s_types, LV_BUTTONMATRIX_CTRL_CHECKABLE);
lv_buttonmatrix_set_one_checked(s_types, true);
lv_buttonmatrix_set_button_ctrl(s_types, s_type, LV_BUTTONMATRIX_CTRL_CHECKED);
lv_obj_set_size(s_types, LV_PCT(100), 38);
lv_obj_set_style_pad_all(s_types, 2, 0);
lv_obj_set_style_pad_column(s_types, 4, 0);
lv_obj_set_style_bg_color(s_types, lv_color_hex(theme::BG), 0);
lv_obj_set_style_border_width(s_types, 0, 0);
lv_obj_set_style_bg_color(s_types, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_set_style_bg_color(s_types, lv_color_hex(theme::ACCENT_DIM), LV_PART_ITEMS | LV_STATE_CHECKED);
lv_obj_set_style_text_color(s_types, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
lv_obj_set_style_text_font(s_types, THEME_FONT_BODY, LV_PART_ITEMS);
lv_obj_set_style_shadow_width(s_types, 0, LV_PART_ITEMS);
lv_obj_add_event_cb(s_types, onChanType, LV_EVENT_VALUE_CHANGED, NULL);
}
s_hint = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(s_hint, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_hint, LV_PCT(100));
s_name = chanField(body, "Name");
lv_textarea_set_max_length(s_name, sizeof(((ChannelDetails*)0)->name) - 1);
s_secret = chanField(body, "Passphrase");
lv_textarea_set_max_length(s_secret, 32);
lv_obj_t* row = radioview::settingRow(body, "Hex key", "32 characters");
s_hex_sw = lv_switch_create(row);
lv_obj_set_size(s_hex_sw, 46, 24);
lv_obj_align(s_hex_sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
lv_obj_add_event_cb(s_hex_sw, onChanHex, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* save = lv_button_create(body);
lv_obj_set_size(save, LV_PCT(100), 38);
lv_obj_set_style_shadow_width(save, 0, 0);
lv_obj_set_style_radius(save, theme::RADIUS, 0);
lv_obj_set_style_bg_color(save, lv_color_hex(theme::ACCENT_DIM), 0);
lv_obj_add_event_cb(save, onChanSave, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(save, LV_SYMBOL_OK " Save", THEME_FONT_BODY, theme::TEXT));
s_status = label(body, "", THEME_FONT_SMALL, theme::FAIL);
lv_label_set_long_mode(s_status, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_status, LV_PCT(100));
if (edit) {
ChannelDetails ch;
char hex[33] = "";
the_mesh.getChannel(s_idx, ch);
chanctl::secretHex(s_idx, hex);
lv_textarea_set_text(s_name, ch.name);
lv_textarea_set_placeholder_text(s_secret, "New passphrase (empty: keep)");
lv_label_set_text_fmt(s_hint, "Key: %s", hex);
}
s_kb = kb::create(lv_screen_active(), _prefs);
lv_obj_set_size(s_kb, LV_PCT(100), 124);
lv_obj_align(s_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_add_event_cb(s_kb, onChanKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(s_kb, onChanKb, LV_EVENT_CANCEL, NULL);
lv_obj_add_flag(s_kb, LV_OBJ_FLAG_HIDDEN);
channelEditType(edit ? T_PRIVATE : s_type);
}
// Show the fields the channel type needs.
void UITask::channelEditType(int type) {
using namespace chanview;
if (!s_name || type < T_PUBLIC || type > T_PRIVATE) return;
bool edit = s_idx >= 0;
if (!edit) s_type = (uint8_t)type;
channelEditKbHide();
auto show = [](lv_obj_t* o, bool on) {
if (on) lv_obj_remove_flag(o, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(o, LV_OBJ_FLAG_HIDDEN);
};
show(s_name, type != T_PUBLIC);
show(s_secret, type == T_PRIVATE);
show(lv_obj_get_parent(s_hex_sw), type == T_PRIVATE);
lv_label_set_text(s_status, "");
if (edit) return; // the hint shows the key
switch (type) {
case T_PUBLIC:
lv_label_set_text(s_hint, "The open channel every MeshCore device starts with. Adds it back if you removed it.");
break;
case T_HASHTAG:
lv_textarea_set_placeholder_text(s_name, "Topic, e.g. hiking");
lv_label_set_text(s_hint, "A public group anyone can join by typing the same topic. The key comes from the name.");
break;
default:
lv_textarea_set_placeholder_text(s_name, "Name");
lv_label_set_text(s_hint, "Readable only by people you give the key to.");
break;
}
}
void UITask::channelEditHex(bool hex) {
using namespace chanview;
s_hex = hex;
if (!s_secret) return;
lv_textarea_set_text(s_secret, ""); // a passphrase isn't a key and vice versa
lv_textarea_set_placeholder_text(s_secret, hex ? "32 hex characters" : (s_idx >= 0 ? "New passphrase (empty: keep)" : "Passphrase"));
lv_textarea_set_accepted_chars(s_secret, hex ? "0123456789abcdefABCDEF" : NULL);
}
void UITask::channelEditField(lv_obj_t* ta) {
using namespace chanview;
if (!s_kb) return;
lv_obj_remove_state(s_name, LV_STATE_FOCUSED);
lv_obj_remove_state(s_secret, LV_STATE_FOCUSED);
lv_obj_add_state(ta, LV_STATE_FOCUSED); // draws the cursor
lv_keyboard_set_textarea(s_kb, ta);
if (lv_obj_has_flag(s_kb, LV_OBJ_FLAG_HIDDEN)) {
lv_obj_remove_flag(s_kb, LV_OBJ_FLAG_HIDDEN);
if (_body) lv_obj_set_height(_body, lv_obj_get_height(_body) - lv_obj_get_height(s_kb));
}
lv_obj_update_layout(lv_screen_active());
lv_obj_scroll_to_view(ta, LV_ANIM_OFF);
}
void UITask::channelEditKbHide() {
using namespace chanview;
if (!s_kb || lv_obj_has_flag(s_kb, LV_OBJ_FLAG_HIDDEN)) return;
lv_obj_add_flag(s_kb, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(s_name, LV_STATE_FOCUSED);
lv_obj_remove_state(s_secret, LV_STATE_FOCUSED);
if (_body) lv_obj_set_height(_body, lv_obj_get_height(_body) + lv_obj_get_height(s_kb));
}
void UITask::channelEditSave() {
using namespace chanview;
if (!s_name) return;
const char* name = lv_textarea_get_text(s_name);
const char* secret = lv_textarea_get_text(s_secret);
chanctl::Result r;
if (s_idx >= 0)
r = secret[0] ? chanctl::savePrivate(s_idx, name, secret, s_hex) : chanctl::rename(s_idx, name);
else if (s_type == T_PUBLIC) r = chanctl::addPublic();
else if (s_type == T_HASHTAG) r = chanctl::addHashtag(name);
else r = chanctl::savePrivate(-1, name, secret, s_hex);
if (r != chanctl::OK) { // say so over the keyboard, and put the cursor where the fix goes
const char* msg = (r == chanctl::NO_NAME && s_type == T_HASHTAG && s_idx < 0) ? "Topic required" : chanctl::resultText(r);
lv_label_set_text(s_status, msg);
showToast(msg, 2000);
if (r == chanctl::NO_NAME) channelEditField(s_name);
else if (r == chanctl::NO_SECRET || r == chanctl::BAD_SECRET) channelEditField(s_secret);
return;
}
channelEditKbHide();
showToast(s_idx >= 0 ? "Channel updated" : "Channel added", 1500);
showChats();
}
+41 -14
View File
@@ -13,6 +13,7 @@
#include "../ui-core/GpsAverager.h"
#include "../ui-core/TrackBack.h"
#include "../ui-core/RadioControl.h"
#include "../ui-core/ChannelControl.h"
#include "Theme.h"
#include "LvglPort.h"
#include "../ui-core/KeyboardData.h"
@@ -453,13 +454,14 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
void UITask::back() {
switch (_screen) {
case SCR_THREAD: showChats(); break;
case SCR_THREAD: if (_nav_overlay) navClosePopup(); else showChats(); break;
case SCR_CONTACTS: showChats(); break;
case SCR_CHANNEL_EDIT: showChats(); break;
case SCR_SETTINGS: showHome(); break;
case SCR_SETTINGS_NAV: showSettings(); break;
case SCR_CLOCK: showHome(); break;
case SCR_RADIO: if (radioPopupOpen()) radioCloseFreq(); else showSettings(); break;
case SCR_CHATS: showHome(); break;
case SCR_CHATS: if (_nav_overlay) navClosePopup(); else showHome(); break;
case SCR_NEARBY:
if (_scan_overlay) closeScanPopup();
else showHome();
@@ -665,6 +667,9 @@ static void onOpenContactRow(lv_event_t* e) {
s_ui->openDM(s_contact_rows[(uintptr_t)lv_event_get_user_data(e)]);
}
static void onNewChat(lv_event_t* e) { (void)e; s_ui->showContacts(); }
static void onChanRowHold(lv_event_t* e); // ChannelScreen.h
static void onChanAdd(lv_event_t* e);
static void onChanThreadMenu(lv_event_t* e);
// One tappable list row: title, optional muted subtitle, optional badge.
static lv_obj_t* listRow(lv_obj_t* parent, const char* title, const char* sub,
@@ -704,20 +709,37 @@ void UITask::showChats() {
void UITask::buildChats() {
lv_obj_t* body = newScreen("Messages", true);
// Channels
// Channels: favourites first (unless turned off), hold a row for its options
sectionTitle(body, "CHANNELS");
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
char sub[64] = "";
int n = _core->history.histCountForChannel(i);
if (n > 0) {
const ChHistEntry& e = _core->history.chAtPos(_core->history.histEntryForChannel(i, 0));
snprintf(sub, sizeof(sub), "%s", e.text);
bool fav_first = !(_prefs && _prefs->fav_sort_off);
for (int pass = fav_first ? 0 : 1; pass < 2; pass++) {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
bool fav = chanctl::favourite(_prefs, i);
if (fav_first && fav != (pass == 0)) continue;
char title[48], sub[64] = "";
snprintf(title, sizeof(title), "%s%s%s", fav ? UI_SYMBOL_STAR " " : "", ch.name,
chanctl::notif(_prefs, i) == chanctl::NOTIF_MUTED ? " " LV_SYMBOL_MUTE : "");
int n = _core->history.histCountForChannel(i);
if (n > 0) {
const ChHistEntry& e = _core->history.chAtPos(_core->history.histEntryForChannel(i, 0));
snprintf(sub, sizeof(sub), "%s", e.text);
}
lv_obj_t* row = listRow(body, title, sub[0] ? sub : NULL, onOpenChannel, (void*)(uintptr_t)i);
lv_obj_add_event_cb(row, onChanRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)i);
badge(row, _core->history.chUnread(i), _core->history.chUnreadOverflow(i));
}
lv_obj_t* row = listRow(body, ch.name, sub[0] ? sub : NULL, onOpenChannel, (void*)(uintptr_t)i);
badge(row, _core->history.chUnread(i), _core->history.chUnreadOverflow(i));
}
lv_obj_t* add_ch = lv_button_create(body);
lv_obj_set_size(add_ch, LV_PCT(100), 32);
lv_obj_set_style_bg_color(add_ch, lv_color_hex(theme::BG), 0);
lv_obj_set_style_border_color(add_ch, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_set_style_border_width(add_ch, 1, 0);
lv_obj_set_style_radius(add_ch, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(add_ch, 0, 0);
lv_obj_add_event_cb(add_ch, onChanAdd, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(add_ch, LV_SYMBOL_PLUS " Add channel", THEME_FONT_SMALL, theme::TEXT_MUTED));
// Recent direct conversations (DM ring, newest first, one row per contact)
sectionTitle(body, "DIRECT");
@@ -1232,6 +1254,7 @@ void UITask::nodeAction(uint8_t action) {
#include "ClockScreen.h"
#include "RadioScreen.h"
#include "WifiScreen.h"
#include "ChannelScreen.h"
// ── Conversation ──────────────────────────────────────────────────────────────
@@ -1306,7 +1329,7 @@ void UITask::buildThread() {
bool can_send = true;
if (_thread_is_channel) {
ChannelDetails ch;
if (the_mesh.getChannel(_thread_channel, ch)) snprintf(title, sizeof(title), "# %s", ch.name);
if (the_mesh.getChannel(_thread_channel, ch)) snprintf(title, sizeof(title), "%s%s", ch.name[0] == '#' ? "" : "# ", ch.name);
else snprintf(title, sizeof(title), "Channel %d", _thread_channel);
} else {
contactName(_thread_key, title, sizeof(title));
@@ -1316,6 +1339,10 @@ void UITask::buildThread() {
lv_obj_t* body = newScreen(title, true);
lv_obj_set_style_pad_all(body, 0, 0);
lv_obj_set_style_pad_row(body, 0, 0);
if (_thread_is_channel && _header) { // channel options
lv_obj_set_width(lv_obj_get_child(_header, 1), 200); // title, clear of the button
headerButton(_header, LV_SYMBOL_SETTINGS, onChanThreadMenu, 4, NULL);
}
_thread_list = lv_obj_create(body);
styleSurface(_thread_list, theme::BG);
+12 -1
View File
@@ -109,6 +109,16 @@ public:
void radioSet(int which, int v);
void radioFreqPopup();
void radioFreqDone(bool ok);
// Channels (ChannelScreen.h)
void channelMenu(int idx);
void channelSet(uint8_t which, int v);
void channelAction(uint8_t act);
void showChannelEdit(int idx);
void channelEditType(int type);
void channelEditHex(bool hex);
void channelEditField(lv_obj_t* ta);
void channelEditKbHide();
void channelEditSave();
void setGps(bool on);
bool ensureGps(); // true with a fix; else turns GPS on / says it's waiting
@@ -117,7 +127,7 @@ public:
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; }
private:
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO };
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT };
void buildStatusBar();
void refreshStatusBar();
@@ -171,6 +181,7 @@ private:
void buildClock();
void buildRadio();
void radioCloseFreq();
void buildChannelEdit();
bool radioPopupOpen() const;
void refreshClock();
void showRing(const char* text);
+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