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