From bdd2ed379d346072f1558c6a53adf713af1ee257 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:04:06 +0200 Subject: [PATCH] feat(mesh): show which repeaters relayed/confirmed a message's path Extends the existing single-boolean channel relay-echo marker into a full count + list of distinct confirming repeaters, since each repeater retransmit already appends its own identity hash to the packet's path and the echo-matching hash deliberately ignores that mutable path -- so every distinct repeater's echo of one send now matches the same tracking slot instead of only the first. Symmetrically captures the hop path a received DM/channel message actually took, so a new "Path"/"Relayed by" row in the existing Hold-Enter Options popup can show the resolved sequence of repeaters (by contact name, or a hex fallback for an unknown one). Also fixes a real bug caught during testing: the popup row's own label ("Path (N hops)"/"Relayed by (N)") was built into a stack-local buffer handed to PopupMenu, which only stores the pointer -- it rendered as garbage once the building function returned. Moved to a persistent member buffer. Bumps the dev-build fallback version and adds release notes/docs for this plus the two other 1.27 features already on this branch (BLE retry backoff, marquee-scroll for selected long text). Co-Authored-By: Claude Sonnet 5 --- docs/design/solo_ui_framework.md | 13 +- .../message_screen/message_screen.md | 9 +- examples/companion_radio/AbstractUITask.h | 20 ++- examples/companion_radio/MyMesh.cpp | 23 ++- examples/companion_radio/MyMesh.h | 2 +- .../companion_radio/ui-new/MessageHistory.h | 89 +++++++++-- .../companion_radio/ui-new/MessagesScreen.h | 146 +++++++++++++++--- examples/companion_radio/ui-new/UITask.cpp | 14 +- examples/companion_radio/ui-new/UITask.h | 8 +- release-notes.md | 14 ++ 10 files changed, 281 insertions(+), 57 deletions(-) diff --git a/docs/design/solo_ui_framework.md b/docs/design/solo_ui_framework.md index 5b250297..65f8a0eb 100644 --- a/docs/design/solo_ui_framework.md +++ b/docs/design/solo_ui_framework.md @@ -104,8 +104,17 @@ Drawing helpers (all clip/measure for you): in the header, so the menu is discoverable without already knowing the shortcut; `menu_open` highlights it while the menu is actually up. - `drawSelectionRow(x, y, w, h, sel)` — the highlight bar behind a list row. -- `drawTextEllipsized(x, y, max_w, str)` — truncates with `…`; **use this for - any user string** (names, labels) so long/UTF-8 text can't overrun. +- `drawTextEllipsized(x, y, max_w, str, selected=false)` — truncates with `…`; + **use this for any user string** (names, labels) so long/UTF-8 text can't + overrun. Pass `selected=true` for the currently-selected row and the text + that doesn't fit marquee-scrolls into view (pause → scroll to the end → + pause → scroll back), instead of just sitting behind the ellipsis; returns + the ms until the next redraw is needed for that animation to stay smooth + (0 when nothing is scrolling) — thread it into your screen's own `render()` + return value the same way you already clamp for anything else that needs a + faster redraw. Only one row UI-wide marquees at a time (whichever is + currently selected), so there's no risk of two animations racing each + other for the shared timing state. - `drawTextCentered(mid_x, y, str)`. - `translateUTF8ToBlocks(dst, src, n)` — map UTF-8 to the panel's glyph set for *display only*. Never run text through it before sending it over the air or diff --git a/docs/solo_features/message_screen/message_screen.md b/docs/solo_features/message_screen/message_screen.md index 4e595e5c..290146c6 100644 --- a/docs/solo_features/message_screen/message_screen.md +++ b/docs/solo_features/message_screen/message_screen.md @@ -64,7 +64,7 @@ Posting to a **room server** needs a login handshake — the device does this on Messages appear as chat bubbles sized to their content — **right**-anchored for outgoing, **left** for incoming — with sender name and a compact age indicator (`3m`, `2h`, `>1d`) in the top-right corner. List runs **newest at the bottom**; opening a history starts at the latest message, scrolling up goes further back. -**Short Enter** on a message opens it in fullscreen. **Hold Enter** — on a history row or in fullscreen — opens the same options menu: Reply, plus **Navigate** / **Save waypoint** when the message contains a location (see Fullscreen message view). You don't need to open the message first. +**Short Enter** on a message opens it in fullscreen. **Hold Enter** — on a history row or in fullscreen — opens the same options menu: Reply, plus **Navigate** / **Save waypoint** when the message contains a location, and **Path** / **Relayed by** when hop data is available (see Fullscreen message view). You don't need to open the message first. --- @@ -89,6 +89,13 @@ If the message is a reply addressed to someone (`@[nick]`), a **To: nick** bar i A location is any `lat,lon` pair in the text — exactly what the `{loc}` placeholder inserts — so you can navigate to anything a contact shares. A `[WAY]lat,lon label` share also carries a name, used as the waypoint label. This works on DMs and channel messages, incoming or outgoing. +When the entry has hop data recorded, the menu also adds one more row: + +- **Path (N hops)** — on a received message (DM or channel), lists every repeater the message actually travelled through to reach you, oldest hop first. +- **Relayed by (N)** — on your own channel post instead, lists every distinct repeater heard rebroadcasting it back into the mesh (order isn't meaningful here — each one heard it independently, not as a chain). + +Selecting the row opens a read-only list of the resolved hops — each shown as the matching contact's name where one is known, or a short `?AABB`-style hex tag for an unrecognised repeater. Only repeaters within range of the message's actual travel — or, for **Relayed by**, within your own device's radio range — can ever be identified this way; a message with no recorded path (e.g. a zero-hop send, or one sent before any repeater relayed or echoed it) doesn't show this row at all. + --- ### Context menu — contact list diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index ae81ec0a..f70a11bb 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -46,7 +46,12 @@ public: virtual void onMsgAck(uint32_t ack_crc) { (void)ack_crc; } // A repeater rebroadcast of one of our channel sends was heard (seq from // lastChannelRelaySeq()) — drives the channel "relayed into mesh" marker. - virtual void onChannelRelayed(uint32_t seq) { (void)seq; } + // May fire once per distinct repeater within earshot for the same seq; + // repeater_hash/hash_size (when given) is that repeater's path hash, so the + // UI can list every repeater that confirmed, not just "was it heard at all". + virtual void onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) { + (void)seq; (void)repeater_hash; (void)hash_size; + } // Result of an on-device-UI-triggered MyMesh::sendRoomLogin() arrived. // pub_key is the contact's key prefix (>=4 bytes valid); permissions is the // room/repeater ACL byte (only meaningful when success is true). @@ -87,16 +92,23 @@ public: // Returns the new entry's ring position (see MessageHistory::addChannelMsg), // or -1 on a UI variant that doesn't track history (default no-op below) -- // callers that need it (to then arm a relay-echo tracker) should check for - // that instead of assuming a valid position. - virtual int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) { return -1; } + // that instead of assuming a valid position. path/path_len (packed + // (hash_size-1)<<6|hop_count, same as mesh::Packet::path_len) is the hop + // route this incoming post actually took -- nullptr/0 when not known (e.g. + // this is our own outgoing post). + virtual int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0, + const uint8_t* path = nullptr, uint8_t path_len = 0) { return -1; } // Arms the "relayed into mesh" tracker (a heard repeater rebroadcast) on the // entry at ring position pos, e.g. right after addChannelMsg for a channel // send this device just originated. seq: MyMesh::lastChannelRelaySeq(). virtual void armChannelRelay(int pos, uint32_t seq) {} // ack_tag/ack_deadline_ms/resends: pending-ACK tracking for an outgoing DM // (0 = none, e.g. incoming or "no ack expected") -- see MessageHistory::addDMMsg. + // path/path_len: the hop route an incoming DM actually took (nullptr/0 for + // outgoing -- a DM's delivery confirmation is the ack_tag above, not a path). virtual void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0, - uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0) {} + uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0, + const uint8_t* path = nullptr, uint8_t path_len = 0) {} // A node shared its current position via a [LOC] message. pub_key is the // sender's key prefix for a verified DM share, or null for a channel share // (keyed by name, best-effort). Default no-op so UI variants opt in. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8303f522..9107297e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -545,7 +545,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe // message fires the notification and reaches the app via the offline queue but // never shows when the room is opened directly on the device. if (from.type == ADV_TYPE_CHAT) { - _ui->addDMMsg(from.id.pub_key, false, text, sender_timestamp); + _ui->addDMMsg(from.id.pub_key, false, text, sender_timestamp, 0, 0, 0, pkt->path, (uint8_t)pkt->path_len); } else if (from.type == ADV_TYPE_ROOM) { // A room carries many guests, so prefix the post with its author so the UI // can attribute each line. The signed message's `extra` holds the sender's @@ -560,7 +560,7 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe } else { snprintf(labeled, sizeof(labeled), "%s", text); } - _ui->addDMMsg(from.id.pub_key, false, labeled, sender_timestamp); + _ui->addDMMsg(from.id.pub_key, false, labeled, sender_timestamp, 0, 0, 0, pkt->path, (uint8_t)pkt->path_len); } } #endif @@ -589,9 +589,20 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) { if (!s.pending || s.len != packet->payload_len) continue; if (!hashed) { packet->calculatePacketHash(h); hashed = true; } if (memcmp(h, s.hash, MAX_HASH_SIZE) == 0) { - s.pending = false; - _relay_active--; - if (_ui) _ui->onChannelRelayed(s.seq); + // Slot stays pending (freed only by the deadline sweep elsewhere) so a + // DIFFERENT repeater's independent echo of this same send can still + // match here too -- onChannelRelayed()/markChannelRelayed() append each + // additionally heard repeater instead of just flipping a single flag. + if (_ui) { + uint8_t hash_size = packet->getPathHashSize(); + uint8_t hop_count = packet->getPathHashCount(); + // The repeater we just heard directly is always the LAST hop appended + // (Mesh.cpp appends its own hash on every retransmit) -- that's the + // one within our own earshot, regardless of how many further hops + // this same packet may go on to take beyond it. + const uint8_t* repeater_hash = hop_count > 0 ? &packet->path[(hop_count - 1) * hash_size] : nullptr; + _ui->onChannelRelayed(s.seq, repeater_hash, repeater_hash ? hash_size : 0); + } break; } } @@ -845,7 +856,7 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe _serial->writeFrame(frame, 1); } #ifdef DISPLAY_CLASS - if (_ui) _ui->addChannelMsg(channel_idx, text, timestamp); + if (_ui) _ui->addChannelMsg(channel_idx, text, timestamp, pkt->path, (uint8_t)pkt->path_len); if (_ui) _ui->notify(UIEventType::channelMessage); const char *channel_name = "Unknown"; ChannelDetails channel_details; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 7597a84d..68e4d63a 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -20,7 +20,7 @@ class UITask; // "dev-" otherwise; see build-solo-firmwares.yml). This default only // shows up for a `pio run` invoked directly, bypassing build.sh entirely. #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "v1.26-dev" +#define FIRMWARE_VERSION "v1.27-dev" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/ui-new/MessageHistory.h b/examples/companion_radio/ui-new/MessageHistory.h index c99fb1c9..8bb10e99 100644 --- a/examples/companion_radio/ui-new/MessageHistory.h +++ b/examples/companion_radio/ui-new/MessageHistory.h @@ -22,12 +22,40 @@ enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL }; // get their tail clipped. static const int MSG_TEXT_BUF = MAX_TEXT_LEN + 1; +// Cap on the per-entry path/relay-hash buffer below. Real mesh hop depths are +// small in practice (a handful at most), so this comfortably covers any +// realistic path at any path_hash_mode (16 hops at 1-byte hashes, down to 4 at +// 4-byte) without the RAM cost of sizing every ring entry to MAX_PATH_SIZE (64). +// A path deeper than this is silently truncated to the first entries that fit +// (capturePath() below keeps the stored hop count consistent with what's +// actually copied, so a display walk over the buffer can never run past it). +static const uint8_t MAX_HIST_PATH_BYTES = 16; + +// Copies up to MAX_HIST_PATH_BYTES of a mesh::Packet-style path into dest_path, +// capping the hop count encoded in dest_len so the two always agree. src_len_packed +// uses the same (hash_size-1)<<6|hash_count packing as mesh::Packet::path_len. +inline void capturePath(uint8_t& dest_len, uint8_t* dest_path, const uint8_t* src_path, uint8_t src_len_packed) { + uint8_t hash_size = (src_len_packed >> 6) + 1; + uint8_t hash_count = src_len_packed & 63; + uint8_t max_hops = MAX_HIST_PATH_BYTES / hash_size; + if (hash_count > max_hops) hash_count = max_hops; + memcpy(dest_path, src_path, (size_t)hash_count * hash_size); + dest_len = ((hash_size - 1) << 6) | hash_count; +} + struct ChHistEntry { uint8_t ch_idx; char text[MSG_TEXT_BUF]; uint32_t timestamp; uint8_t relay_status; // AckState; only PENDING/OK used (no failure for floods) uint32_t relay_seq; // MyMesh relay seq to match against onChannelRelayed() + // Incoming: the hop path this post actually took to reach us (resolved to + // repeater names by the UI). Outgoing: repurposed to hold the distinct + // repeater hashes that have echoed this send back (see markChannelRelayed) -- + // a message is never both directions at once, so one buffer serves either. + // path_len packs (hash_size-1)<<6|hop_count, same as mesh::Packet::path_len. + uint8_t path_len; + uint8_t path[MAX_HIST_PATH_BYTES]; }; struct DmHistEntry { @@ -44,6 +72,11 @@ struct DmHistEntry { uint32_t msg_ts; uint8_t attempt; // last attempt number sent (outgoing); next resend = attempt+1 uint8_t resends_left; // remaining auto-resends before the marker shows ✗ + // Incoming only -- the hop path this DM took to reach us. A DM's outgoing + // confirmation is the real end-to-end ack_status/ack_tag above (not a + // repeater-echo concept), so this stays unset (0) for outgoing entries. + uint8_t path_len; + uint8_t path[MAX_HIST_PATH_BYTES]; }; class MessageHistory { @@ -65,7 +98,11 @@ public: // channel's history (so it isn't counted unread). `timestamp` is the sender's // own send time (0 = unknown — use receipt time). Returns the ring position // (an opaque handle for armChannelRelay / chAtPos), or -1 if rejected. - int addChannelMsg(uint8_t ch_idx, const char* text, bool viewing, uint32_t timestamp = 0) { + // path/path_len_packed: the hop path this incoming post actually took (from + // the received mesh::Packet), or nullptr/0 when not known (e.g. this is our + // own outgoing post, before any relay echo has arrived). + int addChannelMsg(uint8_t ch_idx, const char* text, bool viewing, uint32_t timestamp = 0, + const uint8_t* path = nullptr, uint8_t path_len_packed = 0) { // Guard against bogus channel indices (e.g. findChannelIdx() returned -1 // and was cast to uint8_t → 255). Storing such an entry would burn a ring // slot for a message that no visible channel can ever surface. @@ -95,6 +132,8 @@ public: _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; _hist[pos].relay_status = ACK_NONE; _hist[pos].relay_seq = 0; + if (path && path_len_packed) capturePath(_hist[pos].path_len, _hist[pos].path, path, path_len_packed); + else _hist[pos].path_len = 0; if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; return pos; @@ -122,25 +161,49 @@ public: return -1; } - // Called when a repeater echo of one of our channel sends is heard. - void markChannelRelayed(uint32_t seq) { + // Called when a repeater echo of one of our channel sends is heard. May fire + // more than once per send -- every repeater within earshot that independently + // rebroadcasts triggers its own call with the same seq -- so this matches on + // relay_seq alone (not "still ACK_PENDING") and keeps appending. repeater_hash + // (when given) is that repeater's path hash, appended de-duplicated so + // "Hold Enter" can list every distinct repeater that confirmed the send. + void markChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) { if (seq == 0) return; for (int i = 0; i < _hist_count; i++) { ChHistEntry& e = _hist[(_hist_head + i) % CH_HIST_MAX]; - if (e.relay_status == ACK_PENDING && e.relay_seq == seq) { - e.relay_status = ACK_OK; - return; + if (e.relay_seq != seq) continue; + e.relay_status = ACK_OK; + if (repeater_hash && hash_size) { + uint8_t cur_size = (e.path_len >> 6) + 1; + uint8_t cur_count = e.path_len & 63; + if (cur_count == 0) cur_size = hash_size; // first hash recorded sets the size for this entry + if (cur_size == hash_size) { // ignore a mismatch rather than corrupt the buffer + bool dup = false; + for (uint8_t h = 0; h < cur_count; h++) { + if (memcmp(&e.path[h * hash_size], repeater_hash, hash_size) == 0) { dup = true; break; } + } + uint8_t max_hops = MAX_HIST_PATH_BYTES / hash_size; + if (!dup && cur_count < max_hops) { + memcpy(&e.path[cur_count * hash_size], repeater_hash, hash_size); + cur_count++; + e.path_len = ((hash_size - 1) << 6) | cur_count; + } + } } + return; } } // Arm the "relayed into mesh" marker on a just-sent entry (pos from // addChannelMsg) — MyMesh tracked the flood it originated and will report a - // heard repeater echo by seq. + // heard repeater echo by seq. Clears path_len: this entry's path buffer now + // holds echoing-repeater hashes (see markChannelRelayed), not a received hop + // path, so any stale value from a reused ring slot must not linger. void armChannelRelay(int pos, uint32_t seq) { if (pos < 0 || pos >= CH_HIST_MAX) return; _hist[pos].relay_status = ACK_PENDING; _hist[pos].relay_seq = seq; + _hist[pos].path_len = 0; } ChHistEntry& chAtPos(int pos) { return _hist[pos]; } @@ -184,9 +247,12 @@ public: // ack_deadline_ms; 0 means "sent, no confirmation possible" (no path / incoming). // msg_ts = sender-perspective timestamp (send ts for outgoing / sender_timestamp // for incoming); resends = remaining auto-resends for an outgoing pending DM. + // path/path_len_packed: the hop path this incoming DM actually took, or + // nullptr/0 for outgoing (no path concept there -- see DmHistEntry). void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, - uint32_t msg_ts = 0, uint8_t resends = 0) { + uint32_t msg_ts = 0, uint8_t resends = 0, + const uint8_t* path = nullptr, uint8_t path_len_packed = 0) { int pos; if (_dm_hist_count < DM_HIST_MAX) { pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; @@ -210,6 +276,8 @@ public: _dm_hist[pos].msg_ts = msg_ts; _dm_hist[pos].attempt = 0; _dm_hist[pos].resends_left = (outgoing && ack_tag) ? resends : 0; + if (path && path_len_packed) capturePath(_dm_hist[pos].path_len, _dm_hist[pos].path, path, path_len_packed); + else _dm_hist[pos].path_len = 0; } // ack_tag/ack_deadline_ms/resends let an outgoing DM (e.g. one the phone app @@ -218,7 +286,8 @@ public: // shows with no delivery status at all. Unused (0) for incoming. void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0, uint32_t ack_tag = 0, - uint32_t ack_deadline_ms = 0, uint8_t resends = 0) { + uint32_t ack_deadline_ms = 0, uint8_t resends = 0, + const uint8_t* path = nullptr, uint8_t path_len_packed = 0) { // Drop retried copies of an incoming DM: a resend reuses the sender's // timestamp and text but carries a fresh packet hash, so the mesh dup-filter // lets it through. Match on prefix + sender_timestamp + text to suppress it. @@ -230,7 +299,7 @@ public: return; // duplicate retry — already in history } } - storeDMMsg(pub_key, outgoing, text, ack_tag, ack_deadline_ms, sender_timestamp, resends); + storeDMMsg(pub_key, outgoing, text, ack_tag, ack_deadline_ms, sender_timestamp, resends, path, path_len_packed); } int dmHistCountForContact(const uint8_t* prefix) const { diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index fb569d1d..85ca3335 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -71,11 +71,28 @@ class MessagesScreen : public UIScreen { // Fullscreen-message context menu actions. Built per-message: Reply (when // applicable) plus Navigate / Save waypoint when the message carries a - // location (a {loc} string or a [WAY] share). _fs_act maps each visible row - // back to an action so the index math survives the conditional layout. - enum FsAct : uint8_t { FS_REPLY, FS_NAV, FS_SAVE }; - uint8_t _fs_act[3]; + // location (a {loc} string or a [WAY] share), plus Path/Relayed by when the + // entry has hop data recorded. _fs_act maps each visible row back to an + // action so the index math survives the conditional layout. + enum FsAct : uint8_t { FS_REPLY, FS_NAV, FS_SAVE, FS_PATH }; + uint8_t _fs_act[4]; int _fs_act_n = 0; + // Which history entry the currently-open Options menu (and, after FS_PATH is + // chosen, the path/relay detail popup) refers to -- set by buildFsMenu(). + int _fs_menu_pos = -1; + bool _fs_menu_is_channel = false; + // True while _ctx_menu is showing the path/relay detail list (opened from + // FS_PATH) rather than the original Reply/Navigate/... Options menu -- the + // detail list is purely informational, so any input just dismisses it + // instead of being routed through dispatchFsAction()'s stale _fs_act mapping. + bool _showing_path_detail = false; + // Resolved hop labels for the path/relay detail popup -- PopupMenu only + // stores the pointers it's given (see PopupMenu.h), so these must outlive + // the popup across renders, hence a member buffer, not a stack-local one. + char _path_detail_names[MAX_HIST_PATH_BYTES][24]; + // "Path (N hops)" / "Relayed by (N)" Options-row label -- same lifetime + // requirement as _path_detail_names above (PopupMenu stores the pointer). + char _fs_path_item[24]; // Inline navigate-to-location view layered over the fullscreen message. bool _nav_active = false; int32_t _nav_lat = 0, _nav_lon = 0; @@ -267,19 +284,86 @@ class MessagesScreen : public UIScreen { _phase = KEYBOARD; } - // Build the fullscreen-message options popup: Reply (if allowed) plus - // Navigate / Save waypoint when `body` carries a location. Opens _ctx_menu - // only when there's at least one action. Parses the location once here and - // stashes it for the action handler. - void buildFsMenu(const char* body, bool reply_allowed) { + // Build the fullscreen-message options popup: Reply (if allowed), Navigate / + // Save waypoint when `body` carries a location, and Path / Relayed by when + // the entry (ring_pos, in the DM ring or the channel ring per is_channel) + // has hop data recorded. Opens _ctx_menu only when there's at least one + // action. Parses the location once here and stashes it for the action + // handler; stashes ring_pos/is_channel too, for showPathDetail(). + void buildFsMenu(const char* body, bool reply_allowed, int ring_pos, bool is_channel) { + _fs_menu_pos = ring_pos; + _fs_menu_is_channel = is_channel; + _showing_path_detail = false; bool has_loc = geo::parseLatLon(body, _nav_lat, _nav_lon, _nav_label, sizeof(_nav_label)); - int n = (reply_allowed ? 1 : 0) + (has_loc ? 2 : 0); + + uint8_t hop_count = 0; + bool path_is_relay = false; // outgoing channel: path[] holds echoing-repeater hashes, not a received route + if (ring_pos >= 0) { + uint8_t path_len_packed = is_channel ? _history.chAtPos(ring_pos).path_len : _history.dmAtPos(ring_pos).path_len; + hop_count = path_len_packed & 63; + if (is_channel) path_is_relay = (_history.chAtPos(ring_pos).relay_seq != 0); + } + bool has_path_item = hop_count > 0; + + int n = (reply_allowed ? 1 : 0) + (has_loc ? 2 : 0) + (has_path_item ? 1 : 0); if (n == 0) return; _fs_act_n = 0; _ctx_menu.begin("Options", n); if (reply_allowed) { _ctx_menu.addItem("Reply"); _fs_act[_fs_act_n++] = FS_REPLY; } if (has_loc) { _ctx_menu.addItem("Navigate"); _fs_act[_fs_act_n++] = FS_NAV; _ctx_menu.addItem("Save waypoint"); _fs_act[_fs_act_n++] = FS_SAVE; } + if (has_path_item) { + if (path_is_relay) snprintf(_fs_path_item, sizeof(_fs_path_item), "Relayed by (%u)", (unsigned)hop_count); + else snprintf(_fs_path_item, sizeof(_fs_path_item), "Path (%u hop%s)", (unsigned)hop_count, hop_count == 1 ? "" : "s"); + _ctx_menu.addItem(_fs_path_item); + _fs_act[_fs_act_n++] = FS_PATH; + } + } + + // 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 + // the send, order not meaningful (they're independent, not a chain). + void showPathDetail() { + if (_fs_menu_pos < 0) return; + uint8_t path_len_packed; + const uint8_t* path; + bool path_is_relay = false; + if (_fs_menu_is_channel) { + ChHistEntry& e = _history.chAtPos(_fs_menu_pos); + path_len_packed = e.path_len; path = e.path; path_is_relay = (e.relay_seq != 0); + } else { + DmHistEntry& e = _history.dmAtPos(_fs_menu_pos); + path_len_packed = e.path_len; path = e.path; + } + uint8_t hash_size = (path_len_packed >> 6) + 1; + uint8_t hop_count = path_len_packed & 63; + if (hop_count == 0) return; + if (hop_count > MAX_HIST_PATH_BYTES) hop_count = MAX_HIST_PATH_BYTES; // matches _path_detail_names capacity + 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])); + } + _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]); + _showing_path_detail = true; } // Dispatch the selected fullscreen-options row. `channel` picks which @@ -293,8 +377,10 @@ class MessagesScreen : public UIScreen { startReply(channel); } else if (a == FS_NAV) { _nav_active = true; // keep the message view active underneath - } else { + } else if (a == FS_SAVE) { saveSharedWaypoint(); + } else if (a == FS_PATH) { + showPathDetail(); } } @@ -648,9 +734,10 @@ public: // forwarders to the history store. addChannelMsg computes the "viewing" flag // (a phase-machine fact the store can't see) and returns the ring position so // the outgoing path can attach a relay seq to that exact entry. - int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0) { + int addChannelMsg(uint8_t ch_idx, const char* text, uint32_t timestamp = 0, + const uint8_t* path = nullptr, uint8_t path_len = 0) { bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); - int pos = _history.addChannelMsg(ch_idx, text, viewing, timestamp); + int pos = _history.addChannelMsg(ch_idx, text, viewing, timestamp, path, path_len); // Ring entries are numbered newest-first (0 == newest), so a new insert // shifts every older message's index up by one. If the user has scrolled // up to an older message (_hist_sel > 0), re-point the selection at that @@ -660,13 +747,16 @@ public: if (viewing && _hist_sel > 0) { _hist_sel++; _hist_scroll++; } return pos; } - void markChannelRelayed(uint32_t seq) { _history.markChannelRelayed(seq); } + void markChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) { + _history.markChannelRelayed(seq, repeater_hash, hash_size); + } void armChannelRelay(int pos, uint32_t seq) { _history.armChannelRelay(pos, seq); } void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0, uint32_t ack_tag = 0, - uint32_t ack_deadline_ms = 0, uint8_t resends = 0) { + uint32_t ack_deadline_ms = 0, uint8_t resends = 0, + const uint8_t* path = nullptr, uint8_t path_len = 0) { bool viewing = (_phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0); - _history.addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends); + _history.addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends, path, path_len); if (viewing && _dm_hist_sel > 0) { _dm_hist_sel++; _dm_hist_scroll++; } // see addChannelMsg } void markDmDelivered(uint32_t ack_crc) { _history.markDmDelivered(ack_crc); } @@ -1823,7 +1913,9 @@ public: if (_dm_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED) { + if (_showing_path_detail) { + if (res != PopupMenu::NONE) { _ctx_menu.active = false; _showing_path_detail = false; } + } else if (res == PopupMenu::SELECTED) { dispatchFsAction(false); } else if (res != PopupMenu::NONE) { _ctx_menu.active = false; @@ -1842,14 +1934,16 @@ public: if (ring_pos >= 0) { bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); - buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok); + buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok, ring_pos, false); } } return true; } if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED) { + if (_showing_path_detail) { + if (res != PopupMenu::NONE) { _ctx_menu.active = false; _showing_path_detail = false; } + } else if (res == PopupMenu::SELECTED) { dispatchFsAction(false); } else if (res != PopupMenu::NONE) { _ctx_menu.active = false; @@ -1903,7 +1997,7 @@ public: if (ring_pos >= 0) { bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); - buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok); + buildFsMenu(_history.dmAtPos(ring_pos).text, reply_ok, ring_pos, false); } return true; } @@ -1913,7 +2007,9 @@ public: if (_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED) { + if (_showing_path_detail) { + if (res != PopupMenu::NONE) { _ctx_menu.active = false; _showing_path_detail = false; } + } else if (res == PopupMenu::SELECTED) { dispatchFsAction(true); } else if (res != PopupMenu::NONE) { _ctx_menu.active = false; @@ -1930,13 +2026,15 @@ public: } else if (res == FullscreenMsgView::REPLY) { int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) - buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text)); + buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text), ring_pos, true); } return true; } if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED) { + if (_showing_path_detail) { + if (res != PopupMenu::NONE) { _ctx_menu.active = false; _showing_path_detail = false; } + } else if (res == PopupMenu::SELECTED) { dispatchFsAction(true); } else if (res != PopupMenu::NONE) { _ctx_menu.active = false; @@ -1976,7 +2074,7 @@ public: if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) { int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); if (ring_pos >= 0) - buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text)); + buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text), ring_pos, true); return true; } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 661e994d..fcba7f12 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1766,9 +1766,10 @@ int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], return ((MessagesScreen*)messages_screen)->getRecentDMContacts(out, max); } -int UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp) { +int UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp, + const uint8_t* path, uint8_t path_len) { _last_notif_ch_idx = (int)channel_idx; - return ((MessagesScreen*)messages_screen)->addChannelMsg(channel_idx, text, timestamp); + return ((MessagesScreen*)messages_screen)->addChannelMsg(channel_idx, text, timestamp, path, path_len); } void UITask::armChannelRelay(int pos, uint32_t seq) { @@ -1783,8 +1784,8 @@ void UITask::onMsgAck(uint32_t ack_crc) { ((MessagesScreen*)messages_screen)->markDmDelivered(ack_crc); } -void UITask::onChannelRelayed(uint32_t seq) { - ((MessagesScreen*)messages_screen)->markChannelRelayed(seq); +void UITask::onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash, uint8_t hash_size) { + ((MessagesScreen*)messages_screen)->markChannelRelayed(seq, repeater_hash, hash_size); } void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { @@ -1806,8 +1807,9 @@ void UITask::onAdminReply(const uint8_t* pub_key, const char* text) { } void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp, - uint32_t ack_tag, uint32_t ack_deadline_ms, uint8_t resends) { - ((MessagesScreen*)messages_screen)->addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends); + uint32_t ack_tag, uint32_t ack_deadline_ms, uint8_t resends, + const uint8_t* path, uint8_t path_len) { + ((MessagesScreen*)messages_screen)->addDMMsg(pub_key, outgoing, text, sender_timestamp, ack_tag, ack_deadline_ms, resends, path, path_len); } int UITask::getDMUnreadTotal() const { diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 0259a5ff..6f23a249 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -385,12 +385,14 @@ public: void stopMelody(); bool isMelodyPlaying(); void showAlert(const char* text, int duration_millis); - int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0) override; + int addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp = 0, + const uint8_t* path = nullptr, uint8_t path_len = 0) override; void armChannelRelay(int pos, uint32_t seq) override; void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp = 0, - uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0) override; + uint32_t ack_tag = 0, uint32_t ack_deadline_ms = 0, uint8_t resends = 0, + const uint8_t* path = nullptr, uint8_t path_len = 0) override; void onMsgAck(uint32_t ack_crc) override; - void onChannelRelayed(uint32_t seq) override; + void onChannelRelayed(uint32_t seq, const uint8_t* repeater_hash = nullptr, uint8_t hash_size = 0) override; void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override; void onAdminReply(const uint8_t* pub_key, const char* text) override; int getDMUnreadTotal() const; diff --git a/release-notes.md b/release-notes.md index 405df41e..3f541f70 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,3 +1,17 @@ +## MeshCore Solo Companion Firmware v1.27 + +### What's new + +- **Long names and labels scroll into view when selected instead of staying stuck behind an ellipsis.** Any selected row that would otherwise truncate — contacts, channels, messages, settings values, and more — now pauses, scrolls to reveal the full text, then swings back to the start and repeats, for as long as it stays selected; unselected rows keep the plain static ellipsis. E-ink gets its own slower cadence to suit the panel. +- **Messages sent from the phone app now show up in the device's own chat history**, not just on the phone — open a DM, room, or channel on the device and a message you just sent from the app is right there, with the same delivery-confirmation markers (pending/delivered for DMs, pending/relayed for channels) a message composed on the device itself gets. +- **"Path" / "Relayed by" — see which repeaters actually carried a message.** Hold Enter on a message (list or fullscreen) and, when the data is available, the Options menu gains a new row: **Path (N hops)** on a received message lists the repeaters it actually travelled through, in order; **Relayed by (N)** on your own channel post lists every distinct repeater heard echoing it back into the mesh. Each hop resolves to a contact's name where known, or a short hex tag otherwise. + +### Fixes + +- **Bluetooth connect/disconnect/reconnect cycles (e.g. a phone drifting in and out of range) could make the device nearly unresponsive for a few seconds at a time**, including briefly after turning BLE off. A failed BLE notify was retried on a fixed short interval, repeatedly hitting a blocking wait deep in the BLE stack while the link stayed marginal. Failed sends now back off exponentially (capped at 2s) instead of hammering the same blocking call. + +--- + ## MeshCore Solo Companion Firmware v1.26 ### What's new