diff --git a/docs/solo/developer/build-flags.md b/docs/solo/developer/build-flags.md index 0bc306cb..c899169c 100644 --- a/docs/solo/developer/build-flags.md +++ b/docs/solo/developer/build-flags.md @@ -117,5 +117,5 @@ values above that are set from the phone app or the CLI. | `ADVERT_NAME='"name"'` | Sets the default node name baked into a fresh device, instead of the hex of the first 4 bytes of its public key. Note the doubled quoting — it's a C string literal passed through a build flag. | | `MAP_VECTOR` | Wio Tracker L2: turns on the unfinished vector map (regions from `tools/maps/osm_vector.py`). Without it the vector map stays off whatever was saved. | | `HIST_ARCHIVE` | Wio Tracker L2: message history also kept on the SD card (`ui-lvgl/HistoryStore.h`). Set in its solo build. | -| `HIST_FLASH` | Wio Tracker L1: message history also kept on the QSPI flash, across a power-off (`ui-core/HistoryLog.h`, Settings > Messages > Keep messages, off by default). Implies `HIST_ARCHIVE`'s hooks. Set in its solo builds. | +| `HIST_FLASH` | Wio Tracker L1: message history also kept on the QSPI flash, across a power-off and past what RAM holds, read back a page at a time (`ui-core/HistoryLog.h`, Settings > Messages > Keep messages, off by default). Implies `HIST_ARCHIVE`'s hooks. Set in its solo builds. | | `MESHCORE_VERSION='"x.y"'` | Overrides the upstream MeshCore base-version string shown in diagnostics, for boards whose port hasn't been rebased onto the latest yet. Cosmetic only — doesn't change protocol behaviour. | diff --git a/docs/solo/developer/feature-matrix.md b/docs/solo/developer/feature-matrix.md index 5bf29554..4fe1582e 100644 --- a/docs/solo/developer/feature-matrix.md +++ b/docs/solo/developer/feature-matrix.md @@ -45,7 +45,7 @@ build flag it needs. | Channels, direct, rooms | yes | yes | yes | yes | yes, one screen with sections | | Opens on the first unread, "New" line | yes | yes | yes | yes | yes | | Bubbles | 2-line boxes | whole text | whole text | whole text | whole text | -| History kept | 48 ch / 32 DM | same; the L1s can keep it on flash across a power-off (`HIST_FLASH`) | same | same | 256 / 128 in memory, all on SD (`HIST_ARCHIVE`) | +| History kept | 48 ch / 32 DM | same; the L1s can keep up to 2500 more on flash (`HIST_FLASH`) | same | same | 256 / 128 in memory, all on SD (`HIST_ARCHIVE`) | | Path of a message | Path list | same | same | same | path diagram on the card | | Quick messages, placeholders | yes | yes | yes | yes | yes | | Bytes left while typing | yes | yes | yes | yes | yes | diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 96705945..1dd2257b 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -349,6 +349,7 @@ static void sanitize(NodePrefs& p) { flag(p.fav_sort, 1); flag(p.msg_wake, 1); if (p.msg_keep >= NodePrefs::MSG_KEEP_COUNT) p.msg_keep = 0; + if (p.msg_expire >= NodePrefs::CONTACT_EXPIRY_COUNT) p.msg_expire = 0; if (p.contact_expiry_idx >= NodePrefs::CONTACT_EXPIRY_COUNT) p.contact_expiry_idx = 0; if (p.dst_rule >= NodePrefs::DST_COUNT) p.dst_rule = NodePrefs::DST_OFF; if (p.display_brightness_pct > 100) p.display_brightness_pct = 0; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 1e3ac5f8..b3e9460f 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -333,6 +333,9 @@ struct NodePrefsFields { // Settings > Messages > Keep messages, boards with HIST_FLASH: how many // messages in all stay on flash (index into msgKeepTotal(); 0 = off, none). uint8_t msg_keep; + // Settings > Messages > Delete after: messages older than this go, from the + // list at once (index into contactExpiryDays(); 0 = never). + uint8_t msg_expire; // ui-new's new-message alert: the banner from the top, an envelope with a // count in the corner, or nothing (the sound and the wake still happen). static const uint8_t MSG_ALERT_NORMAL = 0, MSG_ALERT_COMPACT = 1, MSG_ALERT_OFF = 2; @@ -900,7 +903,7 @@ class NodePrefs : public NodePrefsFields, public ConfigSerializer { s.def("dm_all", dm_show_all); s.def("room_fav", room_fav_only); s.hex("ch_fav", ch_fav_bitmask); s.def("ch_fav_only", ch_fav_only); s.hex("favs", favourite_contacts); s.hex("fav_kinds", favourite_kinds); - s.def("fav_sort", fav_sort); s.def("expiry", contact_expiry_idx); s.def("msg_keep", msg_keep); + s.def("fav_sort", fav_sort); s.def("expiry", contact_expiry_idx); s.def("msg_keep", msg_keep); s.def("msg_expire", msg_expire); } void gpioSolo(Section& s) { s.def("m1", gpio1_mode); s.def("m2", gpio2_mode); s.def("m3", gpio3_mode); s.def("m4", gpio4_mode); diff --git a/examples/companion_radio/ui-core/HistoryLog.h b/examples/companion_radio/ui-core/HistoryLog.h index cd8a9ca8..7eca47c2 100644 --- a/examples/companion_radio/ui-core/HistoryLog.h +++ b/examples/companion_radio/ui-core/HistoryLog.h @@ -16,7 +16,9 @@ // gives no warning) leaves the log as it was before the batch. // // A batch is written SAVE_DELAY_MS after the first change not yet saved -- not -// after the last, or a busy channel would never get there -- and at shutdown(). +// after the last, or a busy channel would never get there -- at shutdown(), and +// after SAVE_BURST new messages: a room's backlog can arrive faster than the +// delay, and what leaves the ring unsaved is gone. // A record is written once, so a message waits for its final delivery state; // until then the entries behind it wait too, to keep the log in order. // @@ -24,12 +26,24 @@ // the log can be walked from the end. restore() fills the RAM rings from there. // A channel's messages carry channelKey(), so they follow the channel, not the // slot; those of a channel since deleted aren't restored. +// +// Older messages, past what the rings hold, are read a page at a time +// (pageBegin*(), pageOlder(), pageNewer()): the messages screen measures its +// whole list every frame, so it gets a short one, MessageHistory::older, never +// the log. A page is found by walking the log, as a conversation's records lie +// among all the others; a place in it (Spot) stays good as the log grows. +// +// Settings > Messages > Delete after hides what's older at once; the flash is +// freed a whole segment at a time, once the newest record in it is that old. class HistoryLog : public HistArchive { public: static const uint8_t SEGMENTS = 8; static const uint32_t SAVE_DELAY_MS = 60000; static const uint32_t SETTLE_SECS = 60; // an own channel post no repeater has echoed yet + static const uint8_t SAVE_BURST = 12; + static const uint32_t PRUNE_EVERY_MS = 3600000; + static const int PAGE = 12; void begin(NodePrefs* prefs, MessageHistory* history) { _prefs = prefs; @@ -41,14 +55,15 @@ public: scan(); restore(); } + _next_prune = millis() + SAVE_DELAY_MS; history->setArchive(this); } // ── HistArchive ──────────────────────────────────────────────────────────── // arc_id orders the entries of this run; a restored one keeps 0, already saved. - void chAppend(ChHistEntry& e) override { e.arc_id = ++_seq; touch(); } + void chAppend(ChHistEntry& e) override { e.arc_id = ++_seq; appended(); } void chUpdate(const ChHistEntry&) override { touch(); } - void dmAppend(DmHistEntry& e) override { e.arc_id = ++_seq; touch(); } + void dmAppend(DmHistEntry& e) override { e.arc_id = ++_seq; appended(); } void dmUpdate(const DmHistEntry&) override { touch(); } void loop() { @@ -58,10 +73,108 @@ public: if (on) touch(); else forget(); } if (_dirty && (int32_t)(millis() - _due) >= 0) flush(false); + if (_on && (int32_t)(millis() - _next_prune) >= 0) { + _next_prune = millis() + PRUNE_EVERY_MS; + prune(_ch); + prune(_dm); + } } void onShutdown() { if (_dirty && _prefs->msg_keep) flush(true); } + // Settings > Messages > Delete history: the log and the rings. + void erase() { + pageEnd(); + if (_fs) forget(); + _history->clearAll(); + } + + // ── Older messages ───────────────────────────────────────────────────────── + // Start reading a conversation from just past what the ring holds of it. + // False when the log has nothing more; true: a page is in MessageHistory::older. + bool pageBeginCh(int slot) { + ChannelDetails ch; + if (!the_mesh.getChannel(slot, ch) || !ch.name[0]) return false; + int in_ring = 0; + for (int j = 0; j < _history->chHistCount(); j++) { + const ChHistEntry& e = _history->chAtPos(_history->chHistPosNewest(j)); + if (e.ch_idx == slot && e.arc_id <= _ch_done) in_ring++; + } + return pageBegin(&_ch, channelKey(ch), (uint8_t)slot, in_ring); + } + bool pageBeginDm(const uint8_t* prefix) { + int in_ring = 0; + for (int j = 0; j < _history->dmHistCount(); j++) { + const DmHistEntry& e = _history->dmAtPos(_history->dmHistPosNewest(j)); + if (memcmp(e.prefix, prefix, 4) == 0 && e.arc_id <= _dm_done) in_ring++; + } + uint32_t key; + memcpy(&key, prefix, 4); + return pageBegin(&_dm, key, 0, in_ring); + } + int pageCount() const { return _pg_n; } + void pageEnd() { + free(_history->older); + _history->older = nullptr; + _pg = nullptr; + _pg_n = 0; + } + + // The next page back; false, and the page as it was, at the end of the log. + bool pageOlder() { + if (!_pg) return false; + int got = 0; + Spot start = _lo; + Spot end = back(start, [&](File& f, uint32_t, uint32_t pos, const Rec& r) { + uint8_t buf[REC_MAX]; + if (readBody(f, pos, r, buf)) fill(got++, r, buf); + return got < PAGE; + }); + if (!got) return false; + _pg_n = got; + _hi = start; + _lo = end; + return true; + } + + // The next page forward, up to where the ring takes over; false at that point. + bool pageNewer() { + if (!_pg) return false; + int got = 0; + Spot first = { 0, 0 }, past = _hi; + char p[24]; + for (uint32_t seg = _hi.seg; seg && seg <= _pg->last && seg <= _ceil.seg && got < PAGE; seg++) { + filePath(*_pg, seg, p, sizeof(p)); + File f = _fs->open(p, FILE_O_READ); + if (!f) continue; + uint32_t size = f.size(), pos = seg == _hi.seg ? _hi.pos : 0; + uint32_t stop = seg == _ceil.seg && _ceil.pos < size ? _ceil.pos : size; + while (pos < stop && got < PAGE) { + uint8_t buf[REC_MAX]; + Rec r; + if (!f.seek(pos) || f.read(&r, sizeof(r)) != (int)sizeof(r) || !readBody(f, pos, r, buf)) break; + uint32_t next = pos + sizeof(r) + pathBytes(r.path_len) + r.text_len + 1; + if (mine(r, cutoff())) { + if (!got) first = { seg, pos }; + fill(got++, r, buf); + past = { seg, next }; + } + pos = next; + } + f.close(); + } + if (!got) return false; + for (int i = 0, j = got - 1; i < j; i++, j--) { // index 0 is the newest + OlderSlot t = _history->older[i]; + _history->older[i] = _history->older[j]; + _history->older[j] = t; + } + _pg_n = got; + _lo = first; + _hi = past; + return true; + } + private: static constexpr const char* ROOT = "/h"; @@ -82,12 +195,17 @@ private: uint32_t first, last; uint16_t in_last; }; + struct Spot { uint32_t seg, pos; }; // a place in a log; seg 0: before its start void touch() { if (!_on || _dirty) return; _dirty = true; _due = millis() + SAVE_DELAY_MS; } + void appended() { + touch(); + if (_on && ++_unsaved >= SAVE_BURST) _due = millis(); + } static void dirPath(const Log& l, uint32_t seg, char* out, size_t n) { snprintf(out, n, "%s/%c%lu", ROOT, l.kind, (unsigned long)seg); @@ -107,10 +225,19 @@ private: return r.text_len < MSG_TEXT_BUF && len == (int)sizeof(Rec) + pathBytes(r.path_len) + r.text_len + 1; } + // Timestamps older than this are past Settings > Messages > Delete after (0: + // keep all). Never with a clock not set, or behind the log's newest message: + // a wrong clock must not cost messages. + uint32_t cutoff() const { + uint32_t days = NodePrefs::contactExpiryDays(_prefs->msg_expire), now = rtc_clock.getCurrentTime(); + return days && now >= 1000000000UL && now >= _newest ? now - days * 86400UL : 0; + } + // ── Writing ──────────────────────────────────────────────────────────────── void flush(bool all) { uint32_t t0 = millis(); _dirty = false; + _unsaved = 0; bool more = flushCh(all); if (flushDm(all)) more = true; MESH_DEBUG_PRINTLN("HistoryLog: flush %lu ms", (unsigned long)(millis() - t0)); @@ -170,6 +297,7 @@ private: buf[n] = (uint8_t)(n + 1); n++; if ((int)f.write(buf, n) != n) return false; l.in_last++; + if (ts > _newest) _newest = ts; return true; } @@ -187,9 +315,22 @@ private: } } + // Drops the segments that have gone past Delete after, never the newest. + void prune(Log& l) { + uint32_t cut = cutoff(); + char p[24]; + while (cut && l.first && l.first < l.last) { + Rec r; + if (lastRec(l, l.first, r) && r.timestamp >= cut) break; + dirPath(l, l.first++, p, sizeof(p)); + _fs->rmdir_r(p); + } + } + void forget() { _fs->rmdir_r(ROOT); _dirty = false; + _newest = 0; _ch = { 'c', 0, 0, 0 }; _dm = { 'd', 0, 0, 0 }; _ch_done = _dm_done = 0; // on again: what the rings hold goes back in @@ -227,9 +368,25 @@ private: uint8_t len; for (uint32_t pos = f.size(); pos > 0 && stepBack(f, pos, len); pos -= len) n++; f.close(); + Rec r; + if (n && lastRec(l, l.last, r) && r.timestamp > _newest) _newest = r.timestamp; return n; } + // The last record of a segment. + bool lastRec(const Log& l, uint32_t seg, Rec& r) { + char p[24]; + filePath(l, seg, p, sizeof(p)); + File f = _fs->open(p, FILE_O_READ); + if (!f) return false; + uint32_t pos = f.size(); + uint8_t len; + bool ok = pos > 0 && stepBack(f, pos, len) && f.seek(pos - len) && + f.read(&r, sizeof(r)) == (int)sizeof(r) && sane(r, len); + f.close(); + return ok; + } + // The record ending at pos: its length, false when that makes no sense. static bool stepBack(File& f, uint32_t pos, uint8_t& len) { return f.seek(pos - 1) && f.read(&len, 1) == 1 && len > sizeof(Rec) && len <= pos; @@ -255,7 +412,7 @@ private: void restoreLog(const Log& l, int want, const uint32_t* keys) { if (!l.last) return; char p[24]; - uint32_t seg0 = l.last, off0 = 0; + uint32_t seg0 = l.last, off0 = 0, cut = cutoff(); int got = 0; for (uint32_t seg = l.last; seg >= l.first && got < want; seg--) { filePath(l, seg, p, sizeof(p)); @@ -267,7 +424,7 @@ private: pos -= len; Rec r; if (!f.seek(pos) || f.read(&r, sizeof(r)) != (int)sizeof(r) || !sane(r, len)) { pos += len; break; } - if (!keys || chSlot(keys, r.key) >= 0) got++; + if ((!keys || chSlot(keys, r.key) >= 0) && r.timestamp >= cut) got++; } seg0 = seg; off0 = pos; @@ -285,7 +442,7 @@ private: int rest = pathBytes(r.path_len) + r.text_len + 1; if (r.text_len >= MSG_TEXT_BUF || f.read(buf, rest) != rest) break; if (buf[rest - 1] != (uint8_t)(sizeof(r) + rest)) break; - restoreRec(r, buf, keys); + if (r.timestamp >= cut) restoreRec(r, buf, keys); pos += sizeof(r) + rest; } f.close(); @@ -293,40 +450,121 @@ private: } void restoreRec(const Rec& r, const uint8_t* buf, const uint32_t* keys) { - int pn = pathBytes(r.path_len); if (keys) { int slot = chSlot(keys, r.key); if (slot < 0) return; ChHistEntry e; - memset(&e, 0, sizeof(e)); - e.ch_idx = (uint8_t)slot; - e.timestamp = r.timestamp; - e.relay_status = r.flags & STATUS_MASK; - e.path_len = r.path_len; - memcpy(e.path, buf, pn); - memcpy(e.text, buf + pn, r.text_len); + fillCh(e, r, buf, (uint8_t)slot); _history->restoreCh(e); } else { DmHistEntry e; - memset(&e, 0, sizeof(e)); - memcpy(e.prefix, &r.key, 4); - e.outgoing = (r.flags & OUTGOING) ? 1 : 0; - e.timestamp = r.timestamp; - e.msg_ts = r.msg_ts; - e.ack_status = r.flags & STATUS_MASK; - e.path_len = r.path_len; - memcpy(e.path, buf, pn); - memcpy(e.text, buf + pn, r.text_len); + fillDm(e, r, buf); _history->restoreDm(e); } } + static void fillCh(ChHistEntry& e, const Rec& r, const uint8_t* buf, uint8_t slot) { + int pn = pathBytes(r.path_len); + memset(&e, 0, sizeof(e)); + e.ch_idx = slot; + e.timestamp = r.timestamp; + e.relay_status = r.flags & STATUS_MASK; + e.path_len = r.path_len; + memcpy(e.path, buf, pn); + memcpy(e.text, buf + pn, r.text_len); + } + static void fillDm(DmHistEntry& e, const Rec& r, const uint8_t* buf) { + int pn = pathBytes(r.path_len); + memset(&e, 0, sizeof(e)); + memcpy(e.prefix, &r.key, 4); + e.outgoing = (r.flags & OUTGOING) ? 1 : 0; + e.timestamp = r.timestamp; + e.msg_ts = r.msg_ts; + e.ack_status = r.flags & STATUS_MASK; + e.path_len = r.path_len; + memcpy(e.path, buf, pn); + memcpy(e.text, buf + pn, r.text_len); + } + + // ── Older messages ───────────────────────────────────────────────────────── + bool pageBegin(Log* l, uint32_t key, uint8_t slot, int in_ring) { + pageEnd(); + if (!_on || !l->last) return false; + _history->older = (OlderSlot*)malloc(PAGE * sizeof(OlderSlot)); + if (!_history->older) return false; + _pg = l; + _pg_key = key; + _pg_slot = slot; + Spot from = { l->last, 0xFFFFFFFFUL }; + int skip = in_ring; + if (skip > 0) from = back(from, [&](File&, uint32_t, uint32_t, const Rec&) { return --skip > 0; }); + _ceil = _lo = _hi = from; + if (pageOlder()) return true; + pageEnd(); + return false; + } + + bool mine(const Rec& r, uint32_t cut) const { return r.key == _pg_key && r.timestamp >= cut; } + + // Walks the conversation's records back from `from` until fn says stop: + // where it stopped, or the log's start. fn(file, seg, pos, record). + template Spot back(Spot from, F fn) { + const Log& l = *_pg; + uint32_t cut = cutoff(); + char p[24]; + for (uint32_t seg = from.seg; seg && seg >= l.first; seg--) { + filePath(l, seg, p, sizeof(p)); + File f = _fs->open(p, FILE_O_READ); + if (!f) continue; + uint32_t pos = f.size(); + if (seg == from.seg && from.pos < pos) pos = from.pos; + uint8_t len; + while (pos > 0 && stepBack(f, pos, len)) { + pos -= len; + Rec r; + if (!f.seek(pos) || f.read(&r, sizeof(r)) != (int)sizeof(r) || !sane(r, len)) break; + if (mine(r, cut) && !fn(f, seg, pos, r)) { + f.close(); + return { seg, pos }; + } + } + f.close(); + } + return { 0, 0 }; + } + + static bool readBody(File& f, uint32_t pos, const Rec& r, uint8_t* buf) { + int rest = pathBytes(r.path_len) + r.text_len + 1; + return r.text_len < MSG_TEXT_BUF && f.seek(pos + sizeof(r)) && f.read(buf, rest) == rest && + buf[rest - 1] == (uint8_t)(sizeof(r) + rest); + } + + void fill(int i, const Rec& r, const uint8_t* buf) { + OlderSlot& o = _history->older[i]; + if (_pg == &_ch) { + fillCh(o.ch, r, buf, _pg_slot); + if (o.ch.relay_status == ACK_PENDING) o.ch.relay_status = ACK_NONE; + } else { + fillDm(o.dm, r, buf); + if (o.dm.ack_status == ACK_PENDING) o.dm.ack_status = ACK_NONE; + } + } + NodePrefs* _prefs = nullptr; MessageHistory* _history = nullptr; FILESYSTEM* _fs = nullptr; bool _on = false, _dirty = false; - uint32_t _due = 0; + uint32_t _due = 0, _next_prune = 0; + uint8_t _unsaved = 0; + uint32_t _newest = 0; // the newest timestamp in the log uint32_t _seq = 0, _ch_done = 0, _dm_done = 0; // arc_id: the last given, the last saved Log _ch = { 'c', 0, 0, 0 }; Log _dm = { 'd', 0, 0, 0 }; + // The page being read: its log and conversation, where it starts and where + // the one after it does, and where the ring takes over from the log. + Log* _pg = nullptr; + uint32_t _pg_key = 0; + uint8_t _pg_slot = 0; + int _pg_n = 0; + Spot _lo = { 0, 0 }, _hi = { 0, 0 }, _ceil = { 0, 0 }; }; diff --git a/examples/companion_radio/ui-core/MessageHistory.h b/examples/companion_radio/ui-core/MessageHistory.h index 785f21e4..b8ae4443 100644 --- a/examples/companion_radio/ui-core/MessageHistory.h +++ b/examples/companion_radio/ui-core/MessageHistory.h @@ -113,6 +113,11 @@ public: }; #endif +#ifdef HIST_FLASH +// A page of messages read back from the flash log (HistoryLog.h). +union OlderSlot { ChHistEntry ch; DmHistEntry dm; }; +#endif + // Boards with PSRAM raise these (the L2: -D HIST_CH_MAX=256 -D HIST_DM_MAX=128, // ~80 KB in PSRAM with UiCore); the defaults are sized for the nRF52's heap. #ifndef HIST_CH_MAX @@ -274,8 +279,16 @@ public: #endif } +#ifdef HIST_FLASH + // A position past the ring is a slot of the page of older messages, for the + // messages screen, which reaches both the same way. Allocated while it's open. + OlderSlot* older = nullptr; + ChHistEntry& chAtPos(int pos) { return pos >= CH_HIST_MAX ? older[pos - CH_HIST_MAX].ch : _hist[pos]; } + const ChHistEntry& chAtPos(int pos) const { return pos >= CH_HIST_MAX ? older[pos - CH_HIST_MAX].ch : _hist[pos]; } +#else ChHistEntry& chAtPos(int pos) { return _hist[pos]; } const ChHistEntry& chAtPos(int pos) const { return _hist[pos]; } +#endif // ── Per-channel unread counters ───────────────────────────────────────────── // Always clamped to what the ring still holds for that channel. The raw @@ -541,8 +554,13 @@ public: // 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. +#ifdef HIST_FLASH + DmHistEntry& dmAtPos(int pos) { return pos >= DM_HIST_MAX ? older[pos - DM_HIST_MAX].dm : _dm_hist[pos]; } + const DmHistEntry& dmAtPos(int pos) const { return pos >= DM_HIST_MAX ? older[pos - DM_HIST_MAX].dm : _dm_hist[pos]; } +#else DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; } const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; } +#endif // Whole DM ring, newest first (j = 0 newest) -- for "recent conversations". int dmHistCount() const { return _dm_hist_count; } diff --git a/examples/companion_radio/ui-core/SettingsCli.h b/examples/companion_radio/ui-core/SettingsCli.h index 098e0ed2..beeb35b5 100644 --- a/examples/companion_radio/ui-core/SettingsCli.h +++ b/examples/companion_radio/ui-core/SettingsCli.h @@ -21,7 +21,7 @@ static const Key KEYS[] = { K("autoadd", manual_add_contacts), KB("autoadd.chat", autoadd_config, 0x02), KB("autoadd.repeater", autoadd_config, 0x04), KB("autoadd.room", autoadd_config, 0x08), KB("autoadd.sensor", autoadd_config, 0x10), KB("autoadd.overwrite", autoadd_config, 0x01), K("autoadd.max.hops", autoadd_max_hops), - K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("contacts.channel.order", ch_sort), K("map.flow", flow_show), K("dm.resend", dm_resend_count), K("messages.keep", msg_keep), + K("contacts.expiry", contact_expiry_idx), K("contacts.fav.first", fav_sort), K("contacts.channel.order", ch_sort), K("map.flow", flow_show), K("dm.resend", dm_resend_count), K("messages.keep", msg_keep), K("messages.expire", msg_expire), K("display.brightness", display_brightness), K("display.off", auto_off_secs), K("display.wake", msg_wake), K("display.alert", msg_alert), K("lock", auto_lock), K("lock.clock", lock_compact), K("display.text.size", ui_text_scale), K("display.icon.size", ui_icon_scale), diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index 4090b343..0668e4e0 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -140,6 +140,9 @@ static void optResend(uint8_t v, char* b, int n, const NodePrefs&) { static void optMsgKeep(uint8_t v, char* b, int n, const NodePrefs&) { if (v == 0) snprintf(b, n, "Off"); else snprintf(b, n, "%u", (unsigned)NodePrefs::msgKeepTotal(v)); } +static void optMsgExpire(uint8_t v, char* b, int n, const NodePrefs&) { + if (v == 0) snprintf(b, n, "Never"); else snprintf(b, n, "%u days", (unsigned)NodePrefs::contactExpiryDays(v)); +} #endif static void optBattDisplay(uint8_t v, char* b, int n, const NodePrefs&) { static const char* L[] = { "Icon", "Percent", "Voltage" }; @@ -296,6 +299,8 @@ static const Setting ALL[] = { #ifdef HIST_FLASH IDX("Keep messages", "On flash, after a power-off too", SEC_MESSAGES, msg_keep, NodePrefs::MSG_KEEP_COUNT, optMsgKeep, nullptr), + IDX("Delete after", "Messages older than this", SEC_MESSAGES, msg_expire, NodePrefs::CONTACT_EXPIRY_COUNT, + optMsgExpire, nullptr), #endif IDX("Contact expiry", "Prune after inactivity", SEC_CONTACTS, contact_expiry_idx, diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index fa15391b..964c60c1 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -110,6 +110,9 @@ public: void clearAllDMUnread() { dm_unread.clearAll(); } // Every conversation read: direct, channels, rooms. void markAllRead() { _room_unread = 0; dm_unread.clearAll(); history.clearAllChannelUnread(); } +#ifdef HIST_FLASH + void deleteHistory() { history_log.erase(); markAllRead(); } // Settings > Messages > Delete history +#endif // ── Actions ─────────────────────────────────────────────────────────────── // Send a DM composed on the device and file it (with end-to-end ACK tracking diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 6d474553..1f77b4c2 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -142,6 +142,7 @@ class MessagesScreen : public UIScreen { // DM_HIST view state (the ring itself is in _history). int _dm_hist_sel, _dm_hist_scroll; + bool _older = false; // showing a page of the flash log, not the ring FullscreenMsgView _dm_fs; // "New" over the first message unread when the conversation opened (index @@ -742,6 +743,7 @@ class MessagesScreen : public UIScreen { } _reply_mode = false; _share_mode = false; + dropOlder(); // a reply from an older page: back to the newest if (_sending_to_channel) { _hist_sel = 0; _hist_scroll = 0; @@ -1057,10 +1059,10 @@ public: // relabeling a different one in under them. At sel <= 0 (already at newest, // or -1 == compose button focused) there's nothing to preserve. bool isViewingChannel(uint8_t ch_idx) const { - return _phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx; + return _phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx && !_older; } bool isViewingDM(const uint8_t* pub_key) const { - return _phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0; + return _phase == DM_HIST && memcmp(_sel_contact.id.pub_key, pub_key, 4) == 0 && !_older; } void onViewedHistoryGrew(bool channel) { if (channel) { @@ -1102,6 +1104,73 @@ public: // Open the message history for the currently selected contact/room and reset // the scroll/selection view state. Shared by the Enter-on-contact path and the // post-login auto-enter. + // The conversation being shown: the ring's entries for it, or a page of + // older ones from the flash log. A list of a few either way, as a render + // measures all of it. `_older` is that page. + int chViewCount() const { + return _older ? olderCount() : _history.histCountForChannel(_sel_channel_idx); + } + int chViewPos(int j) const { + if (_older) return j < olderCount() ? MessageHistory::CH_HIST_MAX + j : -1; + return _history.histEntryForChannel(_sel_channel_idx, j); + } + int dmViewCount() const { + return _older ? olderCount() : _history.dmHistCountForContact(_sel_contact.id.pub_key); + } + int dmViewPos(int j) const { + if (_older) return j < olderCount() ? MessageHistory::DM_HIST_MAX + j : -1; + return _history.dmHistEntryForContact(_sel_contact.id.pub_key, j); + } +#ifdef HIST_FLASH + int olderCount() const { return _task->core().history_log.pageCount(); } + void dropOlder() { + if (_older) _task->core().history_log.pageEnd(); + _older = false; + } + // Past the oldest entry: the next page back. True when that's handled + // (at the very end nothing moves); false with nothing older, as a plain list. + bool turnOlder(bool channel) { + HistoryLog& log = _task->core().history_log; + if (_older) { + if (!log.pageOlder()) return true; + } else { + if (!(channel ? log.pageBeginCh(_sel_channel_idx) : log.pageBeginDm(_sel_contact.id.pub_key))) return false; + _older = true; + _new_idx = -1; + _anchor_new = false; + } + (channel ? _hist_sel : _dm_hist_sel) = 0; + (channel ? _hist_scroll : _dm_hist_scroll) = 0; + return true; + } + // Past the newest of a page: the next one forward, then the ring again. + bool turnNewer(bool channel) { + if (!_older) return false; + int count; + if (_task->core().history_log.pageNewer()) { + count = olderCount(); + } else { + bool shown = (channel ? _fs : _dm_fs).active; + leaveOlder(channel); + (channel ? _fs : _dm_fs).active = shown; + _new_idx = -1; + count = channel ? chViewCount() : dmViewCount(); + } + (channel ? _hist_sel : _dm_hist_sel) = count - 1; + (channel ? _hist_scroll : _dm_hist_scroll) = count > _hist_visible ? count - _hist_visible : 0; + return true; + } +#else + int olderCount() const { return 0; } + void dropOlder() {} + bool turnOlder(bool) { return false; } + bool turnNewer(bool) { return false; } +#endif + void leaveOlder(bool channel) { + dropOlder(); + if (channel) beginChannelView(_sel_channel_idx); else beginDmView(); + } + void openDmHistory() { beginDmView(); _phase = DM_HIST; @@ -1110,6 +1179,7 @@ public: // Opening a conversation: on its first unread message ("New" above it), else // on the compose row under the newest. void beginDmView() { + dropOlder(); const uint8_t* key = _sel_contact.id.pub_key; _new_idx = _history.firstUnreadDm(key, _task->getDMUnread(key)); _anchor_new = _new_idx >= 0; @@ -1119,6 +1189,7 @@ public: _dm_fs.active = false; } void beginChannelView(uint8_t channel_idx) { + dropOlder(); _sel_channel_idx = channel_idx; _unread_at_entry = (int)_history.chUnread(channel_idx); _unread_base = 0; @@ -1187,7 +1258,7 @@ public: // resulting count down into the store. The ring lives in _history, but this // "what has the user seen on screen" logic is pure phase-machine state. void updateChannelUnread() { - if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; + if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS || _older) return; // histEntryForChannel is newest-first: index 0 = newest, higher = older; // the unread from opening are _unread_base onwards. Count everything // actually rendered on screen as seen — not just the highlighted row — so @@ -1207,6 +1278,7 @@ public: } void reset() { + dropOlder(); _phase = MODE_SELECT; _mode_sel = 0; _contact_sel = _contact_scroll = 0; @@ -1540,7 +1612,7 @@ public: display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); if (_dm_fs.active && _dm_hist_sel >= 0) { - int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = dmViewPos(_dm_hist_sel); if (ring_pos >= 0) { const DmHistEntry& e = _history.dmAtPos(ring_pos); char sender_buf[33]; @@ -1549,7 +1621,7 @@ public: const char* body = dmDisplayParts(e, _sel_contact.type == ADV_TYPE_ROOM, filtered_name, sender_buf, sizeof(sender_buf)); const char* sender = sender_buf; - int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = dmViewCount(); int ret = _dm_fs.render(display, sender, body, _dm_hist_sel < dm_count - 1, _dm_hist_sel > 0); @@ -1576,7 +1648,7 @@ public: snprintf(title, sizeof(title), "%.23s", filtered_name); drawScreenHeader(display, title, -1, 0, true, _ctx_menu.active); - int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = dmViewCount(); uint32_t now_ts = rtc_clock.getCurrentTime(); bool is_room = (_sel_contact.type == ADV_TYPE_ROOM); @@ -1605,13 +1677,13 @@ public: return t; }; auto bodyAt = [&](int idx, char* sb, size_t sbn) -> const char* { - int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); + int rp = dmViewPos(idx); if (rp < 0) return nullptr; return skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, sb, sbn)); }; // Height of compact bubble idx at a given scrollbar reserve. auto compactH = [&](int idx, int rsv) { - int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, idx); + int rp = dmViewPos(idx); if (rp < 0) return lh + 1; char sb[33]; const char* body = bodyAt(idx, sb, sizeof(sb)); @@ -1651,7 +1723,7 @@ public: for (int ii = 0; ii < MAX_VIS_BOXES && (s + ii) < dm_count; ii++) { int bh = compact_dm ? compactH(s + ii, reserve) : fixed_bh; if (expand) { - int rp = _history.dmHistEntryForContact(_sel_contact.id.pub_key, s + ii); + int rp = dmViewPos(s + ii); if (rp >= 0) { char hsb[33]; const char* hbody = skipReplyPrefix(dmDisplayParts(_history.dmAtPos(rp), is_room, filtered_name, hsb, sizeof(hsb))); @@ -1683,7 +1755,7 @@ public: int y = box_ys[i]; int bh = box_hs[i]; - int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, item); + int ring_pos = dmViewPos(item); if (ring_pos < 0) continue; const DmHistEntry& e = _history.dmAtPos(ring_pos); @@ -1780,8 +1852,8 @@ public: } else if (_phase == CHANNEL_HIST) { if (_fs.active && _hist_sel >= 0) { - int fs_hist_count = _history.histCountForChannel(_sel_channel_idx); - int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); + int fs_hist_count = chViewCount(); + int ring_pos = chViewPos(_hist_sel); if (ring_pos >= 0) { const char* ftext = _history.chAtPos(ring_pos).text; const char* fsep = strstr(ftext, ": "); @@ -1834,7 +1906,7 @@ public: } drawScreenHeader(display, title, -1, 0, true, _ctx_menu.active); - int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); + int ch_hist_count = chViewCount(); uint32_t now_ts = rtc_clock.getCurrentTime(); // On a tall enough screen (expandHistory): boxes as tall as their whole @@ -1847,7 +1919,7 @@ public: return computeHistScroll(display, expand, ch_hist_count, _hist_scroll, hist_start_y, cby, lh, [&](int idx) -> const char* { - int rp = _history.histEntryForChannel(_sel_channel_idx, idx); + int rp = chViewPos(idx); if (rp < 0) return nullptr; const char* t = _history.chAtPos(rp).text; const char* s = strstr(t, ": "); @@ -1868,7 +1940,7 @@ public: for (int ii = 0; ii < MAX_VIS_BOXES && (s + ii) < ch_hist_count; ii++) { int bh = fixed_bh; if (expand) { - int rp = _history.histEntryForChannel(_sel_channel_idx, s + ii); + int rp = chViewPos(s + ii); if (rp >= 0) { const char* rtext = _history.chAtPos(rp).text; const char* rsep = strstr(rtext, ": "); @@ -1903,7 +1975,7 @@ public: int y = box_ys[i]; int bh = box_hs[i]; - int ring_pos = _history.histEntryForChannel(_sel_channel_idx, item); + int ring_pos = chViewPos(item); if (ring_pos < 0) continue; const char* text = _history.chAtPos(ring_pos).text; @@ -2402,7 +2474,7 @@ public: } } else if (_phase == DM_HIST) { - int dm_count = _history.dmHistCountForContact(_sel_contact.id.pub_key); + int dm_count = dmViewCount(); if (_dm_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); @@ -2418,12 +2490,14 @@ public: auto res = _dm_fs.handleInput(c); if (res == FullscreenMsgView::PREV) { if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; } + else if (turnOlder(false)) _dm_fs.scroll = 0; } else if (res == FullscreenMsgView::NEXT) { if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; } + else if (turnNewer(false)) _dm_fs.scroll = 0; } else if (res == FullscreenMsgView::CLOSE) { _dm_fs.active = false; } else if (res == FullscreenMsgView::REPLY) { - int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = dmViewPos(_dm_hist_sel); if (ring_pos >= 0) { bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); @@ -2443,6 +2517,7 @@ public: } return true; } + if (c == KEY_CANCEL && _older) { leaveOlder(false); return true; } if (c == KEY_CANCEL) { if (_direct_entry) { _direct_entry = false; @@ -2458,12 +2533,14 @@ public: // now walks toward newer (lower index, physically downward) and off the // bottom (index 0) reaches the compose row, which sits right below it. if (c == KEY_UP) { + if (dm_count == 0 && turnOlder(false)) return true; // the ring has lost this conversation if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; } else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) { _dm_hist_sel++; if (_dm_hist_sel >= _dm_hist_scroll + _hist_visible) _dm_hist_scroll = _dm_hist_sel - _hist_visible + 1; } else if (_dm_hist_sel == dm_count - 1) { + if (turnOlder(false)) return true; // Oldest (top of the list) -> wrap straight to the newest (bottom // of the list), same ring-wrap every other list in the UI does at // its ends -- the compose row sits below the list itself and is @@ -2478,6 +2555,7 @@ public: _dm_hist_sel--; if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel; } else if (_dm_hist_sel == 0) { + if (turnNewer(false)) return true; _dm_hist_sel = -1; } else if (_dm_hist_sel == -1 && dm_count > 0) { // Compose row -> wrap to the oldest message (top of the list). @@ -2497,7 +2575,7 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _dm_hist_sel >= 0) { - int ring_pos = _history.dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + int ring_pos = dmViewPos(_dm_hist_sel); if (ring_pos >= 0) { bool reply_ok = !_history.dmAtPos(ring_pos).outgoing; if (reply_ok) buildDmReplyPrefix(_history.dmAtPos(ring_pos)); @@ -2507,7 +2585,7 @@ public: } } else if (_phase == CHANNEL_HIST) { - int ch_hist_count = _history.histCountForChannel(_sel_channel_idx); + int ch_hist_count = chViewCount(); if (_fs.active) { if (_ctx_menu.active) { auto res = _ctx_menu.handleInput(c); @@ -2523,12 +2601,14 @@ public: auto res = _fs.handleInput(c); if (res == FullscreenMsgView::PREV) { if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); } + else if (turnOlder(true)) _fs.scroll = 0; } else if (res == FullscreenMsgView::NEXT) { if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); } + else if (turnNewer(true)) _fs.scroll = 0; } else if (res == FullscreenMsgView::CLOSE) { _fs.active = false; } else if (res == FullscreenMsgView::REPLY) { - int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); + int ring_pos = chViewPos(_hist_sel); if (ring_pos >= 0) buildFsMenu(_history.chAtPos(ring_pos).text, buildChannelReplyPrefix(_history.chAtPos(ring_pos).text), ring_pos, true); } @@ -2545,6 +2625,7 @@ public: } return true; } + if (c == KEY_CANCEL && _older) { leaveOlder(true); return true; } if (c == KEY_CANCEL) { if (_direct_entry) { _direct_entry = false; _task->gotoHomeScreen(); } else { enterChannelList(_sel_channel_idx); } @@ -2554,11 +2635,13 @@ public: // render block above) — UP/DOWN swap direction accordingly, same as the // DM history handler above. if (c == KEY_UP) { + if (ch_hist_count == 0 && turnOlder(true)) return true; // the ring has lost this channel if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; } else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) { _hist_sel++; if (_hist_sel >= _hist_scroll + _hist_visible) _hist_scroll = _hist_sel - _hist_visible + 1; } else if (_hist_sel == ch_hist_count - 1) { + if (turnOlder(true)) return true; // Oldest -> wrap straight to the newest, same as the DM history handler. _hist_sel = 0; _hist_scroll = 0; @@ -2568,7 +2651,7 @@ public: } if (c == KEY_DOWN) { if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; } - else if (_hist_sel == 0) _hist_sel = -1; + else if (_hist_sel == 0) { if (turnNewer(true)) return true; _hist_sel = -1; } else if (_hist_sel == -1 && ch_hist_count > 0) { // Compose row -> wrap to the oldest message. _hist_sel = ch_hist_count - 1; @@ -2589,7 +2672,7 @@ public: return true; } if (c == KEY_CONTEXT_MENU && _hist_sel >= 0) { - int ring_pos = _history.histEntryForChannel(_sel_channel_idx, _hist_sel); + int ring_pos = chViewPos(_hist_sel); if (ring_pos >= 0) 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/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index f6846eb4..d34aeaa4 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -73,6 +73,9 @@ class SettingsScreen : public UIScreen { // Messages section SECTION_MESSAGES, SCHEMA_MESSAGES, +#ifdef HIST_FLASH + DELETE_HISTORY, +#endif MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4, MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9, Count, @@ -585,6 +588,10 @@ class SettingsScreen : public UIScreen { val("Rooms", (p && p->room_fav_only) ? "Fav" : "All"); } else if (item == PRUNE_NOW) { display.print("Prune now"); // action row: Enter counts + confirms + removes +#ifdef HIST_FLASH + } else if (item == DELETE_HISTORY) { + display.print("Delete history"); // action row: Enter confirms + erases +#endif } else if (isMsgSlot(item)) { // A message template reads from the left, cut at the edge (scrolls when selected). int slot = msgSlotIndex(item); @@ -994,6 +1001,15 @@ public: // Contacts > "Prune now" confirm if (_prune_confirm.active) { auto res = _prune_confirm.handleInput(c); +#ifdef HIST_FLASH + if (_selected == DELETE_HISTORY) { + if (res == PopupMenu::SELECTED && _prune_confirm.selectedIndex() == 0) { // "Delete" + _task->core().deleteHistory(); + _task->showAlert("History deleted", 1400); + } + return true; + } +#endif if (res == PopupMenu::SELECTED && _prune_confirm.selectedIndex() == 0) { // "Remove" int n = the_mesh.pruneStaleContacts(); char msg[24]; @@ -1238,6 +1254,12 @@ public: } return true; } +#ifdef HIST_FLASH + if (_selected == DELETE_HISTORY && enter) { + _prune_confirm.beginConfirm("Delete all messages?", "Delete"); + return true; + } +#endif if (isMsgSlot(_selected) && enter) { int slot = msgSlotIndex(_selected); _edit_slot = slot;