diff --git a/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/16.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/16.json new file mode 100644 index 000000000..e65cf197b --- /dev/null +++ b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/16.json @@ -0,0 +1,564 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "626fc53854f129654c1007b86d9fdda0", + "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, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT", + "notNull": false + }, + { + "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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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, 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", + "notNull": false + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": false + }, + { + "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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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)", + "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" + } + ], + "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`)" + } + ], + "foreignKeys": [] + }, + { + "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" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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`)" + } + ], + "foreignKeys": [] + }, + { + "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" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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`)" + } + ], + "foreignKeys": [] + }, + { + "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`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "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, '626fc53854f129654c1007b86d9fdda0')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt index e5b479b1b..8c4d9d35e 100644 --- a/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt +++ b/app/src/androidTest/java/com/geeksville/mesh/NodeInfoDaoTest.kt @@ -24,8 +24,10 @@ import com.geeksville.mesh.database.MeshtasticDatabase import com.geeksville.mesh.database.dao.NodeInfoDao import com.geeksville.mesh.database.entity.MyNodeEntity import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.NodeSortOption import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -40,6 +42,18 @@ class NodeInfoDaoTest { private lateinit var database: MeshtasticDatabase private lateinit var nodeInfoDao: NodeInfoDao + private val unknownNode = NodeEntity( + num = 7, + user = user { + id = "!a1b2c3d4" + longName = "Meshtastic c3d4" + shortName = "c3d4" + hwModel = MeshProtos.HardwareModel.UNSET + }, + longName = "Meshtastic c3d4", + shortName = null // Dao filter for includeUnknown + ) + private val ourNode = NodeEntity( num = 8, user = user { @@ -79,7 +93,7 @@ class NodeInfoDaoTest { 39.952583 to -75.165222, // Philadelphia ) - private val testNodes = listOf(ourNode) + testPositions.mapIndexed { index, pos -> + private val testNodes = listOf(ourNode, unknownNode) + testPositions.mapIndexed { index, pos -> NodeEntity( num = 9 + index, user = user { @@ -89,7 +103,7 @@ class NodeInfoDaoTest { hwModel = MeshProtos.HardwareModel.ANDROID_SIM isLicensed = false }, - longName = "Kevin Mester$index", shortName = if (index == 2) null else "KM$index", + longName = "Kevin Mester$index", shortName = "KM$index", latitude = pos.first, longitude = pos.second, lastHeard = 9 + index, ) @@ -124,18 +138,18 @@ class NodeInfoDaoTest { sort = sort.sqlValue, filter = filter, includeUnknown = includeUnknown, - ).first().filter { it != ourNode } + ).map { list -> list.map { it.toModel() } }.first().filter { it.num != ourNode.num } @Test // node list size fun testNodeListSize() = runBlocking { val nodes = nodeInfoDao.nodeDBbyNum().first() - assertEquals(11, nodes.size) + assertEquals(12, nodes.size) } @Test // nodeDBbyNum() re-orders our node at the top of the list fun testOurNodeInfoIsFirst() = runBlocking { val nodes = nodeInfoDao.nodeDBbyNum().first() - assertEquals(ourNode, nodes.values.first()) + assertEquals(ourNode.num, nodes.values.first().node.num) } @Test @@ -155,8 +169,9 @@ class NodeInfoDaoTest { @Test fun testSortByDistance() = runBlocking { val nodes = getNodes(sort = NodeSortOption.DISTANCE) + fun NodeEntity.toNode() = Node(num = num, user = user, position = position) val sortedNodes = nodes.sortedWith( // nodes with invalid (null) positions at the end - compareBy { it.validPosition == null }.thenBy { it.distance(ourNode) } + compareBy { it.validPosition == null }.thenBy { it.distance(ourNode.toNode()) } ) assertEquals(sortedNodes, nodes) } @@ -185,7 +200,7 @@ class NodeInfoDaoTest { @Test fun testIncludeUnknownIsTrue() = runBlocking { val nodes = getNodes(includeUnknown = true) - val containsUnsetNode = nodes.any { it.shortName == null } + val containsUnsetNode = nodes.any { it.isUnknownUser } assertTrue(containsUnsetNode) } } 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 bf3c1579e..c5373ad51 100644 --- a/app/src/main/java/com/geeksville/mesh/database/Converters.kt +++ b/app/src/main/java/com/geeksville/mesh/database/Converters.kt @@ -114,4 +114,19 @@ class Converters : Logging { fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? { return value.toByteArray() } + + @TypeConverter + fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata { + return try { + MeshProtos.DeviceMetadata.parseFrom(bytes) + } catch (ex: InvalidProtocolBufferException) { + errormsg("bytesToMetadata TypeConverter error:", ex) + MeshProtos.DeviceMetadata.getDefaultInstance() + } + } + + @TypeConverter + fun metadataToBytes(value: MeshProtos.DeviceMetadata): 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 01f84d489..e2515e64a 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt @@ -31,6 +31,7 @@ import com.geeksville.mesh.database.dao.NodeInfoDao import com.geeksville.mesh.database.dao.QuickChatActionDao import com.geeksville.mesh.database.entity.ContactSettings 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 @@ -46,6 +47,7 @@ import com.geeksville.mesh.database.entity.ReactionEntity MeshLog::class, QuickChatAction::class, ReactionEntity::class, + MetadataEntity::class, ], autoMigrations = [ AutoMigration(from = 3, to = 4), @@ -60,8 +62,9 @@ import com.geeksville.mesh.database.entity.ReactionEntity AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class), AutoMigration(from = 13, to = 14), AutoMigration(from = 14, to = 15), + AutoMigration(from = 15, to = 16), ], - version = 15, + version = 16, exportSchema = true, ) @TypeConverters(Converters::class) 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 007f881c7..63bb049c4 100644 --- a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt @@ -23,14 +23,19 @@ import com.geeksville.mesh.CoroutineDispatchers import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.database.dao.NodeInfoDao +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.model.Node import com.geeksville.mesh.model.NodeSortOption +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext @@ -49,15 +54,20 @@ class NodeRepository @Inject constructor( .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null) // our node info - private val _ourNodeInfo = MutableStateFlow(null) - val ourNodeInfo: StateFlow get() = _ourNodeInfo + private val _ourNodeInfo = MutableStateFlow(null) + val ourNodeInfo: StateFlow get() = _ourNodeInfo // The unique userId of our node private val _myId = MutableStateFlow(null) val myId: StateFlow get() = _myId - // A map from nodeNum to NodeEntity - val nodeDBbyNum: StateFlow> = nodeInfoDao.nodeDBbyNum() + fun getNodeDBbyNum() = nodeInfoDao.nodeDBbyNum() + .map { map -> map.mapValues { (_, it) -> it.toEntity() } } + + // A map from nodeNum to Node + @OptIn(ExperimentalCoroutinesApi::class) + val nodeDBbyNum: StateFlow> = nodeInfoDao.nodeDBbyNum() + .mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } } .onEach { val ourNodeInfo = it.values.firstOrNull() _ourNodeInfo.value = ourNodeInfo @@ -67,8 +77,8 @@ class NodeRepository @Inject constructor( .conflate() .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) - fun getNode(userId: String): NodeEntity = nodeDBbyNum.value.values.find { it.user.id == userId } - ?: NodeEntity( + fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId } + ?: Node( num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId), ) @@ -84,6 +94,7 @@ class NodeRepository @Inject constructor( .setHwModel(MeshProtos.HardwareModel.UNSET) .build() + @OptIn(ExperimentalCoroutinesApi::class) fun getNodes( sort: NodeSortOption = NodeSortOption.LAST_HEARD, filter: String = "", @@ -92,7 +103,7 @@ class NodeRepository @Inject constructor( sort = sort.sqlValue, filter = filter, includeUnknown = includeUnknown, - ).flowOn(dispatchers.io).conflate() + ).mapLatest { list -> list.map { it.toModel() } }.flowOn(dispatchers.io).conflate() suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoDao.upsert(node) @@ -108,4 +119,8 @@ class NodeRepository @Inject constructor( suspend fun deleteNode(num: Int) = withContext(dispatchers.io) { nodeInfoDao.deleteNode(num) } + + suspend fun insertMetadata(metadata: MetadataEntity) = withContext(dispatchers.io) { + nodeInfoDao.upsert(metadata) + } } 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 99bbb5be4..7b0277462 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 @@ -22,8 +22,11 @@ import androidx.room.Insert import androidx.room.MapColumn import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import androidx.room.Upsert +import com.geeksville.mesh.database.entity.MetadataEntity import com.geeksville.mesh.database.entity.MyNodeEntity +import com.geeksville.mesh.database.entity.NodeWithRelations import com.geeksville.mesh.database.entity.NodeEntity import kotlinx.coroutines.flow.Flow @@ -49,7 +52,8 @@ interface NodeInfoDao { last_heard DESC """ ) - fun nodeDBbyNum(): Flow> + @Transaction + fun nodeDBbyNum(): Flow> @Query( """ @@ -92,11 +96,12 @@ interface NodeInfoDao { last_heard DESC """ ) + @Transaction fun getNodes( sort: String, filter: String, includeUnknown: Boolean, - ): Flow> + ): Flow> @Upsert fun upsert(node: NodeEntity) @@ -109,4 +114,7 @@ interface NodeInfoDao { @Query("DELETE FROM nodes WHERE num=:num") fun deleteNode(num: Int) + + @Upsert + fun upsert(meta: MetadataEntity) } 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 e0d27fceb..96f004ee1 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 @@ -17,11 +17,12 @@ package com.geeksville.mesh.database.entity -import android.graphics.Color import androidx.room.ColumnInfo +import androidx.room.Embedded import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey -import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig +import androidx.room.Relation import com.geeksville.mesh.DeviceMetrics import com.geeksville.mesh.EnvironmentMetrics import com.geeksville.mesh.MeshProtos @@ -31,16 +32,72 @@ import com.geeksville.mesh.PaxcountProtos import com.geeksville.mesh.Position import com.geeksville.mesh.TelemetryProtos import com.geeksville.mesh.copy -import com.geeksville.mesh.util.bearing -import com.geeksville.mesh.util.GPSFormat -import com.geeksville.mesh.util.latLongToMeter -import com.geeksville.mesh.util.toDistanceString +import com.geeksville.mesh.model.Node import com.google.protobuf.ByteString +data class NodeWithRelations( + @Embedded val node: NodeEntity, + @Relation(entity = MetadataEntity::class, parentColumn = "num", entityColumn = "num") + val metadata: MetadataEntity? = null, +) { + fun toModel() = with(node) { + Node( + num = num, + metadata = metadata?.proto, + user = user, + position = position, + snr = snr, + rssi = rssi, + lastHeard = lastHeard, + deviceMetrics = deviceTelemetry.deviceMetrics, + channel = channel, + viaMqtt = viaMqtt, + hopsAway = hopsAway, + isFavorite = isFavorite, + isIgnored = isIgnored, + environmentMetrics = environmentTelemetry.environmentMetrics, + powerMetrics = powerTelemetry.powerMetrics, + paxcounter = paxcounter, + ) + } + + fun toEntity() = with(node) { + NodeEntity( + num = num, + user = user, + position = position, + snr = snr, + rssi = rssi, + lastHeard = lastHeard, + deviceTelemetry = deviceTelemetry, + channel = channel, + viaMqtt = viaMqtt, + hopsAway = hopsAway, + isFavorite = isFavorite, + isIgnored = isIgnored, + environmentTelemetry = environmentTelemetry, + powerTelemetry = powerTelemetry, + paxcounter = paxcounter, + ) + } +} + +@Entity( + tableName = "metadata", + indices = [ + Index(value = ["num"]), + ], +) +data class MetadataEntity( + @PrimaryKey val num: Int, + @ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) + val proto: MeshProtos.DeviceMetadata, + val timestamp: Long = System.currentTimeMillis(), +) + @Suppress("MagicNumber") @Entity(tableName = "nodes") data class NodeEntity( - @PrimaryKey(autoGenerate = false) val num: Int, // This is immutable, and used as a key @@ -92,32 +149,9 @@ data class NodeEntity( val environmentMetrics: TelemetryProtos.EnvironmentMetrics get() = environmentTelemetry.environmentMetrics - val hasEnvironmentMetrics: Boolean - get() = environmentMetrics != TelemetryProtos.EnvironmentMetrics.getDefaultInstance() - - val powerMetrics: TelemetryProtos.PowerMetrics - get() = powerTelemetry.powerMetrics - - val hasPowerMetrics: Boolean - get() = powerMetrics != TelemetryProtos.PowerMetrics.getDefaultInstance() - - val colors: Pair - get() { // returns foreground and background @ColorInt for each 'num' - val r = (num and 0xFF0000) shr 16 - val g = (num and 0x00FF00) shr 8 - val b = num and 0x0000FF - val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255 - return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b) - } - val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET val hasPKC get() = !user.publicKey.isEmpty val errorByteString: ByteString get() = ByteString.copyFrom(ByteArray(32) { 0 }) - val mismatchKey get() = user.publicKey == errorByteString - - val batteryLevel get() = deviceMetrics.batteryLevel - val voltage get() = deviceMetrics.voltage - val batteryStr get() = if (batteryLevel in 1..100) "$batteryLevel%" else "" fun setPosition(p: MeshProtos.Position, defaultTime: Int = currentTime()) { position = p.copy { time = if (p.time != 0) p.time else defaultTime } @@ -125,75 +159,6 @@ data class NodeEntity( longitude = degD(p.longitudeI) } - private fun hasValidPosition(): Boolean { - return latitude != 0.0 && longitude != 0.0 && - (latitude >= -90 && latitude <= 90.0) && - (longitude >= -180 && longitude <= 180) - } - - val validPosition: MeshProtos.Position? get() = position.takeIf { hasValidPosition() } - - // @return distance in meters to some other node (or null if unknown) - fun distance(o: NodeEntity): Int? = when { - validPosition == null || o.validPosition == null -> null - else -> latLongToMeter(latitude, longitude, o.latitude, o.longitude).toInt() - } - - // @return a nice human readable string for the distance, or null for unknown - fun distanceStr(o: NodeEntity, displayUnits: Int = 0): String? = distance(o)?.let { dist -> - val system = DisplayConfig.DisplayUnits.forNumber(displayUnits) - return if (dist > 0) dist.toDistanceString(system) else null - } - - // @return bearing to the other position in degrees - fun bearing(o: NodeEntity?): Int? = when { - validPosition == null || o?.validPosition == null -> null - else -> bearing(latitude, longitude, o.latitude, o.longitude).toInt() - } - - fun gpsString(gpsFormat: Int): String = when (gpsFormat) { - DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude) - DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude) - DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude) - DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude) - else -> GPSFormat.toDEC(latitude, longitude) - } - - private fun TelemetryProtos.EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String { - val temp = if (temperature != 0f) { - if (isFahrenheit) { - val fahrenheit = temperature * 1.8F + 32 - "%.1f°F".format(fahrenheit) - } else { - "%.1f°C".format(temperature) - } - } else { - null - } - val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null - val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null - val current = if (current != 0f) "%.1fmA".format(current) else null - val iaq = if (iaq != 0) "IAQ: $iaq" else null - - return listOfNotNull( - temp, - humidity, - voltage, - current, - iaq, - ).joinToString(" ") - } - - private fun PaxcountProtos.Paxcount.getDisplayString() = - "PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 && wifi != 0 } - - fun getTelemetryString(isFahrenheit: Boolean = false): String { - return listOfNotNull( - paxcounter.getDisplayString(), - environmentMetrics.getDisplayString(isFahrenheit), - ).joinToString(" ") - } - /** * true if the device was heard from recently */ @@ -211,48 +176,48 @@ data class NodeEntity( fun currentTime() = (System.currentTimeMillis() / 1000).toInt() } -} -fun NodeEntity.toNodeInfo() = NodeInfo( - num = num, - user = MeshUser( - id = user.id, - longName = user.longName, - shortName = user.shortName, - hwModel = user.hwModel, - role = user.roleValue, - ).takeIf { user.id.isNotEmpty() }, - position = Position( - latitude = latitude, - longitude = longitude, - altitude = position.altitude, - time = position.time, - satellitesInView = position.satsInView, - groundSpeed = position.groundSpeed, - groundTrack = position.groundTrack, - precisionBits = position.precisionBits, - ).takeIf { it.isValid() }, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceMetrics = DeviceMetrics( - time = deviceTelemetry.time, - batteryLevel = deviceMetrics.batteryLevel, - voltage = deviceMetrics.voltage, - channelUtilization = deviceMetrics.channelUtilization, - airUtilTx = deviceMetrics.airUtilTx, - uptimeSeconds = deviceMetrics.uptimeSeconds, - ), - channel = channel, - environmentMetrics = EnvironmentMetrics( - time = environmentTelemetry.time, - temperature = environmentMetrics.temperature, - relativeHumidity = environmentMetrics.relativeHumidity, - barometricPressure = environmentMetrics.barometricPressure, - gasResistance = environmentMetrics.gasResistance, - voltage = environmentMetrics.voltage, - current = environmentMetrics.current, - iaq = environmentMetrics.iaq, - ), - hopsAway = hopsAway, -) + fun toNodeInfo() = NodeInfo( + num = num, + user = MeshUser( + id = user.id, + longName = user.longName, + shortName = user.shortName, + hwModel = user.hwModel, + role = user.roleValue, + ).takeIf { user.id.isNotEmpty() }, + position = Position( + latitude = latitude, + longitude = longitude, + altitude = position.altitude, + time = position.time, + satellitesInView = position.satsInView, + groundSpeed = position.groundSpeed, + groundTrack = position.groundTrack, + precisionBits = position.precisionBits, + ).takeIf { it.isValid() }, + snr = snr, + rssi = rssi, + lastHeard = lastHeard, + deviceMetrics = DeviceMetrics( + time = deviceTelemetry.time, + batteryLevel = deviceMetrics.batteryLevel, + voltage = deviceMetrics.voltage, + channelUtilization = deviceMetrics.channelUtilization, + airUtilTx = deviceMetrics.airUtilTx, + uptimeSeconds = deviceMetrics.uptimeSeconds, + ), + channel = channel, + environmentMetrics = EnvironmentMetrics( + time = environmentTelemetry.time, + temperature = environmentMetrics.temperature, + relativeHumidity = environmentMetrics.relativeHumidity, + barometricPressure = environmentMetrics.barometricPressure, + gasResistance = environmentMetrics.gasResistance, + voltage = environmentMetrics.voltage, + current = environmentMetrics.current, + iaq = environmentMetrics.iaq, + ), + hopsAway = hopsAway, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt index c36b0c6cb..a5569df44 100644 --- a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt +++ b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt @@ -26,6 +26,7 @@ import androidx.room.Relation import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MeshProtos.User import com.geeksville.mesh.model.Message +import com.geeksville.mesh.model.Node import com.geeksville.mesh.util.getShortDateTime data class PacketEntity( @@ -33,7 +34,7 @@ data class PacketEntity( @Relation(entity = ReactionEntity::class, parentColumn = "packet_id", entityColumn = "reply_id") val reactions: List = emptyList(), ) { - suspend fun toMessage(getNode: suspend (userId: String?) -> NodeEntity) = with(packet) { + suspend fun toMessage(getNode: suspend (userId: String?) -> Node) = with(packet) { Message( uuid = uuid, receivedTime = received_time, @@ -101,7 +102,7 @@ data class ReactionEntity( ) private suspend fun ReactionEntity.toReaction( - getNode: suspend (userId: String?) -> NodeEntity + getNode: suspend (userId: String?) -> Node ) = Reaction( replyId = replyId, user = getNode(userId).user, @@ -110,5 +111,5 @@ private suspend fun ReactionEntity.toReaction( ) private suspend fun List.toReaction( - getNode: suspend (userId: String?) -> NodeEntity + getNode: suspend (userId: String?) -> Node ) = this.map { it.toReaction(getNode) } diff --git a/app/src/main/java/com/geeksville/mesh/model/Message.kt b/app/src/main/java/com/geeksville/mesh/model/Message.kt index 6dc98b8be..6212753b8 100644 --- a/app/src/main/java/com/geeksville/mesh/model/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/model/Message.kt @@ -20,7 +20,6 @@ package com.geeksville.mesh.model import com.geeksville.mesh.MeshProtos.Routing import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R -import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.database.entity.Reaction val Routing.Error.stringRes: Int @@ -47,7 +46,7 @@ val Routing.Error.stringRes: Int data class Message( val uuid: Long, val receivedTime: Long, - val node: NodeEntity, + val node: Node, val text: String, val time: String, val read: Boolean, diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index a27d8a83b..9b8ca0b86 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -38,7 +38,6 @@ import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.android.Logging import com.geeksville.mesh.database.MeshLogRepository import com.geeksville.mesh.database.entity.MeshLog -import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.model.map.CustomTileSource import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.ui.Route @@ -71,7 +70,7 @@ data class MetricsState( val isManaged: Boolean = true, val isFahrenheit: Boolean = false, val displayUnits: DisplayUnits = DisplayUnits.METRIC, - val node: NodeEntity? = null, + val node: Node? = null, val deviceMetrics: List = emptyList(), val environmentMetrics: List = emptyList(), val signalMetrics: List = emptyList(), diff --git a/app/src/main/java/com/geeksville/mesh/model/Node.kt b/app/src/main/java/com/geeksville/mesh/model/Node.kt new file mode 100644 index 000000000..abee3a0ac --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/Node.kt @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2024 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.model + +import android.graphics.Color +import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig +import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.PaxcountProtos +import com.geeksville.mesh.TelemetryProtos.DeviceMetrics +import com.geeksville.mesh.TelemetryProtos.EnvironmentMetrics +import com.geeksville.mesh.TelemetryProtos.PowerMetrics +import com.geeksville.mesh.util.GPSFormat +import com.geeksville.mesh.util.latLongToMeter +import com.geeksville.mesh.util.toDistanceString +import com.google.protobuf.ByteString + +@Suppress("MagicNumber") +data class Node( + val num: Int, + val metadata: MeshProtos.DeviceMetadata? = null, + val user: MeshProtos.User = MeshProtos.User.getDefaultInstance(), + val position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(), + val snr: Float = Float.MAX_VALUE, + val rssi: Int = Int.MAX_VALUE, + val lastHeard: Int = 0, // the last time we've seen this node in secs since 1970 + val deviceMetrics: DeviceMetrics = DeviceMetrics.getDefaultInstance(), + val channel: Int = 0, + val viaMqtt: Boolean = false, + val hopsAway: Int = -1, + val isFavorite: Boolean = false, + val isIgnored: Boolean = false, + val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics.getDefaultInstance(), + val powerMetrics: PowerMetrics = PowerMetrics.getDefaultInstance(), + val paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(), +) { + val colors: Pair + get() { // returns foreground and background @ColorInt for each 'num' + val r = (num and 0xFF0000) shr 16 + val g = (num and 0x00FF00) shr 8 + val b = num and 0x0000FF + val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255 + return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b) + } + + val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET + val hasPKC get() = !user.publicKey.isEmpty + val errorByteString: ByteString get() = ByteString.copyFrom(ByteArray(32) { 0 }) + val mismatchKey get() = user.publicKey == errorByteString + + val hasEnvironmentMetrics: Boolean + get() = environmentMetrics != EnvironmentMetrics.getDefaultInstance() + + val hasPowerMetrics: Boolean + get() = powerMetrics != PowerMetrics.getDefaultInstance() + + val batteryLevel get() = deviceMetrics.batteryLevel + val voltage get() = deviceMetrics.voltage + val batteryStr get() = if (batteryLevel in 1..100) "$batteryLevel%" else "" + + val latitude get() = position.latitudeI * 1e-7 + val longitude get() = position.longitudeI * 1e-7 + + private fun hasValidPosition(): Boolean { + return latitude != 0.0 && longitude != 0.0 && + (latitude >= -90 && latitude <= 90.0) && + (longitude >= -180 && longitude <= 180) + } + + val validPosition: MeshProtos.Position? get() = position.takeIf { hasValidPosition() } + + // @return distance in meters to some other node (or null if unknown) + fun distance(o: Node): Int? = when { + validPosition == null || o.validPosition == null -> null + else -> latLongToMeter(latitude, longitude, o.latitude, o.longitude).toInt() + } + + // @return a nice human readable string for the distance, or null for unknown + fun distanceStr(o: Node, displayUnits: Int = 0): String? = distance(o)?.let { dist -> + val system = DisplayConfig.DisplayUnits.forNumber(displayUnits) + return if (dist > 0) dist.toDistanceString(system) else null + } + + // @return bearing to the other position in degrees + fun bearing(o: Node?): Int? = when { + validPosition == null || o?.validPosition == null -> null + else -> com.geeksville.mesh.util.bearing(latitude, longitude, o.latitude, o.longitude).toInt() + } + + fun gpsString(gpsFormat: Int): String = when (gpsFormat) { + DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude) + DisplayConfig.GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude) + DisplayConfig.GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude) + DisplayConfig.GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude) + else -> GPSFormat.toDEC(latitude, longitude) + } + + private fun EnvironmentMetrics.getDisplayString(isFahrenheit: Boolean): String { + val temp = if (temperature != 0f) { + if (isFahrenheit) { + val fahrenheit = temperature * 1.8F + 32 + "%.1f°F".format(fahrenheit) + } else { + "%.1f°C".format(temperature) + } + } else { + null + } + val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null + val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null + val current = if (current != 0f) "%.1fmA".format(current) else null + val iaq = if (iaq != 0) "IAQ: $iaq" else null + + return listOfNotNull( + temp, + humidity, + voltage, + current, + iaq, + ).joinToString(" ") + } + + private fun PaxcountProtos.Paxcount.getDisplayString() = + "PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 && wifi != 0 } + + fun getTelemetryString(isFahrenheit: Boolean = false): String { + return listOfNotNull( + paxcounter.getDisplayString(), + environmentMetrics.getDisplayString(isFahrenheit), + ).joinToString(" ") + } +} diff --git a/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt index 9ff528aee..04af9d56d 100644 --- a/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/RadioConfigViewModel.kt @@ -37,7 +37,6 @@ import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging import com.geeksville.mesh.config import com.geeksville.mesh.database.entity.MyNodeEntity -import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.deviceProfile import com.geeksville.mesh.moduleConfig import com.geeksville.mesh.repository.datastore.RadioConfigRepository @@ -54,6 +53,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest @@ -71,7 +71,7 @@ data class RadioConfigState( val isLocal: Boolean = false, val connected: Boolean = false, val route: String = "", - val metadata: MeshProtos.DeviceMetadata = MeshProtos.DeviceMetadata.getDefaultInstance(), + val metadata: MeshProtos.DeviceMetadata? = null, val userConfig: MeshProtos.User = MeshProtos.User.getDefaultInstance(), val channelList: List = emptyList(), val radioConfig: ConfigProtos.Config = config {}, @@ -79,9 +79,7 @@ data class RadioConfigState( val ringtone: String = "", val cannedMessageMessages: String = "", val responseState: ResponseState = ResponseState.Empty, -) { - fun hasMetadata() = metadata != MeshProtos.DeviceMetadata.getDefaultInstance() -} +) @HiltViewModel class RadioConfigViewModel @Inject constructor( @@ -92,8 +90,8 @@ class RadioConfigViewModel @Inject constructor( private val meshService: IMeshService? get() = radioConfigRepository.meshService private val destNum = savedStateHandle.toRoute().destNum - private val _destNode = MutableStateFlow(null) - val destNode: StateFlow get() = _destNode + private val _destNode = MutableStateFlow(null) + val destNode: StateFlow get() = _destNode private val requestIds = MutableStateFlow(hashSetOf()) private val _radioConfigState = MutableStateFlow(RadioConfigState()) @@ -104,9 +102,14 @@ class RadioConfigViewModel @Inject constructor( init { @OptIn(ExperimentalCoroutinesApi::class) - radioConfigRepository.nodeDBbyNum.mapLatest { nodes -> - nodes[destNum] ?: nodes.values.firstOrNull() - }.onEach { _destNode.value = it }.launchIn(viewModelScope) + radioConfigRepository.nodeDBbyNum + .mapLatest { nodes -> nodes[destNum] ?: nodes.values.firstOrNull() } + .distinctUntilChanged() + .onEach { + _destNode.value = it + _radioConfigState.update { state -> state.copy(metadata = it?.metadata) } + } + .launchIn(viewModelScope) radioConfigRepository.deviceProfileFlow.onEach { _currentDeviceProfile.value = it @@ -320,7 +323,7 @@ class RadioConfigViewModel @Inject constructor( when (route) { AdminRoute.REBOOT.name -> requestReboot(destNum) AdminRoute.SHUTDOWN.name -> with(radioConfigState.value) { - if (hasMetadata() && !metadata.canShutdown) { + if (metadata != null && !metadata.canShutdown) { setResponseStateError(app.getString(R.string.cant_shutdown)) } else { requestShutdown(destNum) @@ -333,7 +336,7 @@ class RadioConfigViewModel @Inject constructor( } private fun getSessionPasskey(destNum: Int) { - if (radioConfigState.value.hasMetadata()) { + if (radioConfigState.value.metadata != null) { sendAdminRequest(destNum) } else { getConfig(destNum, AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) 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 e07304dc1..5f9802a1c 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -52,7 +52,6 @@ import com.geeksville.mesh.database.NodeRepository import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.QuickChatActionRepository 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.repository.datastore.RadioConfigRepository @@ -235,7 +234,7 @@ class UIViewModel @Inject constructor( ) @OptIn(ExperimentalCoroutinesApi::class) - val nodeList: StateFlow> = nodesUiState.flatMapLatest { state -> + val nodeList: StateFlow> = nodesUiState.flatMapLatest { state -> nodeDB.getNodes(state.sort, state.filter, state.includeUnknown) }.stateIn( scope = viewModelScope, @@ -245,7 +244,7 @@ class UIViewModel @Inject constructor( // hardware info about our local device (can be null) val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo - val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo + val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo val nodesWithPosition get() = nodeDB.nodeDBbyNum.value.values.filter { it.validPosition != null } @@ -484,7 +483,7 @@ class UIViewModel @Inject constructor( updateLoraConfig { it.copy { region = value } } } - fun ignoreNode(node: NodeEntity) = viewModelScope.launch { + fun ignoreNode(node: Node) = viewModelScope.launch { try { radioConfigRepository.onServiceAction(ServiceAction.Ignore(node)) } catch (ex: RemoteException) { diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt index b50de2b1b..0bf7ad1d4 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt @@ -25,12 +25,15 @@ import com.geeksville.mesh.ConfigProtos.Config import com.geeksville.mesh.IMeshService import com.geeksville.mesh.LocalOnlyProtos.LocalConfig import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import com.geeksville.mesh.MeshProtos.DeviceMetadata import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig +import com.geeksville.mesh.database.NodeRepository +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.deviceProfile -import com.geeksville.mesh.database.NodeRepository +import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.getChannelUrl import com.geeksville.mesh.service.MeshService.ConnectionState import com.geeksville.mesh.service.ServiceAction @@ -40,6 +43,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import javax.inject.Inject /** @@ -70,16 +74,20 @@ class RadioConfigRepository @Inject constructor( val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo /** - * Flow representing the [NodeEntity] database. + * Flow representing the [Node] database. */ - val nodeDBbyNum: StateFlow> get() = nodeDB.nodeDBbyNum + val nodeDBbyNum: StateFlow> get() = nodeDB.nodeDBbyNum fun getUser(nodeNum: Int) = nodeDB.getUser(nodeNum) + suspend fun getNodeDBbyNum() = nodeDB.getNodeDBbyNum().first() suspend fun upsert(node: NodeEntity) = nodeDB.upsert(node) suspend fun installNodeDB(mi: MyNodeEntity, nodes: List) { nodeDB.installNodeDB(mi, nodes) } + suspend fun insertMetadata(fromNum: Int, metadata: DeviceMetadata) { + nodeDB.insertMetadata(MetadataEntity(fromNum, metadata)) + } /** * Flow representing the [ChannelSet] data store. diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index f88f23d64..24d283967 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -26,17 +26,38 @@ import android.os.IBinder import android.os.RemoteException import androidx.core.app.ServiceCompat import androidx.core.location.LocationCompat -import com.geeksville.mesh.* +import com.geeksville.mesh.AdminProtos +import com.geeksville.mesh.AppOnlyProtos +import com.geeksville.mesh.BuildConfig +import com.geeksville.mesh.ChannelProtos +import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.CoroutineDispatchers +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.IMeshService import com.geeksville.mesh.LocalOnlyProtos.LocalConfig import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig +import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.MeshProtos.ToRadio +import com.geeksville.mesh.MeshUser +import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.ModuleConfigProtos +import com.geeksville.mesh.MyNodeInfo +import com.geeksville.mesh.NodeInfo +import com.geeksville.mesh.PaxcountProtos +import com.geeksville.mesh.Portnums +import com.geeksville.mesh.Position +import com.geeksville.mesh.R +import com.geeksville.mesh.StoreAndForwardProtos +import com.geeksville.mesh.TelemetryProtos import com.geeksville.mesh.TelemetryProtos.LocalStats import com.geeksville.mesh.analytics.DataPair import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.Logging import com.geeksville.mesh.android.hasLocationPermission import com.geeksville.mesh.concurrent.handledLaunch +import com.geeksville.mesh.config +import com.geeksville.mesh.copy import com.geeksville.mesh.database.MeshLogRepository import com.geeksville.mesh.database.PacketRepository import com.geeksville.mesh.database.entity.MeshLog @@ -44,15 +65,22 @@ 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.ReactionEntity -import com.geeksville.mesh.database.entity.toNodeInfo +import com.geeksville.mesh.fromRadio import com.geeksville.mesh.model.DeviceVersion +import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.getTracerouteResponse +import com.geeksville.mesh.position import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.repository.network.MQTTRepository import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.repository.radio.RadioServiceConnectionState -import com.geeksville.mesh.util.* +import com.geeksville.mesh.telemetry +import com.geeksville.mesh.user +import com.geeksville.mesh.util.anonymize +import com.geeksville.mesh.util.toOneLineString +import com.geeksville.mesh.util.toPIIString +import com.geeksville.mesh.util.toRemoteExceptions import com.google.protobuf.ByteString import com.google.protobuf.InvalidProtocolBufferException import dagger.Lazy @@ -77,7 +105,7 @@ import javax.inject.Inject import kotlin.math.absoluteValue sealed class ServiceAction { - data class Ignore(val node: NodeEntity) : ServiceAction() + data class Ignore(val node: Node) : ServiceAction() data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction() } @@ -375,10 +403,10 @@ class MeshService : Service(), Logging { // BEGINNING OF MODEL - FIXME, move elsewhere // - private fun loadSettings() { + private fun loadSettings() = serviceScope.handledLaunch { discardNodeDB() // Get rid of any old state myNodeInfo = radioConfigRepository.myNodeInfo.value - nodeDBbyNodeNum.putAll(radioConfigRepository.nodeDBbyNum.value) + nodeDBbyNodeNum.putAll(radioConfigRepository.getNodeDBbyNum()) // Note: we do not haveNodeDB = true because that means we've got a valid db from a real device (rather than this possibly stale hint) } @@ -808,15 +836,17 @@ class MeshService : Service(), Logging { } private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) { - if (fromNodeNum == myNodeNum) { - when (a.payloadVariantCase) { - AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { + when (a.payloadVariantCase) { + AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { + if (fromNodeNum == myNodeNum) { val response = a.getConfigResponse debug("Admin: received config ${response.payloadVariantCase}") setLocalConfig(response) } + } - AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { + AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { + if (fromNodeNum == myNodeNum) { val mi = myNodeInfo if (mi != null) { val ch = a.getChannelResponse @@ -827,12 +857,18 @@ class MeshService : Service(), Logging { } } } - else -> warn("No special processing needed for ${a.payloadVariantCase}") } - } else { - debug("Admin: Received session_passkey from $fromNodeNum") - sessionPasskey = a.sessionPasskey + + AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> { + serviceScope.handledLaunch { + radioConfigRepository.insertMetadata(fromNodeNum, a.getDeviceMetadataResponse) + } + } + + else -> warn("No special processing needed for ${a.payloadVariantCase}") } + debug("Admin: Received session_passkey from $fromNodeNum") + sessionPasskey = a.sessionPasskey } // Update our DB of users based on someone sending out a User subpacket @@ -1144,13 +1180,6 @@ class MeshService : Service(), Logging { } } - private fun clearLocalConfig() { - serviceScope.handledLaunch { - radioConfigRepository.clearLocalConfig() - radioConfigRepository.clearLocalModuleConfig() - } - } - private fun updateChannelSettings(ch: ChannelProtos.Channel) = serviceScope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) } @@ -1760,7 +1789,7 @@ class MeshService : Service(), Logging { } } - private fun ignoreNode(node: NodeEntity) = toRemoteExceptions { + private fun ignoreNode(node: Node) = toRemoteExceptions { sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { if (node.isIgnored) { debug("removing node ${node.num} from ignore list") diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt index ac7165273..d80e63a70 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -91,11 +91,11 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits import com.geeksville.mesh.R -import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.model.MetricsState import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.model.Node import com.geeksville.mesh.ui.components.PreferenceCategory -import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider +import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.DistanceUnit import com.geeksville.mesh.util.formatAgo @@ -132,7 +132,7 @@ fun NodeDetailScreen( @Composable private fun NodeDetailList( modifier: Modifier = Modifier, - node: NodeEntity, + node: Node, metricsState: MetricsState, onNavigate: (Any) -> Unit = {}, ) { @@ -255,7 +255,7 @@ private fun DeviceDetailsContent( @Composable private fun NodeDetailsContent( - node: NodeEntity, + node: Node, ) { if (node.mismatchKey) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -411,7 +411,7 @@ private fun InfoCard( @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable private fun EnvironmentMetrics( - node: NodeEntity, + node: Node, isFahrenheit: Boolean = false, ) = with(node.environmentMetrics) { FlowRow( @@ -541,7 +541,7 @@ private fun calculateDewPoint(tempCelsius: Float, humidity: Float): Float { @OptIn(ExperimentalLayoutApi::class) @Composable -private fun PowerMetrics(node: NodeEntity) = with(node.powerMetrics) { +private fun PowerMetrics(node: Node) = with(node.powerMetrics) { FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -595,8 +595,8 @@ private fun PowerMetrics(node: NodeEntity) = with(node.powerMetrics) { @Preview(showBackground = true) @Composable private fun NodeDetailsPreview( - @PreviewParameter(NodeEntityPreviewParameterProvider::class) - node: NodeEntity + @PreviewParameter(NodePreviewParameterProvider::class) + node: Node ) { AppTheme { NodeDetailList( diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt index 6e9a0939b..ee8c7da7b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt @@ -59,14 +59,14 @@ import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig import com.geeksville.mesh.MeshProtos import com.geeksville.mesh.R -import com.geeksville.mesh.database.entity.NodeEntity -import com.geeksville.mesh.ui.components.NodeMenuAction +import com.geeksville.mesh.model.Node import com.geeksville.mesh.ui.components.NodeKeyStatusIcon import com.geeksville.mesh.ui.components.NodeMenu +import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.components.SignalInfo import com.geeksville.mesh.ui.compose.ElevationInfo import com.geeksville.mesh.ui.compose.SatelliteCountInfo -import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider +import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.toDistanceString @@ -74,8 +74,8 @@ import com.geeksville.mesh.util.toDistanceString @OptIn(ExperimentalMaterialApi::class) @Composable fun NodeItem( - thisNode: NodeEntity?, - thatNode: NodeEntity, + thisNode: Node?, + thatNode: Node, gpsFormat: Int, distanceUnits: Int, tempInFahrenheit: Boolean, @@ -293,8 +293,8 @@ fun NodeItem( @Preview(showBackground = false) fun NodeInfoSimplePreview() { AppTheme { - val thisNode = NodeEntityPreviewParameterProvider().values.first() - val thatNode = NodeEntityPreviewParameterProvider().values.last() + val thisNode = NodePreviewParameterProvider().values.first() + val thatNode = NodePreviewParameterProvider().values.last() NodeItem( thisNode = thisNode, thatNode = thatNode, @@ -312,11 +312,11 @@ fun NodeInfoSimplePreview() { uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES, ) fun NodeInfoPreview( - @PreviewParameter(NodeEntityPreviewParameterProvider::class) - thatNode: NodeEntity + @PreviewParameter(NodePreviewParameterProvider::class) + thatNode: Node ) { AppTheme { - val thisNode = NodeEntityPreviewParameterProvider().values.first() + val thisNode = NodePreviewParameterProvider().values.first() Column { Text( text = "Details Collapsed", diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index a7885aa4a..817218066 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -39,7 +39,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.DataPacket import com.geeksville.mesh.android.Logging -import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.components.NodeFilterTextField @@ -53,7 +53,7 @@ class UsersFragment : ScreenFragment("Users"), Logging { private val model: UIViewModel by activityViewModels() - private fun navigateToMessages(node: NodeEntity) = node.user.let { user -> + private fun navigateToMessages(node: Node) = node.user.let { user -> val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC // TODO use meta.hasPKC val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel val contactKey = "$channel${user.id}" @@ -91,7 +91,7 @@ class UsersFragment : ScreenFragment("Users"), Logging { @Suppress("LongMethod") fun NodesScreen( model: UIViewModel = hiltViewModel(), - navigateToMessages: (NodeEntity) -> Unit, + navigateToMessages: (Node) -> Unit, navigateToNodeDetails: (Int) -> Unit, ) { val state by model.nodesUiState.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt index bb319e762..6260198c7 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt @@ -36,12 +36,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.geeksville.mesh.R -import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.model.Node @Suppress("LongMethod") @Composable fun NodeMenu( - node: NodeEntity, + node: Node, showFullMenu: Boolean = false, onDismissRequest: () -> Unit, expanded: Boolean = false, @@ -150,11 +150,11 @@ fun NodeMenu( } sealed class NodeMenuAction { - data class Remove(val node: NodeEntity) : NodeMenuAction() - data class Ignore(val node: NodeEntity) : NodeMenuAction() - data class DirectMessage(val node: NodeEntity) : NodeMenuAction() - data class RequestUserInfo(val node: NodeEntity) : NodeMenuAction() - data class RequestPosition(val node: NodeEntity) : NodeMenuAction() - data class TraceRoute(val node: NodeEntity) : NodeMenuAction() - data class MoreDetails(val node: NodeEntity) : NodeMenuAction() + data class Remove(val node: Node) : NodeMenuAction() + data class Ignore(val node: Node) : NodeMenuAction() + data class DirectMessage(val node: Node) : NodeMenuAction() + data class RequestUserInfo(val node: Node) : NodeMenuAction() + data class RequestPosition(val node: Node) : NodeMenuAction() + data class TraceRoute(val node: Node) : NodeMenuAction() + data class MoreDetails(val node: Node) : NodeMenuAction() } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SignalInfo.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SignalInfo.kt index 730999e17..8c91c0a62 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/SignalInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SignalInfo.kt @@ -26,8 +26,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import com.geeksville.mesh.R -import com.geeksville.mesh.database.entity.NodeEntity -import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider +import com.geeksville.mesh.model.Node +import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider import com.geeksville.mesh.ui.theme.AppTheme const val MAX_VALID_SNR = 100F @@ -36,7 +36,7 @@ const val MAX_VALID_RSSI = 0 @Composable fun SignalInfo( modifier: Modifier = Modifier, - node: NodeEntity, + node: Node, isThisNode: Boolean ) { val text = if (isThisNode) { @@ -81,7 +81,7 @@ fun SignalInfo( fun SignalInfoSimplePreview() { AppTheme { SignalInfo( - node = NodeEntity( + node = Node( num = 1, lastHeard = 0, channel = 0, @@ -97,8 +97,8 @@ fun SignalInfoSimplePreview() { @PreviewLightDark @Composable fun SignalInfoPreview( - @PreviewParameter(NodeEntityPreviewParameterProvider::class) - node: NodeEntity + @PreviewParameter(NodePreviewParameterProvider::class) + node: Node ) { AppTheme { SignalInfo( @@ -111,8 +111,8 @@ fun SignalInfoPreview( @Composable @PreviewLightDark fun SignalInfoSelfPreview( - @PreviewParameter(NodeEntityPreviewParameterProvider::class) - node: NodeEntity + @PreviewParameter(NodePreviewParameterProvider::class) + node: Node ) { AppTheme { SignalInfo( diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt index c54911f9f..469ae07ba 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt @@ -65,8 +65,8 @@ import com.geeksville.mesh.android.gpsDisabled import com.geeksville.mesh.android.hasGps import com.geeksville.mesh.android.hasLocationPermission import com.geeksville.mesh.copy -import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.database.entity.Packet +import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.map.CustomTileSource import com.geeksville.mesh.model.map.MarkerWithLabel @@ -311,7 +311,7 @@ fun MapView( AppCompatResources.getDrawable(context, R.drawable.ic_baseline_location_on_24) } - fun MapView.onNodesChanged(nodes: Collection): List { + fun MapView.onNodesChanged(nodes: Collection): List { val nodesWithPosition = nodes.filter { it.validPosition != null } val ourNode = model.ourNodeInfo.value val gpsFormat = model.config.display.gpsFormat.number diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 97c5bbbcf..61ec2c1f5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -88,8 +88,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.DataPacket import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging -import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.database.entity.QuickChatAction +import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.getChannel import com.geeksville.mesh.ui.components.NodeKeyStatusIcon @@ -114,7 +114,7 @@ internal fun FragmentManager.navigateToMessages(contactKey: String, message: Str class MessagesFragment : Fragment(), Logging { private val model: UIViewModel by activityViewModels() - private fun navigateToMessages(node: NodeEntity) = node.user.let { user -> + private fun navigateToMessages(node: Node) = node.user.let { user -> val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC // TODO use meta.hasPKC val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel val contactKey = "$channel${user.id}" @@ -166,7 +166,7 @@ internal fun MessageScreen( contactKey: String, message: String, viewModel: UIViewModel = hiltViewModel(), - navigateToMessages: (NodeEntity) -> Unit, + navigateToMessages: (Node) -> Unit, navigateToNodeDetails: (Int) -> Unit, onNavigateBack: () -> Unit ) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt index 997af5a2d..5e86abc70 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt @@ -59,16 +59,16 @@ import androidx.compose.ui.unit.dp import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R -import com.geeksville.mesh.database.entity.NodeEntity +import com.geeksville.mesh.model.Node import com.geeksville.mesh.ui.components.AutoLinkText -import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider +import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider import com.geeksville.mesh.ui.theme.AppTheme @Suppress("LongMethod") @OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) @Composable internal fun MessageItem( - node: NodeEntity, + node: Node, messageText: String?, messageTime: String, messageStatus: MessageStatus?, @@ -192,7 +192,7 @@ internal fun MessageItem( private fun MessageItemPreview() { AppTheme { MessageItem( - node = NodeEntityPreviewParameterProvider().values.first(), + node = NodePreviewParameterProvider().values.first(), messageText = stringResource(R.string.sample_message), messageTime = "10:00", messageStatus = MessageStatus.DELIVERED, diff --git a/app/src/main/java/com/geeksville/mesh/ui/preview/NodeEntityPreviewParameterProvider.kt b/app/src/main/java/com/geeksville/mesh/ui/preview/NodePreviewParameterProvider.kt similarity index 64% rename from app/src/main/java/com/geeksville/mesh/ui/preview/NodeEntityPreviewParameterProvider.kt rename to app/src/main/java/com/geeksville/mesh/ui/preview/NodePreviewParameterProvider.kt index 976c43ab2..c192fd352 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/preview/NodeEntityPreviewParameterProvider.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/preview/NodePreviewParameterProvider.kt @@ -20,19 +20,17 @@ package com.geeksville.mesh.ui.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.geeksville.mesh.DeviceMetrics.Companion.currentTime import com.geeksville.mesh.MeshProtos -import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.deviceMetrics import com.geeksville.mesh.environmentMetrics +import com.geeksville.mesh.model.Node import com.geeksville.mesh.paxcount import com.geeksville.mesh.position -import com.geeksville.mesh.telemetry import com.geeksville.mesh.user import com.google.protobuf.ByteString import kotlin.random.Random -class NodeEntityPreviewParameterProvider : PreviewParameterProvider { - - val mickeyMouse = NodeEntity( +class NodePreviewParameterProvider : PreviewParameterProvider { + val mickeyMouse = Node( num = 1955, user = user { id = "mickeyMouseId" @@ -40,28 +38,22 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider shortName = "MM" hwModel = MeshProtos.HardwareModel.TBEAM }, - longName = "Mickey Mouse", - shortName = "MM", position = position { latitudeI = 338125110 longitudeI = -1179189760 altitude = 138 satsInView = 4 }, - latitude = 33.812511, - longitude = -117.918976, lastHeard = currentTime(), channel = 0, snr = 12.5F, rssi = -42, - deviceTelemetry = telemetry { - deviceMetrics = deviceMetrics { - channelUtilization = 2.4F - airUtilTx = 3.5F - batteryLevel = 85 - voltage = 3.7F - uptimeSeconds = 3600 - } + deviceMetrics = deviceMetrics { + channelUtilization = 2.4F + airUtilTx = 3.5F + batteryLevel = 85 + voltage = 3.7F + uptimeSeconds = 3600 }, hopsAway = 0 ) @@ -74,17 +66,13 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider id = "minnieMouseId" hwModel = MeshProtos.HardwareModel.HELTEC_V3 }, - longName = "Minnie Mouse", - shortName = "MiMo", snr = 12.5F, rssi = -42, position = position {}, - latitude = 0.0, - longitude = 0.0, hopsAway = 1 ) - private val donaldDuck = NodeEntity( + private val donaldDuck = Node( num = Random.nextInt(), position = position { latitudeI = 338052347 @@ -92,20 +80,16 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider altitude = 121 satsInView = 66 }, - latitude = 33.8052347, - longitude = -117.9208460, lastHeard = currentTime() - 300, channel = 0, snr = 12.5F, rssi = -42, - deviceTelemetry = telemetry { - deviceMetrics = deviceMetrics { - channelUtilization = 2.4F - airUtilTx = 3.5F - batteryLevel = 85 - voltage = 3.7F - uptimeSeconds = 3600 - } + deviceMetrics = deviceMetrics { + channelUtilization = 2.4F + airUtilTx = 3.5F + batteryLevel = 85 + voltage = 3.7F + uptimeSeconds = 3600 }, user = user { id = "donaldDuckId" @@ -114,18 +98,14 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider hwModel = MeshProtos.HardwareModel.HELTEC_V3 publicKey = ByteString.copyFrom(ByteArray(32) { 1 }) }, - longName = "Donald Duck, the Grand Duck of the Ducks", - shortName = "DoDu", - environmentTelemetry = telemetry { - environmentMetrics = environmentMetrics { - temperature = 28.0F - relativeHumidity = 50.0F - barometricPressure = 1013.25F - gasResistance = 0.0F - voltage = 3.7F - current = 0.0F - iaq = 100 - } + environmentMetrics = environmentMetrics { + temperature = 28.0F + relativeHumidity = 50.0F + barometricPressure = 1013.25F + gasResistance = 0.0F + voltage = 3.7F + current = 0.0F + iaq = 100 }, paxcounter = paxcount { wifi = 30 @@ -142,19 +122,15 @@ class NodeEntityPreviewParameterProvider : PreviewParameterProvider shortName = "myId" hwModel = MeshProtos.HardwareModel.UNSET }, - longName = "Meshtastic myId", - shortName = null, - environmentTelemetry = telemetry { - environmentMetrics = environmentMetrics {} - }, + environmentMetrics = environmentMetrics {}, paxcounter = paxcount {}, ) - private val almostNothing = NodeEntity( + private val almostNothing = Node( num = Random.nextInt(), ) - override val values: Sequence + override val values: Sequence get() = sequenceOf( mickeyMouse, // "this" node unknown,