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:
Jakub
2026-09-24 23:00:46 +02:00
co-authored by Claude Opus 5.5
parent 2a8d4598d4
commit 3b7aa28ff9
7 changed files with 181 additions and 102 deletions
@@ -1,448 +0,0 @@
#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.
//
// Single-TU fragment: included by UITask.cpp 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;
};
@@ -136,12 +136,13 @@ class MessagesScreen : public UIScreen {
bool _pick_bot_room = false;
// The message-history rings (channel + DM), their per-entry delivery state and
// per-channel unread counters live in this store (see MessageHistory.h). The
// per-channel unread counters live in the UI Core's store (ui-core/
// MessageHistory.h); this screen binds to it by reference. The
// phase machine below keeps only the view state — selection, scroll, the
// fullscreen readers — and reaches entries through _history's accessors. The
// shared types (AckState, ChHistEntry, DmHistEntry, MSG_TEXT_BUF) are file-
// scope, so they're still referred to unqualified throughout this screen.
MessageHistory _history;
MessageHistory& _history;
// DM_HIST view state (the ring itself is in _history).
int _dm_hist_sel, _dm_hist_scroll;
@@ -942,10 +943,11 @@ public:
_msg_sel(0), _msg_scroll(0), _active_msg_count(0),
_hist_sel(0), _hist_scroll(0),
_unread_at_entry(0), _viewing_max_seen(0),
_history(task->core().history),
_dm_hist_sel(-1), _dm_hist_scroll(0),
_ctx_dirty(false), _pin_picker_active(false), _direct_entry(false), _reply_mode(false),
_ch_view(task) {
// The history rings + per-channel unread counters init in MessageHistory.
// The history rings + per-channel unread counters live in UiCore.
}
// Forwarded to UITask's GPS duty-cycle hold — true while showing the
@@ -1111,7 +1113,7 @@ public:
bool anyChannelUnreadOverflow() const { return _history.anyChannelUnreadOverflow(); }
// How many DM ring entries this contact/room currently holds -- lets UITask
// clamp its separate _dm_unread_table counters to what the shared 32-slot DM
// clamp the Core's separate DM unread counters to what the shared 32-slot DM
// ring actually still has, the same self-healing shape as the channel fix.
int dmHistCountForContact(const uint8_t* pub_key) const { return _history.dmHistCountForContact(pub_key); }
+13 -63
View File
@@ -160,7 +160,7 @@ static const int QUICK_MSGS_MAX = 10;
#include "FullscreenMsgView.h"
#include "SensorPlaceholders.h"
#include "SettingsScreen.h"
#include "MessageHistory.h" // RAM history rings (DM + channel) used by MessagesScreen
#include "../ui-core/UiCore.h" // shared UI Core: history rings + unread models (MessagesScreen views them)
#include "MessagesScreen.h"
// ── Custom screens (separate files to ease upstream merges) ───────────────────
@@ -1654,6 +1654,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
_ping_snr_back_x4 = 0;
_ping_rtt_ms = 0;
_core = new UiCore(); // before any screen -- MessagesScreen binds to its history
splash = new SplashScreen(this);
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
syncLockToHome(); // booted locked (e.g. cover closed) → home starts on the LOCK page
@@ -2010,53 +2011,15 @@ void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, u
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);
// 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 newMsg() (called
// before it), or the new message itself reads as evicted.
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count == 0) continue;
int held = ((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix);
if (_dm_unread_table[i].count > held) {
_dm_unread_table[i].count = (uint8_t)held;
_dm_unread_table[i].overflow = held > 0; // held == 0 frees the slot -- nothing left to flag
}
}
_core->afterDMInsert();
}
int UITask::getDMUnreadTotal() const {
int total = 0;
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count == 0) continue;
int held = ((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix);
total += (_dm_unread_table[i].count < held) ? _dm_unread_table[i].count : held;
}
return total;
}
uint8_t UITask::getDMUnread(const uint8_t* pub_key) const {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) {
int held = ((MessagesScreen*)messages_screen)->dmHistCountForContact(pub_key);
return _dm_unread_table[i].count < held ? _dm_unread_table[i].count : (uint8_t)held;
}
}
return 0;
}
void UITask::reconcileDMUnread() {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count == 0) continue;
if (((MessagesScreen*)messages_screen)->dmHistCountForContact(_dm_unread_table[i].prefix) == 0) {
_dm_unread_table[i].count = 0; // ring no longer holds anything for this sender -- free the slot
_dm_unread_table[i].overflow = false;
}
}
}
int UITask::getDMUnreadTotal() const { return _core->dmUnreadTotal(); }
uint8_t UITask::getDMUnread(const uint8_t* pub_key) const { return _core->dmUnread(pub_key); }
bool UITask::getDMUnreadOverflow(const uint8_t* pub_key) const { return _core->dmUnreadOverflow(pub_key); }
bool UITask::getAnyDMUnreadOverflow() const { return _core->anyDMUnreadOverflow(); }
void UITask::clearDMUnread(const uint8_t* pub_key) { _core->clearDMUnread(pub_key); }
void UITask::clearAllDMUnread() { _core->clearAllDMUnread(); }
void UITask::showAlert(const char* text, int duration_millis) {
snprintf(_alert, sizeof(_alert), "%s", text);
@@ -2108,8 +2071,7 @@ void UITask::msgRead(int msgcount) {
_msgcount = msgcount;
if (msgcount == 0) {
_room_unread = 0;
memset(_dm_unread_table, 0, sizeof(_dm_unread_table));
((MessagesScreen*)messages_screen)->clearAllChannelUnread();
_core->clearAllUnread();
}
}
@@ -2119,19 +2081,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
if (contact_type == ADV_TYPE_CHAT && pub_key != nullptr) {
memcpy(_last_notif_dm_prefix, pub_key, 4);
_last_notif_dm_valid = true;
int slot = -1, empty_slot = -1;
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++) {
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0) { slot = i; break; }
if (empty_slot < 0 && _dm_unread_table[i].count == 0) empty_slot = i;
}
if (slot >= 0) {
if (_dm_unread_table[slot].count < 99) _dm_unread_table[slot].count++;
} else if (empty_slot >= 0) {
memcpy(_dm_unread_table[empty_slot].prefix, pub_key, 4);
_dm_unread_table[empty_slot].count = 1;
_dm_unread_table[empty_slot].overflow = false; // fresh contact -- don't inherit a stale flag from whoever held this slot before
}
// Eviction/overflow is checked in addDMMsg(), after the ring insert.
_core->noteIncomingDM(pub_key); // eviction/overflow is checked in addDMMsg(), after the ring insert
}
char alert_buf[80];
@@ -2646,7 +2596,7 @@ void UITask::loop() {
// Background delivery: resend pending on-device DMs whose ACK timed out, and
// finalise the ✗ marker — runs regardless of which screen is active.
((MessagesScreen*)messages_screen)->tickDmResends();
reconcileDMUnread();
_core->reconcileDMUnread();
#if UI_HAS_JOYSTICK
uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION;
int ev = user_btn.check();
@@ -3273,7 +3223,7 @@ void UITask::clearTargetIfWaypoint(int32_t lat_1e6, int32_t lon_1e6) {
// cleared here, so a removed contact can't leave a dangling reference. If you
// add such a field, add its cleanup below (and mark the field in NodePrefs.h).
// Currently covered: favourite_contacts, locator_key, loc_share_dm_prefix,
// dm_notif[], dm_melody[]. Also clears _dm_unread_table (RAM-only, not a
// dm_notif[], dm_melody[]. Also clears the DM unread table (RAM-only, not a
// NodePrefs field, so no savePrefs() needed for it) -- same 4-byte-prefix
// shape and same 16-slot starvation risk as dm_notif/dm_melody above. Called
// for both explicit removal and silent auto-eviction (see MyMesh
+11 -31
View File
@@ -42,6 +42,8 @@
#include "../LiveTrack.h"
#include "KeyboardWidget.h"
class UiCore;
class UITask : public AbstractUITask {
DisplayDriver* _display;
SensorManager* _sensors;
@@ -71,12 +73,9 @@ class UITask : public AbstractUITask {
int _last_notif_ch_idx;
uint8_t _last_notif_dm_prefix[4];
bool _last_notif_dm_valid;
// overflow: an unread entry for this contact was evicted off the DM ring
// before ever being seen (see newMsg()) -- count is honest but understates
// the real total. Cleared with the slot (memset) or on a read back to 0.
struct DMUnreadEntry { uint8_t prefix[4]; uint8_t count; bool overflow; };
static const int DM_UNREAD_TABLE_SIZE = 16;
DMUnreadEntry _dm_unread_table[DM_UNREAD_TABLE_SIZE];
// Shared UI Core (../ui-core): message history + unread models. Heap-allocated
// in begin(), before any screen, like the screens themselves.
UiCore* _core = nullptr;
unsigned long ui_started_at, next_batt_chck;
uint16_t _batt_mv; // EMA-filtered battery voltage
unsigned long next_backlight_btn_check = 0;
@@ -307,7 +306,6 @@ public:
_last_notif_ch_idx = -1;
_last_notif_dm_valid = false;
memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix));
memset(_dm_unread_table, 0, sizeof(_dm_unread_table));
curr = NULL;
}
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
@@ -457,35 +455,17 @@ public:
bool getAnyChannelUnreadOverflow() const;
int getRoomUnreadCount() const { return _room_unread; }
void clearRoomUnread() { _room_unread = 0; }
// Clamped to the DM ring's actual occupancy for this contact -- defined in
// UITask.cpp (needs MessagesScreen to be a complete type). Same self-healing
// shape as MessageHistory::chUnread() for channels.
// Clamped to the DM ring's actual occupancy for this contact (UiCore).
uint8_t getDMUnread(const uint8_t* pub_key) const;
bool getDMUnreadOverflow(const uint8_t* pub_key) const {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0)
return _dm_unread_table[i].overflow;
return false;
}
bool getAnyDMUnreadOverflow() const {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
if (_dm_unread_table[i].count > 0 && _dm_unread_table[i].overflow) return true;
return false;
}
bool getDMUnreadOverflow(const uint8_t* pub_key) const;
bool getAnyDMUnreadOverflow() const;
// Aggregate for the clock/lock dashboard's single "Msgs" field -- true if
// ANY channel or DM contact has permanently lost an unread entry to its
// ring cap (rooms have no local ring to overflow the same way).
bool getAnyUnreadOverflow() const;
void clearDMUnread(const uint8_t* pub_key) {
for (int i = 0; i < DM_UNREAD_TABLE_SIZE; i++)
if (_dm_unread_table[i].count > 0 && memcmp(_dm_unread_table[i].prefix, pub_key, 4) == 0)
{ _dm_unread_table[i].count = 0; _dm_unread_table[i].overflow = false; return; }
}
void clearAllDMUnread() { memset(_dm_unread_table, 0, sizeof(_dm_unread_table)); }
// Frees any table slot whose ring occupancy has dropped to zero (evicted or
// deduped-away messages) so a genuinely new sender isn't starved once the
// fixed 16-slot table fills with stale entries. Called once per loop().
void reconcileDMUnread();
void clearDMUnread(const uint8_t* pub_key);
void clearAllDMUnread();
UiCore& core() { return *_core; }
bool hasDisplay() const { return _display != NULL; }
DisplayDriver* getDisplay() const { return _display; }