tdpro refined file export contacts selection json

This commit is contained in:
pelgraine
2026-03-31 02:49:57 +11:00
parent c7d0449181
commit c687133b05
4 changed files with 705 additions and 108 deletions

View File

@@ -8,7 +8,7 @@
#define FIRMWARE_VER_CODE 10
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "29 March 2026"
#define FIRMWARE_BUILD_DATE "31 March 2026"
#endif
#ifndef FIRMWARE_VERSION

View File

@@ -104,6 +104,10 @@
// SD-backed persistence state
static bool sdCardReady = false;
// Contacts screen: deferred Enter for long-press detection
static bool contactsEnterPending = false;
static unsigned long contactsEnterTime = 0;
// ---------------------------------------------------------------------------
// SD Settings Backup / Restore
// ---------------------------------------------------------------------------
@@ -344,6 +348,322 @@
added, skipped, (int)the_mesh.getNumContacts());
return added;
}
// -----------------------------------------------------------------------
// JSON export: write selected contacts (by raw index) to SD card.
// Compatible with MeshCore companion app's meshcore_contacts.json format.
// If indices is NULL, exports ALL contacts.
// Returns number of contacts exported, or -1 on error.
// -----------------------------------------------------------------------
int exportContactsJSON(const uint16_t* indices, int numIndices) {
if (!sdCardReady) {
Serial.println("JSON Export: SD card not ready");
return -1;
}
if (!SD.exists("/meshcore")) SD.mkdir("/meshcore");
// Build timestamped filename: meshcore_contacts_YYYYMMDD_HHMM.json
char jsonPath[64];
uint32_t epoch = rtc_clock.getCurrentTime();
int8_t utcOff = the_mesh.getNodePrefs()->utc_offset_hours;
time_t localEpoch = (time_t)epoch + (utcOff * 3600);
struct tm tmBuf;
gmtime_r(&localEpoch, &tmBuf);
snprintf(jsonPath, sizeof(jsonPath),
"/meshcore/meshcore_contacts_%04d%02d%02d_%02d%02d.json",
tmBuf.tm_year + 1900, tmBuf.tm_mon + 1, tmBuf.tm_mday,
tmBuf.tm_hour, tmBuf.tm_min);
File f = SD.open(jsonPath, "w", true);
if (!f) {
Serial.printf("JSON Export: failed to open %s\n", jsonPath);
digitalWrite(SDCARD_CS, HIGH);
return -1;
}
f.print("{\n \"contacts\": [\n");
int written = 0;
uint32_t total = the_mesh.getNumContacts();
// When indices is NULL, export all contacts (scan = rawIdx).
// When indices is provided, scan iterates over the indices array.
int loopCount = (indices != NULL) ? numIndices : (int)total;
for (int scan = 0; scan < loopCount; scan++) {
int rawIdx = (indices != NULL) ? indices[scan] : scan;
ContactInfo c;
if (!the_mesh.getContactByIdx(rawIdx, c)) continue;
if (written > 0) f.print(",\n");
// Public key -> 64-char hex string
char hexKey[65];
mesh::Utils::toHex(hexKey, c.id.pub_key, PUB_KEY_SIZE);
// GPS: int32 -> decimal string (6 decimal places)
char latStr[16], lonStr[16];
snprintf(latStr, sizeof(latStr), "%.6f", (double)c.gps_lat / 1000000.0);
snprintf(lonStr, sizeof(lonStr), "%.6f", (double)c.gps_lon / 1000000.0);
// Escape name for JSON (backslash and double-quote)
char safeName[80];
int si = 0;
for (int ni = 0; c.name[ni] && si < (int)sizeof(safeName) - 2; ni++) {
if (c.name[ni] == '"' || c.name[ni] == '\\') safeName[si++] = '\\';
safeName[si++] = c.name[ni];
}
safeName[si] = '\0';
f.printf(" {\n");
f.printf(" \"type\": %d,\n", c.type);
f.printf(" \"name\": \"%s\",\n", safeName);
f.printf(" \"custom_name\": null,\n");
f.printf(" \"public_key\": \"%s\",\n", hexKey);
f.printf(" \"flags\": %d,\n", c.flags);
f.printf(" \"latitude\": \"%s\",\n", latStr);
f.printf(" \"longitude\": \"%s\",\n", lonStr);
f.printf(" \"last_advert\": %lu,\n", (unsigned long)c.last_advert_timestamp);
f.printf(" \"last_modified\": %lu,\n", (unsigned long)c.lastmod);
f.printf(" \"out_path_list\": null\n");
f.printf(" }");
written++;
}
f.print("\n ]\n}\n");
f.close();
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("JSON Export: %d contacts to %s\n", written, jsonPath);
return written;
}
// -----------------------------------------------------------------------
// JSON import: merge contacts from newest meshcore_contacts*.json on SD.
// Scans /meshcore/ for the most recent file matching the pattern.
// Compatible with MeshCore companion app export format.
// Skips contacts already present (by pub_key match).
// Returns number of NEW contacts added, or -1 on error.
// -----------------------------------------------------------------------
int importContactsJSON() {
if (!sdCardReady) return -1;
if (!SD.exists("/meshcore")) return -1;
// Scan /meshcore/ for the newest meshcore_contacts*.json file.
// Timestamped filenames (YYYYMMDD_HHMM) sort lexicographically by recency.
// Also matches the plain "meshcore_contacts.json" from the MeshCore app.
char bestPath[64] = {0};
char bestName[48] = {0};
File dir = SD.open("/meshcore");
if (!dir || !dir.isDirectory()) return -1;
File entry = dir.openNextFile();
while (entry) {
if (!entry.isDirectory()) {
const char* name = entry.name();
// SD library may return full path or bare filename — strip to bare name
const char* slash = strrchr(name, '/');
if (slash) name = slash + 1;
// Match: starts with "meshcore_contacts" and ends with ".json"
if (strncmp(name, "meshcore_contacts", 17) == 0) {
int nlen = strlen(name);
if (nlen > 5 && strcmp(name + nlen - 5, ".json") == 0) {
// Keep the lexicographically greatest filename (= newest timestamp)
if (bestName[0] == '\0' || strcmp(name, bestName) > 0) {
strncpy(bestName, name, sizeof(bestName) - 1);
snprintf(bestPath, sizeof(bestPath), "/meshcore/%s", name);
}
}
}
}
entry = dir.openNextFile();
}
dir.close();
if (bestPath[0] == '\0') {
Serial.println("JSON Import: no meshcore_contacts*.json found");
return -1;
}
Serial.printf("JSON Import: opening %s\n", bestPath);
File f = SD.open(bestPath, "r");
if (!f) return -1;
int added = 0, skipped = 0;
// Simple line-by-line parser — no ArduinoJson dependency.
ContactInfo c;
bool inContact = false;
uint8_t pubkey[PUB_KEY_SIZE];
bool gotPubkey = false;
bool gotType = false;
char lineBuf[256];
while (f.available()) {
int len = 0;
while (f.available() && len < (int)sizeof(lineBuf) - 1) {
char ch = f.read();
if (ch == '\n') break;
lineBuf[len++] = ch;
}
lineBuf[len] = '\0';
// Trim leading whitespace
char* line = lineBuf;
while (*line == ' ' || *line == '\t') line++;
// Detect contact block boundaries
if (strstr(line, "{") && !strstr(line, "\"contacts\"")) {
memset(&c, 0, sizeof(c));
gotPubkey = false;
gotType = false;
c.out_path_len = OUT_PATH_UNKNOWN;
c.shared_secret_valid = false;
inContact = true;
continue;
}
if (inContact && line[0] == '}') {
// End of contact object — try to import
if (gotPubkey && gotType) {
c.id = mesh::Identity(pubkey);
if (the_mesh.lookupContactByPubKey(pubkey, PUB_KEY_SIZE) != NULL) {
skipped++;
} else if (the_mesh.addContact(c)) {
added++;
} else {
Serial.printf("JSON Import: table full after %d added\n", added);
break;
}
}
inContact = false;
continue;
}
if (!inContact) continue;
// Parse key-value pairs
char* q1 = strchr(line, '"');
if (!q1) continue;
char* q2 = strchr(q1 + 1, '"');
if (!q2) continue;
*q2 = '\0';
const char* key = q1 + 1;
// Find the value after ": "
char* valStart = q2 + 1;
while (*valStart == ':' || *valStart == ' ' || *valStart == '\t') valStart++;
// Strip trailing comma and whitespace
int vlen = strlen(valStart);
while (vlen > 0 && (valStart[vlen-1] == ',' || valStart[vlen-1] == '\n' ||
valStart[vlen-1] == '\r' || valStart[vlen-1] == ' ')) {
valStart[--vlen] = '\0';
}
if (strcmp(key, "type") == 0) {
c.type = (uint8_t)atoi(valStart);
gotType = true;
}
else if (strcmp(key, "name") == 0) {
if (valStart[0] == '"') valStart++;
int slen = strlen(valStart);
if (slen > 0 && valStart[slen-1] == '"') valStart[slen-1] = '\0';
// Unescape \" and \\ in-place
int wi = 0;
for (int ri = 0; valStart[ri] && wi < 31; ri++) {
if (valStart[ri] == '\\' && valStart[ri+1]) { ri++; }
c.name[wi++] = valStart[ri];
}
c.name[wi] = '\0';
}
else if (strcmp(key, "public_key") == 0) {
if (valStart[0] == '"') valStart++;
int slen = strlen(valStart);
if (slen > 0 && valStart[slen-1] == '"') valStart[slen-1] = '\0';
if (mesh::Utils::fromHex(pubkey, PUB_KEY_SIZE, valStart)) {
gotPubkey = true;
}
}
else if (strcmp(key, "flags") == 0) {
c.flags = (uint8_t)atoi(valStart);
}
else if (strcmp(key, "latitude") == 0) {
if (valStart[0] == '"') valStart++;
int slen = strlen(valStart);
if (slen > 0 && valStart[slen-1] == '"') valStart[slen-1] = '\0';
c.gps_lat = (int32_t)(atof(valStart) * 1000000.0);
}
else if (strcmp(key, "longitude") == 0) {
if (valStart[0] == '"') valStart++;
int slen = strlen(valStart);
if (slen > 0 && valStart[slen-1] == '"') valStart[slen-1] = '\0';
c.gps_lon = (int32_t)(atof(valStart) * 1000000.0);
}
else if (strcmp(key, "last_advert") == 0) {
c.last_advert_timestamp = (uint32_t)strtoul(valStart, NULL, 10);
}
else if (strcmp(key, "last_modified") == 0) {
c.lastmod = (uint32_t)strtoul(valStart, NULL, 10);
}
// custom_name, out_path_list — ignored
}
f.close();
digitalWrite(SDCARD_CS, HIGH);
if (added > 0) {
the_mesh.saveContacts();
}
Serial.printf("JSON Import: %d added, %d skipped, %d total\n",
added, skipped, (int)the_mesh.getNumContacts());
return added;
}
// -----------------------------------------------------------------------
// Select mode actions — delete selected contacts, toggle favourite
// -----------------------------------------------------------------------
int deleteSelectedContacts(const uint16_t* indices, int count) {
// Delete in reverse order so indices remain valid
int deleted = 0;
for (int i = count - 1; i >= 0; i--) {
ContactInfo c;
if (the_mesh.getContactByIdx(indices[i], c)) {
if (the_mesh.removeContact(c)) {
deleted++;
}
}
}
if (deleted > 0) {
the_mesh.saveContacts();
}
Serial.printf("Deleted %d/%d selected contacts\n", deleted, count);
return deleted;
}
int toggleFavouriteSelected(const uint16_t* indices, int count) {
int toggled = 0;
for (int i = 0; i < count; i++) {
ContactInfo tmp;
if (the_mesh.getContactByIdx(indices[i], tmp)) {
ContactInfo* cp = the_mesh.lookupContactByPubKey(tmp.id.pub_key, PUB_KEY_SIZE);
if (cp) {
cp->flags ^= 0x01; // Toggle favourite bit
toggled++;
}
}
}
if (toggled > 0) {
the_mesh.saveContacts();
}
Serial.printf("Toggled favourite on %d contacts\n", toggled);
return toggled;
}
#endif
// =============================================================================
@@ -951,6 +1271,7 @@ static void lastHeardToggleContact() {
}
// Contacts screen: tap to select row, tap same row to activate
// In select mode: tap same row toggles checkbox instead
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
if (cs) {
@@ -959,7 +1280,14 @@ static void lastHeardToggleContact() {
ui_task.forceRefresh();
return 0; // Moved cursor
}
if (result == 2) return KEY_ENTER; // Same row — activate
if (result == 2) {
if (cs->isInSelectMode()) {
cs->toggleSelected();
ui_task.forceRefresh();
return 0;
}
return KEY_ENTER; // Normal mode — activate
}
}
return 0;
}
@@ -1196,49 +1524,25 @@ static void lastHeardToggleContact() {
#endif
}
// Contacts screen: long press → DM for chat contacts, admin for repeaters
// Contacts screen: long press
// If NOT in select mode → enter select mode
// If already in select mode → exit select mode
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
if (cs) {
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
#if defined(LilyGo_T5S3_EPaper_Pro)
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
return 0;
}
char dname[32];
cs->getSelectedContactName(dname, sizeof(dname));
char label[40];
snprintf(label, sizeof(label), "DM: %s", dname);
ui_task.showVirtualKeyboard(VKB_DM, label, "", 137, idx);
return 0;
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
// Room server: open login (after login, auto-redirects to conversation)
ui_task.gotoRepeaterAdmin(idx);
return 0;
} else if (idx >= 0 && ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
if (!cs->isInSelectMode()) {
// Enter select mode on long press
cs->enterSelectMode();
ui_task.forceRefresh();
Serial.println("Contacts: entered select mode (touch long press)");
return 0;
}
#else
// T-Deck Pro: repeater admin works directly, DM via keyboard compose
if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
ui_task.gotoRepeaterAdmin(idx);
return 0;
}
return KEY_ENTER;
#endif
// Already in select mode: long press exits select mode on both platforms
// (T-Deck Pro uses keyboard for actions; T5S3 can use CardKB if attached)
cs->exitSelectMode();
ui_task.forceRefresh();
Serial.println("Contacts: exited select mode (touch long press)");
return 0;
}
return KEY_ENTER;
}
@@ -2448,6 +2752,61 @@ void loop() {
// Handle T-Deck Pro keyboard input
#if defined(LilyGo_TDeck_Pro)
handleKeyboardInput();
// Deferred Enter processing for contacts screen long-press detection.
// Enter is captured in handleKeyboardInput() but action is deferred so
// we can distinguish short press (DM/admin) from long press (select mode).
if (contactsEnterPending && ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
bool inSelect = cs && cs->isInSelectMode();
if (!keyboard.isEnterHeld()) {
// Released — short press
contactsEnterPending = false;
if (inSelect) {
// In select mode: toggle current contact's checkbox
if (cs) { cs->toggleSelected(); ui_task.forceRefresh(); }
} else {
// Normal mode: fire the DM/admin action
if (cs) {
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
} else {
composeDM = true;
composeDMContactIdx = idx;
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
drawComposeScreen();
lastComposeRefresh = millis();
}
} else if (idx >= 0 && (ctype == ADV_TYPE_REPEATER || ctype == ADV_TYPE_ROOM)) {
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0 && ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
}
}
}
} else if (keyboard.enterHeldMs() >= 600 && !inSelect) {
// Long press threshold — enter select mode
contactsEnterPending = false;
if (cs) {
cs->enterSelectMode();
ui_task.forceRefresh();
Serial.println("Contacts: entered select mode (long press)");
}
}
}
#endif
// ---------------------------------------------------------------------------
@@ -2722,9 +3081,13 @@ void loop() {
}
}
} else if (ui_task.isOnContactsScreen()) {
// DM compose for chat contacts, admin for repeaters
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
if (cs) {
if (cs && cs->isInSelectMode()) {
// Select mode: Enter toggles checkbox
cs->toggleSelected();
ui_task.forceRefresh();
} else if (cs) {
// Normal mode: DM compose for chat contacts, admin for repeaters
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
@@ -3556,6 +3919,129 @@ void handleKeyboardInput() {
}
#endif
// ---------------------------------------------------------------------------
// Contacts select mode — intercept keys before normal navigation
// ---------------------------------------------------------------------------
if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
if (cs && cs->isInSelectMode()) {
switch (key) {
case 'q':
// Exit select mode (don't go home)
cs->exitSelectMode();
ui_task.forceRefresh();
Serial.println("Contacts: exited select mode");
return;
case '\b': {
// Backspace in select mode:
// Shift+Backspace = delete selected (with confirmation)
// Plain backspace = exit select mode
if (keyboard.wasShiftConsumed()) {
static unsigned long lastDeleteAttempt = 0;
int selCount = cs->getSelectedCount();
if (selCount == 0) {
ui_task.showAlert("None selected", 1500);
return;
}
if (millis() - lastDeleteAttempt < 3000) {
uint16_t* selBuf = (uint16_t*)malloc(selCount * sizeof(uint16_t));
if (!selBuf) { ui_task.showAlert("Memory error", 1500); return; }
int n = cs->getSelectedRawIndices(selBuf, selCount);
int deleted = deleteSelectedContacts(selBuf, n);
free(selBuf);
char msg[48];
snprintf(msg, sizeof(msg), "Deleted %d contacts", deleted);
ui_task.showAlert(msg, 2000);
cs->exitSelectMode();
cs->invalidateCache();
ui_task.forceRefresh();
lastDeleteAttempt = 0;
} else {
lastDeleteAttempt = millis();
char msg[48];
snprintf(msg, sizeof(msg), "Delete %d? Shift+Del again", selCount);
ui_task.showAlert(msg, 2500);
}
} else {
// Plain backspace = exit select mode
cs->exitSelectMode();
ui_task.forceRefresh();
Serial.println("Contacts: exited select mode (backspace)");
}
return;
}
case 'x': {
// Export selected contacts as JSON
int selCount = cs->getSelectedCount();
if (selCount == 0) {
ui_task.showAlert("None selected", 1500);
return;
}
uint16_t* selBuf = (uint16_t*)malloc(selCount * sizeof(uint16_t));
if (!selBuf) { ui_task.showAlert("Memory error", 1500); return; }
int n = cs->getSelectedRawIndices(selBuf, selCount);
int exported = exportContactsJSON(selBuf, n);
free(selBuf);
if (exported >= 0) {
char msg[48];
snprintf(msg, sizeof(msg), "Exported %d to SD (JSON)", exported);
ui_task.showAlert(msg, 2500);
cs->exitSelectMode();
} else {
ui_task.showAlert("Export failed", 2000);
}
ui_task.forceRefresh();
return;
}
case 'r': {
// Import contacts from JSON
int added = importContactsJSON();
if (added == -1) added = importContactsFromSD();
if (added > 0) {
cs->invalidateCache();
cs->exitSelectMode();
char msg[48];
snprintf(msg, sizeof(msg), "+%d imported (JSON)", added);
ui_task.showAlert(msg, 2500);
} else if (added == 0) {
ui_task.showAlert("No new contacts", 2000);
} else {
ui_task.showAlert("Import failed (no file?)", 2000);
}
ui_task.forceRefresh();
return;
}
case 'f': {
// Toggle favourite on selected contacts
int selCount = cs->getSelectedCount();
if (selCount == 0) {
ui_task.showAlert("None selected", 1500);
return;
}
uint16_t* selBuf = (uint16_t*)malloc(selCount * sizeof(uint16_t));
if (!selBuf) { ui_task.showAlert("Memory error", 1500); return; }
int n = cs->getSelectedRawIndices(selBuf, selCount);
int toggled = toggleFavouriteSelected(selBuf, n);
free(selBuf);
char msg[48];
snprintf(msg, sizeof(msg), "Toggled fav on %d", toggled);
ui_task.showAlert(msg, 1500);
cs->invalidateCache();
ui_task.forceRefresh();
return;
}
// W, S, A, D, Enter — handled by ContactsScreen::handleInput
default:
break; // Fall through to normal switch
}
}
}
switch (key) {
case 'c':
// Open contacts list
@@ -3800,51 +4286,10 @@ void handleKeyboardInput() {
ui_task.gotoContactsScreen();
}
} else if (ui_task.isOnContactsScreen()) {
ContactsScreen* cs = (ContactsScreen*)ui_task.getContactsScreen();
int idx = cs->getSelectedContactIdx();
uint8_t ctype = cs->getSelectedContactType();
if (idx >= 0 && ctype == ADV_TYPE_CHAT) {
// If unread DMs exist, go to conversation view to read first
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
Serial.printf("Unread DMs from %s — opening conversation\n", cname);
} else {
composeDM = true;
composeDMContactIdx = idx;
cs->getSelectedContactName(composeDMName, sizeof(composeDMName));
composeMode = true;
composeBuffer[0] = '\0';
composePos = 0;
Serial.printf("Entering DM compose to %s (idx %d)\n", composeDMName, idx);
drawComposeScreen();
lastComposeRefresh = millis();
}
} else if (idx >= 0 && ctype == ADV_TYPE_REPEATER) {
// Open repeater admin screen
char rname[32];
cs->getSelectedContactName(rname, sizeof(rname));
Serial.printf("Opening repeater admin for %s (idx %d)\n", rname, idx);
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0 && ctype == ADV_TYPE_ROOM) {
// Room server: open login screen (after login, auto-redirects to conversation)
char rname[32];
cs->getSelectedContactName(rname, sizeof(rname));
Serial.printf("Room %s — opening login\n", rname);
ui_task.gotoRepeaterAdmin(idx);
} else if (idx >= 0) {
// Other contacts with unreads
if (ui_task.hasDMUnread(idx)) {
char cname[32];
cs->getSelectedContactName(cname, sizeof(cname));
ui_task.clearDMUnread(idx);
ui_task.gotoDMConversation(cname);
} else {
Serial.printf("Selected contact type=%d idx=%d\n", ctype, idx);
}
}
// Defer Enter for long-press detection (select mode vs DM/admin)
contactsEnterPending = true;
contactsEnterTime = millis();
Serial.println("Contacts: Enter pressed, deferring for long-press check");
} else if (ui_task.isOnChannelScreen()) {
// If path overlay is showing, Enter copies path text to compose buffer
ChannelScreen* chScr2 = (ChannelScreen*)ui_task.getChannelScreen();
@@ -3966,8 +4411,10 @@ void handleKeyboardInput() {
if (ui_task.isOnMapScreen()) {
ui_task.injectKey('x');
} else if (ui_task.isOnContactsScreen()) {
Serial.println("Contacts: Exporting to SD...");
int exported = exportContactsToSD();
// Normal mode: export ALL as JSON + binary backup
Serial.println("Contacts: Exporting all to SD...");
exportContactsToSD(); // Binary backup (legacy)
int exported = exportContactsJSON(NULL, 0); // JSON (interchangeable)
if (exported >= 0) {
char alertBuf[48];
snprintf(alertBuf, sizeof(alertBuf), "Exported %d to SD", exported);
@@ -3983,10 +4430,14 @@ void handleKeyboardInput() {
if (ui_task.isOnChannelScreen()) {
ui_task.injectKey('r');
} else if (ui_task.isOnContactsScreen()) {
// Try JSON first, fall back to binary
Serial.println("Contacts: Importing from SD...");
int added = importContactsFromSD();
int added = importContactsJSON();
if (added == -1) {
// No JSON file — try legacy binary
added = importContactsFromSD();
}
if (added > 0) {
// Invalidate the contacts screen cache so it rebuilds
ContactsScreen* cs2 = (ContactsScreen*)ui_task.getContactsScreen();
if (cs2) cs2->invalidateCache();
char alertBuf[48];
@@ -3996,7 +4447,7 @@ void handleKeyboardInput() {
} else if (added == 0) {
ui_task.showAlert("No new contacts to add", 2000);
} else {
ui_task.showAlert("Import failed (no backup?)", 2000);
ui_task.showAlert("Import failed (no file?)", 2000);
}
}
break;
@@ -4059,6 +4510,17 @@ void handleKeyboardInput() {
}
}
#endif
// Contacts select mode: Q/backspace exits select mode (doesn't go home)
if (ui_task.isOnContactsScreen()) {
ContactsScreen* csq = (ContactsScreen*)ui_task.getContactsScreen();
if (csq && csq->isInSelectMode()) {
csq->exitSelectMode();
ui_task.forceRefresh();
Serial.println("Contacts: exited select mode (Q/backspace)");
break;
}
// Normal mode: fall through to go home
}
// Discovery screen: Q goes back to contacts (not home)
if (ui_task.isOnDiscoveryScreen()) {
the_mesh.stopDiscovery();

View File

@@ -43,6 +43,10 @@ private:
// Pointer to per-contact DM unread array (owned by UITask, set via setter)
const uint8_t* _dmUnread = nullptr;
// --- Select mode state ---
bool _selectMode;
uint8_t* _selectedBits; // Bitfield: 1 bit per MAX_CONTACTS raw index
// --- helpers ---
static const char* filterLabel(FilterMode f) {
@@ -133,16 +137,30 @@ private:
}
}
// --- Bitfield helpers ---
bool isSelectedRaw(int rawIdx) const {
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return false;
return (_selectedBits[rawIdx / 8] & (1 << (rawIdx % 8))) != 0;
}
void setSelectedRaw(int rawIdx, bool sel) {
if (rawIdx < 0 || rawIdx >= MAX_CONTACTS) return;
if (sel) _selectedBits[rawIdx / 8] |= (1 << (rawIdx % 8));
else _selectedBits[rawIdx / 8] &= ~(1 << (rawIdx % 8));
}
public:
ContactsScreen(UITask* task, mesh::RTCClock* rtc)
: _task(task), _rtc(rtc), _scrollPos(0), _filter(FILTER_ALL),
_filteredCount(0), _cacheValid(false), _rowsPerPage(5) {
_filteredCount(0), _cacheValid(false), _rowsPerPage(5),
_selectMode(false) {
#if defined(ESP32) && defined(BOARD_HAS_PSRAM)
_filteredIdx = (uint16_t*)ps_calloc(MAX_CONTACTS, sizeof(uint16_t));
_filteredTs = (uint32_t*)ps_calloc(MAX_CONTACTS, sizeof(uint32_t));
_selectedBits = (uint8_t*)ps_calloc((MAX_CONTACTS + 7) / 8, 1);
#else
_filteredIdx = new uint16_t[MAX_CONTACTS]();
_filteredTs = new uint32_t[MAX_CONTACTS]();
_selectedBits = new uint8_t[(MAX_CONTACTS + 7) / 8]();
#endif
}
@@ -158,6 +176,58 @@ public:
FilterMode getFilter() const { return _filter; }
// --- Select mode API ---
bool isInSelectMode() const { return _selectMode; }
void enterSelectMode() {
_selectMode = true;
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
// Pre-select the currently highlighted contact
if (_filteredCount > 0 && _scrollPos < _filteredCount) {
setSelectedRaw(_filteredIdx[_scrollPos], true);
}
}
void exitSelectMode() {
_selectMode = false;
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
}
void toggleSelected() {
if (_filteredCount == 0 || _scrollPos >= _filteredCount) return;
int rawIdx = _filteredIdx[_scrollPos];
setSelectedRaw(rawIdx, !isSelectedRaw(rawIdx));
}
void selectAll() {
for (int i = 0; i < _filteredCount; i++) {
setSelectedRaw(_filteredIdx[i], true);
}
}
void deselectAll() {
memset(_selectedBits, 0, (MAX_CONTACTS + 7) / 8);
}
int getSelectedCount() const {
int count = 0;
for (int i = 0; i < _filteredCount; i++) {
if (isSelectedRaw(_filteredIdx[i])) count++;
}
return count;
}
// Fill outBuf with raw contact table indices of selected contacts
int getSelectedRawIndices(uint16_t* outBuf, int maxOut) const {
int count = 0;
for (int i = 0; i < _filteredCount && count < maxOut; i++) {
if (isSelectedRaw(_filteredIdx[i])) {
outBuf[count++] = _filteredIdx[i];
}
}
return count;
}
// Tap-to-select: given virtual Y, select contact row.
// Returns: 0=miss, 1=moved, 2=tapped current row.
int selectRowAtVY(int vy) {
@@ -219,7 +289,12 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
if (_selectMode) {
int selCount = getSelectedCount();
snprintf(tmp, sizeof(tmp), "%d Selected [%s]", selCount, filterLabel(_filter));
} else {
snprintf(tmp, sizeof(tmp), "Contacts [%s]", filterLabel(_filter));
}
display.print(tmp);
// Count on right: All → total/max, filtered → matched/total
@@ -268,6 +343,7 @@ public:
if (!the_mesh.getContactByIdx(_filteredIdx[i], contact)) continue;
bool selected = (i == _scrollPos);
bool sel = _selectMode && isSelectedRaw(_filteredIdx[i]);
// Highlight: fill LIGHT rect first, then draw DARK text on top
if (selected) {
@@ -285,9 +361,13 @@ public:
// Set cursor AFTER fillRect so text draws on top of highlight
display.setCursor(0, y);
// Prefix: "> " for selected, type char + space for others
// Prefix: select mode uses * for selected, normal uses > for cursor
char prefix[4];
if (selected) {
if (_selectMode) {
snprintf(prefix, sizeof(prefix), "%c%c",
sel ? '*' : (selected ? '>' : ' '),
typeChar(contact.type));
} else if (selected) {
snprintf(prefix, sizeof(prefix), ">%c", typeChar(contact.type));
} else {
snprintf(prefix, sizeof(prefix), " %c", typeChar(contact.type));
@@ -352,19 +432,30 @@ public:
#if defined(LilyGo_T5S3_EPaper_Pro)
display.setCursor(0, footerY);
display.print("Swipe:Filter");
const char* right = "Hold:DM/Admin";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
if (_selectMode) {
display.print("Swipe:All/Clr");
const char* right = "Tap:Tog Hold:Exit";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
} else {
display.print("Swipe:Filter");
const char* right = "Hold:DM/Admin";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
#else
// Left: Q:Bk
display.setCursor(0, footerY);
display.print("Q:Bk A/D:Filter");
// Right: P:Path Ent:Sel
const char* right = "P:Path Ent:Sel";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
if (_selectMode) {
display.print("A:All D:Clr");
const char* right = "X:Exp F:Fav Q:Done";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
} else {
display.print("Q:Bk A/D:Filter");
const char* right = "P:Path Ent:Sel";
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
#endif
return 5000; // e-ink: next render after 5s
@@ -387,6 +478,29 @@ public:
}
}
// --- Select mode key handling ---
if (_selectMode) {
// Enter/tap: toggle selection on current contact
if (c == 13 || c == KEY_ENTER) {
toggleSelected();
return true;
}
// A: select all in current filter
if (c == 'a' || c == 'A') {
selectAll();
return true;
}
// D: deselect all
if (c == 'd' || c == 'D') {
deselectAll();
return true;
}
// Q, X, F, Backspace — handled by main.cpp (needs mesh/SD access)
return false;
}
// --- Normal mode key handling ---
// A - previous filter
if (c == 'a' || c == 'A') {
_filter = (FilterMode)(((int)_filter + FILTER_COUNT - 1) % FILTER_COUNT);

View File

@@ -38,6 +38,8 @@ private:
bool _symActive; // Sticky sym (one-shot)
bool _micHeld; // Mic key physically held down (for PTT release detection)
unsigned long _lastShiftTime; // For Shift+key combos
bool _enterHeld; // Enter key physically held down
unsigned long _enterPressTime; // millis() when Enter was pressed
uint8_t readReg(uint8_t reg) {
_wire->beginTransmission(_addr);
@@ -154,7 +156,8 @@ private:
public:
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
: _addr(addr), _wire(wire), _initialized(false),
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _micHeld(false), _lastShiftTime(0) {}
_shiftActive(false), _shiftConsumed(false), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _micHeld(false), _lastShiftTime(0),
_enterHeld(false), _enterPressTime(0) {}
bool begin() {
// Check if device responds
@@ -256,6 +259,11 @@ public:
}
// Only act on key press, not release
// (Enter release tracked for long-press detection)
if (!pressed && keyCode == 21) {
_enterHeld = false;
return 0;
}
if (!pressed || keyCode == 0) {
return 0;
}
@@ -279,6 +287,13 @@ public:
Serial.println("KB: Sym activated");
return 0;
}
// Track Enter press for long-press detection
if (keyCode == 21) {
_enterHeld = true;
_enterPressTime = millis();
// Fall through to normal processing — '\r' is returned below
}
// Handle dedicated $ key (key code 22, next to M)
// Bare press = emoji picker, Sym+$ = literal '$'
@@ -368,4 +383,10 @@ public:
bool wasShiftConsumed() const {
return _shiftConsumed;
}
// Enter long-press detection
bool isEnterHeld() const { return _enterHeld; }
unsigned long enterHeldMs() const {
return _enterHeld ? (millis() - _enterPressTime) : 0;
}
};