fix(companion): pack and history files from the card checked, contact loops skip the reserved slots, small UI fixes

- L2 vector packs: a damaged count can't wrap the index size, and an
  entry pointing past the file is refused.
- L2 map share-target list: sized for 40 long channel names and 16
  favourites (it could overflow), off the stack.
- L2 SD history: a file with an unreadable header is set aside as .bad
  instead of written over; Delete message history takes those too.
- Contact lookups by key or hash start after the reserved anon slots (an
  empty one matched a 00 hop hash with a blank name); MessagesScreen uses
  contactctl::hopName().
- Nearby keeps its selection by public key: deleting a contact shifts
  the table.
- ui-new Live Share target cycle holds every channel and favourite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-12 18:31:35 +02:00
co-authored by Claude Opus 5.5
parent 4eee794a17
commit 57db27512d
10 changed files with 51 additions and 41 deletions
@@ -90,7 +90,9 @@ inline uint8_t hopCount(uint8_t path_len_packed) {
inline void hopName(uint8_t path_len_packed, const uint8_t* path, int i, char* out, size_t n) {
uint8_t hash_size = (path_len_packed >> 6) + 1;
const uint8_t* hash = &path[i * hash_size];
for (int idx = 0; ; idx++) {
// From MAX_ANON_CONTACTS: the reserved slots before it are empty or anon
// requesters, and an empty one matched a 00 hash with a blank name.
for (int idx = MAX_ANON_CONTACTS; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (c.id.isHashMatch(hash, hash_size)) { snprintf(out, n, "%s", c.name); return; }
@@ -173,10 +173,12 @@ public:
void refreshModel() { if (_source == SRC_SCAN) refreshScan(); else refreshStored(); }
// Index of the row for raw contact index `contact_idx`, or of the non-contact
// live row named `name` -- how a screen keeps its selection across a rebuild.
int findContact(int contact_idx) const {
for (int i = 0; i < _count; i++) if (_entries[i].contact_idx == contact_idx) return i;
// Index of the contact row with this public key, or of the non-contact live
// row named `name` -- how a screen keeps its selection across a rebuild. By
// key, not by contact index: deleting a contact shifts the table down.
int findContact(const uint8_t* pub_key) const {
for (int i = 0; i < _count; i++)
if (_entries[i].contact_idx >= 0 && memcmp(_entries[i].pub_key, pub_key, PUB_KEY_SIZE) == 0) return i;
return -1;
}
int findLiveByName(const char* name) const {
@@ -177,7 +177,12 @@ public:
}
void clearAll() {
if (!ready()) return;
forEachFile([](const char* path, char) { remove(path); });
forEachFile([](const char* path, char) {
remove(path);
char bad[72]; // a set-aside copy (see append()) goes with it: append() always recreates the .bin
snprintf(bad, sizeof(bad), "%s.bad", path);
remove(bad);
});
}
private:
@@ -241,7 +246,15 @@ private:
f = fopen(path, "r+b");
}
if (!f || !readHdr<E>(f, h)) { // new (or unreadable: start it again)
if (f) fclose(f);
if (f) {
// Unreadable (a damaged header, a card edited elsewhere): set it aside
// as .bad -- one copy, the newest -- instead of writing over the history.
fclose(f);
char bad[72];
snprintf(bad, sizeof(bad), "%s.bad", path);
remove(bad);
rename(path, bad);
}
f = fopen(path, "w+b");
if (!f) return;
h = { MAGIC, VERSION, (uint16_t)sizeof(E), _keep, 0, 0, 1 };
+4 -1
View File
@@ -993,7 +993,10 @@ void UITask::navToolsPopup() {
_nav_share_lbl = noteLabel(list, "", THEME_FONT_SMALL, theme::TEXT);
// Target: channels (as named -- "#" marks a hashtag channel), then
// favourite contacts (starred, so the two stay apart).
char opts[MAX_GROUP_CHANNELS * 24 + 16 * 40];
// Worst case: "\n" + a 31-byte name per channel, "\n" + star (3) + space +
// 31 bytes per favourite. Static: ~1.9 KB is a lot for the loop's stack, and
// the dropdown copies it.
static char opts[MAX_GROUP_CHANNELS * 32 + 16 * 36 + 1];
int o = 0, sel = 0;
navmap::s_share_n = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS && navmap::s_share_n < navmap::SHARE_TARGETS; i++) {
@@ -59,6 +59,7 @@ public:
Pack& p = _p[i];
uint32_t off, len;
if (!lookup(p, key, off, len)) continue;
if ((uint64_t)p.data_off + off + len > p.size) return false; // an entry pointing past the file
FILE* f = handle(i);
if (!f || fseek(f, p.data_off + off, SEEK_SET) != 0) return false;
s.f = f; s.len = len; s.owned = false;
@@ -128,8 +129,10 @@ private:
fseek(f, 0, SEEK_END);
p.size = (uint32_t)ftell(f);
p.index = nullptr;
uint32_t isz = p.count * 16;
ok = p.index_off + isz <= p.size && p.data_off <= p.size;
// In 64 bits / by division: a damaged count would otherwise wrap count * 16
// to a small size, pass, and send the index search far past its buffer.
ok = p.count <= p.size / 16 && (uint64_t)p.index_off + (uint64_t)p.count * 16 <= p.size && p.data_off <= p.size;
uint32_t isz = ok ? p.count * 16 : 0;
if (ok && isz <= INDEX_IN_RAM_MAX) {
p.index = psramBuf<uint8_t>(isz ? isz : 1);
if (p.index && (fseek(f, p.index_off, SEEK_SET) != 0 || !readFast(f, p.index, isz))) { free(p.index); p.index = nullptr; }
+2 -2
View File
@@ -1491,7 +1491,7 @@ public:
}
}
} else if (prefix) {
for (int idx = 0; ; idx++) {
for (int idx = MAX_ANON_CONTACTS; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (memcmp(c.id.pub_key, prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
@@ -1673,7 +1673,7 @@ public:
_task->openChannelHistory(pfx[0]);
return true;
}
for (int idx = 0; ; idx++) {
for (int idx = MAX_ANON_CONTACTS; ; idx++) {
ContactInfo c2;
if (!the_mesh.getContactByIdx(idx, c2)) break;
if (memcmp(c2.id.pub_key, pfx, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
@@ -157,14 +157,17 @@ public:
}
void cycleTarget(int dir) {
struct T { uint8_t type, ch; uint8_t prefix[NodePrefs::FAVOURITE_PREFIX_LEN]; } list[24];
// Every channel and every dial favourite: a cap under MAX_GROUP_CHANNELS
// (40) left the favourites, listed last, out of reach.
static const int LIST_MAX = MAX_GROUP_CHANNELS + NodePrefs::FAVOURITES_COUNT;
struct T { uint8_t type, ch; uint8_t prefix[NodePrefs::FAVOURITE_PREFIX_LEN]; } list[LIST_MAX];
int n = 0;
ChannelDetails ch;
for (int i = 0; i < 64 && n < 24; i++) {
for (int i = 0; i < MAX_GROUP_CHANNELS && n < LIST_MAX; i++) {
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
list[n].type = 0; list[n].ch = (uint8_t)i; n++;
}
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT && n < 24; i++) {
for (int i = 0; i < NodePrefs::FAVOURITES_COUNT && n < LIST_MAX; i++) {
const uint8_t* pre = _prefs->favourite_contacts[i];
bool empty = true;
for (int b = 0; b < NodePrefs::FAVOURITE_PREFIX_LEN; b++) if (pre[b]) { empty = false; break; }
@@ -195,13 +195,13 @@ public:
none.kind = 2; none.lat = 0; none.lon = 0; none.ts = 0; none.live = false; none.fav = false;
memset(none.key, 0, 6);
snprintf(none.name, sizeof(none.name), "(none)");
for (int idx = 0; _target_n < TARGET_MAX; idx++) {
for (int idx = MAX_ANON_CONTACTS; _target_n < TARGET_MAX; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (!contactctl::favourite(c)) continue;
addPersonTarget(c.id.pub_key, c.name, /*require_position=*/false, true);
}
for (int idx = 0; _target_n < TARGET_MAX; idx++) {
for (int idx = MAX_ANON_CONTACTS; _target_n < TARGET_MAX; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
addPersonTarget(c.id.pub_key, c.name, /*require_position=*/true, contactctl::favourite(c));
@@ -383,24 +383,6 @@ class MessagesScreen : public UIScreen {
}
}
// Resolve one hop's path hash to a contact name, falling back to a short hex
// tag ("?AABB") when no known contact matches -- an unnamed/unknown repeater,
// or one whose contact card has since been removed.
void resolveHopName(const uint8_t* hash, uint8_t hash_size, char* out, size_t out_sz) {
for (int idx = 0; ; idx++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(idx, c)) break;
if (c.id.isHashMatch(hash, hash_size)) {
snprintf(out, out_sz, "%s", c.name);
return;
}
}
char hex[9] = {0};
uint8_t n = hash_size > 4 ? 4 : hash_size;
for (uint8_t b = 0; b < n; b++) snprintf(hex + b * 2, 3, "%02X", hash[b]);
snprintf(out, out_sz, "?%s", hex);
}
// Opens the path/relay detail popup for the entry stashed by buildFsMenu().
// Incoming: hops listed oldest-hop-first (the order they were appended as the
// message travelled). Outgoing channel: each distinct repeater heard echoing
@@ -423,7 +405,7 @@ class MessagesScreen : public UIScreen {
uint8_t max_hops = MAX_HIST_PATH_BYTES / hash_size; // matches capturePath()/markChannelRelayed()'s write-side bound
if (hop_count > max_hops) hop_count = max_hops;
for (uint8_t i = 0; i < hop_count; i++) {
resolveHopName(&path[i * hash_size], hash_size, _path_detail_names[i], sizeof(_path_detail_names[i]));
contactctl::hopName(path_len_packed, path, i, _path_detail_names[i], sizeof(_path_detail_names[i]));
}
_ctx_menu.begin(path_is_relay ? "Relayed by" : "Path", hop_count);
for (uint8_t i = 0; i < hop_count; i++) _ctx_menu.addItem(_path_detail_names[i]);
@@ -724,7 +706,7 @@ class MessagesScreen : public UIScreen {
DisplayDriver::translateUTF8Static(nm + 1, chanctl::bareName(ch.name), sizeof(nm) - 1);
}
} else {
for (int idx = 0; ; idx++) {
for (int idx = MAX_ANON_CONTACTS; ; idx++) {
ContactInfo c2;
if (!the_mesh.getContactByIdx(idx, c2)) break;
if (memcmp(c2.id.pub_key, pfx, NodePrefs::FAVOURITE_PREFIX_LEN) == 0) {
@@ -105,20 +105,22 @@ class NearbyScreen : public UIScreen, protected NearbyModel {
void refresh() { refreshModel(); clampSelection(); }
// Rebuild the stored list (re-merge live shares + re-sort) while keeping the
// currently highlighted node selected across the rebuild — by contact index
// currently highlighted node selected across the rebuild — by public key
// for a stored node, or by name for a non-contact live sender. Returns false
// if that node is no longer in the list (caller decides whether to drop the
// detail/nav view). Shared by the list, detail and navigate refresh paths.
bool refreshKeepingSelection() {
int saved_idx = (_sel < _count) ? _entries[_sel].contact_idx : -1;
bool saved_live = (_sel < _count) && _entries[_sel].is_live && saved_idx < 0;
bool saved_contact = (_sel < _count) && _entries[_sel].contact_idx >= 0;
bool saved_live = (_sel < _count) && _entries[_sel].is_live && !saved_contact;
uint8_t saved_key[PUB_KEY_SIZE];
if (saved_contact) memcpy(saved_key, _entries[_sel].pub_key, PUB_KEY_SIZE);
char saved_name[sizeof(_entries[0].name)]; saved_name[0] = '\0';
if (_sel < _count) {
strncpy(saved_name, _entries[_sel].name, sizeof(saved_name) - 1);
saved_name[sizeof(saved_name) - 1] = '\0';
}
refreshStored();
int i = saved_idx >= 0 ? findContact(saved_idx)
int i = saved_contact ? findContact(saved_key)
: (saved_live && saved_name[0]) ? findLiveByName(saved_name) : -1;
if (i >= 0) { _sel = i; return true; }
return false;