mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-09 03:06:39 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user