diff --git a/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/19.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/19.json new file mode 100644 index 000000000..4bfa3e7f7 --- /dev/null +++ b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/19.json @@ -0,0 +1,721 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "f7d2e680949edbc8df82cd1467e3b10b", + "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, 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" + } + ], + "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, 'f7d2e680949edbc8df82cd1467e3b10b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/database/Converters.kt b/app/src/main/java/com/geeksville/mesh/database/Converters.kt index 97ddf5d21..7917027eb 100644 --- a/app/src/main/java/com/geeksville/mesh/database/Converters.kt +++ b/app/src/main/java/com/geeksville/mesh/database/Converters.kt @@ -23,6 +23,7 @@ import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.PaxcountProtos import com.geeksville.mesh.TelemetryProtos import com.geeksville.mesh.android.Logging +import com.google.protobuf.ByteString import com.google.protobuf.InvalidProtocolBufferException import kotlinx.serialization.json.Json @@ -145,4 +146,14 @@ class Converters : Logging { } return Json.encodeToString(list) } + + @TypeConverter + fun bytesToByteString(bytes: ByteArray?): ByteString? { + return if (bytes == null) null else ByteString.copyFrom(bytes) + } + + @TypeConverter + fun byteStringToBytes(value: ByteString?): ByteArray? { + return value?.toByteArray() + } } 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 5e6525022..e81c4f664 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt @@ -71,8 +71,9 @@ import com.geeksville.mesh.database.entity.ReactionEntity AutoMigration(from = 15, to = 16), AutoMigration(from = 16, to = 17), AutoMigration(from = 17, to = 18), + AutoMigration(from = 18, to = 19), ], - version = 18, + version = 19, exportSchema = true, ) @TypeConverters(Converters::class) 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 c4239af02..e79c5e29e 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 @@ -25,17 +25,62 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert import com.geeksville.mesh.android.BuildUtils.warn -import com.geeksville.mesh.copy 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.NodeWithRelations +import com.google.protobuf.ByteString import kotlinx.coroutines.flow.Flow @Suppress("TooManyFunctions") @Dao interface NodeInfoDao { + // Helper function to contain all validation logic + private fun getVerifiedNodeForUpsert(node: NodeEntity): NodeEntity? { + // Populate the new publicKey field for lazy migration + node.publicKey = node.user.publicKey + + val existingNode = getNodeByNum(node.num)?.node + + return if (existingNode == null) { + // This is a new node. We must check if its public key is already claimed by another node. + if (node.publicKey != null && node.publicKey?.isEmpty == false) { + val nodeWithSamePK = findNodeByPublicKey(node.publicKey) + if (nodeWithSamePK != null && nodeWithSamePK.num != node.num) { + // This is the impersonation attempt we want to block. + @Suppress("MaxLineLength") + warn("NodeInfoDao: Blocking new node #${node.num} because its public key is already used by #${nodeWithSamePK.num}.") + return null // ABORT + } + } + // If we're here, the new node is safe to add. + node + } else { + // This is an update to an existing node. + val keyMatch = + existingNode.user.publicKey == node.user.publicKey || existingNode.user.publicKey.isEmpty + if (keyMatch) { + // Keys match, trust the incoming node completely. + // This allows for legit nodeId changes etc. + node + } else { + // Keys do NOT match. This is a potential attack. + // Log it, and create a NEW entity based on the EXISTING trusted one, + // only updating dynamic data and setting the public key to EMPTY to signal a conflict. + @Suppress("MaxLineLength") + warn("NodeInfoDao: Received packet for #${node.num} with non-matching public key. Identity data ignored, key set to EMPTY.") + existingNode.copy( + lastHeard = node.lastHeard, + snr = node.snr, + position = node.position, + user = existingNode.user.toBuilder().setPublicKey(ByteString.EMPTY).build(), + publicKey = ByteString.EMPTY + ) + } + } + } + @Query("SELECT * FROM my_node") fun getMyNodeInfo(): Flow @@ -113,40 +158,18 @@ interface NodeInfoDao { lastHeardMin: Int, ): Flow> - @Upsert + @Transaction fun upsert(node: NodeEntity) { - val found = getNodeByNum(node.num)?.node - found?.let { - val keyMatch = !it.hasPKC || it.user.publicKey == node.user.publicKey - it.user = if (keyMatch) { - node.user - } else { - node.user.copy { - warn("Public key mismatch from $longName ($shortName)") - publicKey = NodeEntity.ERROR_BYTE_STRING - } - } - } - doUpsert(node) + getVerifiedNodeForUpsert(node)?.let { doUpsert(it) } } - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Suppress("NestedBlockDepth") + @Transaction fun putAll(nodes: List) { - nodes.forEach { node -> - val found = getNodeByNum(node.num)?.node - found?.let { - val keyMatch = !it.hasPKC || it.user.publicKey == node.user.publicKey - it.user = if (keyMatch) { - node.user - } else { - node.user.copy { - warn("Public key mismatch from $longName ($shortName)") - publicKey = NodeEntity.ERROR_BYTE_STRING - } - } - } + val safeNodes = nodes.mapNotNull { getVerifiedNodeForUpsert(it) } + if (safeNodes.isNotEmpty()) { + doPutAll(safeNodes) } - doPutAll(nodes) } @Query("DELETE FROM nodes") @@ -165,6 +188,9 @@ interface NodeInfoDao { @Transaction fun getNodeByNum(num: Int): NodeWithRelations? + @Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1") + fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity? + @Upsert fun doUpsert(node: NodeEntity) 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 025fea8b3..e7b9c800d 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 @@ -35,6 +35,7 @@ import com.geeksville.mesh.copy import com.geeksville.mesh.model.Node import com.geeksville.mesh.util.onlineTimeThreshold import com.google.protobuf.ByteString +import com.google.protobuf.kotlin.isNotEmpty data class NodeWithRelations( @Embedded val node: NodeEntity, @@ -143,6 +144,9 @@ data class NodeEntity( @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(), + + @ColumnInfo(name = "public_key") + var publicKey: ByteString? = null, ) { val deviceMetrics: TelemetryProtos.DeviceMetrics get() = deviceTelemetry.deviceMetrics @@ -151,8 +155,7 @@ data class NodeEntity( get() = environmentTelemetry.environmentMetrics val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET - val hasPKC get() = !user.publicKey.isEmpty - val errorByteString: ByteString get() = ERROR_BYTE_STRING + val hasPKC get() = (publicKey ?: user.publicKey).isNotEmpty() fun setPosition(p: MeshProtos.Position, defaultTime: Int = currentTime()) { position = p.copy { time = if (p.time != 0) p.time else defaultTime } 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 c4864e0ae..1b631e90a 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Node.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Node.kt @@ -29,6 +29,8 @@ import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.util.GPSFormat import com.geeksville.mesh.util.latLongToMeter import com.geeksville.mesh.util.toDistanceString +import com.google.protobuf.ByteString +import com.google.protobuf.kotlin.isNotEmpty @Suppress("MagicNumber") data class Node( @@ -48,6 +50,7 @@ data class Node( val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics.getDefaultInstance(), val powerMetrics: PowerMetrics = PowerMetrics.getDefaultInstance(), val paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(), + val publicKey: ByteString? = null, ) { val colors: Pair get() { // returns foreground and background @ColorInt for each 'num' @@ -59,8 +62,8 @@ data class Node( } val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET - val hasPKC get() = !user.publicKey.isEmpty - val mismatchKey get() = user.publicKey == NodeEntity.ERROR_BYTE_STRING + val hasPKC get() = (publicKey ?: user.publicKey).isNotEmpty() + val mismatchKey get() = (publicKey ?: user.publicKey) == NodeEntity.ERROR_BYTE_STRING val hasEnvironmentMetrics: Boolean get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance()