From 1d5dbe2f9ae93afe4001acb3a58212bde642fdb1 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:55:46 +0200 Subject: [PATCH] feat(companion): contact expiry + prune, and scope-list follow-up fixes Settings > Contacts gains "Expire" (Off/7d/30d/90d) and a "Prune now" action that counts first and asks before removing anything. A contact with no advert/update within the threshold is eligible; favourites are always kept. Thresholds and their labels live in one NodePrefs table so the age shown and the age enforced can't drift. SCHEMA_SENTINEL -> 0xC0DE002C (sizeof unchanged at 2824, confirmed on native and a real WioTrackerL1 build). Also fixes four bugs in the scope list from 89c02eea: - removeScope() saved /scopes1 but never savePrefs(), so the ch_scope_idx[] and repeat_extra_scope_mask fix-ups it makes were lost on reboot, leaving shifted entries against unshifted indices. - CMD_SET_DEFAULT_FLOOD_SCOPE wrote the legacy fields directly instead of going through setPrimaryScope(), so the app's default-scope setting had nothing reading it once sends resolved through the list. - The reverse direction was stale too: an on-device "Set as default" never refreshed default_scope_name/key, so CMD_GET_DEFAULT_FLOOD_SCOPE reported a scope the device had stopped using. New syncLegacyDefaultScope(). - Upgrading from the old single Scope field set the default (so DMs kept it) but left every channel unscoped, since "*" means unscoped, not "inherit". loadScopeList() now reports when it migrated and begin() seeds the channels that already exist, leaving empty slots alone. "Set default" -> "Set as default", and setting it now says what it governs ("Default: DMs + relay") rather than leaving the [default] marker to imply more than it does. Co-Authored-By: Claude Opus 5 --- examples/companion_radio/DataStore.cpp | 30 +++- examples/companion_radio/DataStore.h | 14 +- examples/companion_radio/MyMesh.cpp | 139 ++++++++++++++++-- examples/companion_radio/MyMesh.h | 24 +++ examples/companion_radio/NodePrefs.h | 28 +++- .../companion_radio/ui-new/SettingsScreen.h | 60 +++++++- examples/companion_radio/ui-new/UITask.cpp | 3 +- release-notes.md | 3 +- 8 files changed, 273 insertions(+), 28 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 15f427ed..8a442d44 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -627,6 +627,14 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no rd(&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask)); rd(_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx)); + // → 0xC0DE002C: append contact_expiry_idx. A pre-0x2C file has a stray + // sentinel byte here; clamp anything outside the real option range (see + // NodePrefs::contactExpiryDays) back to 0/Off -- an upgrader must opt into + // pruning deliberately, not inherit a garbage index that happens to alias a + // real option. + rd(&_prefs.contact_expiry_idx, sizeof(_prefs.contact_expiry_idx)); + if (_prefs.contact_expiry_idx >= NodePrefs::CONTACT_EXPIRY_COUNT) _prefs.contact_expiry_idx = 0; + // Schema sentinel: bumped on layout changes. Mismatch means an older file // (or a different schema); rd() already zero-inits any fields not present, // so we just log it — next savePrefs writes the current sentinel. @@ -693,6 +701,10 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no _prefs.repeat_extra_scope_mask = 0; memset(_prefs.ch_scope_idx, 0, sizeof(_prefs.ch_scope_idx)); } + // 0xC0DE002B → 0xC0DE002C: contact_expiry_idx appended. Deliberately no + // entry here -- unlike the scope fields above it has a small closed set of + // legal values, so the unconditional range clamp at its rd() already turns + // any stray pre-0x2C byte back into 0/Off on every load. } file.close(); @@ -862,6 +874,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.msg_wake_screen_off, sizeof(_prefs.msg_wake_screen_off)); file.write((uint8_t *)&_prefs.repeat_extra_scope_mask, sizeof(_prefs.repeat_extra_scope_mask)); file.write((uint8_t *)_prefs.ch_scope_idx, sizeof(_prefs.ch_scope_idx)); + file.write((uint8_t *)&_prefs.contact_expiry_idx, sizeof(_prefs.contact_expiry_idx)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good @@ -1110,7 +1123,7 @@ void DataStore::saveChannels(DataStoreHost* host) { bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) { File file = openRead("/scopes1"); if (file) { - uint8_t hdr[2]; + uint8_t hdr[2] = { 0, 0 }; // default_idx is read back below even if the header read fails bool success = (file.read(hdr, 2) == 2); uint8_t count = success ? hdr[1] : 0; if (count > ScopeList::MAX_SCOPE_ENTRIES) count = 0; // corrupt header -- start empty rather than overrun entries[] @@ -1127,17 +1140,20 @@ bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) { file.close(); list.count = loaded; list.default_idx = list.clamp(hdr[0]); - return true; + return false; // the file was already there -- nothing migrated this boot } // No /scopes1 yet -- one-time migration of an existing single // default_scope_name/key (Settings > Radio > Scope, pre-list) into list - // entry 1, so an already-configured device keeps sending under the same - // scope after upgrading. A never-configured device just stays at the - // default-constructed ScopeList (empty, default_idx 0 == "*"). + // entry 1 and mark it default, which covers DMs and the relay filter. The + // caller finishes the job for channels by seeding their per-channel picks + // once channels[] is loaded (see this function's return value). A + // never-configured device just stays at the default-constructed ScopeList + // (empty, default_idx 0 == "*"). list.count = 0; list.default_idx = 0; - if (prefs.default_scope_name[0] != '\0') { + bool migrated = (prefs.default_scope_name[0] != '\0'); + if (migrated) { ScopeEntry& e = list.entries[0]; StrHelper::strncpy(e.name, prefs.default_scope_name, sizeof(e.name)); memcpy(e.key, prefs.default_scope_key, sizeof(e.key)); // already-derived key, no need to re-derive @@ -1145,7 +1161,7 @@ bool DataStore::loadScopeList(ScopeList& list, const NodePrefs& prefs) { list.default_idx = 1; } saveScopeList(list); // write /scopes1 so this migration runs only once - return true; + return migrated; // caller seeds the existing channels with entry 1 } void DataStore::saveScopeList(const ScopeList& list) { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index dc7ab616..b0035de3 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -49,10 +49,16 @@ public: // /scopes1: the shared named-scope list (see ScopeList.h). `prefs` is only // read, for a one-time migration of a pre-existing single // default_scope_name/key into list entry 1 -- the file is authoritative - // once it exists. Returns true if the file existed or the migration ran - // (i.e. `list` reflects real prior configuration); false only means a - // genuinely fresh, unconfigured device (list left at its default: empty, - // default_idx 0 == "*"). + // once it exists. + // + // Returns true ONLY when that legacy migration just ran, i.e. this boot is + // the first on a device that had a Scope configured the old way. The caller + // uses that to seed the channels that already exist with the migrated entry + // (see MyMesh::begin) -- channels carry their own scope now, so without the + // seed an upgrader's channel traffic would silently drop to unscoped even + // though their DMs kept the old scope. Returns false when /scopes1 was + // already there, and on a genuinely fresh device with nothing to migrate + // (list left empty, default_idx 0 == "*"). bool loadScopeList(ScopeList& list, const NodePrefs& prefs); void saveScopeList(const ScopeList& list); void migrateToSecondaryFS(); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b6ed6265..feda3545 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -474,6 +474,63 @@ bool MyMesh::setContactFavourite(const uint8_t* pub_key, bool fav) { return true; } +// Shared staleness test for the Settings > Contacts "Prune now" sweep. +// +// Caveats on lastmod, deliberately accepted: it's "last touched", not purely +// "last heard over the air" -- setContactFavourite() above bumps it, and the +// app can write it outright (CMD_IMPORT_CONTACT / the contact-update handler). +// Every one of those pushes a contact *away* from being pruned, never towards +// it, so the error only ever errs on the side of keeping data. +// +// Favourites are always exempt; a contact never actually heard from +// (lastmod==0, shouldn't happen for a real saved contact, but a defensive +// check costs nothing) or whose lastmod reads ahead of "now" (clock skew, or +// an unset RTC after a battery pull) is left alone rather than guessed at +// either way. +static bool contactIsStale(const ContactInfo& ci, uint32_t now, uint32_t threshold_secs) { + if (ci.flags & 0x01) return false; // favourite bit, same one setContactFavourite() writes + if (ci.lastmod == 0 || now < ci.lastmod) return false; + return (now - ci.lastmod) >= threshold_secs; +} + +// Threshold in seconds for the configured expiry index, or 0 when expiry is +// Off -- the one place the NodePrefs table is read, so count and prune can't +// disagree about which contacts are in scope. +uint32_t MyMesh::staleContactThresholdSecs() const { + return (uint32_t)NodePrefs::contactExpiryDays(_prefs.contact_expiry_idx) * 86400UL; +} + +int MyMesh::countStaleContacts() { + uint32_t threshold_secs = staleContactThresholdSecs(); + if (threshold_secs == 0) return 0; + uint32_t now = getRTCClock()->getCurrentTime(); + int count = 0; + int n = getNumContacts(); + for (int i = 0; i < n; i++) { + ContactInfo ci; + if (getContactByIdx(MAX_ANON_CONTACTS + i, ci) && contactIsStale(ci, now, threshold_secs)) count++; + } + return count; +} + +int MyMesh::pruneStaleContacts() { + uint32_t threshold_secs = staleContactThresholdSecs(); + if (threshold_secs == 0) return 0; + uint32_t now = getRTCClock()->getCurrentTime(); + int removed = 0; + // Walk backwards: removeContact() compacts the array by shifting everything + // *after* the removed slot down one, so entries at lower indices keep their + // positions and a descending scan never revisits or skips one. (Forwards + // would need a restart after every delete.) + for (int i = getNumContacts() - 1; i >= 0; i--) { + ContactInfo ci; + if (getContactByIdx(MAX_ANON_CONTACTS + i, ci) && contactIsStale(ci, now, threshold_secs)) { + if (deleteContactByKey(ci.id.pub_key)) removed++; + } + } + return removed; +} + void MyMesh::onContactPathUpdated(const ContactInfo &contact) { out_frame[0] = PUSH_CODE_PATH_UPDATED; memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE); @@ -703,12 +760,28 @@ void MyMesh::setPrimaryScope(const char* name) { if (strcmp(_scope_list.entries[i].name, _prefs.default_scope_name) == 0) { idx = i + 1; break; } } if (idx == 0) idx = _scope_list.add(_prefs.default_scope_name); - _scope_list.default_idx = idx; // still 0 ("*") if the list was full + // add() returns 0 only when the list is full. Keep the previous default + // rather than taking that as "*": silently dropping to unscoped would put + // traffic on the air outside any region, which is a louder failure than + // ignoring a request we had no room to honour. + if (idx != 0) _scope_list.default_idx = idx; } if (_store) _store->saveScopeList(_scope_list); rebuildRepeatScopes(); } +void MyMesh::syncLegacyDefaultScope() { + uint8_t idx = _scope_list.default_idx; + if (idx == 0) { // "*" -- no default scope, same as the field never being set + memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); + memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); + } else { + const ScopeEntry& e = _scope_list.entries[idx - 1]; + StrHelper::strncpy(_prefs.default_scope_name, e.name, sizeof(_prefs.default_scope_name)); + memcpy(_prefs.default_scope_key, e.key, sizeof(_prefs.default_scope_key)); + } +} + uint8_t MyMesh::addScope(const char* name) { uint8_t idx = _scope_list.add(name); if (idx && _store) _store->saveScopeList(_scope_list); @@ -721,6 +794,10 @@ void MyMesh::renameScope(uint8_t idx, const char* name) { StrHelper::strncpy(e.name, name, sizeof(e.name)); ScopeList::deriveKey(e.name, e.key); if (_store) _store->saveScopeList(_scope_list); + if (_scope_list.default_idx == idx) { // renaming the default changes its key too + syncLegacyDefaultScope(); + savePrefs(); + } rebuildRepeatScopes(); // this entry's key may be repeat_scopes[]'s default or an extra slot } @@ -745,13 +822,23 @@ void MyMesh::removeScope(uint8_t idx) { if (ci == idx) _prefs.ch_scope_idx[i] = 0; else if (ci > idx) _prefs.ch_scope_idx[i] = ci - 1; } + syncLegacyDefaultScope(); // ScopeList::remove() may have moved or cleared the default if (_store) _store->saveScopeList(_scope_list); + // The fix-ups above live in NodePrefs, not in /scopes1, so both files have to + // be written here. Saving only the list would leave the two out of step after + // a reboot: the entries would be shifted down but every channel's saved index + // (and the repeater's mask) would still point at the pre-delete positions, so + // they'd silently resolve to the wrong scope -- the same class of bug the + // stray-bits clamp in DataStore guards against, just from the other side. + savePrefs(); rebuildRepeatScopes(); } void MyMesh::setDefaultScope(uint8_t idx) { _scope_list.default_idx = _scope_list.clamp(idx); + syncLegacyDefaultScope(); // so CMD_GET_DEFAULT_FLOOD_SCOPE answers with what we now use if (_store) _store->saveScopeList(_scope_list); + savePrefs(); rebuildRepeatScopes(); } @@ -806,9 +893,13 @@ void MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk sendFloodScoped(send_scope, pkt, delay_millis); } else { // Resolve THIS channel's own scope-list pick (Messages > channel context - // menu > Scope:), falling back to the list's default if this channel has - // none of its own or can't be identified (e.g. a bot/room send path that - // doesn't go through a slot in channels[]). + // menu > Scope:). Index 0 is "*", which means unscoped -- NOT "inherit the + // default": the default is what DMs and the relay filter's primary slot + // use, and what a channel is seeded with on upgrade, but once a channel + // has a pick that pick is the whole story. The list default is only the + // fallback for a channel we can't identify at all (findChannelIdx() == -1, + // e.g. a send whose secret isn't in channels[]), where there's no pick to + // read in the first place. int channel_idx = findChannelIdx(channel); uint8_t list_idx = (channel_idx >= 0 && channel_idx < NodePrefs::MAX_SCOPED_CHANNELS) ? _prefs.ch_scope_idx[channel_idx] : _scope_list.default_idx; @@ -1819,7 +1910,9 @@ void MyMesh::begin(bool has_display) { // load persisted prefs _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon); - _store->loadScopeList(_scope_list, _prefs); + // True only on the first boot after upgrading a device that had the old + // single Scope field set -- acted on once the channels are loaded, below. + bool scope_migrated_legacy = _store->loadScopeList(_scope_list, _prefs); rebuildRepeatScopes(); // sanitise bad pref values. NaN/inf must be reset BEFORE constrain(): constrain @@ -1871,6 +1964,20 @@ void MyMesh::begin(bool has_display) { addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel } + // First boot after upgrading from the single device-wide Scope field: every + // channel now carries its own pick, and an unset pick means "*" == unscoped, + // not "inherit the default". Left alone, an upgrader's channel traffic would + // quietly go out unscoped while their DMs kept the old scope. Seed only the + // slots that actually hold a channel today -- a blanket fill would also hand + // the scope to whatever channel gets created in an empty slot later on. + if (scope_migrated_legacy && _scope_list.default_idx >= 1) { + for (uint8_t i = 0; i < NodePrefs::MAX_SCOPED_CHANNELS; i++) { + ChannelDetails ch; + if (getChannel(i, ch) && ch.name[0]) _prefs.ch_scope_idx[i] = _scope_list.default_idx; + } + savePrefs(); + } + applyRepeaterRadio(); // companion params, or the repeater profile if relaying with one set applyApc(); // sets TX power to the ceiling and arms APC if enabled radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain); @@ -2925,19 +3032,29 @@ void MyMesh::handleCmdFrame(size_t len) { // avoid reading into the key (or past the frame) when no NUL is present. int n = (int)strnlen((char *) &cmd_frame[1], 31); if (n > 0 && n < 31) { - strcpy(_prefs.default_scope_name, (char *) &cmd_frame[1]); + // Must go through setPrimaryScope(), not straight into the legacy + // fields: since the scope list landed, every send and the relay filter + // resolve through _scope_list, so writing default_scope_name/key alone + // would leave the app's request with nothing reading it. + setPrimaryScope((char *) &cmd_frame[1]); + // Honour the key the app derived rather than the one setPrimaryScope() + // re-derived from the name. They agree today (same "#name" -> SHA256), + // but the app is the authority on its own regions, and a silent + // mismatch here would be an on-air difference nothing surfaces. memcpy(_prefs.default_scope_key, &cmd_frame[1+31], 16); - rebuildRepeatScopes(); // slot 0 of the relay filter tracks this key + if (_scope_list.default_idx >= 1) { + memcpy(_scope_list.entries[_scope_list.default_idx - 1].key, &cmd_frame[1+31], 16); + if (_store) _store->saveScopeList(_scope_list); + rebuildRepeatScopes(); // slot 0 of the relay filter tracks this key + } savePrefs(); writeOKFrame(); } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } } else { - memset(_prefs.default_scope_name, 0, sizeof(_prefs.default_scope_name)); // set default scope to null - memset(_prefs.default_scope_key, 0, sizeof(_prefs.default_scope_key)); - rebuildRepeatScopes(); // drop it from the relay filter too, not just from sends - savePrefs(); + setPrimaryScope(""); // clears the legacy fields and points the list back at "*" + savePrefs(); // setPrimaryScope() writes /scopes1 and rebuilds the relay filter writeOKFrame(); } } else if (cmd_frame[0] == CMD_GET_DEFAULT_FLOOD_SCOPE) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 47edcb0e..509b2079 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -140,6 +140,22 @@ public: bool addDiscoveredContact(const uint8_t* pub_key, const char* name, uint8_t type); bool deleteContactByKey(const uint8_t* pub_key); + // Settings > Contacts > "Expire" / "Prune now" -- a contact with no + // advert/update (ContactInfo::lastmod) within NodePrefs::contactExpiryDays( + // _prefs.contact_expiry_idx) days is eligible for removal; index 0 = Off. + // Favourites are always exempt. Both take the threshold from that one + // NodePrefs table, the same one the Settings row labels itself from. + uint32_t staleContactThresholdSecs() const; // 0 when expiry is Off + // How many contacts pruneStaleContacts() would remove right now, without + // removing anything -- backs the confirm dialog. 0 whenever expiry is Off. + // Not const: BaseChatMesh::getContactByIdx() isn't either. + int countStaleContacts(); + // Removes every non-favourite contact whose lastmod is older than the + // configured threshold. Returns the number actually removed. The contacts + // file is written lazily, like every other contact edit (see + // dirty_contacts_expiry / flushDirtyContacts()). + int pruneStaleContacts(); + // Ping/Trace functionality #define PING_RESULT_MAX 4 typedef void (*PingCallback)(uint32_t tag, int16_t snr_out_x4, int16_t snr_back_x4, uint32_t rtt_ms); @@ -362,6 +378,14 @@ public: // rebuildRepeatScopes() and persists the list. void setPrimaryScope(const char* name); + // Mirrors the list's current default entry back into the legacy + // NodePrefs::default_scope_name/key pair. Those two fields are the only + // shape CMD_GET_DEFAULT_FLOOD_SCOPE can report, so any on-device change to + // which entry is default (or a rename/delete that moves it) has to refresh + // them -- otherwise the app keeps showing, and re-sending, a scope the + // device stopped using. Callers persist prefs themselves. + void syncLegacyDefaultScope(); + // Rebuilds repeat_scopes[]/repeat_scope_count from the scope list's current // default entry (slot 0) plus repeat_extra_scope_mask (Tools > Repeater > // Extra scopes -- a toggle over the same list). Call after loading prefs + diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 2046a9ca..6a10a75f 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -498,6 +498,13 @@ struct NodePrefs { // persisted to file // memset and an older prefs file (no bytes here at all) mean "on", which is // the default -- a positive flag would read back as off for every upgrader. uint8_t fav_sort_off; // 0 = favourites first in every list (default), 1 = natural order + // Settings > Contacts > "Expire" + "Prune now". Index into + // contactExpiryDays()/contactExpiryLabel() below (0=Off/never, 1=7d, 2=30d, + // 3=90d) -- a contact whose ContactInfo::lastmod is older than this is + // eligible for the manual Prune-now sweep. Favourites are always exempt + // regardless of age. On-disk position is the struct's append-only tail (see + // the serialization tripwire below), same as fav_sort_off above. + uint8_t contact_expiry_idx; // 0 = off (default) // ── Advert ───────────────────────────────────────────────────────────── uint8_t advert_loc_policy; @@ -579,6 +586,21 @@ struct NodePrefs { // persisted to file // tuning only — not persisted. static const uint16_t TRAIL_AUTOPAUSE_MOVE_M = 15; + // Contact-expiry thresholds (days) for contact_expiry_idx. Single source of + // truth for both the Settings > Contacts "Expire" row's label and the age + // MyMesh::countStaleContacts()/pruneStaleContacts() actually applies, so the + // number the user picks and the one enforced can't drift apart. 0 = off, + // which is also the clamp target for any out-of-range saved index. + static const uint8_t CONTACT_EXPIRY_COUNT = 4; + static uint16_t contactExpiryDays(uint8_t idx) { + static const uint16_t D[CONTACT_EXPIRY_COUNT] = { 0, 7, 30, 90 }; + return D[idx < CONTACT_EXPIRY_COUNT ? idx : 0]; + } + static const char* contactExpiryLabel(uint8_t idx) { + static const char* L[CONTACT_EXPIRY_COUNT] = { "Off", "7d", "30d", "90d" }; + return L[idx < CONTACT_EXPIRY_COUNT ? idx : 0]; + } + // Tail sentinel written at the end of /new_prefs. Bump the low byte when // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). @@ -588,7 +610,7 @@ struct NodePrefs { // persisted to file // repeat_* fields) instead of at the tail, which shifted every field after // them by 25 bytes when loading an older file. Never released, but a dev // build wrote it, so the number must not be reused for anything else. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE002B; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE002C; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage @@ -733,6 +755,10 @@ struct NodePrefs { // persisted to file // earlier bump -- confirmed via a real sim_companion_radio (native) build // and a real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof // 2824 on both. +// contact_expiry_idx (0xC0DE002C) landed in existing padding elsewhere in +// the struct -- confirmed via a real sim_companion_radio (native) build and a +// real WioTrackerL1_companion_solo_dual (nRF52/ARM) build, sizeof unchanged +// at 2824 on both. static_assert(sizeof(NodePrefs) == 2824, "NodePrefs layout changed — sync DataStore save/load + clamp, bump " "SCHEMA_SENTINEL, then update this size (see steps above)."); diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 7f90061b..ee282b31 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -83,7 +83,7 @@ class SettingsScreen : public UIScreen { KEYBOARD_CARDKB_COMPACT, #endif // Contacts section - SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, FAV_SORT, + SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, FAV_SORT, EXPIRE_AFTER, PRUNE_NOW, // Messages section SECTION_MESSAGES, DM_RESEND, @@ -136,6 +136,8 @@ class SettingsScreen : public UIScreen { static const int SOUND_COUNT = 4; static const char* AD_SCOPE_LABELS[2]; static const int AD_SCOPE_COUNT = 2; + // ("Expire" reads its labels straight from NodePrefs::contactExpiryLabel(), + // which is also where MyMesh takes the matching day count from.) #if FEAT_FULL_REFRESH_SETTING static const char* EINK_FULL_REFRESH_LABELS[5]; static const int EINK_FULL_REFRESH_COUNT = 5; @@ -681,6 +683,12 @@ class SettingsScreen : public UIScreen { display.print("Favs top"); display.setCursor(valCol(display), y); display.print((p && p->fav_sort_off) ? "OFF" : "ON"); + } else if (item == EXPIRE_AFTER) { + display.print("Expire"); + display.setCursor(valCol(display), y); + display.print(NodePrefs::contactExpiryLabel(p ? p->contact_expiry_idx : 0)); + } else if (item == PRUNE_NOW) { + display.print("Prune now"); // action row: Enter counts + confirms + removes } else if (item == DM_RESEND) { display.print("Resend"); display.setCursor(valCol(display), y); @@ -721,6 +729,12 @@ class SettingsScreen : public UIScreen { int _scope_rename_idx = -2; bool _scope_delete_confirm_active = false; + // Contacts > "Prune now" confirm -- a separate PopupMenu from + // _scope_action_menu since this one pops up directly over the flat + // Settings list (PRUNE_NOW's own row), not nested inside a sub-screen. + PopupMenu _prune_confirm; + char _prune_confirm_title[40]; + int renderScopeMgmt(DisplayDriver& display) { display.setColor(DisplayDriver::LIGHT); display.drawCenteredHeader("SCOPE", true, _scope_action_menu.active); @@ -767,6 +781,7 @@ public: _scope_rename_idx = -2; _scope_action_menu.active = false; _scope_delete_confirm_active = false; + _prune_confirm.active = false; resetList(); _editor.freq.active = false; } @@ -802,6 +817,7 @@ public: }); if (_picker.menu.active) _picker.menu.render(display); + if (_prune_confirm.active) _prune_confirm.render(display); return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000; } @@ -869,8 +885,14 @@ public: auto res = _scope_action_menu.handleInput(c); if (res == PopupMenu::SELECTED) { int sel = _scope_action_menu.selectedIndex(); - if (sel == 0) { // Set default + if (sel == 0) { // Set as default the_mesh.setDefaultScope((uint8_t)_scope_action_idx); + // "Default" is the protocol's own word (CMD_SET_DEFAULT_FLOOD_SCOPE, + // and what the app shows), but on its own it doesn't say default + // for *what* -- channels carry their own pick and don't inherit it. + // Spell out the two things it actually governs, at the moment the + // user sets it, rather than leaving the list marker to imply more. + _task->showAlert("Default: DMs + relay", 1400); } else if (sel == 1 && _scope_action_idx >= 1) { // Rename _scope_rename_idx = _scope_action_idx; _kb->begin(sl.name((uint8_t)_scope_action_idx), 23); @@ -895,7 +917,7 @@ public: _scope_action_idx = _scope_mgmt_sel; bool is_named = _scope_action_idx >= 1; _scope_action_menu.begin("Scope", is_named ? 3 : 1); - _scope_action_menu.addItem("Set default"); + _scope_action_menu.addItem("Set as default"); if (is_named) { _scope_action_menu.addItem("Rename"); _scope_action_menu.addItem("Delete"); } } return true; @@ -951,6 +973,18 @@ public: return true; } + // Contacts > "Prune now" confirm + if (_prune_confirm.active) { + auto res = _prune_confirm.handleInput(c); + if (res == PopupMenu::SELECTED && _prune_confirm.selectedIndex() == 0) { // "Remove" + int n = the_mesh.pruneStaleContacts(); + char msg[24]; + snprintf(msg, sizeof(msg), "Removed %d contact%s", n, n == 1 ? "" : "s"); + _task->showAlert(msg, 1400); + } + return true; + } + if (c == KEY_CANCEL) { _task->savePrefsIfDirty(_dirty); _task->gotoHomeScreen(); @@ -1212,6 +1246,26 @@ public: _dirty = true; return true; } + if (_selected == EXPIRE_AFTER && p && (left || right || enter)) { + const int n_opt = NodePrefs::CONTACT_EXPIRY_COUNT; + int idx = (p->contact_expiry_idx < n_opt) ? p->contact_expiry_idx : 0; + idx = (idx + (left ? n_opt - 1 : 1)) % n_opt; + p->contact_expiry_idx = (uint8_t)idx; + _dirty = true; + return true; + } + if (_selected == PRUNE_NOW && enter) { + int n = the_mesh.countStaleContacts(); + if (n == 0) { + // Two different "nothing happened" reasons, told apart so the row + // doesn't look broken when the threshold simply isn't set yet. + _task->showAlert(p && p->contact_expiry_idx == 0 ? "Expire is Off" : "No inactive contacts", 1400); + } else { + snprintf(_prune_confirm_title, sizeof(_prune_confirm_title), "Remove %d contact%s?", n, n == 1 ? "" : "s"); + _prune_confirm.beginConfirm(_prune_confirm_title, "Remove"); + } + return true; + } if (isMsgSlot(_selected) && enter) { int slot = msgSlotIndex(_selected); _edit_slot = slot; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index a83acf78..39e26e4c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -3277,7 +3277,8 @@ void UITask::onContactRemoved(const uint8_t* pub_key) { // 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_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; diff --git a/release-notes.md b/release-notes.md index c57d594b..3b980725 100644 --- a/release-notes.md +++ b/release-notes.md @@ -4,7 +4,8 @@ - **The Clock/Lock dashboard gets a separate "Altitude (GPS)" field**, alongside the existing barometric one (now labelled "Altitude (Baro)") — the original single Altitude field only ever read a barometric sensor's telemetry, showing `--` on any board without one even with a perfectly good GPS fix. - **Received messages now show how many hops they actually took to reach you**, right in the message list — the same tiny digit-icon a sent message already uses for its repeater/echo count, now shown for incoming DMs and channel posts too, using the hop path the mesh already records for them. -- **Scope is now a shared, freely-definable list, not one device-wide text field.** Settings › Radio › Scope manages a small named list (`*`/wildcard always first, plus a movable default) instead of a single free-typed name. Each channel picks exactly one scope of its own — a `Scope: ` row in the channel's context menu, matching the phone app's own per-channel region picker — and the channel's history title shows the tag when it's set to anything but `*`. Tools › Repeater's "Extra scopes" now multi-selects from the same list instead of comma-typing region names, capped at the same 4 active relay scopes as before. DMs and any not-yet-assigned channel keep using the list's current default, so an existing single-scope setup carries over unchanged on upgrade. +- **Scope is now a shared, freely-definable list, not one device-wide text field.** Settings › Radio › Scope manages a small named list (`*`/wildcard always first, plus a movable default) instead of a single free-typed name. Each channel picks exactly one scope of its own — a `Scope: ` row in the channel's context menu, matching the phone app's own per-channel region picker — and the channel's history title shows the tag when it's set to anything but `*`. Tools › Repeater's "Extra scopes" now multi-selects from the same list instead of comma-typing region names, capped at the same 4 active relay scopes as before. A channel set to `*` sends unscoped; DMs and the repeater's primary relay slot follow the list's default entry. Upgrading carries an existing single-scope setup over unchanged: the old Scope becomes the default entry and every channel you already had is seeded with it, so nothing changes on the air. +- **Settings › Contacts can now clean out contacts you haven't heard from in a while.** New "Expire" option (Off/7d/30d/90d) plus a "Prune now" action that shows exactly how many contacts would be removed before you confirm. Favourites are always kept regardless of age. ### Fixes