From 79fd5a50fb6a387549c8428d4476f7ad424bd2bf Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:42:44 +0200 Subject: [PATCH] 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 --- docs/design/solo_ui_framework.md | 5 +- docs/development/l2-hardware-checklist.md | 10 + docs/development/ui-core.md | 2 +- .../companion_radio/ui-core/ChannelControl.h | 211 ++++++++++ examples/companion_radio/ui-core/Favourites.h | 61 +++ examples/companion_radio/ui-core/UiCore.h | 53 ++- .../companion_radio/ui-lvgl/ChannelScreen.h | 362 ++++++++++++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 55 ++- examples/companion_radio/ui-lvgl/UITask.h | 13 +- .../companion_radio/ui-new/ChannelsView.h | 106 +---- .../companion_radio/ui-new/MessagesScreen.h | 24 +- examples/companion_radio/ui-new/UITask.cpp | 98 ----- examples/companion_radio/ui-new/UITask.h | 71 +--- 13 files changed, 782 insertions(+), 289 deletions(-) create mode 100644 examples/companion_radio/ui-core/ChannelControl.h create mode 100644 examples/companion_radio/ui-core/Favourites.h create mode 100644 examples/companion_radio/ui-lvgl/ChannelScreen.h diff --git a/docs/design/solo_ui_framework.md b/docs/design/solo_ui_framework.md index 65f8a0eb..1ede0aa7 100644 --- a/docs/design/solo_ui_framework.md +++ b/docs/design/solo_ui_framework.md @@ -358,8 +358,9 @@ with `resolvePersonPos()` (live `[LOC]` share, else last-advertised fix). (the ringtone player moved to a hardware timer for this reason). - **Reference cleanup.** Anything that remembers a contact by pubkey (favourite slot, Locator/Live-Share target, per-contact mute/melody) or a channel by - index must drop that reference when the entity goes away — hook - `UITask::onContactRemoved()` / `onChannelRemoved()`. New per-contact or + index must drop that reference when the entity goes away. The UI Core does + it for every frontend: `UiCore::cleanupContactPrefs()` (ui-core/UiCore.h) and + `chanctl::onRemoved()` (ui-core/ChannelControl.h). New per-contact or per-channel state should clear there too. - **Toasts:** `_task->showAlert("msg", duration_ms)` overlays a transient banner over any screen; no redraw plumbing needed. diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 0e5fb68a..e02aaf58 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -42,6 +42,16 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set - [ ] Settings > Radio: Preset list (built-ins + your saved ones), Frequency (tap, type), SF, Bandwidth, Coding rate, TX power, Auto power; changes apply at once — check messages still flow after switching back to your usual preset - [ ] L1 (ui-new): Settings > Radio TX power / Auto power / presets still apply (now through the shared RadioControl) +## New: Channels (2026-09-25) +- [ ] Messages: favourite channels first with a star, muted ones with a speaker icon +- [ ] Hold a channel row (or ⚙ in an open channel) → options: Alerts (Default / Muted / Always, one tap), Scope (only if regions exist), Fav, Read (only with unread), Edit, Delete (second tap confirms) +- [ ] "+ Add channel" → Public (re-adds the open channel; "already exists" if it's there), Hashtag (topic → "#topic"), Private (name + passphrase, or Hex key switch → exactly 32 hex chars) +- [ ] A hashtag / private channel made on L2 talks to the same channel made on L1 or in the phone app +- [ ] Edit: rename keeps the key (shown at the top); typing a new passphrase changes it +- [ ] Deleting a channel that was the live share / bot target turns those off; a channel re-added in that slot starts with default notifications / no favourite (also when deleted from the phone app) +- [ ] L1 (ui-new): Messages > Channels add / edit / delete / mute / pin still work (now through the shared ChannelControl) +- [ ] L1: deleting a contact from the app still unpins it from the dial and clears its mute / melody (cleanup moved into the Core) + ## Basics after the_mesh moved to PSRAM (`MESH_IN_PSRAM`) - [ ] Boots normally, contacts and channels are all there - [ ] Messages arrive and send (channel + DM), delivery ticks work diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 4a1de29c..fadc2531 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation. ### 5. Declarative settings schema -`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. +`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h`. Replaces the per-item code in `SettingsScreen`: diff --git a/examples/companion_radio/ui-core/ChannelControl.h b/examples/companion_radio/ui-core/ChannelControl.h new file mode 100644 index 00000000..cd95d17e --- /dev/null +++ b/examples/companion_radio/ui-core/ChannelControl.h @@ -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 +#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 diff --git a/examples/companion_radio/ui-core/Favourites.h b/examples/companion_radio/ui-core/Favourites.h new file mode 100644 index 00000000..2df7b0fa --- /dev/null +++ b/examples/companion_radio/ui-core/Favourites.h @@ -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 diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 1c6cdd50..c13b2741 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -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; diff --git a/examples/companion_radio/ui-lvgl/ChannelScreen.h b/examples/companion_radio/ui-lvgl/ChannelScreen.h new file mode 100644 index 00000000..6d054b40 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/ChannelScreen.h @@ -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(); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 6b472008..f14162ca 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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); diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 58fd2443..08f5c6a5 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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); diff --git a/examples/companion_radio/ui-new/ChannelsView.h b/examples/companion_radio/ui-new/ChannelsView.h index 75792d05..e6c382bd 100644 --- a/examples/companion_radio/ui-new/ChannelsView.h +++ b/examples/companion_radio/ui-new/ChannelsView.h @@ -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 +#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: diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index f6c92c8b..dfb7da82 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -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) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6f855c15..eabdff60 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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 diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 7cdf47e8..de81a161 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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