mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-16 18:42:07 +00:00
refactor(ui-core): add UI Core skeleton; move MessageHistory + DM unread into it
New examples/companion_radio/ui-core/ (header-only for now, reached from ui-new/UITask.cpp by relative include, so no platformio.ini changes): UiCore facade, MessageHistory (moved verbatim), DmUnreadTable (extracted from UITask). UITask heap-allocates the Core in begin(); MessagesScreen binds to core().history. Behaviour unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
// Per-contact DM unread counters. Kept apart from the DM ring in MessageHistory
|
||||
// (which has no per-contact index), keyed by the same 4-byte pub_key prefix,
|
||||
// and always read back clamped to what the ring still holds for that contact --
|
||||
// the same self-healing shape as MessageHistory::chUnread() for channels.
|
||||
//
|
||||
// Header-only UI Core model (see docs/development/ui-core.md); no drawing, no
|
||||
// screen state.
|
||||
|
||||
class DmUnreadTable {
|
||||
public:
|
||||
static const int SIZE = 16;
|
||||
|
||||
DmUnreadTable() { clearAll(); }
|
||||
|
||||
// An incoming DM from pub_key is about to be stored (called before the ring
|
||||
// insert). A sender that doesn't fit in the fixed table is simply not counted.
|
||||
void onIncoming(const uint8_t* pub_key) {
|
||||
int slot = -1, empty_slot = -1;
|
||||
for (int i = 0; i < SIZE; i++) {
|
||||
if (_t[i].count > 0 && memcmp(_t[i].prefix, pub_key, 4) == 0) { slot = i; break; }
|
||||
if (empty_slot < 0 && _t[i].count == 0) empty_slot = i;
|
||||
}
|
||||
if (slot >= 0) {
|
||||
if (_t[slot].count < 99) _t[slot].count++;
|
||||
} else if (empty_slot >= 0) {
|
||||
memcpy(_t[empty_slot].prefix, pub_key, 4);
|
||||
_t[empty_slot].count = 1;
|
||||
_t[empty_slot].overflow = false; // fresh contact -- don't inherit a stale flag from whoever held this slot before
|
||||
}
|
||||
// Eviction/overflow is checked in afterInsert(), after the ring insert.
|
||||
}
|
||||
|
||||
// The DM ring (unlike the channel ring) doesn't proactively decrement the
|
||||
// unread counters as it evicts old entries, so catch it here, right after
|
||||
// the insert: a raw count claiming more unread than the ring still holds
|
||||
// for that contact means one of their unread entries was just evicted. Any
|
||||
// contact can lose one -- not just this sender -- so check every slot.
|
||||
// Clamp back to the honest value and flag it (mirrors MessageHistory's
|
||||
// channel-side fix). Must run after the insert, not in onIncoming() (called
|
||||
// before it), or the new message itself reads as evicted.
|
||||
void afterInsert(const MessageHistory& h) {
|
||||
for (int i = 0; i < SIZE; i++) {
|
||||
if (_t[i].count == 0) continue;
|
||||
int held = h.dmHistCountForContact(_t[i].prefix);
|
||||
if (_t[i].count > held) {
|
||||
_t[i].count = (uint8_t)held;
|
||||
_t[i].overflow = held > 0; // held == 0 frees the slot -- nothing left to flag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int total(const MessageHistory& h) const {
|
||||
int total = 0;
|
||||
for (int i = 0; i < SIZE; i++) {
|
||||
if (_t[i].count == 0) continue;
|
||||
int held = h.dmHistCountForContact(_t[i].prefix);
|
||||
total += (_t[i].count < held) ? _t[i].count : held;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
uint8_t get(const MessageHistory& h, const uint8_t* pub_key) const {
|
||||
for (int i = 0; i < SIZE; i++) {
|
||||
if (_t[i].count > 0 && memcmp(_t[i].prefix, pub_key, 4) == 0) {
|
||||
int held = h.dmHistCountForContact(pub_key);
|
||||
return _t[i].count < held ? _t[i].count : (uint8_t)held;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// True once an unread entry for this contact was evicted off the DM ring
|
||||
// before ever being seen -- count is honest but understates the real total.
|
||||
bool overflow(const uint8_t* pub_key) const {
|
||||
for (int i = 0; i < SIZE; i++)
|
||||
if (_t[i].count > 0 && memcmp(_t[i].prefix, pub_key, 4) == 0)
|
||||
return _t[i].overflow;
|
||||
return false;
|
||||
}
|
||||
bool anyOverflow() const {
|
||||
for (int i = 0; i < SIZE; i++)
|
||||
if (_t[i].count > 0 && _t[i].overflow) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void clear(const uint8_t* pub_key) {
|
||||
for (int i = 0; i < SIZE; i++)
|
||||
if (_t[i].count > 0 && memcmp(_t[i].prefix, pub_key, 4) == 0)
|
||||
{ _t[i].count = 0; _t[i].overflow = false; return; }
|
||||
}
|
||||
void clearAll() { memset(_t, 0, sizeof(_t)); }
|
||||
|
||||
// Frees any slot whose ring occupancy has dropped to zero (evicted or
|
||||
// deduped-away messages) so a genuinely new sender isn't starved once the
|
||||
// fixed table fills with stale entries.
|
||||
void reconcile(const MessageHistory& h) {
|
||||
for (int i = 0; i < SIZE; i++) {
|
||||
if (_t[i].count == 0) continue;
|
||||
if (h.dmHistCountForContact(_t[i].prefix) == 0) {
|
||||
_t[i].count = 0; // ring no longer holds anything for this sender -- free the slot
|
||||
_t[i].overflow = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
struct Entry { uint8_t prefix[4]; uint8_t count; bool overflow; };
|
||||
Entry _t[SIZE];
|
||||
};
|
||||
@@ -0,0 +1,449 @@
|
||||
#pragma once
|
||||
// Message history store for MessagesScreen: two RAM ring buffers (channel + DM)
|
||||
// with their per-entry delivery state (channel "relayed into mesh" echo, DM
|
||||
// end-to-end ACK + auto-resend) and the per-channel unread counters. Pure
|
||||
// storage + queries — no UI/phase state lives here. The screen keeps selection,
|
||||
// scroll, the unread "viewing session" bookkeeping, the room-login table, and
|
||||
// all rendering, and reaches entries through the accessors below.
|
||||
//
|
||||
// UI Core model, owned by UiCore (UiCore.h). Single-TU fragment: reached from
|
||||
// the frontend's UITask.cpp via UiCore.h, before MessagesScreen.h. AckState,
|
||||
// MSG_TEXT_BUF and the two entry structs are file-scope (not nested) so the
|
||||
// phase machine in MessagesScreen keeps referring to them unqualified.
|
||||
|
||||
// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to
|
||||
// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no
|
||||
// recipient ACK exists for floods), so a missing echo is NOT shown as failure.
|
||||
enum AckState : uint8_t { ACK_NONE = 0, ACK_PENDING, ACK_OK, ACK_FAIL };
|
||||
|
||||
// History text holds a full received message. Channel messages carry the sender
|
||||
// embedded as "Name: body" in the payload, so a message can be up to the
|
||||
// over-the-air maximum (MAX_TEXT_LEN). Size the buffers to that + NUL, otherwise
|
||||
// long messages (and Polish text, where each accented char is two UTF-8 bytes)
|
||||
// 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 {
|
||||
uint8_t prefix[4];
|
||||
uint8_t outgoing;
|
||||
char text[MSG_TEXT_BUF];
|
||||
uint32_t timestamp;
|
||||
uint8_t ack_status; // AckState; meaningful only when outgoing
|
||||
uint32_t ack_tag; // expected_ack CRC to match against onMsgAck()
|
||||
uint32_t ack_deadline_ms; // millis() by which a pending ACK must arrive
|
||||
// Sender-perspective message timestamp: the send timestamp for outgoing
|
||||
// (reused verbatim on resend so the recipient treats it as a retry), or the
|
||||
// sender_timestamp for incoming (used to dedup retried copies). 0 = unknown.
|
||||
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 {
|
||||
public:
|
||||
// Shared ring for all channels combined; each slot carries a full MSG_TEXT_BUF,
|
||||
// so this ring dominates RAM — kept modest to leave heap headroom (see
|
||||
// DM_HIST_MAX). The DM ring is likewise bounded.
|
||||
static const int CH_HIST_MAX = 48;
|
||||
static const int DM_HIST_MAX = 32;
|
||||
|
||||
MessageHistory()
|
||||
: _hist_head(0), _hist_count(0), _dm_hist_head(0), _dm_hist_count(0) {
|
||||
memset(_ch_unread, 0, sizeof(_ch_unread));
|
||||
memset(_ch_unread_overflow, 0, sizeof(_ch_unread_overflow));
|
||||
}
|
||||
|
||||
// ── Channel ring ──────────────────────────────────────────────────────────
|
||||
|
||||
// Append a channel message. `viewing` = the user is currently in this
|
||||
// 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.
|
||||
// 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).
|
||||
// own_message: this is our own post (e.g. mirrored from an app-originated
|
||||
// send) -- never counted unread, regardless of `viewing`. An on-device
|
||||
// compose already forces `viewing` true itself (it's necessarily looking at
|
||||
// the channel it just sent to), so this only matters for a send the device's
|
||||
// own UI wasn't necessarily showing at the time -- otherwise our own sent
|
||||
// message could bump the very badge it's supposed to leave alone.
|
||||
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,
|
||||
bool own_message = false) {
|
||||
// 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.
|
||||
if (ch_idx >= MAX_GROUP_CHANNELS) return -1;
|
||||
int pos;
|
||||
if (_hist_count < CH_HIST_MAX) {
|
||||
pos = (_hist_head + _hist_count) % CH_HIST_MAX;
|
||||
_hist_count++;
|
||||
} else {
|
||||
pos = _hist_head;
|
||||
// Evicting the oldest entry — drop its share of the unread counter so
|
||||
// the badge can't claim a message the ring no longer holds. The counter
|
||||
// refers to the channel's NEWEST unread messages, so losing the oldest
|
||||
// entry only costs an unread one when everything the ring still holds
|
||||
// for that channel is unread; otherwise the evicted entry was already
|
||||
// read and decrementing would undercount the newer unread ones.
|
||||
uint8_t evicted = _hist[pos].ch_idx;
|
||||
if (evicted < MAX_GROUP_CHANNELS && _ch_unread[evicted] > 0 &&
|
||||
_ch_unread[evicted] >= histCountForChannel(evicted)) {
|
||||
_ch_unread[evicted]--;
|
||||
// The entry just evicted was itself still unread (every held entry for
|
||||
// this channel was) -- it's gone for good, so the badge can never again
|
||||
// show the true total. Sticky until chUnread() reads back down to 0.
|
||||
_ch_unread_overflow[evicted] = true;
|
||||
}
|
||||
_hist_head = (_hist_head + 1) % CH_HIST_MAX;
|
||||
}
|
||||
_hist[pos].ch_idx = ch_idx;
|
||||
_hist[pos].timestamp = displayTimestamp(timestamp, own_message);
|
||||
strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1);
|
||||
_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 && !own_message && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++;
|
||||
return pos;
|
||||
}
|
||||
|
||||
// count history entries for a specific channel
|
||||
int histCountForChannel(int ch_idx) const {
|
||||
int n = 0;
|
||||
for (int i = 0; i < _hist_count; i++) {
|
||||
if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// get ring-buffer position of j-th history entry for channel (newest first)
|
||||
int histEntryForChannel(int ch_idx, int j) const {
|
||||
int n = 0;
|
||||
for (int i = _hist_count - 1; i >= 0; i--) {
|
||||
int pos = (_hist_head + i) % CH_HIST_MAX;
|
||||
if (_hist[pos].ch_idx == (uint8_t)ch_idx) {
|
||||
if (n == j) return pos;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 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_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. 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]; }
|
||||
const ChHistEntry& chAtPos(int pos) const { return _hist[pos]; }
|
||||
|
||||
// ── Per-channel unread counters ─────────────────────────────────────────────
|
||||
// Always clamped to what the ring still holds for that channel. The raw
|
||||
// counter and the ring can drift apart — MessagesScreen's viewing-session
|
||||
// bookkeeping re-applies the unread snapshot taken when the channel was
|
||||
// opened, so entries evicted mid-session would otherwise leave the badge
|
||||
// promising messages the history list can no longer show (badge says 7,
|
||||
// opening the channel shows an empty list). Capping on read keeps the badge
|
||||
// honest whatever the raw counter says.
|
||||
uint8_t chUnread(int ch) const {
|
||||
if (ch < 0 || ch >= MAX_GROUP_CHANNELS) return 0;
|
||||
int held = histCountForChannel(ch);
|
||||
return _ch_unread[ch] < held ? _ch_unread[ch] : (uint8_t)held;
|
||||
}
|
||||
// True once an unread entry for this channel has been evicted off the ring
|
||||
// (see addChannelMsg) -- the count above is honest but understates the real
|
||||
// total. Clears itself once the user reads back down to 0 (setChUnread(ch,0)
|
||||
// below): at that point there's no backlog left to warn about.
|
||||
bool chUnreadOverflow(int ch) const {
|
||||
return ch >= 0 && ch < MAX_GROUP_CHANNELS && _ch_unread_overflow[ch];
|
||||
}
|
||||
// Only channels that still show unread count: eviction alone can take a
|
||||
// channel's counter down to 0 with the flag left set (nothing then calls
|
||||
// setChUnread(ch, 0) to clear it), and a "+" on the aggregate must not come
|
||||
// from a channel contributing nothing to it (e.g. a dashboard "0+ msgs").
|
||||
bool anyChannelUnreadOverflow() const {
|
||||
for (int i = 0; i < MAX_GROUP_CHANNELS; i++)
|
||||
if (_ch_unread_overflow[i] && chUnread(i) > 0) return true;
|
||||
return false;
|
||||
}
|
||||
void setChUnread(int ch, uint8_t v) {
|
||||
if (ch < 0 || ch >= MAX_GROUP_CHANNELS) return;
|
||||
_ch_unread[ch] = v;
|
||||
if (v == 0) _ch_unread_overflow[ch] = false;
|
||||
}
|
||||
void clearAllChannelUnread() {
|
||||
memset(_ch_unread, 0, sizeof(_ch_unread));
|
||||
memset(_ch_unread_overflow, 0, sizeof(_ch_unread_overflow));
|
||||
}
|
||||
int getTotalChannelUnread() const {
|
||||
// Same clamp as chUnread(), but counting ring occupancy for every channel
|
||||
// in one pass instead of re-walking the ring once per channel.
|
||||
uint8_t held[MAX_GROUP_CHANNELS];
|
||||
memset(held, 0, sizeof(held));
|
||||
for (int i = 0; i < _hist_count; i++) {
|
||||
uint8_t ch = _hist[(_hist_head + i) % CH_HIST_MAX].ch_idx;
|
||||
if (ch < MAX_GROUP_CHANNELS && held[ch] < 255) held[ch]++;
|
||||
}
|
||||
int total = 0;
|
||||
for (int i = 0; i < MAX_GROUP_CHANNELS; i++)
|
||||
total += (_ch_unread[i] < held[i]) ? _ch_unread[i] : held[i];
|
||||
return total;
|
||||
}
|
||||
|
||||
// ── DM ring ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ack_tag != 0 marks an outgoing DM as awaiting an end-to-end ACK by
|
||||
// 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).
|
||||
// The timestamp an entry's age is measured from, on THIS device's clock.
|
||||
// An outgoing message was sent just now, so its own send time is simply
|
||||
// "now" -- taking the phone app's timestamp instead (CMD_SEND_*) put it on
|
||||
// the phone's clock, and a phone running ahead of the device (or on local
|
||||
// time) left the age reading "0s" until the device caught up. A sender
|
||||
// timestamp that is unknown or still in the future is likewise clamped to
|
||||
// receipt time, so it ages normally from here on instead of sticking at 0s.
|
||||
static uint32_t displayTimestamp(uint32_t ts, bool outgoing) {
|
||||
uint32_t now = rtc_clock.getCurrentTime();
|
||||
return (outgoing || ts == 0 || ts > now) ? now : ts;
|
||||
}
|
||||
|
||||
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,
|
||||
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;
|
||||
_dm_hist_count++;
|
||||
} else {
|
||||
pos = _dm_hist_head;
|
||||
_dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX;
|
||||
}
|
||||
memcpy(_dm_hist[pos].prefix, pub_key, 4);
|
||||
_dm_hist[pos].outgoing = outgoing ? 1 : 0;
|
||||
// Prefer the sender's own timestamp — a room-sync replay or an
|
||||
// offline-queued message held by a repeater can arrive long after it was
|
||||
// actually sent, so "now" would mislabel every backlog message as fresh.
|
||||
// Fall back to receipt time when the sender's timestamp is unknown or
|
||||
// ahead of our clock, and always use our own clock for outgoing.
|
||||
_dm_hist[pos].timestamp = displayTimestamp(msg_ts, outgoing);
|
||||
strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1);
|
||||
_dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0';
|
||||
_dm_hist[pos].ack_status = (outgoing && ack_tag) ? ACK_PENDING : ACK_NONE;
|
||||
_dm_hist[pos].ack_tag = ack_tag;
|
||||
_dm_hist[pos].ack_deadline_ms = ack_deadline_ms;
|
||||
_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
|
||||
// just sent via CMD_SEND_TXT_MSG) carry the same pending-ACK tracking a
|
||||
// message composed on-device gets from storeDMMsg() directly — otherwise it
|
||||
// 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,
|
||||
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.
|
||||
if (!outgoing && sender_timestamp != 0) {
|
||||
for (int i = 0; i < _dm_hist_count; i++) {
|
||||
const DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
||||
if (!e.outgoing && e.msg_ts == sender_timestamp &&
|
||||
memcmp(e.prefix, pub_key, 4) == 0 && strcmp(e.text, text) == 0)
|
||||
return; // duplicate retry — already in history
|
||||
}
|
||||
}
|
||||
storeDMMsg(pub_key, outgoing, text, ack_tag, ack_deadline_ms, sender_timestamp, resends, path, path_len_packed);
|
||||
}
|
||||
|
||||
int dmHistCountForContact(const uint8_t* prefix) const {
|
||||
int n = 0;
|
||||
for (int i = 0; i < _dm_hist_count; i++)
|
||||
if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest
|
||||
int n = 0;
|
||||
for (int i = _dm_hist_count - 1; i >= 0; i--) {
|
||||
int pos = (_dm_hist_head + i) % DM_HIST_MAX;
|
||||
if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) {
|
||||
if (n == j) return pos;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Effective status for display. A pending ACK only reads as failed once its
|
||||
// deadline has passed AND no auto-resends remain — while resends_left > 0 the
|
||||
// entry stays pending (tickDmResends() retries / finalises it). Safety net for
|
||||
// when the tick hasn't run yet; the tick is the authority that writes ACK_FAIL.
|
||||
AckState dmEffectiveStatus(const DmHistEntry& e) const {
|
||||
if (e.ack_status == ACK_PENDING && e.resends_left == 0 &&
|
||||
(int32_t)(millis() - e.ack_deadline_ms) >= 0)
|
||||
return ACK_FAIL;
|
||||
return (AckState)e.ack_status;
|
||||
}
|
||||
|
||||
// Called when an end-to-end ACK arrives (routed from MyMesh::onAckRecv).
|
||||
// Marks the matching pending outgoing DM as delivered.
|
||||
void markDmDelivered(uint32_t ack_crc) {
|
||||
if (ack_crc == 0) return;
|
||||
for (int i = 0; i < _dm_hist_count; i++) {
|
||||
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
||||
if (e.outgoing && e.ack_status == ACK_PENDING && e.ack_tag == ack_crc) {
|
||||
e.ack_status = ACK_OK;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic resend driver for outgoing DMs whose ACK deadline lapsed with no
|
||||
// ACK: resend with the next attempt# (reusing the original timestamp so the
|
||||
// recipient dedups) while resends remain, else mark it failed (✗).
|
||||
void tickDmResends() {
|
||||
uint32_t now = millis();
|
||||
for (int i = 0; i < _dm_hist_count; i++) {
|
||||
DmHistEntry& e = _dm_hist[(_dm_hist_head + i) % DM_HIST_MAX];
|
||||
if (!e.outgoing || e.ack_status != ACK_PENDING) continue;
|
||||
if ((int32_t)(now - e.ack_deadline_ms) < 0) continue; // still waiting
|
||||
if (e.resends_left == 0) { e.ack_status = ACK_FAIL; continue; }
|
||||
ContactInfo c;
|
||||
if (!contactByPrefix(e.prefix, c)) { e.ack_status = ACK_FAIL; continue; }
|
||||
uint32_t expected_ack = 0, est_timeout = 0;
|
||||
uint8_t next_attempt = e.attempt + 1;
|
||||
if (the_mesh.sendMessage(c, e.msg_ts, next_attempt, e.text,
|
||||
expected_ack, est_timeout) > 0 && expected_ack) {
|
||||
e.attempt = next_attempt;
|
||||
e.ack_tag = expected_ack; // each attempt has a distinct ACK CRC
|
||||
e.ack_deadline_ms = now + est_timeout + 4000;
|
||||
e.resends_left--;
|
||||
} else {
|
||||
e.ack_status = ACK_FAIL; // couldn't compose/send — give up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recent DM contacts, newest first, deduped. Resolves the 4-byte _dm_hist
|
||||
// prefix to a 6-byte pub_key prefix by walking the contact list once per
|
||||
// unique sender. Returns the number filled.
|
||||
DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; }
|
||||
const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; }
|
||||
|
||||
private:
|
||||
// Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry).
|
||||
bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const {
|
||||
int total = the_mesh.getNumContacts();
|
||||
// +MAX_ANON_CONTACTS: see MessagesScreen.h's buildContactList() for why
|
||||
// getContactByIdx() needs this offset (raw table index, not the
|
||||
// anon-excluded logical count getNumContacts() returns).
|
||||
for (int i = 0; i < total; i++) {
|
||||
ContactInfo c;
|
||||
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue;
|
||||
if (memcmp(c.id.pub_key, prefix, 4) == 0) { out = c; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ChHistEntry _hist[CH_HIST_MAX];
|
||||
int _hist_head, _hist_count;
|
||||
uint8_t _ch_unread[MAX_GROUP_CHANNELS];
|
||||
bool _ch_unread_overflow[MAX_GROUP_CHANNELS];
|
||||
|
||||
DmHistEntry _dm_hist[DM_HIST_MAX];
|
||||
int _dm_hist_head, _dm_hist_count;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
// UI Core: hardware-independent UI state and logic shared by every frontend
|
||||
// (ui-new today, ui-lvgl later). See docs/development/ui-core.md.
|
||||
//
|
||||
// For now the Core is header-only and compiled as part of the frontend's single
|
||||
// translation unit (ui-new/UITask.cpp includes this file), so no platformio.ini
|
||||
// needs a new source filter or include path. It relies on the same globals the
|
||||
// frontend does (the_mesh, rtc_clock).
|
||||
|
||||
#include "MessageHistory.h"
|
||||
#include "DmUnreadTable.h"
|
||||
|
||||
class UiCore {
|
||||
public:
|
||||
// ── Models ────────────────────────────────────────────────────────────────
|
||||
MessageHistory history; // channel + DM rings, delivery state, channel unread
|
||||
DmUnreadTable dm_unread; // per-contact DM unread counters
|
||||
|
||||
// ── DM unread (clamped to what the DM ring still holds) ──────────────────
|
||||
void noteIncomingDM(const uint8_t* pub_key) { dm_unread.onIncoming(pub_key); }
|
||||
void afterDMInsert() { dm_unread.afterInsert(history); }
|
||||
int dmUnreadTotal() const { return dm_unread.total(history); }
|
||||
uint8_t dmUnread(const uint8_t* pub_key) const { return dm_unread.get(history, pub_key); }
|
||||
bool dmUnreadOverflow(const uint8_t* pub_key) const { return dm_unread.overflow(pub_key); }
|
||||
bool anyDMUnreadOverflow() const { return dm_unread.anyOverflow(); }
|
||||
void clearDMUnread(const uint8_t* pub_key) { dm_unread.clear(pub_key); }
|
||||
void clearAllDMUnread() { dm_unread.clearAll(); }
|
||||
void reconcileDMUnread() { dm_unread.reconcile(history); }
|
||||
|
||||
// All messages synced to the app: nothing is unread any more (room unread
|
||||
// still lives in the frontend).
|
||||
void clearAllUnread() {
|
||||
dm_unread.clearAll();
|
||||
history.clearAllChannelUnread();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user