From fac93f69a17b73ba9c70106fd22c3af46427fceb Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:54:11 -0500 Subject: [PATCH 01/10] feat(database): add local display_name column to nodes A per-node name that stays on the device, preserved across NodeInfo upserts, renumbers and database merges. Adds the Node resolver and search match. --- .../66.json | 1911 +++++++++++++++++ .../core/database/DatabaseBackup.kt | 2 +- .../core/database/DatabaseMerger.kt | 17 +- .../core/database/MeshtasticDatabase.kt | 3 +- .../core/database/dao/NodeInfoDao.kt | 7 + .../core/database/entity/NodeEntity.kt | 3 + .../core/database/DatabaseMergerTest.kt | 11 +- .../dao/CommonNodeIdentityMigrationDaoTest.kt | 25 + .../database/dao/CommonNodeInfoDaoTest.kt | 59 + .../core/database/entity/NodeMappingTest.kt | 1 + .../database/NodeDisplayNameMigrationTest.kt | 83 + .../org/meshtastic/core/model/HamName.kt | 2 +- .../kotlin/org/meshtastic/core/model/Node.kt | 30 +- .../core/model/NodeDisplayNameTest.kt | 97 + .../core/model/util/ToSharedContactTest.kt | 10 + 15 files changed, 2245 insertions(+), 16 deletions(-) create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/66.json create mode 100644 core/database/src/jvmTest/kotlin/org/meshtastic/core/database/NodeDisplayNameMigrationTest.kt create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeDisplayNameTest.kt diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/66.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/66.json new file mode 100644 index 00000000000..03e260568a3 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/66.json @@ -0,0 +1,1911 @@ +{ + "formatVersion": 1, + "database": { + "version": 66, + "identityHash": "fc0e7ddb2c8849e8a2b48216e53b5ee0", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `soil_water_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `display_name` TEXT NOT NULL DEFAULT '', `power_channel_labels` TEXT NOT NULL DEFAULT '[]', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, `heard_on_current_lora` INTEGER NOT NULL DEFAULT 1, `key_match` INTEGER NOT NULL DEFAULT 1, `new_public_key` BLOB, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "airQualityTelemetry", + "columnName": "air_quality_metrics", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + }, + { + "fieldPath": "soilWaterTelemetry", + "columnName": "soil_water_metrics", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "powerChannelLabels", + "columnName": "power_channel_labels", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "signsPackets", + "columnName": "has_xeddsa_signed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "heardOnCurrentLora", + "columnName": "heard_on_current_lora", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "keyMatch", + "columnName": "key_match", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "newPublicKey", + "columnName": "new_public_key", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "translatedText", + "columnName": "translated_text", + "affinity": "TEXT" + }, + { + "fieldPath": "showTranslated", + "columnName": "show_translated", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "packet_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)", + "fields": [ + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "packet", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END" + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, `draft` TEXT NOT NULL DEFAULT '', `pinned` INTEGER NOT NULL DEFAULT 0, `display_name` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "draft", + "columnName": "draft", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_log_received_date", + "unique": false, + "columnNames": [ + "received_date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_received_date` ON `${TABLE_NAME}` (`received_date`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, `xeddsa_signed` INTEGER NOT NULL DEFAULT 0, `ack_proof_status` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "xeddsaSigned", + "columnName": "xeddsa_signed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ackProofStatus", + "columnName": "ack_proof_status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `is_maker` INTEGER NOT NULL DEFAULT 0, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "isMaker", + "columnName": "is_maker", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "device_link", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))", + "fields": [ + { + "fieldPath": "shortCode", + "columnName": "short_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkDescription", + "columnName": "link_description", + "affinity": "TEXT" + }, + { + "fieldPath": "isVendor", + "columnName": "is_vendor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "regions", + "columnName": "regions", + "affinity": "TEXT" + }, + { + "fieldPath": "targets", + "columnName": "targets", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "short_code" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "discovery_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0, `device_address` TEXT, `home_lora_config` BLOB, `home_primary_channel` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetsScanned", + "columnName": "presets_scanned", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homePreset", + "columnName": "home_preset", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalUniqueNodes", + "columnName": "total_unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalMessages", + "columnName": "total_messages", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalSensorPackets", + "columnName": "total_sensor_packets", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "furthestNodeDistance", + "columnName": "furthest_node_distance", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "completionStatus", + "columnName": "completion_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'complete'" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "userLatitude", + "columnName": "user_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "userLongitude", + "columnName": "user_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalDwellSeconds", + "columnName": "total_dwell_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "deviceAddress", + "columnName": "device_address", + "affinity": "TEXT" + }, + { + "fieldPath": "homeLoraConfig", + "columnName": "home_lora_config", + "affinity": "BLOB" + }, + { + "fieldPath": "homePrimaryChannel", + "columnName": "home_primary_channel", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "discovery_preset_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetName", + "columnName": "preset_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dwellDurationSeconds", + "columnName": "dwell_duration_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uniqueNodes", + "columnName": "unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "directNeighborCount", + "columnName": "direct_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "meshNeighborCount", + "columnName": "mesh_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "infrastructureNodeCount", + "columnName": "infrastructure_node_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "avgAirtimeRate", + "columnName": "avg_airtime_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetSuccessRate", + "columnName": "packet_success_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetFailureRate", + "columnName": "packet_failure_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "numPacketsTx", + "columnName": "num_packets_tx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRx", + "columnName": "num_packets_rx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRxBad", + "columnName": "num_packets_rx_bad", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numRxDupe", + "columnName": "num_rx_dupe", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelay", + "columnName": "num_tx_relay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelayCanceled", + "columnName": "num_tx_relay_canceled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numOnlineNodes", + "columnName": "num_online_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTotalNodes", + "columnName": "num_total_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uptimeSeconds", + "columnName": "uptime_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovery_preset_result_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_session", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "discovered_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL, `rssi` INTEGER, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetResultId", + "columnName": "preset_result_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "neighborType", + "columnName": "neighbor_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'direct'" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL" + }, + { + "fieldPath": "distanceFromUser", + "columnName": "distance_from_user", + "affinity": "REAL" + }, + { + "fieldPath": "hopCount", + "columnName": "hop_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isInfrastructure", + "columnName": "is_infrastructure", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovered_node_preset_result_id", + "unique": false, + "columnNames": [ + "preset_result_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)" + }, + { + "name": "index_discovered_node_node_num", + "unique": false, + "columnNames": [ + "node_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_preset_result", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "preset_result_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "event_firmware_edition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`edition` TEXT NOT NULL, `display_name` TEXT NOT NULL, `welcome_message` TEXT NOT NULL, `event_start` TEXT, `event_end` TEXT, `time_zone` TEXT, `location` TEXT, `icon_url` TEXT, `accent_color` TEXT, `tag` TEXT, `domain` TEXT, `theme_json` TEXT, `firmware_json` TEXT, `links_json` TEXT NOT NULL, PRIMARY KEY(`edition`))", + "fields": [ + { + "fieldPath": "edition", + "columnName": "edition", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "welcomeMessage", + "columnName": "welcome_message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventStart", + "columnName": "event_start", + "affinity": "TEXT" + }, + { + "fieldPath": "eventEnd", + "columnName": "event_end", + "affinity": "TEXT" + }, + { + "fieldPath": "timeZone", + "columnName": "time_zone", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "accentColor", + "columnName": "accent_color", + "affinity": "TEXT" + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "themeJson", + "columnName": "theme_json", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareJson", + "columnName": "firmware_json", + "affinity": "TEXT" + }, + { + "fieldPath": "linksJson", + "columnName": "links_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "edition" + ] + } + }, + { + "tableName": "merge_marker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source_db_name` TEXT NOT NULL, `merged_at` INTEGER NOT NULL, PRIMARY KEY(`source_db_name`))", + "fields": [ + { + "fieldPath": "sourceDbName", + "columnName": "source_db_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mergedAt", + "columnName": "merged_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "source_db_name" + ] + } + }, + { + "tableName": "channel_set", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `channel_set` BLOB NOT NULL, `last_reconciled` BLOB, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelSet", + "columnName": "channel_set", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastReconciled", + "columnName": "last_reconciled", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "bootloader_ota_quirks_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `devices_json` TEXT NOT NULL, `soft_device_variants_json` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "devicesJson", + "columnName": "devices_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "softDeviceVariantsJson", + "columnName": "soft_device_variants_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "maintenance_uf2_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `manifest_json` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manifestJson", + "columnName": "manifest_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "mqtt_roots_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `roots_json` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rootsJson", + "columnName": "roots_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fc0e7ddb2c8849e8a2b48216e53b5ee0')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseBackup.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseBackup.kt index 91bf8b2277c..0d0f83ed1fc 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseBackup.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseBackup.kt @@ -26,7 +26,7 @@ import kotlin.time.Instant /** Pure rules for backing up and restoring the app database: file naming, header validation and staging paths. */ object DatabaseBackup { /** Must equal `@Database(version)` on [MeshtasticDatabase]; a jvmTest opens a real database and checks it. */ - const val SCHEMA_VERSION: Int = 65 + const val SCHEMA_VERSION: Int = 66 const val FILE_NAME_PREFIX: String = "meshtastic_backup" const val FILE_EXTENSION: String = "db" diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt index 18919d4a2be..f2cb748aa1f 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt @@ -124,8 +124,8 @@ object DatabaseMerger { /** * Bring over every node the destination has never seen (so its last-known identity/telemetry/position shows * immediately, including offline nodes the radio may not re-report). For overlapping nodes, keep the destination's - * row — the same radio re-dumps it on connect — but fill in a user note where the destination has none, never - * clobbering one the user already wrote on the canonical DB. + * row — the same radio re-dumps it on connect — but fill in a user note or display name where the destination has + * none, never clobbering one the user already wrote on the canonical DB. */ private suspend fun mergeNodes(source: MeshtasticDatabase, dest: MeshtasticDatabase) { val destNums = dest.nodeInfoDao().getAllNodesSnapshot().mapTo(mutableSetOf()) { it.num } @@ -133,10 +133,15 @@ object DatabaseMerger { when { node.num !in destNums -> dest.nodeInfoDao().upsert(node) - node.notes.isNotBlank() -> { - val existing = dest.nodeInfoDao().getNodeByNum(node.num) - if (existing != null && existing.node.notes.isBlank()) { - dest.nodeInfoDao().setNodeNotes(node.num, node.notes) + node.notes.isNotBlank() || node.displayName.isNotBlank() -> { + val existing = dest.nodeInfoDao().getNodeByNum(node.num)?.node + if (existing != null) { + if (node.notes.isNotBlank() && existing.notes.isBlank()) { + dest.nodeInfoDao().setNodeNotes(node.num, node.notes) + } + if (node.displayName.isNotBlank() && existing.displayName.isBlank()) { + dest.nodeInfoDao().setNodeDisplayName(node.num, node.displayName) + } } } } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index a49421bba7d..b8f7a73278a 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -156,8 +156,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 62, to = 63), // 63 -> 64 is the manual MIGRATION_63_64 (log index added in place), applied via configureCommon(). AutoMigration(from = 64, to = 65), + AutoMigration(from = 65, to = 66), ], - version = 65, + version = 66, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index 0fe1d080749..7771f83a1db 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -120,6 +120,7 @@ interface NodeInfoDao { */ private suspend fun migrateNodeIdentity(from: NodeEntity, to: NodeEntity) { if (to.notes.isBlank()) to.notes = from.notes + if (to.displayName.isBlank()) to.displayName = from.displayName if (to.powerChannelLabels.isEmpty()) to.powerChannelLabels = from.powerChannelLabels to.manuallyVerified = to.manuallyVerified || from.manuallyVerified to.isFavorite = to.isFavorite || from.isFavorite @@ -232,6 +233,7 @@ interface NodeInfoDao { trustIncomingKey: Boolean = false, ): NodeEntity { val resolvedNotes = incomingNode.notes.ifBlank { existingNode.notes } + val resolvedDisplayName = incomingNode.displayName.ifBlank { existingNode.displayName } val resolvedPowerChannelLabels = incomingNode.powerChannelLabels.ifEmpty { existingNode.powerChannelLabels } val isPlaceholder = incomingNode.user.hw_model == HardwareModel.UNSET @@ -248,6 +250,7 @@ interface NodeInfoDao { shortName = existingNode.shortName, manuallyVerified = incomingNode.manuallyVerified || existingNode.manuallyVerified, notes = resolvedNotes, + displayName = resolvedDisplayName, powerChannelLabels = resolvedPowerChannelLabels, ) } @@ -268,6 +271,7 @@ interface NodeInfoDao { newPublicKey = resolved.newPublicKey, manuallyVerified = incomingNode.manuallyVerified || existingNode.manuallyVerified, notes = resolvedNotes, + displayName = resolvedDisplayName, powerChannelLabels = resolvedPowerChannelLabels, ) } @@ -468,6 +472,9 @@ interface NodeInfoDao { @Query("UPDATE nodes SET notes = :notes WHERE num = :num") suspend fun setNodeNotes(num: Int, notes: String) + @Query("UPDATE nodes SET display_name = :displayName WHERE num = :num") + suspend fun setNodeDisplayName(num: Int, displayName: String) + @Query("UPDATE nodes SET power_channel_labels = :labels WHERE num = :num") suspend fun setPowerChannelLabels(num: Int, labels: List) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index 8a4ee249a7e..a829a3e9ede 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -69,6 +69,7 @@ fun Node.toEntity() = NodeEntity( paxcounter = paxcounter, publicKey = publicKey, notes = notes, + displayName = displayName, powerChannelLabels = powerChannelLabels, manuallyVerified = manuallyVerified, nodeStatus = nodeStatus, @@ -130,6 +131,7 @@ data class NodeEntity( @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var paxcounter: Paxcount = Paxcount.Builder().build(), @ColumnInfo(name = "public_key") var publicKey: ByteString? = null, @ColumnInfo(name = "notes", defaultValue = "") var notes: String = "", + @ColumnInfo(name = "display_name", defaultValue = "") var displayName: String = "", @ColumnInfo(name = "power_channel_labels", defaultValue = "[]") var powerChannelLabels: List = emptyList(), @ColumnInfo(name = "manually_verified", defaultValue = "0") var manuallyVerified: Boolean = false, // ONLY set true when scanned/imported manually @@ -241,6 +243,7 @@ data class NodeEntity( paxcounter = paxcounter, publicKey = publicKey ?: user.public_key, notes = notes, + displayName = displayName, powerChannelLabels = powerChannelLabels, manuallyVerified = manuallyVerified, nodeStatus = nodeStatus, diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt index a27b80dc169..fdfe882a330 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt @@ -94,7 +94,7 @@ class DatabaseMergerTest { messageText = text, // FTS indexes this column ) - private fun node(num: Int, notes: String = "") = NodeEntity( + private fun node(num: Int, notes: String = "", displayName: String = "") = NodeEntity( num = num, user = User.Builder() @@ -105,6 +105,7 @@ class DatabaseMergerTest { } .build(), notes = notes, + displayName = displayName, ) private fun logEntry(uuid: String, portNum: Int, time: Long) = MeshLog( @@ -123,6 +124,8 @@ class DatabaseMergerTest { dest.packetDao().insert(textPacket("0!broadcast", "destmsg", time = 100)) dest.nodeInfoDao().upsert(node(10, notes = "keep me")) dest.nodeInfoDao().upsert(node(20)) // no notes yet + dest.nodeInfoDao().upsert(node(50, displayName = "dest name")) + dest.nodeInfoDao().upsert(node(60)) dest.packetDao().upsertContactSettings(listOf(ContactSettings(contact_key = "0!broadcast", muteUntil = 999))) dest .packetDao() @@ -141,6 +144,8 @@ class DatabaseMergerTest { source.packetDao().insert(textPacket("0!broadcast", "srcmsgtwo", time = 300)) source.nodeInfoDao().upsert(node(20, notes = "src note")) source.nodeInfoDao().upsert(node(30, notes = "src-only note")) + source.nodeInfoDao().upsert(node(50, displayName = "src name")) + source.nodeInfoDao().upsert(node(60, displayName = "src name")) source.packetDao().upsertContactSettings(listOf(ContactSettings(contact_key = "0!broadcast", muteUntil = 1))) source .packetDao() @@ -204,6 +209,10 @@ class DatabaseMergerTest { assertEquals("src note", dest.nodeInfoDao().getNodeByNum(20)?.node?.notes) assertNotNull(dest.nodeInfoDao().getNodeByNum(30), "source-only noted node inserted") + // Display names follow the same rule: dest's kept, blank filled from source. + assertEquals("dest name", dest.nodeInfoDao().getNodeByNum(50)?.node?.displayName) + assertEquals("src name", dest.nodeInfoDao().getNodeByNum(60)?.node?.displayName) + // Reactions dedupe on composite PK: R1 (already in dest) + R2 (new) = 2. assertEquals(2, dest.packetDao().getAllReactionsSnapshot().size) diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt index 77390f18839..6a218c82c94 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt @@ -82,6 +82,7 @@ abstract class CommonNodeIdentityMigrationDaoTest { key: ByteString, longName: String = "Node $num", notes: String = "", + displayName: String = "", isFavorite: Boolean = false, ) = NodeEntity( num = num, @@ -96,6 +97,7 @@ abstract class CommonNodeIdentityMigrationDaoTest { } .build(), notes = notes, + displayName = displayName, isFavorite = isFavorite, lastHeard = 1000, ) @@ -173,6 +175,29 @@ abstract class CommonNodeIdentityMigrationDaoTest { assertNotNull(dao.getNodeByNum(newNum)) } + @Test + fun renumberCarriesDisplayNameWithoutOverwritingDestinationOne() = runTest { + createDb(myNodeEntity(oldNum, deviceId = deviceIdA)) + dao.upsert(nodeEntity(oldNum, keyA, displayName = "old name")) + + val newNum = 300 + dao.installConfig(myNodeEntity(newNum, deviceId = deviceIdA), listOf(nodeEntity(newNum, keyB))) + assertEquals("old name", assertNotNull(dao.getNodeByNum(newNum)).node.displayName) + } + + @Test + fun renumberKeepsDestinationDisplayNameWhenSet() = runTest { + createDb(myNodeEntity(oldNum, deviceId = deviceIdA)) + dao.upsert(nodeEntity(oldNum, keyA, displayName = "old name")) + + val newNum = 300 + dao.installConfig( + myNodeEntity(newNum, deviceId = deviceIdA), + listOf(nodeEntity(newNum, keyB, displayName = "fresh")), + ) + assertEquals("fresh", assertNotNull(dao.getNodeByNum(newNum)).node.displayName) + } + @Test fun sameHardwareEraseMigratesAcrossKeyChange() = runTest { createDb(myNodeEntity(oldNum, deviceId = deviceIdA)) diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt index b127cea87c0..02e958f5d79 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt @@ -116,6 +116,65 @@ abstract class CommonNodeInfoDaoTest { assertEquals(null, result) } + @Test + fun `setNodeDisplayName writes and clears the local name`() = runTest { + createDb() + dao.upsert(NodeEntity(num = 1, user = User.Builder().also { wb -> wb.id = "!1" }.build())) + + dao.setNodeDisplayName(1, "Mine") + assertEquals("Mine", dao.getNodeByNum(1)?.node?.displayName) + + dao.setNodeDisplayName(1, "") + assertEquals("", dao.getNodeByNum(1)?.node?.displayName) + } + + @Test + fun `an incoming node without a display name keeps the stored one`() = runTest { + createDb() + val user = + User.Builder() + .also { wb -> + wb.id = "!1" + wb.long_name = "Broadcast" + wb.hw_model = org.meshtastic.proto.HardwareModel.TBEAM + } + .build() + dao.upsert(NodeEntity(num = 1, user = user, displayName = "Mine")) + + dao.upsert(NodeEntity(num = 1, user = user.newBuilder().also { wb -> wb.long_name = "Renamed" }.build())) + + val stored = dao.getNodeByNum(1)?.node + assertEquals("Renamed", stored?.longName) + assertEquals("Mine", stored?.displayName) + } + + @Test + fun `a placeholder upsert keeps the stored display name`() = runTest { + createDb() + val known = + User.Builder() + .also { wb -> + wb.id = "!1" + wb.long_name = "Broadcast" + wb.hw_model = org.meshtastic.proto.HardwareModel.TBEAM + } + .build() + dao.upsert(NodeEntity(num = 1, user = known, displayName = "Mine")) + + val placeholder = + User.Builder() + .also { wb -> + wb.id = "!1" + wb.long_name = "Meshtastic 0001" + } + .build() + dao.upsert(NodeEntity(num = 1, user = placeholder)) + + val stored = dao.getNodeByNum(1)?.node + assertEquals("Broadcast", stored?.longName) + assertEquals("Mine", stored?.displayName) + } + @Test fun `an update that does not carry verification keeps a stored verified contact verified`() = runTest { createDb() diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/entity/NodeMappingTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/entity/NodeMappingTest.kt index a3b8848bb37..2ee4ae4df99 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/entity/NodeMappingTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/entity/NodeMappingTest.kt @@ -87,6 +87,7 @@ internal fun fullyPopulatedNode(): Node { paxcounter = Paxcount.Builder().also { wb -> wb.ble = 3 }.build(), publicKey = key, notes = "fixture notes", + displayName = "Fixture Display", powerChannelLabels = listOf("Solar", "Battery"), manuallyVerified = true, signsPackets = true, diff --git a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/NodeDisplayNameMigrationTest.kt b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/NodeDisplayNameMigrationTest.kt new file mode 100644 index 00000000000..7002aadcb9f --- /dev/null +++ b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/NodeDisplayNameMigrationTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import androidx.room3.testing.MigrationTestHelper +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.sqlite.execSQL +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.io.path.Path +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** 65→66 adds `nodes.display_name`, the local-only name a user gives a node. */ +class NodeDisplayNameMigrationTest { + + private val dbFile = File.createTempFile("meshtastic-display-name-migration", ".db").apply { delete() } + + private val helper = + MigrationTestHelper( + schemaDirectoryPath = Path("schemas"), + databasePath = dbFile.toPath(), + driver = BundledSQLiteDriver(), + databaseClass = MeshtasticDatabase::class, + databaseFactory = { MeshtasticDatabaseConstructor.initialize() }, + ) + + @AfterTest + fun cleanUp() { + dbFile.delete() + } + + @Test + fun displayNameColumnAddedWithEmptyDefaultAndPreservesNodes() = runTest { + helper.createDatabase(FROM_VERSION).use { connection -> + val columns = + "num, user, position, latitude, longitude, snr, rssi, last_heard, device_metrics, channel, " + + "via_mqtt, hops_away, is_favorite, environment_metrics, power_metrics, paxcounter" + connection.execSQL( + "INSERT INTO nodes ($columns, long_name, notes) VALUES " + + "(42, x'', x'', 0.0, 0.0, 0.0, 0, 1000, x'', 0, 0, 1, 1, x'', x'', x'', 'Minnie Mouse', 'a note')", + ) + } + + helper.runMigrationsAndValidate(TO_VERSION, emptyList()).use { connection -> + assertEquals(listOf("42"), queryColumn(connection, "SELECT num FROM nodes")) + assertEquals(listOf(""), queryColumn(connection, "SELECT display_name FROM nodes WHERE num = 42")) + assertEquals(listOf("Minnie Mouse"), queryColumn(connection, "SELECT long_name FROM nodes WHERE num = 42")) + assertEquals(listOf("a note"), queryColumn(connection, "SELECT notes FROM nodes WHERE num = 42")) + assertEquals(listOf("1000"), queryColumn(connection, "SELECT last_heard FROM nodes WHERE num = 42")) + } + } + + private fun queryColumn(connection: SQLiteConnection, sql: String): List = + connection.prepare(sql).use { statement -> + buildList { + while (statement.step()) { + add(if (statement.isNull(0)) null else statement.getText(0)) + } + } + } + + private companion object { + const val FROM_VERSION = 65 + const val TO_VERSION = 66 + } +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/HamName.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/HamName.kt index cc0b7d34f06..71f563d3126 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/HamName.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/HamName.kt @@ -88,7 +88,7 @@ object HamName { fun String.utf8Size(): Int = encodeToByteArray().size /** Clips to at most [maxBytes] of UTF-8, stepping whole code points so a surrogate pair is never cut in half. */ -private fun String.limitToBytes(maxBytes: Int): String { +fun String.limitToBytes(maxBytes: Int): String { if (utf8Size() <= maxBytes) return this var end = 0 var used = 0 diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 866bd9a5363..07ea2cb8e16 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -66,6 +66,8 @@ data class Node( val paxcounter: Paxcount = Paxcount.Builder().build(), val publicKey: ByteString? = null, val notes: String = "", + /** Local-only name that replaces [User.long_name] on display surfaces. Never sent, shared or exported. */ + val displayName: String = "", /** User-editable labels per power-metrics channel (e.g. "Solar", "Battery"), indexed by channel - 1. */ val powerChannelLabels: List = emptyList(), val manuallyVerified: Boolean = false, @@ -249,12 +251,9 @@ data class Node( fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? { val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK - val candidateRelayNodes = - nodes.filter { - it.num != ourNodeNum && - it.lastHeard != 0 && - (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix - } + val candidateRelayNodes = nodes.filter { + it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix + } val closestRelayNode = if (candidateRelayNodes.size == 1) { @@ -295,6 +294,24 @@ fun Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in Config.DeviceConfig.Role.TAK_TRACKER, ) +/** True when the user has set a local display name for this node. */ +val Node.hasDisplayName: Boolean + get() = displayName.isNotBlank() + +/** Local display name, never sent. Broadcast name for share and export is `user.long_name`. */ +val Node.effectiveLongName: String + get() = if (hasDisplayName) displayName else user.long_name + +/** The broadcast long name while a display name hides it, else null. */ +val Node.hiddenLongName: String? + get() = user.long_name.takeIf { hasDisplayName && it.isNotBlank() } + +/** Longest local display name, matching the `User.long_name` cap in UTF-8 bytes. */ +const val MAX_DISPLAY_NAME_BYTES = 36 + +/** Trims and clips [input] to [MAX_DISPLAY_NAME_BYTES]; blank input becomes the empty string, which means unset. */ +fun normalizeDisplayName(input: String): String = input.trim().limitToBytes(MAX_DISPLAY_NAME_BYTES).trim() + /** Offset converting a negative [Node.num] into its unsigned 32-bit decimal representation. */ private const val UNSIGNED_INT_OFFSET = 4294967296L @@ -309,6 +326,7 @@ private val Node.unsignedNum: Long */ fun Node.matchesSearch(filter: String): Boolean = filter.isBlank() || user.long_name.contains(filter, ignoreCase = true) || + displayName.contains(filter, ignoreCase = true) || user.short_name.contains(filter, ignoreCase = true) || user.id.contains(filter, ignoreCase = true) || nodeStatus?.contains(filter, ignoreCase = true) == true || diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeDisplayNameTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeDisplayNameTest.kt new file mode 100644 index 00000000000..509372c0f85 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeDisplayNameTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NodeDisplayNameTest { + + private fun node(longName: String = "Broadcast", shortName: String = "BC", displayName: String = "") = Node( + num = 1, + user = + User.Builder() + .also { wb -> + wb.id = "!00000001" + wb.long_name = longName + wb.short_name = shortName + } + .build(), + displayName = displayName, + ) + + @Test + fun effectiveLongName_isDisplayNameWhenSet() { + assertEquals("Mine", node(displayName = "Mine").effectiveLongName) + } + + @Test + fun effectiveLongName_isBroadcastNameWhenUnset() { + assertEquals("Broadcast", node().effectiveLongName) + } + + @Test + fun effectiveLongName_ignoresWhitespaceOnlyDisplayName() { + val n = node(displayName = " ") + assertFalse(n.hasDisplayName) + assertEquals("Broadcast", n.effectiveLongName) + } + + @Test + fun effectiveLongName_isDisplayNameWhenBroadcastNameBlank() { + assertEquals("Mine", node(longName = "", displayName = "Mine").effectiveLongName) + } + + @Test + fun shortNameIsNeverOverridden() { + assertEquals("BC", node(displayName = "Mine").user.short_name) + } + + @Test + fun hiddenLongName_isNullWithoutDisplayName() { + assertNull(node().hiddenLongName) + } + + @Test + fun hiddenLongName_isBroadcastNameWhileDisplayNameSet() { + assertEquals("Broadcast", node(displayName = "Mine").hiddenLongName) + } + + @Test + fun hiddenLongName_isNullWhenBroadcastNameBlank() { + assertNull(node(longName = "", displayName = "Mine").hiddenLongName) + } + + @Test + fun hiddenLongName_isKeptWhenDisplayNameEqualsBroadcastName() { + assertEquals("Broadcast", node(displayName = "Broadcast").hiddenLongName) + } + + @Test + fun matchesSearch_matchesDisplayNameAndStillMatchesBroadcastNameShortNameAndId() { + val n = node(displayName = "Mine") + assertTrue(n.matchesSearch("mine")) + assertTrue(n.matchesSearch("broad")) + assertTrue(n.matchesSearch("bc")) + assertTrue(n.matchesSearch("00000001")) + assertFalse(n.matchesSearch("zzz")) + } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ToSharedContactTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ToSharedContactTest.kt index dc70dced654..4ba3847c8d7 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ToSharedContactTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ToSharedContactTest.kt @@ -17,6 +17,7 @@ package org.meshtastic.core.model.util import org.meshtastic.core.model.Node +import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import kotlin.test.Test import kotlin.test.assertEquals @@ -52,6 +53,15 @@ class ToSharedContactTest { assertTrue(node(manuallyVerified = true).toSharedContact(isOwnContact = false).manually_verified) } + @Test + fun `a local display name never reaches the shared contact`() { + val shared = node().copy(displayName = "Secret").toSharedContact() + + assertEquals("Seven", shared.user?.long_name) + assertFalse(SharedContact.ADAPTER.encode(shared).decodeToString().contains("Secret")) + assertFalse(shared.getSharedContactUrl().toString().contains("Secret")) + } + @Test fun `carries the node number and user through`() { val shared = node().toSharedContact() From 26b61941c57aedebc8287c44eff1d60dbf895cbf Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:54:12 -0500 Subject: [PATCH 02/10] feat(node): set a local display name and show the broadcast long name row Edit from the node details card or the node list menu. Node details shows the broadcast long name in its own row while a display name is set. --- .skills/compose-ui/strings-index.txt | 3 + .../datasource/NodeInfoWriteDataSource.kt | 3 + .../SwitchingNodeInfoWriteDataSource.kt | 5 + .../data/repository/NodeRepositoryImpl.kt | 3 + .../core/repository/NodeRepository.kt | 8 ++ .../composeResources/values/strings.xml | 3 + .../core/takserver/TAKMeshIntegrationTest.kt | 2 + .../core/testing/FakeNodeRepository.kt | 5 + .../core/testing/FakeNodeRepositoryTest.kt | 12 ++ .../node/component/DisplayNameDialog.kt | 66 +++++++++++ .../node/component/DisplayNameSection.kt | 106 ++++++++++++++++++ .../feature/node/component/NodeContextMenu.kt | 9 ++ .../node/component/NodeDetailsSection.kt | 11 ++ .../feature/node/detail/NodeDetailContent.kt | 5 + .../feature/node/detail/NodeDetailPreviews.kt | 4 + .../feature/node/detail/NodeDetailScreens.kt | 1 + .../feature/node/list/NodeListScreen.kt | 11 ++ .../node/detail/NodeManagementActionsTest.kt | 50 +++++++++ .../usecase/NodeDetailNameUseCaseTest.kt | 101 +++++++++++++++++ 19 files changed, 408 insertions(+) create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameDialog.kt create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameSection.kt create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/NodeDetailNameUseCaseTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 3b969f7da73..2570a633203 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -496,6 +496,8 @@ discovery_view_map disk_free_indexed display display_config +display_name +display_name_hint dissolved_oxygen ### DISTANCE ### distance @@ -1851,6 +1853,7 @@ serial serial_config session_active session_refresh_required +set_display_name set_time set_up_connection set_your_region diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt index 65015ade486..dc15a073f19 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt @@ -20,6 +20,7 @@ import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.NodeEntity +@Suppress("TooManyFunctions") // One method per persisted node field; the count tracks the schema. interface NodeInfoWriteDataSource { suspend fun upsert(node: NodeEntity) @@ -38,6 +39,8 @@ interface NodeInfoWriteDataSource { suspend fun setNodeNotes(num: Int, notes: String) + suspend fun setNodeDisplayName(num: Int, displayName: String) + suspend fun markAllHeardOnCurrentLora() suspend fun updatePowerChannelLabel(num: Int, channelIndex: Int, label: String) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt index 3a4b35bf8b0..19758d42c45 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt @@ -24,6 +24,7 @@ import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.di.CoroutineDispatchers +@Suppress("TooManyFunctions") // One method per persisted node field; the count tracks the schema. @Single class SwitchingNodeInfoWriteDataSource( private val dbManager: DatabaseProvider, @@ -67,6 +68,10 @@ class SwitchingNodeInfoWriteDataSource( withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } } } + override suspend fun setNodeDisplayName(num: Int, displayName: String) { + withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeDisplayName(num, displayName) } } + } + override suspend fun markAllHeardOnCurrentLora() { withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().markAllHeardOnCurrentLora() } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt index a15b5295252..ea62c7a1d9b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt @@ -263,6 +263,9 @@ class NodeRepositoryImpl( override suspend fun setNodeNotes(num: Int, notes: String) = withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) } + override suspend fun setNodeDisplayName(num: Int, displayName: String) = + withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeDisplayName(num, displayName) } + override suspend fun markAllHeardOnCurrentLora() = withContext(dispatchers.io) { nodeInfoWriteDataSource.markAllHeardOnCurrentLora() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt index 5ef9643c106..5889567ecd1 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt @@ -160,6 +160,14 @@ interface NodeRepository { */ suspend fun setNodeNotes(num: Int, notes: String) + /** + * Sets the local display name for a node. It never leaves the device. An empty string clears it. + * + * @param num The node number. + * @param displayName The name to show in place of the broadcast long name. + */ + suspend fun setNodeDisplayName(num: Int, displayName: String) + /** Clears the unheard flag on every node. See NodeInfoDao.markAllHeardOnCurrentLora. */ suspend fun markAllHeardOnCurrentLora() diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index cf6eded0fd8..5bebb73b02b 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -532,6 +532,8 @@ Disk Free %1$d Display Device Display + Display name + Only shown on this device. Sharing and export use the node's real name. Dissolved O₂ Distance @@ -1917,6 +1919,7 @@ Serial Config Session active Refresh required + Set display name Set time Set up connection Set your region diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt index f32e0d0bdad..c1c7df3332c 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -147,6 +147,8 @@ class TAKMeshIntegrationTest { override suspend fun setNodeNotes(num: Int, notes: String) {} + override suspend fun setNodeDisplayName(num: Int, displayName: String) {} + override suspend fun markAllHeardOnCurrentLora() {} override suspend fun upsert(node: Node) {} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt index 84b233fc4f9..f21bd750ec4 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt @@ -163,6 +163,11 @@ class FakeNodeRepository : _nodeDBbyNum.value = _nodeDBbyNum.value + (num to node.copy(notes = notes)) } + override suspend fun setNodeDisplayName(num: Int, displayName: String) { + val node = _nodeDBbyNum.value[num] ?: return + _nodeDBbyNum.value = _nodeDBbyNum.value + (num to node.copy(displayName = displayName)) + } + override suspend fun updatePowerChannelLabel(num: Int, channelIndex: Int, label: String) { val node = _nodeDBbyNum.value[num] ?: return val merged = mergePowerChannelLabel(node.powerChannelLabels, channelIndex, label) diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeNodeRepositoryTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeNodeRepositoryTest.kt index 796c6dde514..cac36c4e1d4 100644 --- a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeNodeRepositoryTest.kt +++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeNodeRepositoryTest.kt @@ -149,6 +149,18 @@ class FakeNodeRepositoryTest { assertEquals("My Note", node?.notes) } + @Test + fun `setNodeDisplayName persists and clears the display name`() = runTest { + val nodeNum = 1234 + repository.upsert(Node(num = nodeNum)) + + repository.setNodeDisplayName(nodeNum, "Mine") + assertEquals("Mine", repository.nodeDBbyNum.value[nodeNum]?.displayName) + + repository.setNodeDisplayName(nodeNum, "") + assertEquals("", repository.nodeDBbyNum.value[nodeNum]?.displayName) + } + @Test fun `clearNodeDB preserves favorites`() = runTest { val node1 = Node(num = 1, isFavorite = true) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameDialog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameDialog.kt new file mode 100644 index 00000000000..41c9ef06068 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameDialog.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.component + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.ImeAction +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.MAX_DISPLAY_NAME_BYTES +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.limitToBytes +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.cancel +import org.meshtastic.core.resources.display_name_hint +import org.meshtastic.core.resources.save +import org.meshtastic.core.resources.set_display_name + +/** Node-list entry point for the local display name. Saving an empty name clears it. */ +@Composable +fun DisplayNameDialog(node: Node, onSave: (Int, String) -> Unit, onDismiss: () -> Unit) { + var name by remember(node.num) { mutableStateOf(node.displayName) } + val save = { + onSave(node.num, name.trim()) + onDismiss() + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(Res.string.set_display_name)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it.limitToBytes(MAX_DISPLAY_NAME_BYTES) }, + singleLine = true, + placeholder = { Text(node.user.long_name) }, + supportingText = { Text(stringResource(Res.string.display_name_hint)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { save() }), + ) + }, + confirmButton = { TextButton(onClick = { save() }) { Text(stringResource(Res.string.save)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } }, + ) +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameSection.kt new file mode 100644 index 00000000000..e38af7934f3 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameSection.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.MAX_DISPLAY_NAME_BYTES +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.limitToBytes +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.display_name +import org.meshtastic.core.resources.display_name_hint +import org.meshtastic.core.resources.save +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Save + +/** Edits the local-only display name. The broadcast long name is the placeholder, so the user sees what is replaced. */ +@Composable +fun DisplayNameSection(node: Node, onSaveDisplayName: (Int, String) -> Unit, modifier: Modifier = Modifier) { + ElevatedCard( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + shape = MaterialTheme.shapes.extraLarge, + ) { + Column(modifier = Modifier.padding(20.dp)) { + Text( + text = stringResource(Res.string.display_name), + style = MaterialTheme.typography.titleMediumEmphasized, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + + Spacer(Modifier.height(16.dp)) + + var name by remember(node.displayName) { mutableStateOf(node.displayName) } + val edited = name.trim() != node.displayName.trim() + val keyboardController = LocalSoftwareKeyboardController.current + + OutlinedTextField( + value = name, + onValueChange = { name = it.limitToBytes(MAX_DISPLAY_NAME_BYTES) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(node.user.long_name) }, + supportingText = { Text(stringResource(Res.string.display_name_hint)) }, + shape = MaterialTheme.shapes.large, + trailingIcon = { + IconButton( + onClick = { + onSaveDisplayName(node.num, name.trim()) + keyboardController?.hide() + }, + enabled = edited, + ) { + Icon(imageVector = MeshtasticIcons.Save, contentDescription = stringResource(Res.string.save)) + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = + KeyboardActions( + onDone = { + onSaveDisplayName(node.num, name.trim()) + keyboardController?.hide() + }, + ), + ) + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt index f2bbc1a38fa..0a1d435a164 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt @@ -46,6 +46,7 @@ import org.meshtastic.core.resources.open_compass import org.meshtastic.core.resources.remove import org.meshtastic.core.resources.remove_favorite import org.meshtastic.core.resources.remove_ignored +import org.meshtastic.core.resources.set_display_name import org.meshtastic.core.resources.trace_route import org.meshtastic.core.resources.unmute import org.meshtastic.core.resources.update_status @@ -53,6 +54,7 @@ import org.meshtastic.core.ui.icon.CenterFocusStrong import org.meshtastic.core.ui.icon.Compass import org.meshtastic.core.ui.icon.DeleteNode import org.meshtastic.core.ui.icon.DoDisturb +import org.meshtastic.core.ui.icon.Edit import org.meshtastic.core.ui.icon.Favorite import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Message @@ -87,6 +89,7 @@ fun NodeContextMenu( onDismiss: () -> Unit, onOpenMeshView: () -> Unit = {}, onOpenCompass: () -> Unit = {}, + onSetDisplayName: () -> Unit = {}, ) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) { @@ -95,6 +98,12 @@ fun NodeContextMenu( MuteMenuItem(node, onMute, onDismiss) } MessageMenuItem(node, onMessage, onDismiss) + MenuItem( + MeshtasticIcons.Edit, + stringResource(Res.string.set_display_name), + onSetDisplayName, + onDismiss, + ) TraceRouteMenuItem(node, lastTracerouteTime, onTraceRoute, onDismiss) if (node.validPosition != null) { MenuItem(MeshtasticIcons.Compass, stringResource(Res.string.open_compass), onOpenCompass, onDismiss) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index fc678a0a8e5..c2e5a6efd01 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -61,6 +61,7 @@ import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.NodeSecurityIndicator +import org.meshtastic.core.model.hiddenLongName import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.a11y_label_value import org.meshtastic.core.resources.copy @@ -70,6 +71,7 @@ import org.meshtastic.core.resources.encryption_error_text import org.meshtastic.core.resources.error import org.meshtastic.core.resources.formatDuration import org.meshtastic.core.resources.hops_away +import org.meshtastic.core.resources.long_name import org.meshtastic.core.resources.no_public_key import org.meshtastic.core.resources.no_public_key_text import org.meshtastic.core.resources.node_id @@ -181,6 +183,15 @@ private fun MismatchKeyWarning(modifier: Modifier = Modifier) { private fun MainNodeDetails(node: Node, isLocal: Boolean, onRequestUserInfo: (() -> Unit)?) { Column { NameAndRoleRow(node) + node.hiddenLongName?.let { broadcastName -> + SectionDivider() + InfoItem( + label = stringResource(Res.string.long_name), + value = broadcastName, + icon = MeshtasticIcons.Person, + modifier = Modifier.fillMaxWidth(), + ) + } node.nodeStatus?.let { status -> SectionDivider() StatusMessageRow(status) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt index 7216222cde6..306bb70ee1c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt @@ -39,6 +39,7 @@ import org.meshtastic.core.resources.loading import org.meshtastic.feature.node.component.AdministrationSection import org.meshtastic.feature.node.component.DeviceActions import org.meshtastic.feature.node.component.DeviceLinksSection +import org.meshtastic.feature.node.component.DisplayNameSection import org.meshtastic.feature.node.component.NodeDetailsSection import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.component.NotesSection @@ -57,6 +58,7 @@ fun NodeDetailContent( onAction: (NodeDetailAction) -> Unit, onFirmwareSelect: (FirmwareRelease) -> Unit, onSaveNotes: (Int, String) -> Unit, + onSaveDisplayName: (Int, String) -> Unit, modifier: Modifier = Modifier, listState: LazyListState = rememberLazyListState(), trackedInLiveUpdate: Boolean? = null, @@ -71,6 +73,7 @@ fun NodeDetailContent( onAction = onAction, onFirmwareSelect = onFirmwareSelect, onSaveNotes = onSaveNotes, + onSaveDisplayName = onSaveDisplayName, trackedInLiveUpdate = trackedInLiveUpdate, ) } else { @@ -95,6 +98,7 @@ fun NodeDetailList( onAction: (NodeDetailAction) -> Unit, onFirmwareSelect: (FirmwareRelease) -> Unit, onSaveNotes: (Int, String) -> Unit, + onSaveDisplayName: (Int, String) -> Unit, modifier: Modifier = Modifier, trackedInLiveUpdate: Boolean? = null, ) { @@ -136,6 +140,7 @@ fun NodeDetailList( trackedInLiveUpdate = trackedInLiveUpdate, ) } + item { DisplayNameSection(node = node, onSaveDisplayName = onSaveDisplayName) } item { NotesSection(node = node, onSaveNotes = onSaveNotes) } if (!uiState.metricsState.isManaged) { item { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt index 34823ca5732..b68b7e798b9 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt @@ -60,6 +60,7 @@ fun NodeDetailContentRemotePreview() { onAction = {}, onFirmwareSelect = {}, onSaveNotes = { _, _ -> }, + onSaveDisplayName = { _, _ -> }, ) } } @@ -82,6 +83,7 @@ fun NodeDetailContentLocalPreview() { onAction = {}, onFirmwareSelect = {}, onSaveNotes = { _, _ -> }, + onSaveDisplayName = { _, _ -> }, ) } } @@ -97,6 +99,7 @@ fun NodeDetailContentLoadingPreview() { onAction = {}, onFirmwareSelect = {}, onSaveNotes = { _, _ -> }, + onSaveDisplayName = { _, _ -> }, ) } } @@ -120,6 +123,7 @@ fun NodeDetailContentMinimalPreview() { onAction = {}, onFirmwareSelect = {}, onSaveNotes = { _, _ -> }, + onSaveDisplayName = { _, _ -> }, ) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index 4a2890b11af..918a63142fb 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -186,6 +186,7 @@ private fun NodeDetailScaffold( }, onFirmwareSelect = { activeOverlay = NodeDetailOverlay.FirmwareReleaseInfo(it) }, onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) }, + onSaveDisplayName = { num, name -> viewModel.setNodeDisplayName(num, name) }, modifier = Modifier.padding(paddingValues), ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 800e86f7327..fe80529f0df 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -98,6 +98,7 @@ import org.meshtastic.core.ui.icon.Nodes import org.meshtastic.core.ui.icon.SignalOff import org.meshtastic.core.ui.theme.StatusColors.StatusOrange import org.meshtastic.core.ui.util.parseDeepLinkOrInvalid +import org.meshtastic.feature.node.component.DisplayNameDialog import org.meshtastic.feature.node.component.LocalNodeContextMenu import org.meshtastic.feature.node.component.NodeContextMenu import org.meshtastic.feature.node.component.NodeCountSummary @@ -202,6 +203,15 @@ fun NodeListScreen( // It stays a LazyItemScope lambda rather than a composable of its own because animateItem() needs that receiver. val nodeRow: @Composable LazyItemScope.(Node) -> Unit = { node -> var expanded by remember { mutableStateOf(false) } + var showDisplayNameDialog by remember { mutableStateOf(false) } + + if (showDisplayNameDialog) { + DisplayNameDialog( + node = node, + onSave = viewModel::setNodeDisplayName, + onDismiss = { showDisplayNameDialog = false }, + ) + } Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { val isThisNode = node.num == ourNode?.num @@ -279,6 +289,7 @@ fun NodeListScreen( onDismiss = { expanded = false }, onOpenMeshView = { navigateToMeshView(node.num) }, onOpenCompass = { navigateToCompass(node.num) }, + onSetDisplayName = { showDisplayNameDialog = true }, ) } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 6c7ba5dcf3e..f308794206f 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -28,8 +28,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.model.MAX_DISPLAY_NAME_BYTES import org.meshtastic.core.model.Node import org.meshtastic.core.repository.LocalNodeUnavailableException +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketQueueRejectedException import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioController @@ -174,4 +177,51 @@ class NodeManagementActionsTest { assertEquals(node, nodeRepository.nodeDBbyNum.value[123]) assertEquals(listOf(NODE_REQUEST_SEND_FAILED_TEXT), snackbarManager.messages) } + + @Test + fun setNodeDisplayName_trims_and_stores_the_name() = runTest { + nodeRepository.setNodes(listOf(Node(num = 123))) + + actions.setNodeDisplayName(123, " Mine ") + + assertEquals("Mine", nodeRepository.nodeDBbyNum.value[123]?.displayName) + } + + @Test + fun setNodeDisplayName_blank_input_clears_the_name() = runTest { + nodeRepository.setNodes(listOf(Node(num = 123, displayName = "Mine"))) + + actions.setNodeDisplayName(123, " ") + + assertEquals("", nodeRepository.nodeDBbyNum.value[123]?.displayName) + } + + @Test + fun setNodeDisplayName_clamps_to_the_long_name_byte_cap() = runTest { + nodeRepository.setNodes(listOf(Node(num = 123))) + + actions.setNodeDisplayName(123, "é".repeat(40)) + + val stored = nodeRepository.nodeDBbyNum.value[123]?.displayName.orEmpty() + assertEquals(MAX_DISPLAY_NAME_BYTES, stored.encodeToByteArray().size) + } + + @Test + fun setNodeDisplayName_swallows_repository_failure() = runTest { + val failing = mock(MockMode.autofill) + everySuspend { failing.setNodeDisplayName(any(), any()) } throws IllegalStateException("db closed") + val failingActions = + NodeManagementActions( + nodeRepository = failing, + radioController = radioController, + alertManager = alertManager, + analytics = analytics, + snackbarManager = snackbarManager, + resolveUiText = resolveUiText, + ) + + failingActions.setNodeDisplayName(123, "Mine") + + assertTrue(snackbarManager.messages.isEmpty()) + } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/NodeDetailNameUseCaseTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/NodeDetailNameUseCaseTest.kt new file mode 100644 index 00000000000..ba28dab0de7 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/NodeDetailNameUseCaseTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.domain.usecase + +import dev.mokkery.MockMode +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.mock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.model.Node +import org.meshtastic.core.resources.UiText +import org.meshtastic.core.testing.FakeDeviceHardwareRepository +import org.meshtastic.core.testing.FakeFirmwareReleaseRepository +import org.meshtastic.core.testing.FakeLocaleUnitsProvider +import org.meshtastic.core.testing.FakeMeshLogRepository +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeRadioConfigRepository +import org.meshtastic.feature.node.detail.NodeRequestActions +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** The detail title is the display name when set, else the broadcast long name, else the fallback. */ +class NodeDetailNameUseCaseTest { + + private val nodeRepository = FakeNodeRepository().apply { setOurNode(Node(num = OUR_NODE)) } + private val nodeRequestActions = + mock(MockMode.autofill).also { + every { it.lastTracerouteTime } returns MutableStateFlow(null) + every { it.lastRequestNeighborTimes } returns MutableStateFlow(emptyMap()) + } + + private val useCase = + CommonGetNodeDetailsUseCase( + nodeRepository = nodeRepository, + meshLogRepository = FakeMeshLogRepository(), + radioConfigRepository = FakeRadioConfigRepository(), + deviceHardwareRepository = FakeDeviceHardwareRepository(), + deviceLinkRepository = mock(MockMode.autofill), + deviceModelRepository = mock(MockMode.autofill), + firmwareReleaseRepository = FakeFirmwareReleaseRepository(), + nodeRequestActions = nodeRequestActions, + localeUnitsProvider = FakeLocaleUnitsProvider(), + ) + + private suspend fun nodeNameFor(longName: String, displayName: String): UiText { + nodeRepository.setNodes( + listOf( + Node( + num = NODE_X, + user = + User.Builder() + .also { wb -> + wb.id = "!00000002" + wb.long_name = longName + } + .build(), + displayName = displayName, + ), + ), + ) + return useCase(NODE_X).first { it.node?.num == NODE_X }.nodeName + } + + @Test + fun `the display name titles the detail screen when set`() = runTest { + assertEquals(UiText.DynamicString("Mine"), nodeNameFor(longName = "Broadcast", displayName = "Mine")) + } + + @Test + fun `the broadcast long name titles the detail screen without a display name`() = runTest { + assertEquals(UiText.DynamicString("Broadcast"), nodeNameFor(longName = "Broadcast", displayName = "")) + } + + @Test + fun `the fallback names a node with neither name`() = runTest { + assertIs(nodeNameFor(longName = "", displayName = "")) + } + + private companion object { + const val OUR_NODE = 1 + const val NODE_X = 2 + } +} From 6255c8407f3e1bc93263272040f2f9df1d901247 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:54:16 -0500 Subject: [PATCH 03/10] test: guard display name exclusion from share, export and CoT --- .../usecase/settings/ExportDataUseCaseTest.kt | 49 +++++++++++++++++++ .../settings/ExportNodeDatabaseUseCaseTest.kt | 20 ++++++++ .../takserver/MeshNodeCoTConversionTest.kt | 5 ++ 3 files changed, 74 insertions(+) diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt index 813e70a96cb..0bafa5157c0 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt @@ -20,15 +20,18 @@ import kotlinx.coroutines.test.runTest import okio.Buffer import okio.ByteString.Companion.encodeUtf8 import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.Node import org.meshtastic.core.testing.FakeMeshLogRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.proto.Data import org.meshtastic.proto.FromRadio import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum +import org.meshtastic.proto.User import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class ExportDataUseCaseTest { @@ -92,6 +95,52 @@ class ExportDataUseCaseTest { assertTrue(output.contains("Hello")) } + @Test + fun `invoke names the sender by broadcast name, never the local display name`() = runTest { + nodeRepository.setNodes( + listOf( + Node( + num = 1234, + user = User.Builder().also { wb -> wb.long_name = "Broadcast" }.build(), + displayName = "Secret", + ), + ), + ) + val log = + MeshLog( + uuid = "1", + message_type = "TEXT", + received_date = 1000000000L, + raw_message = "", + fromRadio = + FromRadio.Builder() + .also { wb -> + wb.packet = + MeshPacket.Builder() + .also { pb -> + pb.from = 1234 + pb.decoded = + Data.Builder() + .also { db -> + db.portnum = PortNum.TEXT_MESSAGE_APP + db.payload = "Hello".encodeUtf8() + } + .build() + } + .build() + } + .build(), + ) + meshLogRepository.setLogs(listOf(log)) + val buffer = Buffer() + + useCase(buffer, 1) + + val output = buffer.readUtf8() + assertTrue(output.contains("Broadcast")) + assertFalse(output.contains("Secret")) + } + @Test fun `invoke writes hop start and relay node columns`() = runTest { val buffer = Buffer() diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCaseTest.kt index f579f3f77d3..7ff1abb58a9 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCaseTest.kt @@ -92,6 +92,26 @@ class ExportNodeDatabaseUseCaseTest { assertFalse("publicKey" in node) } + @Test + fun `the export carries the broadcast name and never the local display name`() = runTest { + nodeRepository.setNodes( + listOf( + Node( + num = 5, + user = User.Builder().also { wb -> wb.long_name = "Broadcast" }.build(), + displayName = "Secret", + ), + ), + ) + + val buffer = Buffer() + useCase(buffer) + val text = buffer.readUtf8() + + assertTrue("Broadcast" in text) + assertFalse("Secret" in text) + } + @Test fun `lightning readings export from environment metrics`() = runTest { val env = diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt index e8045d6b49d..999de277bdc 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt @@ -78,6 +78,11 @@ class MeshNodeCoTConversionTest { assertEquals("WOLF - Wolf Ridge Relay", meshNode().cotCallsign()) } + @Test + fun `callsign uses the broadcast name, never the local display name`() { + assertEquals("WOLF - Wolf Ridge Relay", meshNode().copy(displayName = "Secret").cotCallsign()) + } + @Test fun `callsign falls back when a name is missing`() { assertEquals("WOLF", meshNode(longName = "").cotCallsign()) From 01a1ab7632b4075861c1100b57a54a1c20aa6dfa Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:54:17 -0500 Subject: [PATCH 04/10] docs(user): document local display names --- docs/en/user/nodes.md | 12 ++++++++++-- docs/en/user/settings-radio-user.md | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/en/user/nodes.md b/docs/en/user/nodes.md index 9355ca7eb43..4c66492cc5a 100644 --- a/docs/en/user/nodes.md +++ b/docs/en/user/nodes.md @@ -2,7 +2,7 @@ title: Nodes parent: User Guide nav_order: 4 -last_updated: 2026-10-21 +last_updated: 2026-10-24 description: Browse, filter, and sort mesh nodes — view details, signal quality, roles, and quick actions. aliases: - node-list @@ -20,7 +20,7 @@ The Nodes screen lists every node visible on your mesh. ## Node list The node list shows every node your node has heard, including: -- **Node name** — user-configured long name +- **Node name** — the node's long name, or the display name you gave it (see [Display names](#display-names)) - **Short name** — 4-character identifier - **Signal quality** — SNR, RSSI, and a quality word, shown only for nodes your node heard directly. In the Complete layout a node reached through a relay shows its hop count here instead; a node heard only over MQTT shows neither - **Last heard** — time since last communication @@ -127,6 +127,14 @@ Sharing your own contact this way marks it as verified in person, so whoever imp To add someone else's contact, use the import button on the node list and choose **Scan Shared Contact QR Code**, **Scan Shared Contact NFC**, or **Input Shared Contact URL**. The app asks you to confirm with **Import Shared Contact?**, and warns you when the contact is one you already have. +## Display names + +You can give any node a local display name that replaces its long name in the node list, node details, messages, notifications, maps and the node tracker widget. Set it from the node's long-press menu (**Set display name**) or from the **Display name** card in node details. Saving an empty name clears it. Names are limited to 36 bytes of UTF-8. + +The avatar always shows the node's broadcast short name. While a display name is set, node details shows the node's broadcast long name in a **Long Name** row under the name, so the real name stays readable. + +Display names are stored only on this device. They are never sent over the mesh, shared, or exported, so QR codes, contact sharing, message export and the node database export all use the node's real name. Removing a node also removes its display name. Your own node's name is set in [Settings - Radio & User](settings-radio-user). + ## Filtering & sorting ### Text search diff --git a/docs/en/user/settings-radio-user.md b/docs/en/user/settings-radio-user.md index c4d9342b49a..eaab233f0c3 100644 --- a/docs/en/user/settings-radio-user.md +++ b/docs/en/user/settings-radio-user.md @@ -2,7 +2,7 @@ title: Settings — Radio & User parent: User Guide nav_order: 7 -last_updated: 2026-10-21 +last_updated: 2026-10-24 description: Configure your node hardware, LoRa presets, user profile, position sharing, power management, and security. aliases: - settings @@ -48,7 +48,7 @@ On **Settings → User**. The screen also shows the read-only **Node ID** and ** | Setting | Description | Default | |---------|-------------|---------| | Licensed amateur radio (Ham) | Turn on if you hold an amateur radio license. Turning it on is staged behind a confirmation dialog, because licensed mode removes the channel encryption keys and sends all traffic as plaintext. On your own node it then relabels **Long Name** as **Call sign** (up to 7 characters) and adds a separate, optional **Long Name** that is appended to the call sign. Over remote admin the field stays **Long Name** | Off | -| Long Name | Your display name. Up to 24 bytes on firmware 2.8 or newer, and 39 on older firmware | Set by the firmware | +| Long Name | The name your node broadcasts. Up to 24 bytes on firmware 2.8 or newer, and 39 on older firmware. To rename another node only on this device, see [Display names](nodes#display-names) | Set by the firmware | | Short Name | Up to four characters | Set by the firmware | | Status Message | A short, public free-text status other nodes display alongside your node, up to 80 bytes, cleared with the **✕** in the field. The node broadcasts it to the mesh when you change it and again every 12 hours. Needs firmware 2.7.20 or newer, and is absent otherwise | Empty | | Unmessageable | Marks the node as unmonitored or infrastructure, one nobody should try to message. This app shows it with an icon in the node list. Firmware 2.6.9 or newer is needed to change it | Off | From a54ef598cf4f15c15801637306bca2a45df6a5df Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:54:17 -0500 Subject: [PATCH 05/10] feat(ui): use display name across lists, messages, maps, notifications and widget Share, export, QR and CoT keep the broadcast long name. --- .../kotlin/org/meshtastic/app/map/MapView.kt | 3 +- .../core/data/ai/AiFunctionProviderImpl.kt | 13 ++++--- .../core/data/ai/FuzzyNameResolver.kt | 5 ++- .../core/data/manager/GeofenceMonitor.kt | 3 +- .../core/data/manager/MeshDataHandlerImpl.kt | 5 ++- .../service/ConversationShortcutPublisher.kt | 3 +- .../service/MeshNotificationManagerImpl.kt | 19 ++++++---- .../meshtastic/core/ui/component/NodeItem.kt | 3 +- .../core/ui/component/NodeItemCompact.kt | 3 +- .../DesktopMeshNotificationManager.kt | 9 ++++- .../map/maplibre/geojson/NodeFeatures.kt | 3 +- .../map/component/SitePlannerLaunch.kt | 3 +- .../meshtastic/feature/map/hud/HudChips.kt | 3 +- .../meshtastic/feature/messaging/Message.kt | 13 ++++--- .../feature/messaging/MessageViewModel.kt | 6 +++ .../messaging/component/MessageItem.kt | 7 ++-- .../messaging/ui/contact/ContactsViewModel.kt | 2 +- .../feature/node/compass/CompassViewModel.kt | 3 +- .../node/component/AdministrationSection.kt | 3 +- .../node/component/LinkedCoordinatesItem.kt | 3 +- .../node/detail/NodeDetailViewModel.kt | 15 +++++--- .../node/detail/NodeManagementActions.kt | 20 ++++++++-- .../usecase/CommonGetNodeDetailsUseCase.kt | 3 +- .../feature/node/list/NodeListViewModel.kt | 9 ++++- .../feature/node/metrics/AirQualityMetrics.kt | 37 ++++++++++--------- .../feature/node/metrics/DeviceMetrics.kt | 3 +- .../node/metrics/EnvironmentMetrics.kt | 3 +- .../feature/node/metrics/HostMetricsLog.kt | 3 +- .../feature/node/metrics/MetricsViewModel.kt | 9 +++-- .../feature/node/metrics/NeighborInfoLog.kt | 3 +- .../feature/node/metrics/PaxMetrics.kt | 3 +- .../node/metrics/PositionLogScreens.kt | 3 +- .../feature/node/metrics/PowerMetrics.kt | 3 +- .../feature/node/metrics/SignalMetrics.kt | 3 +- .../feature/node/metrics/TracerouteLog.kt | 3 +- .../node/metrics/TracerouteMapScreen.kt | 3 +- .../metrics/terminal/RemoteShellViewModel.kt | 3 +- .../feature/widget/NodeTrackerCandidates.kt | 1 + .../widget/NodeTrackerConfigActivity.kt | 7 +++- .../feature/widget/NodeTrackerWidgetState.kt | 3 +- 40 files changed, 162 insertions(+), 87 deletions(-) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 809374d2b55..3632ee2f364 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -149,6 +149,7 @@ import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Node import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.geofence.toGeofence import org.meshtastic.core.model.hasFix import org.meshtastic.core.model.isLocked @@ -1329,7 +1330,7 @@ internal fun rememberNodeClusterItems( node = node, nodePosition = latLng, nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time, unknownText, nowText)}", - nodeSnippet = node.user.long_name, + nodeSnippet = node.effectiveLongName, myNodeNum = myNodeNum, ) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt index 189bcdfce5c..0a19fde686c 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.withTimeout import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository @@ -118,7 +119,7 @@ class AiFunctionProviderImpl( val totalCount = nodeRepository.totalNodeCount.first() val ourNode = nodeRepository.ourNodeInfo.value val batteryLevel = ourNode?.batteryLevel?.takeIf { it in 1..MAX_BATTERY_LEVEL } - val nodeName = ourNode?.user?.long_name?.takeIf { it.isNotBlank() } + val nodeName = ourNode?.effectiveLongName?.takeIf { it.isNotBlank() } MeshStatusResult( connectionState = connectionState.name, @@ -141,7 +142,7 @@ class AiFunctionProviderImpl( nodeMap.values.map { node -> NodeSummary( id = NodeAddress.numToDefaultId(node.num), - name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}", + name = node.effectiveLongName.takeIf { it.isNotBlank() } ?: "Node ${node.num}", batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL), lastHeard = node.lastHeard.toLong() * TimeConstants.MS_PER_SEC, isOnline = node.isOnline, @@ -194,7 +195,7 @@ class AiFunctionProviderImpl( firmwareVersion = ourNode.metadata?.firmware_version ?: "Unknown", batteryLevel = ourNode.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL), chargingStatus = "UNKNOWN", - deviceName = ourNode.user.long_name.takeIf { it.isNotBlank() }, + deviceName = ourNode.effectiveLongName.takeIf { it.isNotBlank() }, isActive = serviceRepository.connectionState.value == ConnectionState.Connected, ) GetDeviceStatusResult.Success(deviceStatus) @@ -236,7 +237,7 @@ class AiFunctionProviderImpl( NodeDetails( id = NodeAddress.numToDefaultId(node.num), userId = node.user.id, - name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}", + name = node.effectiveLongName.takeIf { it.isNotBlank() } ?: "Node ${node.num}", batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL), voltage = node.deviceMetrics.voltage, hardwareModel = node.metadata?.hw_model?.name ?: "Unknown", @@ -367,7 +368,7 @@ class AiFunctionProviderImpl( val channelSet = radioConfigRepository.channelSetFlow.first() val summaries = messages.map { msg -> MessageSummary( - senderName = msg.node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${msg.node.num}", + senderName = msg.node.effectiveLongName.takeIf { it.isNotBlank() } ?: "Node ${msg.node.num}", text = msg.text, contactName = resolveContactDisplayName(msg, channelSet), receivedTime = msg.receivedTime, @@ -407,7 +408,7 @@ class AiFunctionProviderImpl( } else { val userId = lastPacket.from ?: "" val node = nodeMap.values.find { it.user.id == userId } - node?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Unknown" + node?.effectiveLongName?.takeIf { it.isNotBlank() } ?: "Unknown" } ContactUnread( diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt index d809284f62c..af639ccc205 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.data.ai import kotlinx.coroutines.flow.first import org.koin.core.annotation.Single +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository @@ -38,8 +39,8 @@ class FuzzyNameResolver( val nodes = nodeRepository.nodeDBbyNum.value val candidates = nodes.values - .filter { it.user.long_name.isNotBlank() } - .map { NameCandidate(it.user.long_name, it.num, it.user.id) } + .filter { it.effectiveLongName.isNotBlank() } + .map { NameCandidate(it.effectiveLongName, it.num, it.user.id) } return matchName(query, candidates) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt index 24b32679bf1..3307c61b9a3 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/GeofenceMonitor.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.common.di.ServiceScope import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.geofence.activeWaypointPackets import org.meshtastic.core.model.geofence.geofencesToMonitor import org.meshtastic.core.model.geofence.toGeofence @@ -171,7 +172,7 @@ class GeofenceMonitor( try { val nodeName = - nodeManager.nodeDBbyNodeNum[nodeNum]?.user?.long_name?.takeIf { it.isNotBlank() } + nodeManager.nodeDBbyNodeNum[nodeNum]?.effectiveLongName?.takeIf { it.isNotBlank() } ?: getStringSuspend(Res.string.unknown_username) val waypointName = waypoint.name.takeIf { it.isNotBlank() } ?: getStringSuspend(Res.string.geofence) val title = diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 0ebed3c9905..9f5483ebdab 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -35,6 +35,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.Reaction import org.meshtastic.core.model.destination +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.geofence.activeWaypointPackets import org.meshtastic.core.model.isBroadcast import org.meshtastic.core.model.isFromLocal @@ -588,9 +589,9 @@ class MeshDataHandlerImpl( private suspend fun getSenderName(packet: DataPacket): String { if (packet.source is NodeAddress.Local) { val myId = nodeManager.getMyId() - return nodeManager.getNodeById(myId)?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) + return nodeManager.getNodeById(myId)?.effectiveLongName ?: getStringSuspend(Res.string.unknown_username) } - return nodeManager.getNodeById(packet.from.orEmpty())?.user?.long_name + return nodeManager.getNodeById(packet.from.orEmpty())?.effectiveLongName ?: getStringSuspend(Res.string.unknown_username) } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt index b08bed43ff7..67186f0078e 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt @@ -37,6 +37,7 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Channel import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.nodeColorsFromNum import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI import org.meshtastic.core.repository.NodeRepository @@ -171,7 +172,7 @@ class ConversationShortcutPublisher( // the raw contactKey is a user-traceable identifier and must stay confined to internal ids and deep-link // metadata (privacy-first convention). val shortName = node?.user?.short_name?.takeIf { it.isNotBlank() } - val longName = node?.user?.long_name?.takeIf { it.isNotBlank() } + val longName = node?.effectiveLongName?.takeIf { it.isNotBlank() } val label = longName ?: shortName ?: getStringSuspend(Res.string.unknown_username) // A node-colored pill avatar showing the short name identifies the person and matches the in-app node chip. diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt index 5d2f56db462..ac4a35c998e 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt @@ -69,6 +69,7 @@ import org.meshtastic.core.model.MeshBeaconOffer import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.noiseFloorOrNull import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI @@ -871,7 +872,7 @@ class MeshNotificationManagerImpl( val userId = "!" + contactKey.substringAfter("!", missingDelimiterValue = "") val peer = nodeRepository.value.nodeDBbyNum.value.values.find { it.user.id == userId } conversationName = - peer?.user?.long_name?.takeIf { it.isNotBlank() } ?: getStringSuspend(Res.string.unknown_username) + peer?.effectiveLongName?.takeIf { it.isNotBlank() } ?: getStringSuspend(Res.string.unknown_username) } showConversationNotification(contactKey, isBroadcast, channelName, conversationName, isSilent = true) } @@ -992,7 +993,9 @@ class MeshNotificationManagerImpl( ?.let { formatDurationSuspend(it, largestUnit(it)) } return createLiveUpdateNotification( title = - node.user.long_name.ifBlank { node.user.short_name }.ifBlank { NodeAddress.numToDefaultId(node.num) }, + node.effectiveLongName + .ifBlank { node.user.short_name } + .ifBlank { NodeAddress.numToDefaultId(node.num) }, subText = connectionTitle.takeIf { state !is ConnectionState.Connected }, lines = lines, chip = chip, @@ -1065,7 +1068,7 @@ class MeshNotificationManagerImpl( } val ourNode = nodeRepository.value.ourNodeInfo.value - val meName = ourNode?.user?.long_name ?: getStringSuspend(Res.string.you) + val meName = ourNode?.effectiveLongName ?: getStringSuspend(Res.string.you) val me = Person.Builder() .setName(meName) @@ -1093,7 +1096,7 @@ class MeshNotificationManagerImpl( // Use the node attached to the message directly to ensure correct identification val person = Person.Builder() - .setName(msg.node.user.long_name) + .setName(msg.node.effectiveLongName) .setKey(msg.node.user.id) // Favorite nodes feed the system's conversation-priority ranking (shade ordering, suggestions). .setImportant(msg.node.isFavorite) @@ -1123,7 +1126,7 @@ class MeshNotificationManagerImpl( val reactorNode = nodeRepository.value.getNode(reaction.user.id) val reactor = Person.Builder() - .setName(reaction.user.long_name) + .setName(reactorNode.displayName.ifBlank { reaction.user.long_name }) .setKey(reaction.user.id) .setIcon( cachedPersonIcon( @@ -1162,7 +1165,7 @@ class MeshNotificationManagerImpl( // notification's own person list, so the counterpart is added here too. .addPerson( Person.Builder() - .setName(bubbleNode.user.long_name.ifEmpty { bubbleNode.user.short_name }) + .setName(bubbleNode.effectiveLongName.ifEmpty { bubbleNode.user.short_name }) .setKey(bubbleNode.user.id) .setIcon(bubbleIcon) .build(), @@ -1232,7 +1235,7 @@ class MeshNotificationManagerImpl( .build() private fun createNewNodeSeenNotification(title: String, node: Node): Notification { - val message = node.user.long_name + val message = node.effectiveLongName return commonBuilder(NotificationChannelSpec.NewNodes, createOpenNodeDetailIntent(node.num)) .setCategory(Notification.CATEGORY_STATUS) .setAutoCancel(true) @@ -1248,7 +1251,7 @@ class MeshNotificationManagerImpl( val type = if (isRemote) NotificationChannelSpec.LowBatteryRemote else NotificationChannelSpec.LowBattery val title = getStringSuspend(Res.string.low_battery_title, node.user.short_name) val batteryLevel = node.deviceMetrics.battery_level ?: 0 - val message = getStringSuspend(Res.string.low_battery_message, node.user.long_name, batteryLevel) + val message = getStringSuspend(Res.string.low_battery_message, node.effectiveLongName, batteryLevel) // Not ongoing: an ongoing notification never bridges to a watch, and recovery cancels this one anyway. return commonBuilder(type, createOpenNodeDetailIntent(node.num)) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt index eab4fb9ee6d..b39cf637a96 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt @@ -57,6 +57,7 @@ import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.isUnmessageableRole import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res @@ -98,7 +99,7 @@ fun NodeItem( showTelemetry: Boolean = true, deviceImageUrl: String? = null, ) { - val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) } + val originalLongName = thatNode.effectiveLongName.ifEmpty { stringResource(Res.string.unknown_username) } val isMuted = remember(thatNode) { thatNode.isMuted } val isIgnored = thatNode.isIgnored val isFavorite = thatNode.isFavorite diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt index e63a1882d62..a6adf259c30 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt @@ -57,6 +57,7 @@ import org.jetbrains.compose.resources.vectorResource import org.meshtastic.core.common.util.MeasurementSystem import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.isUnmessageableRole import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res @@ -121,7 +122,7 @@ fun NodeItemCompact( tempInFahrenheit: Boolean = false, deviceImageUrl: String? = null, ) { - val longName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) } + val longName = thatNode.effectiveLongName.ifEmpty { stringResource(Res.string.unknown_username) } val isFavorite = thatNode.isFavorite val isIgnored = thatNode.isIgnored val isThisNode = remember(thisNode, thatNode) { thisNode?.num == thatNode.num } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt index d137f7ab2e0..399570be5c3 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt @@ -20,6 +20,7 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.FirmwareUpdateNotice import org.meshtastic.core.model.MeshBeaconOffer import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager @@ -132,7 +133,7 @@ class DesktopMeshNotificationManager(private val notificationManager: Notificati notificationManager.dispatch( Notification( title = title, - message = node.user.long_name, + message = node.effectiveLongName, category = Notification.Category.NodeEvent, id = node.num, ), @@ -154,7 +155,11 @@ class DesktopMeshNotificationManager(private val notificationManager: Notificati Notification( title = getStringSuspend(Res.string.low_battery_title, node.user.short_name), message = - getStringSuspend(Res.string.low_battery_message, node.user.long_name, node.batteryLevel ?: 0), + getStringSuspend( + Res.string.low_battery_message, + node.effectiveLongName, + node.batteryLevel ?: 0, + ), category = Notification.Category.Battery, id = node.num, ) diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatures.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatures.kt index 1c308d43aac..590141c9563 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatures.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/geojson/NodeFeatures.kt @@ -24,6 +24,7 @@ import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point import org.maplibre.spatialk.geojson.Position import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.precisionRadiusMetersOrNull /** Formats an `@ColorInt` ARGB value as the `#rrggbb` string MapLibre expressions parse. */ @@ -50,7 +51,7 @@ fun nodesToFeatureCollection(nodes: List, myNodeNum: Int? = null): Feature buildJsonObject { put(NodeFeatureKeys.NODE_NUM, node.num) put(NodeFeatureKeys.SHORT_NAME, node.user.short_name) - put(NodeFeatureKeys.LONG_NAME, node.user.long_name) + put(NodeFeatureKeys.LONG_NAME, node.effectiveLongName) put(NodeFeatureKeys.IS_FAVORITE, node.isFavorite) put(NodeFeatureKeys.IS_ONLINE, node.isOnline) put(NodeFeatureKeys.IS_SELF, myNodeNum != null && node.num == myNodeNum) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerLaunch.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerLaunch.kt index d5b86f92c1d..1d06e1425b6 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerLaunch.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/SitePlannerLaunch.kt @@ -17,6 +17,7 @@ package org.meshtastic.feature.map.component import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.primaryChannel import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Config.LoRaConfig.ModemPreset @@ -40,7 +41,7 @@ fun Node?.toSitePlannerParams(channelSet: ChannelSet?): SitePlannerParams { val lora = channelSet?.lora_config val freqMhz = channelSet?.primaryChannel?.radioFreq?.toDouble()?.takeIf { it > 0.0 } return SitePlannerParams( - name = this?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Coverage", + name = this?.effectiveLongName?.takeIf { it.isNotBlank() } ?: "Coverage", latitude = this?.latitude ?: 0.0, longitude = this?.longitude ?: 0.0, txFreqMhz = freqMhz ?: SitePlannerParams.DEFAULT_TX_FREQ_MHZ, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt index 1e576fd15d6..244d239dee8 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/hud/HudChips.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.toCodePointString import org.meshtastic.core.model.util.waypointIconOrDefault import org.meshtastic.core.resources.Res @@ -88,7 +89,7 @@ internal fun clockHour(bearingDeg: Double, headingDeg: Double): Int { */ internal fun HudSubject.spokenName(): String = when (this) { is HudSubject.NodeSubject -> - node.user.long_name.ifBlank { node.user.short_name.ifBlank { NodeAddress.numToDefaultId(node.num) } } + node.effectiveLongName.ifBlank { node.user.short_name.ifBlank { NodeAddress.numToDefaultId(node.num) } } is HudSubject.WaypointSubject -> waypoint.name.ifBlank { waypoint.icon.waypointIconOrDefault().toCodePointString() } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt index 047d9219d5f..4050b66da85 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -97,6 +97,7 @@ import org.meshtastic.core.model.MENTION_TOKEN_REGEX import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.QuickChatAction +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.getChannel import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.archived_channel_read_only @@ -164,7 +165,7 @@ fun MessageScreen( val mentionCandidates by remember { derivedStateOf { nodes - .associate { it.user.id to MentionCandidate(it.user.id, it.user.long_name, it.user.short_name) } + .associate { it.user.id to MentionCandidate(it.user.id, it.effectiveLongName, it.user.short_name) } .toPersistentMap() } } @@ -240,10 +241,10 @@ fun MessageScreen( val channelName = rawChannelName ?: retiredName.ifEmpty { unknownChannelText } val title = - remember(nodeId, channelName, viewModel) { + remember(nodeId, channelName, viewModel, nodes) { when (nodeId) { NodeAddress.ID_BROADCAST -> channelName - else -> viewModel.getUser(nodeId).long_name + else -> viewModel.getLongName(nodeId) } } @@ -327,10 +328,10 @@ fun MessageScreen( pagedMessages.itemSnapshotList .firstOrNull { it != null && !it.fromLocal && !it.read } ?.node - ?.user // Blank, not just empty: a name of only spaces would render an empty pill. - ?.let { user -> - user.long_name.takeIf { it.isNotBlank() } ?: user.short_name.takeIf { it.isNotBlank() } + ?.let { node -> + node.effectiveLongName.takeIf { it.isNotBlank() } + ?: node.user.short_name.takeIf { it.isNotBlank() } } } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 6f66ca97f07..79b52ec6c55 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -379,6 +379,12 @@ class MessageViewModel( fun getUser(userId: String?) = nodeRepository.getUser(userId ?: NodeAddress.ID_BROADCAST) + /** Name to show for a node: its local display name when set, else the broadcast long name. */ + fun getLongName(userId: String?): String { + val id = userId ?: NodeAddress.ID_BROADCAST + return nodeRepository.getNode(id).displayName.ifBlank { nodeRepository.getUser(id).long_name } + } + /** * Sends a message to a contact or channel. * diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index b8454c72d84..45ab8f3914a 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -80,6 +80,7 @@ import org.meshtastic.core.model.Message import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.Node import org.meshtastic.core.model.Reaction +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.isAckProofForged import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.a11y_message_from @@ -297,7 +298,7 @@ fun MessageItem( Modifier }, ) - val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name + val senderName = if (message.fromLocal) ourNode.effectiveLongName else node.effectiveLongName val messageA11yText = stringResource(Res.string.a11y_message_from, senderName, bodyText) // Timestamp lives in the group header (Google Chat pattern) rather than inside every bubble; grouping is // time-windowed upstream, so the header time is always close to every message in the run. @@ -317,7 +318,7 @@ fun MessageItem( ) { NodeChip(node = node, onClick = onClickChip, modifier = Modifier.heightIn(min = 28.dp)) Text( - text = node.user.long_name, + text = node.effectiveLongName, modifier = Modifier.weight(1f, fill = false), overflow = TextOverflow.Ellipsis, maxLines = 1, @@ -465,7 +466,7 @@ fun MessageItem( val mentionDisplayName = remember(resolveMention) { { id: String -> - resolveMention(id)?.let { it.user.long_name.ifEmpty { it.user.short_name } } + resolveMention(id)?.let { it.effectiveLongName.ifEmpty { it.user.short_name } } } } AutoLinkText( diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index e28cdffef83..2ff412f133c 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -133,7 +133,7 @@ class ContactsViewModel( toBroadcast -> channelSet.getChannel(packetData.channel)?.name ?: "Channel ${packetData.channel}" - else -> user.long_name + else -> node.displayName.ifBlank { user.long_name } } Contact( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt index ead4cb7a34f..e3a4f5bec69 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt @@ -34,6 +34,7 @@ import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.model.util.uncertaintyRadiusMetersOrNull import org.meshtastic.core.repository.MagneticFieldProvider @@ -71,7 +72,7 @@ class CompassViewModel( targetPosition = targetPos targetPositionProto = node.position val targetColor = Color(node.colors.second) - val targetName = node.user.long_name.ifBlank { node.user.short_name.ifBlank { node.num.toString() } } + val targetName = node.effectiveLongName.ifBlank { node.user.short_name.ifBlank { node.num.toString() } } targetPositionTimeSec = node.position.timestamp.takeIf { it > 0 }?.toLong() ?: node.position.time.takeIf { it > 0 }?.toLong() diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt index 1655d4b98e1..8f35494a92c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AdministrationSection.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.model.FirmwareRelease import org.meshtastic.core.model.Node import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.model.asDeviceVersion +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.repository.EventFirmwareRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.administration @@ -133,7 +134,7 @@ fun AdministrationSection( leadingIcon = MeshtasticIcons.Memory, trailingIcon = null, enabled = !isEnsuringSession, - onClick = { onAction(NodeDetailAction.RefreshMetadata(node.num, node.user.long_name)) }, + onClick = { onAction(NodeDetailAction.RefreshMetadata(node.num, node.effectiveLongName)) }, ) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt index bcd2d1cc347..a75ca1952d5 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt @@ -32,6 +32,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.GPSFormat import org.meshtastic.core.common.util.MeasurementSystem import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.toElevationString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.copy @@ -79,7 +80,7 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: MeasurementSystem = Measurem leadingIcon = MeshtasticIcons.LocationOn, supportingText = "$ago • $coordinates$elevationText", trailingContent = MeshtasticIcons.ChevronRight.icon(), - onClick = { openMap(node.latitude, node.longitude, node.user.long_name) }, + onClick = { openMap(node.latitude, node.longitude, node.effectiveLongName) }, onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(coordinates, copyLabel)) } }, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 3807f8855b0..fe99b82b968 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -40,6 +40,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.RoutingNak import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.getMessageRoutingErrorDetailResFrom import org.meshtastic.core.model.getStringResFrom import org.meshtastic.core.navigation.NodeDetailRoute @@ -199,27 +200,27 @@ class NodeDetailViewModel( is NodeMenuAction.RequestUserInfo -> safeLaunch(tag = "requestUserInfo") { - nodeRequestActions.requestUserInfo(action.node.num, action.node.user.long_name) + nodeRequestActions.requestUserInfo(action.node.num, action.node.effectiveLongName) } is NodeMenuAction.RequestNeighborInfo -> safeLaunch(tag = "requestNeighborInfo") { - nodeRequestActions.requestNeighborInfo(action.node.num, action.node.user.long_name) + nodeRequestActions.requestNeighborInfo(action.node.num, action.node.effectiveLongName) } is NodeMenuAction.RequestPosition -> safeLaunch(tag = "requestPosition") { - nodeRequestActions.requestPosition(action.node.num, action.node.user.long_name) + nodeRequestActions.requestPosition(action.node.num, action.node.effectiveLongName) } is NodeMenuAction.RequestTelemetry -> safeLaunch(tag = "requestTelemetry") { - nodeRequestActions.requestTelemetry(action.node.num, action.node.user.long_name, action.type) + nodeRequestActions.requestTelemetry(action.node.num, action.node.effectiveLongName, action.type) } is NodeMenuAction.TraceRoute -> safeLaunch(tag = "requestTraceroute") { - nodeRequestActions.requestTraceroute(action.node.num, action.node.user.long_name) + nodeRequestActions.requestTraceroute(action.node.num, action.node.effectiveLongName) } else -> {} @@ -295,6 +296,10 @@ class NodeDetailViewModel( safeLaunch(tag = "setNodeNotes") { nodeManagementActions.setNodeNotes(nodeNum, notes) } } + fun setNodeDisplayName(nodeNum: Int, displayName: String) { + safeLaunch(tag = "setNodeDisplayName") { nodeManagementActions.setNodeDisplayName(nodeNum, displayName) } + } + /** Returns the type-safe navigation route for a direct message to this node. */ fun getDirectMessageRoute(node: Node, ourNode: Node?): String { val hasPKC = ourNode?.hasPKC == true && node.hasPKC diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index d7a27aab4ee..f83754c655c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -27,6 +27,8 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName +import org.meshtastic.core.model.normalizeDisplayName import org.meshtastic.core.repository.LocalNodeUnavailableException import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketQueueRejectedException @@ -50,6 +52,7 @@ import org.meshtastic.core.resources.unmute import org.meshtastic.core.ui.util.AlertManager import org.meshtastic.core.ui.util.SnackbarManager +@Suppress("TooManyFunctions") // One method per node action. @Single open class NodeManagementActions constructor( @@ -91,7 +94,10 @@ constructor( open fun requestIgnoreNode(scope: CoroutineScope, node: Node) { scope.launch { val message = - getString(if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add, node.user.long_name) + getString( + if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add, + node.effectiveLongName, + ) alertManager.showAlert( titleRes = Res.string.ignore, message = message, @@ -107,7 +113,7 @@ constructor( open fun requestMuteNode(scope: CoroutineScope, node: Node) { scope.launch { val message = - getString(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.long_name) + getString(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.effectiveLongName) alertManager.showAlert( titleRes = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications, message = message, @@ -125,7 +131,7 @@ constructor( val message = getString( if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add, - node.user.long_name, + node.effectiveLongName, ) alertManager.showAlert( titleRes = Res.string.favorite, @@ -178,4 +184,12 @@ constructor( val failure = safeCatching { nodeRepository.setNodeNotes(nodeNum, notes) }.exceptionOrNull() if (failure != null) Logger.e(failure) { "Set node notes error" } } + + open suspend fun setNodeDisplayName(nodeNum: Int, displayName: String) { + val failure = safeCatching { + nodeRepository.setNodeDisplayName(nodeNum, normalizeDisplayName(displayName)) + } + .exceptionOrNull() + if (failure != null) Logger.e(failure) { "Set node display name error" } + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt index 8de43017f82..f4c2e1fdb64 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt @@ -34,6 +34,7 @@ import org.meshtastic.core.model.FirmwareRelease import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.heroImageFile import org.meshtastic.core.model.util.hasValidEnvironmentMetrics import org.meshtastic.core.model.util.isDirectSignal @@ -261,7 +262,7 @@ constructor( @Suppress("MagicNumber") val nodeName = - node.user.long_name.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) } + node.effectiveLongName.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) } ?: UiText.Resource(Res.string.fallback_node_name, node.user.id.takeLast(4)) NodeDetailUiState( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index b6201b5e967..2fc2f00c966 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -36,6 +36,7 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.NodeListDensity import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.repository.AdminController import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.DeviceHardwareRepository @@ -51,7 +52,7 @@ import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Config -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @KoinViewModel class NodeListViewModel( private val savedStateHandle: SavedStateHandle, @@ -243,6 +244,10 @@ class NodeListViewModel( fun ignoreNode(node: Node) = nodeManagementActions.requestIgnoreNode(viewModelScope, node) + fun setNodeDisplayName(nodeNum: Int, displayName: String) { + safeLaunch(tag = "setNodeDisplayName") { nodeManagementActions.setNodeDisplayName(nodeNum, displayName) } + } + fun muteNode(node: Node) = nodeManagementActions.requestMuteNode(viewModelScope, node) fun removeNodes(nodes: List) = nodeManagementActions.requestRemoveNodes(viewModelScope, nodes) @@ -260,7 +265,7 @@ class NodeListViewModel( /** Initiates a trace route request to the specified node. */ fun traceRoute(node: Node) { - safeLaunch(tag = "requestTraceroute") { nodeRequestActions.requestTraceroute(node.num, node.user.long_name) } + safeLaunch(tag = "requestTraceroute") { nodeRequestActions.requestTraceroute(node.num, node.effectiveLongName) } } companion object { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt index 47b83ba304b..072029d519e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt @@ -56,6 +56,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.AirQualityIndex import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.UnitConversions.toTempString @@ -136,12 +137,11 @@ internal fun withNowCastAqi(telemetries: List): List - telemetry.air_quality_metrics?.pm25_standard?.let { pm25 -> - index to (telemetry.time.toLong() to pm25.toDouble()) - } + val pm25Readings = ascending.mapIndexedNotNull { index, telemetry -> + telemetry.air_quality_metrics?.pm25_standard?.let { pm25 -> + index to (telemetry.time.toLong() to pm25.toDouble()) } + } val aqiSeries = AirQualityIndex.nowCastAqiSeries(pm25Readings.map { it.second }) val aqiByIndex = pm25Readings.mapIndexed { position, (index, _) -> index to aqiSeries[position] }.toMap() return ascending.mapIndexed { index, telemetry -> AirQualitySample(telemetry, aqiByIndex[index]) } @@ -153,7 +153,9 @@ internal fun withNowCastAqi(telemetries: List): List, samples: List): List = - candidates.filter { metric -> samples.any { metric.getValue(it) != null } } + candidates.filter { metric -> + samples.any { metric.getValue(it) != null } + } private val LEGEND_DATA = AirQuality.entries.map { metric -> LegendData(nameRes = metric.labelRes, color = metric.color, isLine = true) } @@ -182,7 +184,7 @@ fun AirQualityMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Uni onNavigateUp = onNavigateUp, telemetryType = TelemetryType.AIR_QUALITY, titleRes = Res.string.air_quality_metrics_log, - nodeName = state.node?.user?.long_name ?: "", + nodeName = state.node?.effectiveLongName.orEmpty(), data = data, timeProvider = { it.time.toDouble() }, onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.AIR_QUALITY) }, @@ -312,17 +314,16 @@ private fun AirQualityChart( } } - val chartLayers = - layers.map { (metric, _) -> - rememberConditionalLayer( - hasData = true, - lineProvider = - LineCartesianLayer.LineProvider.series( - ChartStyling.createStyledLine(metric.color, ChartStyling.THIN_LINE_WIDTH_DP), - ), - verticalAxisPosition = Axis.Position.Vertical.Start, - ) - } + val chartLayers = layers.map { (metric, _) -> + rememberConditionalLayer( + hasData = true, + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createStyledLine(metric.color, ChartStyling.THIN_LINE_WIDTH_DP), + ), + verticalAxisPosition = Axis.Position.Vertical.Start, + ) + } val nonNullLayers = remember(chartLayers) { chartLayers.filterNotNull() } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index f359795b18e..204201b95f5 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -61,6 +61,7 @@ import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.air_util_definition @@ -166,7 +167,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { onNavigateUp = onNavigateUp, telemetryType = TelemetryType.DEVICE, titleRes = Res.string.device_metrics_log, - nodeName = state.node?.user?.long_name ?: "", + nodeName = state.node?.effectiveLongName.orEmpty(), data = data, timeProvider = { it.time.toDouble() }, infoData = infoItems, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 4d9ca5bdc3c..43363efab26 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -48,6 +48,7 @@ import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.adcVoltage import org.meshtastic.core.model.util.oneWireTemperature @@ -114,7 +115,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un onNavigateUp = onNavigateUp, telemetryType = TelemetryType.ENVIRONMENT, titleRes = Res.string.env_metrics_log, - nodeName = state.node?.user?.long_name ?: "", + nodeName = state.node?.effectiveLongName.orEmpty(), data = filteredTelemetries, timeProvider = { it.time.toDouble() }, infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt index 916b83757e9..76408fd58e7 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt @@ -55,6 +55,7 @@ import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatByteSize import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.disk_free_indexed @@ -87,7 +88,7 @@ fun HostMetricsLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) onNavigateUp = onNavigateUp, telemetryType = TelemetryType.HOST, titleRes = Res.string.host_metrics_log, - nodeName = state.node?.user?.long_name ?: "", + nodeName = state.node?.effectiveLongName.orEmpty(), data = filteredData, timeProvider = { it.time.toDouble() }, infoData = HOST_METRICS_INFO_DATA, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 7b8616fde66..5474b44bc75 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -48,6 +48,7 @@ import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.Node import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.fixOrNull import org.meshtastic.core.model.noiseFloorOrNull @@ -273,7 +274,7 @@ open class MetricsViewModel( fun requestPosition() { (manualNodeId.value ?: nodeIdFromRoute)?.let { safeLaunch(tag = "requestPosition") { - nodeRequestActions.requestPosition(it, state.value.node?.user?.long_name ?: "") + nodeRequestActions.requestPosition(it, state.value.node?.effectiveLongName.orEmpty()) } } } @@ -281,7 +282,7 @@ open class MetricsViewModel( fun requestTelemetry(type: TelemetryType) { (manualNodeId.value ?: nodeIdFromRoute)?.let { safeLaunch(tag = "requestTelemetry") { - nodeRequestActions.requestTelemetry(it, state.value.node?.user?.long_name ?: "", type) + nodeRequestActions.requestTelemetry(it, state.value.node?.effectiveLongName.orEmpty(), type) } } } @@ -289,7 +290,7 @@ open class MetricsViewModel( fun requestTraceroute() { (manualNodeId.value ?: nodeIdFromRoute)?.let { safeLaunch(tag = "requestTraceroute") { - nodeRequestActions.requestTraceroute(it, state.value.node?.user?.long_name ?: "") + nodeRequestActions.requestTraceroute(it, state.value.node?.effectiveLongName.orEmpty()) } } } @@ -297,7 +298,7 @@ open class MetricsViewModel( fun requestNeighborInfo() { (manualNodeId.value ?: nodeIdFromRoute)?.let { safeLaunch(tag = "requestNeighborInfo") { - nodeRequestActions.requestNeighborInfo(it, state.value.node?.user?.long_name ?: "") + nodeRequestActions.requestNeighborInfo(it, state.value.node?.effectiveLongName.orEmpty()) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt index f25941e5562..da96bfd3660 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.getNeighborInfoResponse import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.neighbor_info @@ -74,7 +75,7 @@ fun NeighborInfoLogScreen(modifier: Modifier = Modifier, viewModel: MetricsViewM topBar = { val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle() MainAppBar( - title = state.node?.user?.long_name ?: "", + title = state.node?.effectiveLongName.orEmpty(), subtitle = stringResource(Res.string.neighbor_info), ourNode = null, showNodeChip = false, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index d650118b795..9f4e4d876f0 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -51,6 +51,7 @@ import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.ble_devices @@ -190,7 +191,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni onNavigateUp = onNavigateUp, telemetryType = TelemetryType.PAX, titleRes = Res.string.pax_metrics_log, - nodeName = state.node?.user?.long_name ?: "", + nodeName = state.node?.effectiveLongName.orEmpty(), data = paxMetrics, timeProvider = { (it.first.received_date / MS_PER_SEC).toDouble() }, onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt index fbd14d0148c..9a1b8f0c0ee 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.hasFix import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.clear @@ -58,7 +59,7 @@ fun PositionLogScreen( val positions = state.positionLogs val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) } - val nodeName = state.node?.user?.long_name ?: "" + val nodeName = state.node?.effectiveLongName.orEmpty() val exportGpxLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionGpx(uri, positions, nodeName) } val hasTrackPoint = remember(positions) { positions.any { it.hasFix() } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index 3020ab76818..ec07ef7b0f1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -63,6 +63,7 @@ import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.channel_1 @@ -129,7 +130,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) { onNavigateUp = onNavigateUp, telemetryType = TelemetryType.POWER, titleRes = Res.string.power_metrics_log, - nodeName = state.node?.user?.long_name ?: "", + nodeName = state.node?.effectiveLongName.orEmpty(), data = data, timeProvider = { it.time.toDouble() }, onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.POWER) }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 1e6b0c4cfc8..878ac8102be 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -57,6 +57,7 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.noiseFloorOrNull import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.model.util.rxTimeOrNull @@ -176,7 +177,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit, m onNavigateUp = onNavigateUp, telemetryType = null, titleRes = Res.string.signal_quality, - nodeName = state.node?.user?.long_name ?: "", + nodeName = state.node?.effectiveLongName.orEmpty(), data = data, timeProvider = { it.timeSeconds.toDouble() }, modifier = modifier, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index d15efd40e87..61745708a8b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -61,6 +61,7 @@ import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.tracerouteHopName @@ -154,7 +155,7 @@ fun TracerouteLogScreen( onNavigateUp = onNavigateUp, telemetryType = null, titleRes = Res.string.traceroute_log, - nodeName = state.node?.user?.long_name ?: "", + nodeName = state.node?.effectiveLongName.orEmpty(), data = points, timeProvider = { it.timeSeconds }, infoData = TRACEROUTE_INFO_DATA, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index 2727874de7b..8526b41e81e 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -43,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.flowOf import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.TracerouteOverlay +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.mesh_hud_open @@ -90,7 +91,7 @@ fun TracerouteMapScreen( LaunchedEffect(Unit) { metricsViewModel.clearTracerouteResponse() } TracerouteMapScaffold( - title = state.node?.user?.long_name ?: stringResource(Res.string.traceroute), + title = state.node?.effectiveLongName ?: stringResource(Res.string.traceroute), overlay = overlay, snapshotPositions = snapshotPositions, onNavigateUp = onNavigateUp, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt index f91332df781..161785da6ee 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/terminal/RemoteShellViewModel.kt @@ -43,6 +43,7 @@ import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RemoteShellHandler @@ -153,7 +154,7 @@ class RemoteShellViewModel( val bell: SharedFlow = _bell.asSharedFlow() val nodeLongName: String - get() = nodeRepository.nodeDBbyNum.value[destNum]?.user?.long_name ?: destNum.toString() + get() = nodeRepository.nodeDBbyNum.value[destNum]?.effectiveLongName ?: destNum.toString() private val linkMutex = Mutex() private var link: RemoteShellLink? = null diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt index 76c129c7fae..e7b47807b70 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerCandidates.kt @@ -31,6 +31,7 @@ internal fun trackerCandidates(nodes: Collection, query: String, ourNodeNu .filter { node -> needle.isEmpty() || node.user.long_name.contains(needle, ignoreCase = true) || + node.displayName.contains(needle, ignoreCase = true) || node.user.short_name.contains(needle, ignoreCase = true) || node.user.id.contains(needle, ignoreCase = true) } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt index 1ed505806df..f25375d23a5 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerConfigActivity.kt @@ -61,6 +61,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.UiPrefs @@ -218,7 +219,11 @@ private fun ColumnScope.CandidateList(nodes: List, onPick: (Node) -> Unit) modifier = Modifier.clickable { onPick(node) }, leadingContent = { NodeChip(node = node) }, headlineContent = { - Text(node.user.long_name.ifEmpty { node.user.id }, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + node.effectiveLongName.ifEmpty { node.user.id }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) }, supportingContent = { Text(formatAgo(node.lastHeard)) }, ) diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt index c6f7f77cdfa..bd9e82187a3 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/NodeTrackerWidgetState.kt @@ -34,6 +34,7 @@ import org.meshtastic.core.common.util.MeasurementSystem import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.repository.ConnectionStateProvider @@ -103,7 +104,7 @@ private fun trackingOf(node: Node, inputs: NodeTrackerInputs, onlineThreshold: I return NodeTrackerWidgetState.Tracking( nodeNum = node.num, shortName = node.user.short_name.ifEmpty { "???" }, - longName = node.user.long_name.ifEmpty { NodeAddress.numToDefaultId(node.num) }, + longName = node.effectiveLongName.ifEmpty { NodeAddress.numToDefaultId(node.num) }, colors = node.colors, batteryLevel = node.batteryLevel?.takeIf { it > 0 }, snr = if (direct) node.snrOrNull else null, From f0d99df3e517130a38e08bf053838d73d7883ed3 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:05:52 -0500 Subject: [PATCH 06/10] fix(nodes): honor display names in sort and reaction list, fix iOS test name The alpha node sort and the reaction details list used the broadcast name while the rest of the UI shows the display name. A test name with a comma failed Kotlin/Native compilation. --- .../core/database/dao/NodeInfoDao.kt | 2 +- .../database/dao/CommonNodeInfoDaoTest.kt | 21 +++++++++++++++++++ .../usecase/settings/ExportDataUseCaseTest.kt | 2 +- .../core/testing/FakeNodeRepository.kt | 3 ++- .../feature/messaging/MessageListPaged.kt | 5 +++++ .../feature/messaging/component/Reaction.kt | 7 +++++-- 6 files changed, 35 insertions(+), 5 deletions(-) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index 7771f83a1db..8ca2fdb3253 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -347,7 +347,7 @@ interface NodeInfoDao { END, CASE WHEN :sort = 'last_heard' THEN last_heard * -1 - WHEN :sort = 'alpha' THEN UPPER(long_name) + WHEN :sort = 'alpha' THEN UPPER(CASE WHEN TRIM(display_name) <> '' THEN display_name ELSE long_name END) WHEN :sort = 'distance' THEN CASE WHEN latitude IS NULL OR longitude IS NULL OR diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt index 02e958f5d79..f107a199fce 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt @@ -128,6 +128,27 @@ abstract class CommonNodeInfoDaoTest { assertEquals("", dao.getNodeByNum(1)?.node?.displayName) } + @Test + fun `alpha sort orders by the display name when one is set`() = runTest { + createDb() + fun entity(num: Int, longName: String, displayName: String = "") = NodeEntity( + num = num, + user = + User.Builder() + .also { wb -> + wb.id = "!$num" + wb.long_name = longName + wb.short_name = "n$num" + } + .build(), + displayName = displayName, + ) + dao.putAll(listOf(entity(1, "Alpha", displayName = "Zulu"), entity(2, "Bravo"), entity(3, "Charlie"))) + + val order = dao.getNodes("alpha", true, false, -1).first().map { it.node.num } + assertEquals(listOf(2, 3, 1), order) + } + @Test fun `an incoming node without a display name keeps the stored one`() = runTest { createDb() diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt index 0bafa5157c0..59212cfc77f 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt @@ -96,7 +96,7 @@ class ExportDataUseCaseTest { } @Test - fun `invoke names the sender by broadcast name, never the local display name`() = runTest { + fun `invoke names the sender by broadcast name and never the local display name`() = runTest { nodeRepository.setNodes( listOf( Node( diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt index f21bd750ec4..11cea644b43 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.map import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.mergePowerChannelLabel import org.meshtastic.core.repository.NodeRepository import org.meshtastic.proto.DeviceMetadata @@ -91,7 +92,7 @@ class FakeNodeRepository : .toList() .let { nodes -> when (sort) { - NodeSortOption.ALPHABETICAL -> nodes.sortedBy { it.user.long_name.lowercase() } + NodeSortOption.ALPHABETICAL -> nodes.sortedBy { it.effectiveLongName.lowercase() } NodeSortOption.LAST_HEARD -> nodes.sortedByDescending { it.lastHeard } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index a191e739f99..e287f8f5b0f 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -64,6 +64,7 @@ import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.Reaction +import org.meshtastic.core.model.hasDisplayName import org.meshtastic.core.ui.component.ListScrollbar import org.meshtastic.feature.messaging.component.DateSeparator import org.meshtastic.feature.messaging.component.MessageItem @@ -157,11 +158,15 @@ internal fun MessageListPaged( ) } + val reactorNames = + remember(state.nodes) { state.nodes.filter { it.hasDisplayName }.associate { it.user.id to it.displayName } } + var showReactionDialog by remember { mutableStateOf?>(null) } showReactionDialog?.let { reactions -> ReactionDialog( reactions = reactions, myId = state.ourNode?.user?.id, + reactorNames = reactorNames, canReact = state.canReact, onDismiss = { showReactionDialog = null }, onResend = { reaction -> diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 872cc427db5..bf30f598ce0 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -205,6 +205,8 @@ internal fun ReactionDialog( reactions: List, onDismiss: () -> Unit = {}, myId: String? = null, + /** Reactor id to local display name, for reactors the user has renamed. */ + reactorNames: Map = emptyMap(), /** False for an archived conversation: a failed reaction cannot be retried there. */ canReact: Boolean = true, onResend: (Reaction) -> Unit = {}, @@ -283,11 +285,12 @@ internal fun ReactionDialog( verticalAlignment = Alignment.CenterVertically, ) { val isLocal = reaction.user.id == myId || reaction.user.id == NodeAddress.ID_LOCAL + val name = reactorNames[reaction.user.id] ?: reaction.user.long_name val displayName = if (isLocal) { - "${reaction.user.long_name} (${stringResource(Res.string.you)})" + "$name (${stringResource(Res.string.you)})" } else { - reaction.user.long_name + name } Row( modifier = Modifier.weight(1f, fill = false), From 87e50021b5c13f1e8ea8387a11197954c456cca9 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:24:50 -0500 Subject: [PATCH 07/10] test(takserver): drop the comma from a CoT test name Kotlin/Native rejects commas in backtick test names. --- .../org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt index 999de277bdc..65b3b295a4e 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt @@ -79,7 +79,7 @@ class MeshNodeCoTConversionTest { } @Test - fun `callsign uses the broadcast name, never the local display name`() { + fun `callsign uses the broadcast name and never the local display name`() { assertEquals("WOLF - Wolf Ridge Relay", meshNode().copy(displayName = "Secret").cotCallsign()) } From 01f5a869449a561e0c7d12b4fa2bdf2abadff537 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:32:22 -0500 Subject: [PATCH 08/10] fix(nodes): keep display names out of exports and update more local surfaces The GPX track name now carries the broadcast long name. The contact list recomputes when a display name changes. Connections, settings, traceroute hops, neighbor info and the AI assistant identity use the display name. Display name limit docs no longer claim to match User.long_name. --- .../app/ai/GeminiNanoDocAssistant.kt | 3 +- .../data/manager/NeighborInfoHandlerImpl.kt | 7 +- .../data/manager/TracerouteHandlerImpl.kt | 9 ++- .../manager/NeighborInfoHandlerImplTest.kt | 32 +++++++++ .../dao/CommonNodeIdentityMigrationDaoTest.kt | 2 +- .../kotlin/org/meshtastic/core/model/Node.kt | 2 +- .../meshtastic/core/model/RouteDiscovery.kt | 8 ++- .../core/model/RouteDiscoveryTest.kt | 10 +++ docs/en/user/nodes.md | 2 +- .../connections/LiveUpdateViewModel.kt | 3 +- .../ui/components/CurrentlyConnectedInfo.kt | 3 +- .../ui/components/DeviceListItem.kt | 3 +- .../ui/components/LiveUpdateCard.kt | 7 +- .../messaging/ui/contact/ContactsViewModel.kt | 15 ++++- .../ui/contact/ContactsViewModelTest.kt | 65 +++++++++++++++++++ .../feature/node/metrics/MetricsViewModel.kt | 5 ++ .../node/metrics/PositionLogScreens.kt | 3 +- .../feature/node/metrics/TracerouteLog.kt | 3 +- .../node/detail/NodeManagementActionsTest.kt | 17 ++++- .../feature/node/metrics/GpxTrackNameTest.kt | 51 +++++++++++++++ .../feature/settings/SettingsScreen.kt | 3 +- .../settings/component/RadioAdminAppBar.kt | 5 +- .../component/ShutdownConfirmationDialog.kt | 3 +- 23 files changed, 233 insertions(+), 28 deletions(-) create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/GpxTrackNameTest.kt diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/GeminiNanoDocAssistant.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/GeminiNanoDocAssistant.kt index fbe6f6cb9ce..23fa0b89dde 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/ai/GeminiNanoDocAssistant.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/GeminiNanoDocAssistant.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.repository.NodeRepository import org.meshtastic.feature.docs.ai.AIDocAssistant import org.meshtastic.feature.docs.data.DocBundleLoader @@ -496,7 +497,7 @@ class GeminiNanoDocAssistant( """ |- Node Battery Status: $batteryStr ($voltStr) - |- User Identity: ${ourNode.user.short_name} (${ourNode.user.long_name}) + |- User Identity: ${ourNode.user.short_name} (${ourNode.effectiveLongName}) |- Position: ${if (ourNode.isUnknownUser) "Unknown user" else "Valid user profile synced"} """ .trimIndent() diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index ed010c7bedb..31357526ffa 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -39,6 +39,9 @@ class NeighborInfoHandlerImpl( override fun recordStartTime(requestId: Int) = requestTimer.start(requestId) + private fun neighborLongName(nodeNum: Int, broadcastName: String): String = + nodeRepository.nodeDBbyNum.value[nodeNum]?.displayName?.takeIf { it.isNotBlank() } ?: broadcastName + override fun handleNeighborInfo(packet: MeshPacket) { val payload = packet.decoded?.payload ?: return val ni = NeighborInfo.ADAPTER.decode(payload) @@ -56,12 +59,12 @@ class NeighborInfoHandlerImpl( val neighbors = ni.neighbors.joinToString("\n") { n -> val user = nodeRepository.getUser(n.node_id) - val name = "${user.long_name} (${user.short_name})" + val name = "${neighborLongName(n.node_id, user.long_name)} (${user.short_name})" "• $name (SNR: ${MetricFormatter.snr(n.snr)})" } val fromUser = nodeRepository.getUser(from) - val formatted = "Neighbors of ${fromUser.long_name}:\n$neighbors" + val formatted = "Neighbors of ${neighborLongName(from, fromUser.long_name)}:\n$neighbors" val responseText = requestTimer.appendDuration(requestId, formatted, "Neighbor info") diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index 13569f8352c..0a825f243e6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -57,7 +57,14 @@ class TracerouteHandlerImpl( internal var renderResponse: suspend (RouteDiscovery) -> String = { routeDiscovery -> val unknownHopLabel = getStringSuspend(Res.string.traceroute_unknown_hop) routeDiscovery.getTracerouteResponse( - getUser = { num -> tracerouteHopName(num, nodeRepository.getUser(num), unknownHopLabel) }, + getUser = { num -> + tracerouteHopName( + num, + nodeRepository.getUser(num), + unknownHopLabel, + nodeRepository.nodeDBbyNum.value[num]?.displayName.orEmpty(), + ) + }, headerTowards = getStringSuspend(Res.string.traceroute_route_towards_dest), headerBack = getStringSuspend(Res.string.traceroute_route_back_to_us), ) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImplTest.kt index 3b273c6cd75..061d3f4559b 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImplTest.kt @@ -20,10 +20,12 @@ import dev.mokkery.MockMode import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.matcher.any +import dev.mokkery.matcher.capture.capture import dev.mokkery.mock import dev.mokkery.verify import kotlinx.coroutines.flow.MutableStateFlow import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.Node import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository @@ -35,7 +37,9 @@ import org.meshtastic.proto.User import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class NeighborInfoHandlerImplTest { @@ -50,6 +54,7 @@ class NeighborInfoHandlerImplTest { @BeforeTest fun setUp() { every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) handler = NeighborInfoHandlerImpl(nodeManager, serviceRepository, nodeRepository) } @@ -184,6 +189,33 @@ class NeighborInfoHandlerImplTest { verify { serviceRepository.setNeighborInfoResponse(any()) } } + @Test + fun `handleNeighborInfo names a neighbor by its local display name`() { + every { nodeRepository.nodeDBbyNum } returns + MutableStateFlow(mapOf(100 to Node(num = 100, displayName = "Nickname"))) + val ni = + NeighborInfo.Builder() + .also { wb -> + wb.node_id = myNodeNum + wb.neighbors = listOf(Neighbor.Builder().also { nb -> nb.node_id = 100 }.build()) + } + .build() + every { nodeRepository.getUser(any()) } returns + User.Builder() + .also { wb -> + wb.long_name = "Alice" + wb.short_name = "AL" + } + .build() + val responses = mutableListOf() + every { serviceRepository.setNeighborInfoResponse(capture(responses)) } returns Unit + + handler.handleNeighborInfo(createPacketWithNeighborInfo(from = myNodeNum, ni = ni)) + + assertTrue(responses.single().orEmpty().contains("Nickname (AL)")) + assertFalse(responses.single().orEmpty().contains("Alice (AL)")) + } + @Test fun `handleNeighborInfo ignores packet with null decoded`() { val packet = MeshPacket.Builder().also { wb -> wb.from = myNodeNum }.build() diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt index 6a218c82c94..7e90af1f4db 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt @@ -176,7 +176,7 @@ abstract class CommonNodeIdentityMigrationDaoTest { } @Test - fun renumberCarriesDisplayNameWithoutOverwritingDestinationOne() = runTest { + fun renumberCarriesDisplayNameToTheNewRow() = runTest { createDb(myNodeEntity(oldNum, deviceId = deviceIdA)) dao.upsert(nodeEntity(oldNum, keyA, displayName = "old name")) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 07ea2cb8e16..166e73fffce 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -306,7 +306,7 @@ val Node.effectiveLongName: String val Node.hiddenLongName: String? get() = user.long_name.takeIf { hasDisplayName && it.isNotBlank() } -/** Longest local display name, matching the `User.long_name` cap in UTF-8 bytes. */ +/** Longest local display name in UTF-8 bytes. A cross-client limit shared with Apple, not the `User.long_name` cap. */ const val MAX_DISPLAY_NAME_BYTES = 36 /** Trims and clips [input] to [MAX_DISPLAY_NAME_BYTES]; blank input becomes the empty string, which means unset. */ diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt index 10fcdb504dd..1ec89692ab8 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RouteDiscovery.kt @@ -85,14 +85,16 @@ val MeshPacket.inboundRouteDiscovery: RouteDiscovery? * Display name for a traceroute hop. A node missing from the node DB comes back from the repository as a placeholder * user with no hardware model and a generated "Meshtastic xxxx" name; that case is named by its full node id. Firmware * fills a relay that did not add itself with the broadcast address, which is not a node, so it gets [unknownHopLabel]. + * A non-blank local [displayName] replaces the broadcast long name. */ -fun tracerouteHopName(nodeNum: Int, user: User, unknownHopLabel: String): String { +fun tracerouteHopName(nodeNum: Int, user: User, unknownHopLabel: String, displayName: String = ""): String { if (nodeNum == NodeAddress.NODENUM_BROADCAST) return unknownHopLabel val id = NodeAddress.numToDefaultId(nodeNum) val placeholder = - user.hw_model == HardwareModel.UNSET && + displayName.isBlank() && + user.hw_model == HardwareModel.UNSET && (user.long_name.isBlank() || user.long_name == "Meshtastic ${id.takeLast(PLACEHOLDER_SUFFIX_LENGTH)}") - return if (placeholder) id else "${user.long_name} (${user.short_name})" + return if (placeholder) id else "${displayName.ifBlank { user.long_name }} (${user.short_name})" } private const val PLACEHOLDER_SUFFIX_LENGTH = 4 diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt index a388dbcb373..3555174da48 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/RouteDiscoveryTest.kt @@ -160,6 +160,16 @@ class RouteDiscoveryTest { assertEquals("Base Station (BASE)", tracerouteHopName(0xa1b2c3d4.toInt(), known, UNKNOWN_HOP)) } + @Test + fun hopName_prefersLocalDisplayName_overBroadcastLongName() { + val known = user("!a1b2c3d4", "Base Station", "BASE", HardwareModel.TBEAM) + + assertEquals( + "My Base (BASE)", + tracerouteHopName(0xa1b2c3d4.toInt(), known, UNKNOWN_HOP, displayName = "My Base"), + ) + } + @Test fun tracerouteResponse_namesUnknownHopById_andKnownHopByName() { val users = diff --git a/docs/en/user/nodes.md b/docs/en/user/nodes.md index 4c66492cc5a..032acc97c86 100644 --- a/docs/en/user/nodes.md +++ b/docs/en/user/nodes.md @@ -129,7 +129,7 @@ To add someone else's contact, use the import button on the node list and choose ## Display names -You can give any node a local display name that replaces its long name in the node list, node details, messages, notifications, maps and the node tracker widget. Set it from the node's long-press menu (**Set display name**) or from the **Display name** card in node details. Saving an empty name clears it. Names are limited to 36 bytes of UTF-8. +You can give any node a local display name that replaces its long name in the node list, node details, messages, notifications, maps and the node tracker widget. Set it from the node's long-press menu (**Set display name**) or from the **Display name** card in node details. Saving an empty name clears it. Names are limited to 36 bytes of UTF-8, the same limit as the Apple app. The avatar always shows the node's broadcast short name. While a display name is set, node details shows the node's broadcast long name in a **Long Name** row under the name, so the real name stays readable. diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/LiveUpdateViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/LiveUpdateViewModel.kt index ef2780eeb97..883a0168c90 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/LiveUpdateViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/LiveUpdateViewModel.kt @@ -52,12 +52,13 @@ fun liveUpdateCandidates(nodes: Collection, ourNodeNum: Int?): List .filter { it.num != ourNodeNum && !it.isIgnored } .sortedWith(compareByDescending { it.isFavorite }.thenByDescending { it.lastHeard }.thenBy { it.num }) -/** [candidates] whose long name, short name or node id contains [query], ignoring case. */ +/** [candidates] whose long name, display name, short name or node id contains [query], ignoring case. */ fun filterLiveUpdateCandidates(candidates: List, query: String): List { val needle = query.trim() if (needle.isEmpty()) return candidates return candidates.filter { node -> node.user.long_name.contains(needle, ignoreCase = true) || + node.displayName.contains(needle, ignoreCase = true) || node.user.short_name.contains(needle, ignoreCase = true) || node.user.id.contains(needle, ignoreCase = true) } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt index 76b2c7739cb..09003314e26 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/CurrentlyConnectedInfo.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.withTimeout import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.ble.BleConnectionState import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.disconnect import org.meshtastic.core.resources.rssi @@ -154,7 +155,7 @@ fun CurrentlyConnectedInfo( NodeChip(node = node, onClick = { onNavigateToNodeDetails(it.num) }) Column(modifier = Modifier.weight(1f, fill = true)) { - Text(text = node.user.long_name, style = MaterialTheme.typography.titleMediumEmphasized) + Text(text = node.effectiveLongName, style = MaterialTheme.typography.titleMediumEmphasized) text.firmwareVersion?.let { firmwareVersion -> Text( diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index c2b58d59aea..d092eac5be0 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.action_select_device import org.meshtastic.core.resources.add @@ -183,7 +184,7 @@ private fun DeviceHeadline(device: DeviceListEntry) { if (node != null) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { NodeChip(node = node) - DeviceName(text = node.user.long_name.ifBlank { device.name }, modifier = Modifier.weight(1f)) + DeviceName(text = node.effectiveLongName.ifBlank { device.name }, modifier = Modifier.weight(1f)) } } else { DeviceName(text = device.name) diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/LiveUpdateCard.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/LiveUpdateCard.kt index 60bc1a13af9..efd2cdea2a3 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/LiveUpdateCard.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/LiveUpdateCard.kt @@ -50,6 +50,7 @@ import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.repository.LiveUpdateMode import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.favorite @@ -136,7 +137,7 @@ fun LiveUpdateCard( is LiveUpdateMode.TrackNode -> stringResource( Res.string.live_update_tracking, - trackedNode?.displayName() ?: NodeAddress.numToDefaultId(mode.nodeNum), + trackedNode?.listName() ?: NodeAddress.numToDefaultId(mode.nodeNum), ) } Card(modifier = modifier.fillMaxWidth()) { @@ -296,7 +297,7 @@ private fun NodeChoiceRow(node: Node, selected: Boolean, onSelect: () -> Unit) { ) { RadioButton(selected = selected, onClick = null) Column(modifier = Modifier.weight(1f).padding(start = 16.dp)) { - Text(text = node.displayName(), style = MaterialTheme.typography.bodyLarge) + Text(text = node.listName(), style = MaterialTheme.typography.bodyLarge) Text( text = formatAgo(node.lastHeard), style = MaterialTheme.typography.bodySmall, @@ -313,4 +314,4 @@ private fun NodeChoiceRow(node: Node, selected: Boolean, onSelect: () -> Unit) { } } -private fun Node.displayName(): String = user.long_name.ifBlank { user.short_name }.ifBlank { user.id } +private fun Node.listName(): String = effectiveLongName.ifBlank { user.short_name }.ifBlank { user.id } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index 2ff412f133c..dadbe0efa91 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -30,6 +30,7 @@ import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.isBroadcast import org.meshtastic.core.model.isFromLocal import org.meshtastic.core.model.util.getChannel @@ -88,6 +89,13 @@ class ContactsViewModel( .map { db -> db.values.filter { it.hasPKC }.mapTo(mutableSetOf()) { node -> node.num } } .distinctUntilChanged() + /** Local display names by node number, so a rename recomputes the list without observing the whole node DB. */ + private val displayNames: Flow> = + nodeRepository.nodeDBbyNum.map { db -> db.mapValues { (_, node) -> node.displayName } }.distinctUntilChanged() + + private val nodeProjection: Flow, Map>> = + combine(keyedNodeNums, displayNames) { keyed, names -> keyed to names } + /** * Flat contact list (channels + DMs) with empty-channel placeholders merged in, consumed by both ContactsScreen and * ShareScreen. ContactsScreen groups it into collapsible Channels/DirectMessages sections at render time. @@ -98,8 +106,9 @@ class ContactsViewModel( packetRepository.getContacts(), channels, packetRepository.getContactSettings(), - keyedNodeNums, - ) { identity, contacts, channelSet, settings, keyedNodes -> + nodeProjection, + ) { identity, contacts, channelSet, settings, projection -> + val keyedNodes = projection.first val (myNodeInfo, _) = identity val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList() // Add empty channel placeholders (always show Broadcast contacts, even when empty) @@ -133,7 +142,7 @@ class ContactsViewModel( toBroadcast -> channelSet.getChannel(packetData.channel)?.name ?: "Channel ${packetData.channel}" - else -> node.displayName.ifBlank { user.long_name } + else -> node.effectiveLongName } Contact( diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt index 56ccd638885..9b12804baf6 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt @@ -29,13 +29,18 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import okio.ByteString.Companion.encodeUtf8 import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.Node import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.ui.util.SnackbarManager import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.User import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -139,4 +144,64 @@ class ContactsViewModelTest { cancelAndIgnoreRemainingEvents() } } + + @Test + fun `contact name follows a display name change`() = runTest(testDispatcher) { + val peer = + Node( + num = 2, + user = + User.Builder() + .also { wb -> + wb.id = "!00000002" + wb.long_name = "Broadcast" + wb.short_name = "BC" + } + .build(), + ) + nodeRepository.setNodes(listOf(peer)) + nodeRepository.setMyNodeInfo( + MyNodeInfo( + myNodeNum = 1, + hasGPS = false, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 0L, + messageTimeoutMsec = 0, + minAppVersion = 0, + maxChannels = 8, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, + ), + ) + nodeRepository.setMyId("!00000001") + val dm = DataPacket(to = "!00000001", from = "!00000002", bytes = "hi".encodeUtf8(), dataType = 1) + every { packetRepository.getContacts() } returns MutableStateFlow(mapOf("0!00000002" to dm)) + every { packetRepository.getContactSettings() } returns MutableStateFlow(emptyMap()) + viewModel = + ContactsViewModel( + SavedStateHandle(), + nodeRepository, + packetRepository, + SnackbarManager(), + radioConfigRepository, + connectionStateProvider, + ) + + viewModel.contactList.test { + var latest = awaitItem() + while (latest.none { it.contactKey == "0!00000002" }) latest = awaitItem() + assertEquals("Broadcast", latest.single { it.contactKey == "0!00000002" }.longName) + + nodeRepository.setNodeDisplayName(2, "Nickname") + + var renamed = awaitItem() + while (renamed.single { it.contactKey == "0!00000002" }.longName != "Nickname") renamed = awaitItem() + cancelAndIgnoreRemainingEvents() + } + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 5474b44bc75..bc0abec7157 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -201,6 +201,8 @@ open class MetricsViewModel( fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum) + fun getDisplayName(nodeNum: Int): String = nodeRepository.nodeDBbyNum.value[nodeNum]?.displayName.orEmpty() + /** Persists a user-editable label (e.g. "Solar", "Battery") for a power-metrics channel (0-based index). */ fun setPowerChannelLabel(nodeNum: Int, channelIndex: Int, label: String) = safeLaunch(context = dispatchers.io, tag = "setPowerChannelLabel") { @@ -611,6 +613,9 @@ internal fun positionCsvRow(pos: org.meshtastic.proto.Position): String { return "\"$lat\",\"$lon\",\"$altitude\",\"${pos.sats_in_view}\",\"$speed\",\"${heading.orEmpty()}\"" } +/** The exported file leaves the device, so it carries the broadcast name, never the local display name. */ +internal fun gpxTrackName(node: Node?): String = node?.user?.long_name.orEmpty() + /** * Coordinates are formatted invariantly: GPX is XML another program parses, and a comma decimal makes * `lat="52,5200000"` — a file no importer accepts. Internal so a test can pin a comma locale and prove it. diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt index 9a1b8f0c0ee..44f315351a4 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt @@ -60,7 +60,8 @@ fun PositionLogScreen( val exportPositionLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri, positions) } val nodeName = state.node?.effectiveLongName.orEmpty() - val exportGpxLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionGpx(uri, positions, nodeName) } + val gpxTrackName = gpxTrackName(state.node) + val exportGpxLauncher = rememberSaveFileLauncher { uri -> viewModel.savePositionGpx(uri, positions, gpxTrackName) } val hasTrackPoint = remember(positions) { positions.any { it.hasFix() } } val trackMap = LocalNodeTrackMapProvider.current diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 61745708a8b..66ca840d9db 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -121,7 +121,8 @@ fun TracerouteLogScreen( val unknownHopLabel = stringResource(Res.string.traceroute_unknown_hop) - fun getUsername(nodeNum: Int): String = tracerouteHopName(nodeNum, viewModel.getUser(nodeNum), unknownHopLabel) + fun getUsername(nodeNum: Int): String = + tracerouteHopName(nodeNum, viewModel.getUser(nodeNum), unknownHopLabel, viewModel.getDisplayName(nodeNum)) val statusGreen = MaterialTheme.colorScheme.StatusGreen val statusYellow = MaterialTheme.colorScheme.StatusYellow diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index f308794206f..c46d0ba8843 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -24,6 +24,7 @@ import dev.mokkery.everySuspend import dev.mokkery.matcher.any import dev.mokkery.mock import dev.mokkery.verify +import dev.mokkery.verifySuspend import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -200,14 +201,24 @@ class NodeManagementActionsTest { fun setNodeDisplayName_clamps_to_the_long_name_byte_cap() = runTest { nodeRepository.setNodes(listOf(Node(num = 123))) - actions.setNodeDisplayName(123, "é".repeat(40)) + actions.setNodeDisplayName(123, "a".repeat(MAX_DISPLAY_NAME_BYTES + 10)) val stored = nodeRepository.nodeDBbyNum.value[123]?.displayName.orEmpty() assertEquals(MAX_DISPLAY_NAME_BYTES, stored.encodeToByteArray().size) } @Test - fun setNodeDisplayName_swallows_repository_failure() = runTest { + fun setNodeDisplayName_never_splits_a_multibyte_character_at_the_cap() = runTest { + nodeRepository.setNodes(listOf(Node(num = 123))) + + actions.setNodeDisplayName(123, "é".repeat(MAX_DISPLAY_NAME_BYTES)) + + val stored = nodeRepository.nodeDBbyNum.value[123]?.displayName.orEmpty() + assertEquals("é".repeat(MAX_DISPLAY_NAME_BYTES / 2), stored) + } + + @Test + fun setNodeDisplayName_attempts_the_write_and_swallows_repository_failure() = runTest { val failing = mock(MockMode.autofill) everySuspend { failing.setNodeDisplayName(any(), any()) } throws IllegalStateException("db closed") val failingActions = @@ -222,6 +233,6 @@ class NodeManagementActionsTest { failingActions.setNodeDisplayName(123, "Mine") - assertTrue(snackbarManager.messages.isEmpty()) + verifySuspend { failing.setNodeDisplayName(123, "Mine") } } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/GpxTrackNameTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/GpxTrackNameTest.kt new file mode 100644 index 00000000000..dfec8311f33 --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/GpxTrackNameTest.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import org.meshtastic.core.model.Node +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GpxTrackNameTest { + + private val node = + Node( + num = 1, + user = User.Builder().also { wb -> wb.long_name = "Broadcast" }.build(), + displayName = "Secret nickname", + ) + + @Test + fun `gpx track name is the broadcast name when a display name is set`() { + assertEquals("Broadcast", gpxTrackName(node)) + } + + @Test + fun `exported gpx names the track with the broadcast name and omits the display name`() { + val gpx = buildGpx(emptyList(), gpxTrackName(node)) + assertTrue(gpx.contains("Broadcast")) + assertFalse(gpx.contains("Secret nickname")) + } + + @Test + fun `gpx track name is empty without a node`() { + assertEquals("", gpxTrackName(null)) + } +} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 11842832acc..c6201b62813 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -47,6 +47,7 @@ import org.koin.core.qualifier.named import org.meshtastic.core.common.di.GOOGLE_SERVICES_AVAILABLE import org.meshtastic.core.common.util.UnitsOverride import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.navigation.FirmwareRoute import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute @@ -216,7 +217,7 @@ fun SettingsScreen( isLocal = state.isLocal, destNode = destNode, onNavigateUp = { onBack?.invoke() }, - localSubtitle = ourNode?.user?.long_name, + localSubtitle = ourNode?.effectiveLongName, ourNode = ourNode, onClickChip = { node -> onClickNodeChip(node.num) }, showNodeChip = ourNode != null && isConnected && state.isLocal, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/RadioAdminAppBar.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/RadioAdminAppBar.kt index 5eab39dfa60..452c8fa6493 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/RadioAdminAppBar.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/RadioAdminAppBar.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.remotely_administrating import org.meshtastic.core.ui.component.MainAppBar @@ -35,7 +36,7 @@ internal fun RadioAdminAppBar( destNode: Node?, onNavigateUp: () -> Unit, modifier: Modifier = Modifier, - localSubtitle: String? = destNode?.user?.long_name, + localSubtitle: String? = destNode?.effectiveLongName, ourNode: Node? = null, onClickChip: (Node) -> Unit = {}, showNodeChip: Boolean = false, @@ -48,7 +49,7 @@ internal fun RadioAdminAppBar( if (isLocal) { localSubtitle } else { - stringResource(Res.string.remotely_administrating, destNode?.user?.long_name.orEmpty()) + stringResource(Res.string.remotely_administrating, destNode?.effectiveLongName.orEmpty()) }, ourNode = ourNode, showNodeChip = showNodeChip, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt index 644f7a8df64..517056ed35b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/ShutdownConfirmationDialog.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Node +import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.send @@ -51,7 +52,7 @@ fun ShutdownConfirmationDialog( warning: StringResource? = null, onConfirm: () -> Unit, ) { - val nodeLongName = node?.user?.long_name ?: "Unknown Node" + val nodeLongName = node?.effectiveLongName ?: "Unknown Node" val resolvedIcon = icon ?: MeshtasticIcons.Warning val resolvedWarning = warning ?: Res.string.shutdown_warning.takeIf { isShutdown } From 720cb5efb32ed20188155611ec82c514f5a656ef Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:38:47 -0500 Subject: [PATCH 09/10] feat(nodes): add edit name and share contact to the own node menu Long press on our node now offers Update status, Edit name (opens the device User settings) and Share contact, each only when available. --- .skills/compose-ui/strings-index.txt | 1 + .../composeResources/values/strings.xml | 1 + .../feature/node/component/NodeContextMenu.kt | 41 +++++++--- .../feature/node/list/NodeListScreen.kt | 30 ++++++-- .../node/navigation/AdaptiveNodeListScreen.kt | 1 + .../feature/node/list/OwnNodeMenuItemsTest.kt | 77 +++++++++++++++++++ 6 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/OwnNodeMenuItemsTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 2570a633203..e9ad34f94c3 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -583,6 +583,7 @@ dynamic easily_set_up_private_mesh_networks edit edit_custom_tile_source +edit_name eight_hours electrical_conductivity elevation_suffix diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 5bebb73b02b..4f8c52414f1 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -619,6 +619,7 @@ Easily set up private mesh networks for secure and reliable communication in remote areas. Edit Edit Network Tile Source + Edit name 8 Hours Conductivity MSL diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt index 0a1d435a164..e8c6f49ba05 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeContextMenu.kt @@ -33,11 +33,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Node import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.add_favorite +import org.meshtastic.core.resources.edit_name import org.meshtastic.core.resources.ignore import org.meshtastic.core.resources.mesh_hud_open import org.meshtastic.core.resources.message @@ -47,6 +49,7 @@ import org.meshtastic.core.resources.remove import org.meshtastic.core.resources.remove_favorite import org.meshtastic.core.resources.remove_ignored import org.meshtastic.core.resources.set_display_name +import org.meshtastic.core.resources.share_contact import org.meshtastic.core.resources.trace_route import org.meshtastic.core.resources.unmute import org.meshtastic.core.resources.update_status @@ -61,6 +64,7 @@ import org.meshtastic.core.ui.icon.Message import org.meshtastic.core.ui.icon.NotFavorite import org.meshtastic.core.ui.icon.Notes import org.meshtastic.core.ui.icon.Route +import org.meshtastic.core.ui.icon.Share import org.meshtastic.core.ui.icon.VolumeOff import org.meshtastic.core.ui.icon.VolumeUp import org.meshtastic.core.ui.theme.StatusColors.StatusRed @@ -124,23 +128,40 @@ fun NodeContextMenu( } } -/** A menu of its own: favoriting, muting, messaging or removing yourself is meaningless (design#115). */ +/** + * A menu of its own: favoriting, muting, messaging or removing yourself is meaningless (design#115). Each action is + * absent, never disabled, when its callback is null: update status, edit the broadcast name in device settings, and + * share our contact. + */ @Composable -fun LocalNodeContextMenu(expanded: Boolean, onUpdateStatus: () -> Unit, onDismiss: () -> Unit) { +fun LocalNodeContextMenu( + expanded: Boolean, + onDismiss: () -> Unit, + onUpdateStatus: (() -> Unit)? = null, + onEditName: (() -> Unit)? = null, + onShareContact: (() -> Unit)? = null, +) { DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) { DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) { - DropdownMenuItem( - onClick = { - onUpdateStatus() - onDismiss() - }, - leadingIcon = { Icon(imageVector = MeshtasticIcons.Notes, contentDescription = null) }, - text = { Text(text = stringResource(Res.string.update_status)) }, - ) + onUpdateStatus?.let { LocalMenuItem(MeshtasticIcons.Notes, Res.string.update_status, it, onDismiss) } + onEditName?.let { LocalMenuItem(MeshtasticIcons.Edit, Res.string.edit_name, it, onDismiss) } + onShareContact?.let { LocalMenuItem(MeshtasticIcons.Share, Res.string.share_contact, it, onDismiss) } } } } +@Composable +private fun LocalMenuItem(icon: ImageVector, label: StringResource, onClick: () -> Unit, onDismiss: () -> Unit) { + DropdownMenuItem( + onClick = { + onClick() + onDismiss() + }, + leadingIcon = { Icon(imageVector = icon, contentDescription = null) }, + text = { Text(text = stringResource(label)) }, + ) +} + @Composable private fun FavoriteMenuItem(node: Node, onFavorite: () -> Unit, onDismiss: () -> Unit) { val isFavorite = node.isFavorite diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index fe80529f0df..5b393c8d729 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -108,6 +108,15 @@ import org.meshtastic.feature.node.component.NodeHopHistogramSheet import org.meshtastic.feature.node.component.NodeListHelp import org.meshtastic.proto.ExcludedModules +internal fun ownNodeMenuItems(node: Node, ourNode: Node?, connectionState: ConnectionState): OwnNodeMenuItems { + val connected = node.num == ourNode?.num && connectionState == ConnectionState.Connected + return OwnNodeMenuItems( + updateStatus = canEditStatusMessage(node, ourNode, connectionState), + editName = connected, + shareContact = connected && ourNode.capabilities.supportsQrCodeSharing, + ) +} + /** * design#115: status message editing is offered on the connected local node only, and only where the firmware has the * module — absent, never disabled, everywhere else. @@ -118,6 +127,12 @@ internal fun canEditStatusMessage(node: Node, ourNode: Node?, connectionState: C node.capabilities.supportsStatusMessage && !node.metadata.excludes(ExcludedModules.STATUSMESSAGE_CONFIG) +/** Which actions our own node's long-press menu offers; the menu opens only when [any] is true. */ +internal data class OwnNodeMenuItems(val updateStatus: Boolean, val editName: Boolean, val shareContact: Boolean) { + val any: Boolean + get() = updateStatus || editName || shareContact +} + @Suppress("LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalFoundationApi::class) @Composable @@ -133,6 +148,7 @@ fun NodeListScreen( onEditStatusMessage: () -> Unit = {}, navigateToMeshView: (Int) -> Unit = {}, navigateToCompass: (Int) -> Unit = {}, + onEditName: () -> Unit = {}, ) { val showToast = org.meshtastic.core.ui.util.rememberShowToastResource() val scope = rememberCoroutineScope() @@ -215,10 +231,10 @@ fun NodeListScreen( Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { val isThisNode = node.num == ourNode?.num - val canEditStatus = canEditStatusMessage(node, ourNode, connectionState) + val ownItems = ownNodeMenuItems(node, ourNode, connectionState) // Our own node only earns a long press while it has something to offer, or it opens an empty menu. val longClick = - if (!isThisNode || canEditStatus) { + if (!isThisNode || ownItems.any) { { expanded = true } } else { null @@ -265,13 +281,15 @@ fun NodeListScreen( deviceImageUrl = deviceImageUrls[node.user.hw_model.value], ) } - if (canEditStatus) { + if (isThisNode) { LocalNodeContextMenu( - expanded = expanded, - onUpdateStatus = onEditStatusMessage, + expanded = expanded && ownItems.any, onDismiss = { expanded = false }, + onUpdateStatus = onEditStatusMessage.takeIf { ownItems.updateStatus }, + onEditName = onEditName.takeIf { ownItems.editName }, + onShareContact = { showShareContact = true }.takeIf { ownItems.shareContact }, ) - } else if (!isThisNode) { + } else { NodeContextMenu( expanded = expanded, node = node, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt index 605e59f9e5e..a62907bd4e3 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt @@ -49,6 +49,7 @@ fun AdaptiveNodeListScreen( // A bare radio-config route on this tab's stack resolves to the local session, the same as Connections -> LoRa. onEditStatusMessage = { backStack.add(SettingsRoute.UserStatusMessage) }, navigateToMeshView = { num -> backStack.add(MapRoute.MeshHud(focusNodeNum = num)) }, + onEditName = { backStack.add(SettingsRoute.User) }, navigateToCompass = { num -> backStack.add(NodesRoute.NodeDetail(num, openCompass = true)) }, ) } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/OwnNodeMenuItemsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/OwnNodeMenuItemsTest.kt new file mode 100644 index 00000000000..b26eef019ad --- /dev/null +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/OwnNodeMenuItemsTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.list + +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node +import org.meshtastic.proto.DeviceMetadata +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class OwnNodeMenuItemsTest { + + private fun node(num: Int, firmware: String?) = Node( + num = num, + metadata = firmware?.let { DeviceMetadata.Builder().also { wb -> wb.firmware_version = it }.build() }, + ) + + @Test + fun `a connected local node on 2 8 firmware offers every item`() { + val ours = node(1, "2.8.0") + + val items = ownNodeMenuItems(ours, ours, ConnectionState.Connected) + + assertTrue(items.updateStatus && items.editName && items.shareContact) + assertTrue(items.any) + } + + @Test + fun `old firmware without status messages still offers edit name and share contact`() { + val ours = node(1, "2.7.0") + + val items = ownNodeMenuItems(ours, ours, ConnectionState.Connected) + + assertFalse(items.updateStatus) + assertTrue(items.editName) + assertTrue(items.shareContact) + } + + @Test + fun `firmware before contact sharing offers edit name only`() { + val ours = node(1, "2.6.0") + + val items = ownNodeMenuItems(ours, ours, ConnectionState.Connected) + + assertFalse(items.shareContact) + assertTrue(items.editName) + } + + @Test + fun `a disconnected local node offers nothing`() { + val ours = node(1, "2.8.0") + + assertFalse(ownNodeMenuItems(ours, ours, ConnectionState.Disconnected).any) + } + + @Test + fun `another node offers nothing`() { + val ours = node(1, "2.8.0") + + assertFalse(ownNodeMenuItems(node(2, "2.8.0"), ours, ConnectionState.Connected).any) + } +} From 307a98c5196e88af422161c10371f1de6e24be4a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:47:32 -0500 Subject: [PATCH 10/10] fix(nodes): address display name review findings - AI recipient lookup treats an exact name shared by two nodes as ambiguous - DM shortcuts republish when only a display name changes - Open-in-maps passes the broadcast name to the external app - Name fields trim leading whitespace before the byte cap; the node details draft is keyed by node - User docs cover backups, the maps handoff and own-node Edit name --- .../core/data/ai/FuzzyNameResolver.kt | 13 +++--- .../core/data/ai/FuzzyNameResolverTest.kt | 40 +++++++++++++++++++ .../ConversationShortcutPublisherTest.kt | 26 +++++++++--- .../service/ConversationShortcutPublisher.kt | 16 ++++++-- docs/en/user/nodes.md | 4 +- .../node/component/DisplayNameDialog.kt | 2 +- .../node/component/DisplayNameSection.kt | 4 +- .../node/component/LinkedCoordinatesItem.kt | 3 +- 8 files changed, 88 insertions(+), 20 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt index af639ccc205..2b89b322edb 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolver.kt @@ -88,12 +88,13 @@ class FuzzyNameResolver( private fun matchName(query: String, candidates: List): NodeNameResult { if (candidates.isEmpty()) return NodeNameResult.NotFound - // Exact match first (case-insensitive) - candidates - .firstOrNull { it.name.equals(query, ignoreCase = true) } - ?.let { - return NodeNameResult.Found(it.nodeNum, it.userId) - } + // Exact match first (case-insensitive). A display name can repeat another node's name, so a shared one is + // ambiguous rather than whichever node the map yields first. + val exact = candidates.filter { it.name.equals(query, ignoreCase = true) }.distinctBy { it.nodeNum } + if (exact.isNotEmpty()) { + return exact.singleOrNull()?.let { NodeNameResult.Found(it.nodeNum, it.userId) } + ?: NodeNameResult.Ambiguous(exact.map { it.name }) + } // Fuzzy match using longest common substring val minScore = (query.length * MATCH_THRESHOLD).toInt().coerceAtLeast(1) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt index 6cf47a17e7b..6b5bedffa87 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/FuzzyNameResolverTest.kt @@ -217,6 +217,46 @@ class FuzzyNameResolverTest { assertEquals(2, result.candidates.size) } + @Test + fun resolveNodeName_exact_name_shared_by_a_display_name_is_ambiguous() { + val nodeRepository: NodeRepository = mock(MockMode.autofill) + val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) + val nodes = + mapOf( + 1 to + Node( + num = 1, + user = + User.Builder() + .also { wb -> + wb.id = "!00000001" + wb.long_name = "Base Camp" + wb.short_name = "BC" + } + .build(), + ), + 2 to + Node( + num = 2, + user = + User.Builder() + .also { wb -> + wb.id = "!00000002" + wb.long_name = "Hilltop" + wb.short_name = "HT" + } + .build(), + displayName = "Base Camp", + ), + ) + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes) + + val result = FuzzyNameResolver(nodeRepository, radioConfigRepository).resolveNodeName("base camp") + + assertIs(result) + assertEquals(2, result.candidates.size) + } + @Test fun resolveNodeName_not_found_when_no_nodes() { val nodeRepository: NodeRepository = mock(MockMode.autofill) diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ConversationShortcutPublisherTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ConversationShortcutPublisherTest.kt index a02267aa5d2..edd9a9dcf86 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ConversationShortcutPublisherTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ConversationShortcutPublisherTest.kt @@ -120,7 +120,7 @@ class ConversationShortcutPublisherTest { ), ) - publisher.startObserving(this) + publisher.startObserving(backgroundScope) advanceUntilIdle() val ranks = shortcutManager.dynamicShortcuts.associate { it.id to it.rank } @@ -134,7 +134,7 @@ class ConversationShortcutPublisherTest { every { packetRepository.getContacts() } returns flowOf(mapOf("0!00000007" to contact(from = "!00000007", time = 1_000))) - publisher.startObserving(this) + publisher.startObserving(backgroundScope) advanceUntilIdle() val byId = shortcutManager.dynamicShortcuts.associateBy { it.id } @@ -144,6 +144,22 @@ class ConversationShortcutPublisherTest { assertEquals("Beta", byId.getValue("1^all").shortLabel) } + @Test + fun `a display name change alone republishes the dm shortcut label`() = runTest { + val nodes = MutableStateFlow(mapOf(7 to hawk)) + every { nodeRepository.nodeDBbyNum } returns nodes + every { packetRepository.getContacts() } returns + flowOf(mapOf("0!00000007" to contact(from = "!00000007", time = 1_000))) + + publisher.startObserving(backgroundScope) + advanceUntilIdle() + nodes.value = mapOf(7 to hawk.copy(displayName = "Ridge Base")) + advanceUntilIdle() + + val shortcut = shortcutManager.dynamicShortcuts.single { it.id == "0!00000007" } + assertEquals("Ridge Base", shortcut.shortLabel) + } + @Test fun `dm shortcut for a node missing from the node db is titled with the unknown-user name`() = runTest { every { packetRepository.getContacts() } returns @@ -152,7 +168,7 @@ class ConversationShortcutPublisherTest { // advanceUntilIdle does not wait for. val expected = getStringSuspend(Res.string.unknown_username) - publisher.startObserving(this) + publisher.startObserving(backgroundScope) advanceUntilIdle() val published = shortcutManager.dynamicShortcuts.first { it.id == "0!000000fe" } @@ -168,7 +184,7 @@ class ConversationShortcutPublisherTest { every { packetRepository.getContacts() } returns flowOf(emptyMap()) - publisher.startObserving(this) + publisher.startObserving(backgroundScope) advanceUntilIdle() assertNull(shortcutManager.dynamicShortcuts.find { it.id == "9^all" }, "stale shortcut should be pruned") @@ -182,7 +198,7 @@ class ConversationShortcutPublisherTest { every { packetRepository.getContacts() } returns flowOf(emptyMap()) - publisher.startObserving(this) + publisher.startObserving(backgroundScope) advanceUntilIdle() assertTrue( diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt index 67186f0078e..1c2fdef1513 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers @@ -89,11 +90,20 @@ class ConversationShortcutPublisher( // Combine the message DB (recency + DM peers) with the channel config (names). Ranking both by // last-activity time lets launchers / Android Auto surface the *recently active* conversations first // (the shortcut display cap is small), instead of an arbitrary slice of stale channels. - combine(packetRepository.getContacts(), radioConfigRepository.channelSetFlow) { contacts, channelSet -> - rankConversationsByRecency(contacts, channelSet) + // DM labels come from the node's display name, so a rename alone has to republish too. + val displayNames = + nodeRepository.nodeDBbyNum + .map { db -> db.mapValues { it.value.displayName } } + .distinctUntilChanged() + combine(packetRepository.getContacts(), radioConfigRepository.channelSetFlow, displayNames) { + contacts, + channelSet, + names, + -> + rankConversationsByRecency(contacts, channelSet) to names } .distinctUntilChanged() - .collect { conversations -> publishShortcuts(conversations) } + .collect { (conversations, _) -> publishShortcuts(conversations) } } } diff --git a/docs/en/user/nodes.md b/docs/en/user/nodes.md index 032acc97c86..66ce1d211c4 100644 --- a/docs/en/user/nodes.md +++ b/docs/en/user/nodes.md @@ -133,7 +133,9 @@ You can give any node a local display name that replaces its long name in the no The avatar always shows the node's broadcast short name. While a display name is set, node details shows the node's broadcast long name in a **Long Name** row under the name, so the real name stays readable. -Display names are stored only on this device. They are never sent over the mesh, shared, or exported, so QR codes, contact sharing, message export and the node database export all use the node's real name. Removing a node also removes its display name. Your own node's name is set in [Settings - Radio & User](settings-radio-user). +Display names are stored only on this device. They are never sent over the mesh or handed to another app, so QR codes, contact sharing, opening a position in a maps app, and the message, node database and GPX exports all use the node's real name. App backups do include display names, the same as notes. Removing a node also removes its display name. + +Your own node can have a display name too, from its **Display name** card. The **Edit name** item in your own node's long-press menu does something different: it opens [Settings - Radio & User](settings-radio-user), where you change the name your node broadcasts to everyone. ## Filtering & sorting diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameDialog.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameDialog.kt index 41c9ef06068..27c72a03434 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameDialog.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameDialog.kt @@ -52,7 +52,7 @@ fun DisplayNameDialog(node: Node, onSave: (Int, String) -> Unit, onDismiss: () - text = { OutlinedTextField( value = name, - onValueChange = { name = it.limitToBytes(MAX_DISPLAY_NAME_BYTES) }, + onValueChange = { name = it.trimStart().limitToBytes(MAX_DISPLAY_NAME_BYTES) }, singleLine = true, placeholder = { Text(node.user.long_name) }, supportingText = { Text(stringResource(Res.string.display_name_hint)) }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameSection.kt index e38af7934f3..c179c229e05 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DisplayNameSection.kt @@ -69,13 +69,13 @@ fun DisplayNameSection(node: Node, onSaveDisplayName: (Int, String) -> Unit, mod Spacer(Modifier.height(16.dp)) - var name by remember(node.displayName) { mutableStateOf(node.displayName) } + var name by remember(node.num, node.displayName) { mutableStateOf(node.displayName) } val edited = name.trim() != node.displayName.trim() val keyboardController = LocalSoftwareKeyboardController.current OutlinedTextField( value = name, - onValueChange = { name = it.limitToBytes(MAX_DISPLAY_NAME_BYTES) }, + onValueChange = { name = it.trimStart().limitToBytes(MAX_DISPLAY_NAME_BYTES) }, modifier = Modifier.fillMaxWidth(), singleLine = true, placeholder = { Text(node.user.long_name) }, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt index a75ca1952d5..bcd2d1cc347 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/LinkedCoordinatesItem.kt @@ -32,7 +32,6 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.GPSFormat import org.meshtastic.core.common.util.MeasurementSystem import org.meshtastic.core.model.Node -import org.meshtastic.core.model.effectiveLongName import org.meshtastic.core.model.util.toElevationString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.copy @@ -80,7 +79,7 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: MeasurementSystem = Measurem leadingIcon = MeshtasticIcons.LocationOn, supportingText = "$ago • $coordinates$elevationText", trailingContent = MeshtasticIcons.ChevronRight.icon(), - onClick = { openMap(node.latitude, node.longitude, node.effectiveLongName) }, + onClick = { openMap(node.latitude, node.longitude, node.user.long_name) }, onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(coordinates, copyLabel)) } }, ) }