diff --git a/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/20.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/20.json
new file mode 100644
index 000000000..ea7bf6a8f
--- /dev/null
+++ b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/20.json
@@ -0,0 +1,728 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 20,
+ "identityHash": "8e135ee12f121e05420754f5c1f748a5",
+ "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, 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"
+ }
+ ],
+ "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, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', 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": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ }
+ },
+ {
+ "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, `reply_id` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1)",
+ "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": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ }
+ ],
+ "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`)"
+ }
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "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`)"
+ }
+ ]
+ },
+ {
+ "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}` (`reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "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
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "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`)"
+ }
+ ]
+ },
+ {
+ "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, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`hwModel`))",
+ "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": "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": [
+ "hwModel"
+ ]
+ }
+ },
+ {
+ "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"
+ ]
+ }
+ }
+ ],
+ "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, '8e135ee12f121e05420754f5c1f748a5')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt
index e81c4f664..3f639f4e2 100644
--- a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt
@@ -1,106 +1,105 @@
-/*
- * Copyright (c) 2025 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 com.geeksville.mesh.database
-
-import android.content.Context
-import androidx.room.AutoMigration
-import androidx.room.Database
-import androidx.room.DeleteTable
-import androidx.room.Room
-import androidx.room.RoomDatabase
-import androidx.room.TypeConverters
-import androidx.room.migration.AutoMigrationSpec
-import com.geeksville.mesh.database.dao.DeviceHardwareDao
-import com.geeksville.mesh.database.dao.FirmwareReleaseDao
-import com.geeksville.mesh.database.dao.MeshLogDao
-import com.geeksville.mesh.database.dao.NodeInfoDao
-import com.geeksville.mesh.database.dao.PacketDao
-import com.geeksville.mesh.database.dao.QuickChatActionDao
-import com.geeksville.mesh.database.entity.ContactSettings
-import com.geeksville.mesh.database.entity.DeviceHardwareEntity
-import com.geeksville.mesh.database.entity.FirmwareReleaseEntity
-import com.geeksville.mesh.database.entity.MeshLog
-import com.geeksville.mesh.database.entity.MetadataEntity
-import com.geeksville.mesh.database.entity.MyNodeEntity
-import com.geeksville.mesh.database.entity.NodeEntity
-import com.geeksville.mesh.database.entity.Packet
-import com.geeksville.mesh.database.entity.QuickChatAction
-import com.geeksville.mesh.database.entity.ReactionEntity
-
-@Database(
- entities = [
- MyNodeEntity::class,
- NodeEntity::class,
- Packet::class,
- ContactSettings::class,
- MeshLog::class,
- QuickChatAction::class,
- ReactionEntity::class,
- MetadataEntity::class,
- DeviceHardwareEntity::class,
- FirmwareReleaseEntity::class,
- ],
- autoMigrations = [
- AutoMigration(from = 3, to = 4),
- AutoMigration(from = 4, to = 5),
- AutoMigration(from = 5, to = 6),
- AutoMigration(from = 6, to = 7),
- AutoMigration(from = 7, to = 8),
- AutoMigration(from = 8, to = 9),
- AutoMigration(from = 9, to = 10),
- AutoMigration(from = 10, to = 11),
- AutoMigration(from = 11, to = 12),
- AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class),
- AutoMigration(from = 13, to = 14),
- AutoMigration(from = 14, to = 15),
- AutoMigration(from = 15, to = 16),
- AutoMigration(from = 16, to = 17),
- AutoMigration(from = 17, to = 18),
- AutoMigration(from = 18, to = 19),
- ],
- version = 19,
- exportSchema = true,
-)
-@TypeConverters(Converters::class)
-abstract class MeshtasticDatabase : RoomDatabase() {
- abstract fun nodeInfoDao(): NodeInfoDao
- abstract fun packetDao(): PacketDao
- abstract fun meshLogDao(): MeshLogDao
- abstract fun quickChatActionDao(): QuickChatActionDao
- abstract fun deviceHardwareDao(): DeviceHardwareDao
- abstract fun firmwareReleaseDao(): FirmwareReleaseDao
-
- companion object {
- fun getDatabase(context: Context): MeshtasticDatabase {
-
- return Room.databaseBuilder(
- context.applicationContext,
- MeshtasticDatabase::class.java,
- "meshtastic_database"
- )
- .fallbackToDestructiveMigration(false)
- .build()
- }
- }
-}
-
-@DeleteTable.Entries(
- DeleteTable(tableName = "NodeInfo"),
- DeleteTable(tableName = "MyNodeInfo")
-)
-class AutoMigration12to13 : AutoMigrationSpec
+/*
+ * Copyright (c) 2025 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 com.geeksville.mesh.database
+
+import android.content.Context
+import androidx.room.AutoMigration
+import androidx.room.Database
+import androidx.room.DeleteTable
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import androidx.room.migration.AutoMigrationSpec
+import com.geeksville.mesh.database.dao.DeviceHardwareDao
+import com.geeksville.mesh.database.dao.FirmwareReleaseDao
+import com.geeksville.mesh.database.dao.MeshLogDao
+import com.geeksville.mesh.database.dao.NodeInfoDao
+import com.geeksville.mesh.database.dao.PacketDao
+import com.geeksville.mesh.database.dao.QuickChatActionDao
+import com.geeksville.mesh.database.entity.ContactSettings
+import com.geeksville.mesh.database.entity.DeviceHardwareEntity
+import com.geeksville.mesh.database.entity.FirmwareReleaseEntity
+import com.geeksville.mesh.database.entity.MeshLog
+import com.geeksville.mesh.database.entity.MetadataEntity
+import com.geeksville.mesh.database.entity.MyNodeEntity
+import com.geeksville.mesh.database.entity.NodeEntity
+import com.geeksville.mesh.database.entity.Packet
+import com.geeksville.mesh.database.entity.QuickChatAction
+import com.geeksville.mesh.database.entity.ReactionEntity
+
+@Database(
+ entities =
+ [
+ MyNodeEntity::class,
+ NodeEntity::class,
+ Packet::class,
+ ContactSettings::class,
+ MeshLog::class,
+ QuickChatAction::class,
+ ReactionEntity::class,
+ MetadataEntity::class,
+ DeviceHardwareEntity::class,
+ FirmwareReleaseEntity::class,
+ ],
+ autoMigrations =
+ [
+ AutoMigration(from = 3, to = 4),
+ AutoMigration(from = 4, to = 5),
+ AutoMigration(from = 5, to = 6),
+ AutoMigration(from = 6, to = 7),
+ AutoMigration(from = 7, to = 8),
+ AutoMigration(from = 8, to = 9),
+ AutoMigration(from = 9, to = 10),
+ AutoMigration(from = 10, to = 11),
+ AutoMigration(from = 11, to = 12),
+ AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class),
+ AutoMigration(from = 13, to = 14),
+ AutoMigration(from = 14, to = 15),
+ AutoMigration(from = 15, to = 16),
+ AutoMigration(from = 16, to = 17),
+ AutoMigration(from = 17, to = 18),
+ AutoMigration(from = 18, to = 19),
+ AutoMigration(from = 19, to = 20),
+ ],
+ version = 20,
+ exportSchema = true,
+)
+@TypeConverters(Converters::class)
+abstract class MeshtasticDatabase : RoomDatabase() {
+ abstract fun nodeInfoDao(): NodeInfoDao
+
+ abstract fun packetDao(): PacketDao
+
+ abstract fun meshLogDao(): MeshLogDao
+
+ abstract fun quickChatActionDao(): QuickChatActionDao
+
+ abstract fun deviceHardwareDao(): DeviceHardwareDao
+
+ abstract fun firmwareReleaseDao(): FirmwareReleaseDao
+
+ companion object {
+ fun getDatabase(context: Context): MeshtasticDatabase =
+ Room.databaseBuilder(context.applicationContext, MeshtasticDatabase::class.java, "meshtastic_database")
+ .fallbackToDestructiveMigration(false)
+ .build()
+ }
+}
+
+@DeleteTable.Entries(DeleteTable(tableName = "NodeInfo"), DeleteTable(tableName = "MyNodeInfo"))
+class AutoMigration12to13 : AutoMigrationSpec
diff --git a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt
index aa1e04ea0..16c737979 100644
--- a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt
@@ -157,4 +157,7 @@ constructor(
val totalNodeCount: Flow =
nodeInfoDao.nodeDBbyNum().mapLatest { map -> map.values.count() }.flowOn(dispatchers.io).conflate()
+
+ suspend fun setNodeNotes(num: Int, notes: String) =
+ withContext(dispatchers.io) { nodeInfoDao.setNodeNotes(num, notes) }
}
diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt
index 494d04524..cc4ec1e8f 100644
--- a/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/dao/NodeInfoDao.kt
@@ -83,7 +83,8 @@ interface NodeInfoDao {
return if (isPublicKeyMatchingOrExistingIsEmpty) {
// Keys match or existing key was empty: trust the incoming node data completely.
// This allows for legitimate updates to user info and other fields.
- incomingNode
+ val resolvedNotes = if (incomingNode.notes.isBlank()) existingNode.notes else incomingNode.notes
+ incomingNode.copy(notes = resolvedNotes)
} else {
existingNode.copy(
lastHeard = incomingNode.lastHeard,
@@ -93,6 +94,7 @@ interface NodeInfoDao {
// to reflect the conflict state.
user = existingNode.user.toBuilder().setPublicKey(ByteString.EMPTY).build(),
publicKey = ByteString.EMPTY,
+ notes = existingNode.notes,
)
}
}
@@ -216,4 +218,7 @@ interface NodeInfoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun putAll(nodes: List)
+
+ @Query("UPDATE nodes SET notes = :notes WHERE num = :num")
+ fun setNodeNotes(num: Int, notes: String)
}
diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt
index 677d60eff..4cf73cd0e 100644
--- a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt
@@ -60,6 +60,7 @@ data class NodeWithRelations(
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
paxcounter = paxcounter,
+ notes = notes,
)
}
@@ -80,6 +81,7 @@ data class NodeWithRelations(
environmentTelemetry = environmentTelemetry,
powerTelemetry = powerTelemetry,
paxcounter = paxcounter,
+ notes = notes,
)
}
}
@@ -119,6 +121,7 @@ data class NodeEntity(
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
@ColumnInfo(name = "public_key") var publicKey: ByteString? = null,
+ @ColumnInfo(name = "notes", defaultValue = "") var notes: String = "",
) {
val deviceMetrics: TelemetryProtos.DeviceMetrics
get() = deviceTelemetry.deviceMetrics
@@ -172,6 +175,7 @@ data class NodeEntity(
powerMetrics = powerTelemetry.powerMetrics,
paxcounter = paxcounter,
publicKey = publicKey ?: user.publicKey,
+ notes = notes,
)
fun toNodeInfo() = NodeInfo(
diff --git a/app/src/main/java/com/geeksville/mesh/model/Node.kt b/app/src/main/java/com/geeksville/mesh/model/Node.kt
index aa86651a6..e79d423f9 100644
--- a/app/src/main/java/com/geeksville/mesh/model/Node.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/Node.kt
@@ -52,6 +52,7 @@ data class Node(
val powerMetrics: PowerMetrics = PowerMetrics.getDefaultInstance(),
val paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
val publicKey: ByteString? = null,
+ val notes: String = "",
) {
val colors: Pair
get() { // returns foreground and background @ColorInt for each 'num'
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index 65985983f..07e96951f 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -762,6 +762,16 @@ constructor(
}
}
+ fun setNodeNotes(nodeNum: Int, notes: String) = viewModelScope.launch(Dispatchers.IO) {
+ try {
+ nodeDB.setNodeNotes(nodeNum, notes)
+ } catch (ex: java.io.IOException) {
+ errormsg("Set node notes IO error: ${ex.message}")
+ } catch (ex: java.sql.SQLException) {
+ errormsg("Set node notes SQL error: ${ex.message}")
+ }
+ }
+
// managed mode disables all access to configuration
val isManaged: Boolean
get() = config.device.isManaged || config.security.isManaged
diff --git a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt
index ef37619f5..53587b87c 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt
@@ -91,6 +91,7 @@ import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -331,9 +332,35 @@ private fun NodeDetailContent(
},
modifier = modifier,
availableLogs = availableLogs,
+ onSaveNotes = { num, notes -> uiViewModel.setNodeNotes(num, notes) },
)
}
+@Composable
+private fun notesSection(node: Node, onSaveNotes: (Int, String) -> Unit) {
+ if (node.isFavorite) {
+ TitledCard(title = stringResource(R.string.notes)) {
+ val originalNotes = node.notes
+ var notes by remember(node.notes) { mutableStateOf(node.notes) }
+ OutlinedTextField(
+ value = notes,
+ onValueChange = { notes = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text(stringResource(id = R.string.add_a_note)) },
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ val edited = notes.trim() != originalNotes.trim()
+ if (edited) {
+ NodeActionButton(
+ title = stringResource(id = R.string.save),
+ enabled = true,
+ onClick = { onSaveNotes(node.num, notes.trim()) },
+ )
+ }
+ }
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NodeDetailList(
@@ -344,6 +371,7 @@ private fun NodeDetailList(
metricsState: MetricsState,
onAction: (NodeDetailAction) -> Unit,
availableLogs: Set,
+ onSaveNotes: (Int, String) -> Unit,
) {
var showFirmwareSheet by remember { mutableStateOf(false) }
var selectedFirmware by remember { mutableStateOf(null) }
@@ -369,6 +397,7 @@ private fun NodeDetailList(
TitledCard(title = stringResource(R.string.details)) {
NodeDetailsContent(node, ourNode, metricsState.displayUnits)
}
+ notesSection(node = node, onSaveNotes = onSaveNotes)
DeviceActions(
isLocal = metricsState.isLocal,
@@ -1067,6 +1096,7 @@ private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::c
metricsState = MetricsState.Empty,
availableLogs = emptySet(),
onAction = {},
+ onSaveNotes = { _, _ -> },
)
}
}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b4010469b..1a68f8af5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -611,6 +611,8 @@
Dynamic
Scan QR Code
Share Contact
+ Notes
+ Add a private noteā¦
Import Shared Contact?
Unmessageable
Unmonitored or Infrastructure