SMS: show unread count on the inbox badge instead of conversation count.

The "SMS Inbox" badge on the Phone & SMS landing screen previously showed
the number of conversations regardless of read state. It now shows the
number of unread received messages and disappears when there are none.
Read state is tracked persistently, so it survives reboots and modem
power-cycles (the flag lives in each record on the SD card).

SMSStore:
- Add a `read` byte to SMSRecord, reusing one of the reserved bytes so the
  record stays 256 bytes and every existing field offset is unchanged
  (existing .sms files remain readable).
- saveMessage marks sent messages read and received messages unread.
- loadConversations scans each file and reports unreadCount (received
  messages with read=0); preview / last-message behaviour is unchanged.
- markConversationRead / markFileRead flip received-unread records to read
  in place (open "r+", rewrite only the affected records).
- begin() runs a one-time migration on first boot that marks all
  pre-existing history read, gated by a marker file (/sms/rdmig.dat), so
  old messages do not all appear as unread after the update.

SMSScreen:
- Landing-screen badge sums unreadCount across conversations, hidden at zero.
- Opening a conversation marks it read and clears its in-RAM count.
- A message arriving while its conversation is open is marked read.
- Inbox reloads on the landing screen as well as the inbox view, so the
  badge updates live when a message arrives.
This commit is contained in:
pelgraine
2026-06-07 06:15:45 +10:00
parent 57b63bed47
commit ed039aa711
3 changed files with 105 additions and 11 deletions
+9 -4
View File
@@ -240,9 +240,10 @@ public:
smsStore.saveMessage(phone, body, false, timestamp);
}
if (_view == CONVERSATION && strcmp(_activePhone, phone) == 0) {
smsStore.markConversationRead(_activePhone);
refreshConversation();
}
if (_view == INBOX) {
if (_view == INBOX || _view == APP_MENU) {
refreshInbox();
}
_needsRefresh = true;
@@ -348,10 +349,12 @@ public:
else display.print(" ");
display.print("SMS Inbox");
// Show conversation count hint
if (_convCount > 0) {
// Show unread count hint (hidden when there are none)
int unread = 0;
for (int i = 0; i < _convCount; i++) unread += _conversations[i].unreadCount;
if (unread > 0) {
char countHint[12];
snprintf(countHint, sizeof(countHint), " [%d]", _convCount);
snprintf(countHint, sizeof(countHint), " [%d]", unread);
display.setColor(DisplayDriver::LIGHT);
display.print(countHint);
}
@@ -1317,6 +1320,8 @@ public:
case '\r': // Enter - open conversation
if (_convCount > 0 && _inboxCursor < _convCount) {
strncpy(_activePhone, _conversations[_inboxCursor].phone, SMS_PHONE_LEN - 1);
smsStore.markConversationRead(_activePhone);
_conversations[_inboxCursor].unreadCount = 0;
refreshConversation();
_view = CONVERSATION;
}
+84 -6
View File
@@ -14,6 +14,17 @@ void SMSStore::begin() {
MESH_DEBUG_PRINTLN("[SMSStore] created %s", SMS_DIR);
}
_ready = true;
// One-time migration: history saved before read-tracking existed has read=0,
// which would otherwise all show as unread. Mark it read once, gated by a
// marker file so genuine unread state survives later reboots.
if (!SD.exists(SMS_READ_MIGRATED)) {
migrateExistingAsRead();
File m = SD.open(SMS_READ_MIGRATED, FILE_WRITE);
if (m) m.close();
digitalWrite(SDCARD_CS, HIGH);
}
MESH_DEBUG_PRINTLN("[SMSStore] ready");
}
@@ -43,6 +54,7 @@ bool SMSStore::saveMessage(const char* phone, const char* body, bool isSent, uin
memset(&rec, 0, sizeof(rec));
rec.timestamp = timestamp;
rec.isSent = isSent ? 1 : 0;
rec.read = isSent ? 1 : 0; // sent messages are never unread; received start unread
rec.bodyLen = strlen(body);
if (rec.bodyLen >= SMS_BODY_LEN) rec.bodyLen = SMS_BODY_LEN - 1;
strncpy(rec.phone, phone, SMS_PHONE_LEN - 1);
@@ -86,13 +98,20 @@ int SMSStore::loadConversations(SMSConversation* out, int maxCount) {
int numRecords = fileSize / sizeof(SMSRecord);
// Read the last record for preview
// Scan all records: count unread received messages, keep the last for preview
SMSRecord rec;
SMSRecord lastRec;
entry.seek(fileSize - sizeof(SMSRecord));
if (entry.read((uint8_t*)&lastRec, sizeof(SMSRecord)) != sizeof(SMSRecord)) {
entry.close();
continue;
memset(&lastRec, 0, sizeof(lastRec));
int unread = 0;
bool haveLast = false;
for (int i = 0; i < numRecords; i++) {
entry.seek((size_t)i * sizeof(SMSRecord));
if (entry.read((uint8_t*)&rec, sizeof(SMSRecord)) != sizeof(SMSRecord)) continue;
if (rec.isSent == 0 && rec.read == 0) unread++;
lastRec = rec;
haveLast = true;
}
if (!haveLast) { entry.close(); continue; }
SMSConversation& conv = out[count];
memset(&conv, 0, sizeof(SMSConversation));
@@ -101,7 +120,7 @@ int SMSStore::loadConversations(SMSConversation* out, int maxCount) {
conv.preview[39] = '\0';
conv.lastTimestamp = lastRec.timestamp;
conv.messageCount = numRecords;
conv.unreadCount = 0; // TODO: track read state
conv.unreadCount = unread;
conv.valid = true;
count++;
@@ -193,4 +212,63 @@ int SMSStore::getMessageCount(const char* phone) {
return count;
}
void SMSStore::markFileRead(const char* filepath) {
// In-place flag update: open read+write without truncating ("r+").
// Caller releases SDCARD_CS afterwards.
File f = SD.open(filepath, "r+");
if (!f) return;
size_t fileSize = f.size();
int numRecords = fileSize / sizeof(SMSRecord);
SMSRecord rec;
for (int i = 0; i < numRecords; i++) {
f.seek((size_t)i * sizeof(SMSRecord));
if (f.read((uint8_t*)&rec, sizeof(SMSRecord)) != sizeof(SMSRecord)) continue;
if (rec.isSent == 0 && rec.read == 0) {
rec.read = 1;
f.seek((size_t)i * sizeof(SMSRecord));
f.write((uint8_t*)&rec, sizeof(SMSRecord));
}
}
f.close();
}
void SMSStore::markConversationRead(const char* phone) {
if (!_ready) return;
char filepath[64];
phoneToFilename(phone, filepath, sizeof(filepath));
markFileRead(filepath);
digitalWrite(SDCARD_CS, HIGH);
}
void SMSStore::migrateExistingAsRead() {
File dir = SD.open(SMS_DIR);
if (!dir || !dir.isDirectory()) return;
File entry;
while ((entry = dir.openNextFile())) {
const char* name = entry.name();
if (!strstr(name, ".sms")) { entry.close(); continue; }
// name() may be a bare basename or a full path depending on core version
char fullpath[64];
if (name[0] == '/') {
strncpy(fullpath, name, sizeof(fullpath) - 1);
fullpath[sizeof(fullpath) - 1] = '\0';
} else {
snprintf(fullpath, sizeof(fullpath), "%s/%s", SMS_DIR, name);
}
entry.close();
markFileRead(fullpath);
}
dir.close();
digitalWrite(SDCARD_CS, HIGH);
}
#endif // HAS_4G_MODEM
+12 -1
View File
@@ -22,12 +22,14 @@
#define SMS_BODY_LEN 161
#define SMS_MAX_CONVERSATIONS 20
#define SMS_DIR "/sms"
#define SMS_READ_MIGRATED "/sms/rdmig.dat" // one-time read-state migration marker
// Fixed-size on-disk record (256 bytes, easy alignment)
struct SMSRecord {
uint32_t timestamp; // epoch seconds
uint8_t isSent; // 1=sent, 0=received
uint8_t reserved[2];
uint8_t read; // 1=read, 0=unread (received messages only)
uint8_t reserved[1];
uint8_t bodyLen; // actual length of body
char phone[SMS_PHONE_LEN]; // 20
char body[SMS_BODY_LEN]; // 161
@@ -73,11 +75,20 @@ public:
// Get total message count for a phone number
int getMessageCount(const char* phone);
// Mark all received messages in a conversation as read (persisted to SD)
void markConversationRead(const char* phone);
private:
bool _ready = false;
// Convert phone number to safe filename
void phoneToFilename(const char* phone, char* out, size_t outLen);
// Set read=1 on every received-unread record in a conversation file
void markFileRead(const char* filepath);
// One-time: mark all pre-existing history read (it pre-dates read tracking)
void migrateExistingAsRead();
};
// Global singleton