From 98337958646d8ed81fbae71495a26b141de66cd0 Mon Sep 17 00:00:00 2001 From: Jord <650645+DivineOmega@users.noreply.github.com> Date: Thu, 18 Dec 2025 14:14:03 +0000 Subject: [PATCH] Traceroute map position snapshots (#4035) Signed-off-by: Jord <650645+DivineOmega@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../mesh/navigation/NodesNavigation.kt | 11 +- .../geeksville/mesh/service/MeshService.kt | 53 +- .../main/java/com/geeksville/mesh/ui/Main.kt | 6 +- .../TracerouteSnapshotRepository.kt | 63 ++ .../25.json | 836 ++++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 8 +- .../database/dao/TracerouteNodePositionDao.kt | 38 + .../core/database/di/DatabaseModule.kt | 5 + .../entity/TracerouteNodePositionEntity.kt | 45 + .../org/meshtastic/core/navigation/Routes.kt | 2 +- .../core/service/ServiceRepository.kt | 1 + .../org/meshtastic/feature/map/MapView.kt | 19 +- .../org/meshtastic/feature/map/MapView.kt | 12 +- .../feature/map/BaseMapViewModel.kt | 53 +- .../feature/node/metrics/MetricsViewModel.kt | 4 + .../feature/node/metrics/TracerouteLog.kt | 29 +- .../node/metrics/TracerouteMapScreen.kt | 54 +- 17 files changed, 1195 insertions(+), 44 deletions(-) create mode 100644 core/data/src/main/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/25.json create mode 100644 core/database/src/main/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt create mode 100644 core/database/src/main/kotlin/org/meshtastic/core/database/entity/TracerouteNodePositionEntity.kt diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt index 1847d37c2..df4b26822 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt @@ -141,8 +141,14 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo TracerouteLogScreen( viewModel = metricsViewModel, onNavigateUp = navController::navigateUp, - onViewOnMap = { requestId -> - navController.navigate(NodeDetailRoutes.TracerouteMap(args.destNum, requestId)) + onViewOnMap = { requestId, responseLogUuid -> + navController.navigate( + NodeDetailRoutes.TracerouteMap( + destNum = args.destNum, + requestId = requestId, + logUuid = responseLogUuid, + ), + ) }, ) } @@ -166,6 +172,7 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo TracerouteMapScreen( metricsViewModel = metricsViewModel, requestId = args.requestId, + logUuid = args.logUuid, onNavigateUp = navController::navigateUp, ) } 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 f63c3768d..2df148a0d 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -64,6 +64,7 @@ import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.data.repository.PacketRepository import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.data.repository.TracerouteSnapshotRepository import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.MetadataEntity @@ -147,6 +148,8 @@ class MeshService : Service() { @Inject lateinit var meshLogRepository: Lazy + @Inject lateinit var tracerouteSnapshotRepository: TracerouteSnapshotRepository + @Inject lateinit var radioInterfaceService: RadioInterfaceService @Inject lateinit var locationRepository: LocationRepository @@ -176,6 +179,8 @@ class MeshService : Service() { @Inject lateinit var analytics: PlatformAnalytics private val tracerouteStartTimes = ConcurrentHashMap() + private val logUuidByPacketId = ConcurrentHashMap() + private val logInsertJobByPacketId = ConcurrentHashMap() companion object { @@ -826,6 +831,8 @@ class MeshService : Service() { // that is not shared' var shouldBroadcast = !fromUs + val logUuid = logUuidByPacketId[packet.id] + val logInsertJob = logInsertJobByPacketId[packet.id] when (data.portnumValue) { Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> { if (data.replyId != 0 && data.emoji == 0) { @@ -939,6 +946,28 @@ class MeshService : Service() { val full = packet.getFullTracerouteResponse(::getUserName) if (full != null) { val requestId = packet.decoded.requestId + if (logUuid != null) { + serviceScope.handledLaunch { + logInsertJob?.join() + val forwardRoute = routeDiscovery?.routeList.orEmpty() + val returnRoute = routeDiscovery?.routeBackList.orEmpty() + val routeNodeNums = (forwardRoute + returnRoute).distinct() + val nodeDbByNum = nodeRepository.nodeDBbyNum.value + val snapshotPositions = + routeNodeNums + .mapNotNull { nodeNum -> + val position = + nodeDbByNum[nodeNum]?.validPosition ?: return@mapNotNull null + nodeNum to position + } + .toMap() + tracerouteSnapshotRepository.upsertSnapshotPositions( + logUuid = logUuid, + requestId = requestId, + positions = snapshotPositions, + ) + } + } val start = tracerouteStartTimes.remove(requestId) val responseText = if (start != null) { @@ -960,6 +989,7 @@ class MeshService : Service() { requestId = requestId, forwardRoute = routeDiscovery?.routeList.orEmpty(), returnRoute = routeDiscovery?.routeBackList.orEmpty(), + logUuid = logUuid, ), ) } @@ -1412,7 +1442,7 @@ class MeshService : Service() { portNum = packet.decoded.portnumValue, fromRadio = fromRadio { this.packet = packet }, ) - insertMeshLog(packetToSave) + val logInsertJob = insertMeshLog(packetToSave) serviceScope.handledLaunch { serviceRepository.emitMeshPacket(packet) } @@ -1441,17 +1471,22 @@ class MeshService : Service() { // Generate our own hopsAway, comparing hopStart to hopLimit. it.hopsAway = getHopsAwayForPacket(packet) } - handleReceivedData(packet) + logInsertJobByPacketId[packet.id] = logInsertJob + logUuidByPacketId[packet.id] = packetToSave.uuid + try { + handleReceivedData(packet) + } finally { + logUuidByPacketId.remove(packet.id) + logInsertJobByPacketId.remove(packet.id) + } } } - private fun insertMeshLog(packetToSave: MeshLog) { - serviceScope.handledLaunch { - // Do not log, because might contain PII - // info("insert: ${packetToSave.message_type} = - // ${packetToSave.raw_message.toOneLineString()}") - meshLogRepository.get().insert(packetToSave) - } + private fun insertMeshLog(packetToSave: MeshLog): Job = serviceScope.handledLaunch { + // Do not log, because might contain PII + // info("insert: ${packetToSave.message_type} = + // ${packetToSave.raw_message.toOneLineString()}") + meshLogRepository.get().insert(packetToSave) } private fun setLocalConfig(config: ConfigProtos.Config) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 6ccce6e81..989923fcf 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -267,7 +267,11 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode if (errorRes == null) { dismissedTracerouteRequestId = response.requestId navController.navigate( - NodeDetailRoutes.TracerouteMap(response.destinationNodeNum, response.requestId), + NodeDetailRoutes.TracerouteMap( + destNum = response.destinationNodeNum, + requestId = response.requestId, + logUuid = response.logUuid, + ), ) } else { tracerouteMapError = errorRes diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt new file mode 100644 index 000000000..73c8d3fcb --- /dev/null +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepository.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.core.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.withContext +import org.meshtastic.core.database.DatabaseManager +import org.meshtastic.core.database.entity.TracerouteNodePositionEntity +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.proto.MeshProtos +import javax.inject.Inject + +class TracerouteSnapshotRepository +@Inject +constructor( + private val dbManager: DatabaseManager, + private val dispatchers: CoroutineDispatchers, +) { + + fun getSnapshotPositions(logUuid: String): Flow> = dbManager.currentDb + .flatMapLatest { it.tracerouteNodePositionDao().getByLogUuid(logUuid) } + .distinctUntilChanged() + .mapLatest { list -> list.associate { it.nodeNum to it.position } } + .flowOn(dispatchers.io) + .conflate() + + suspend fun upsertSnapshotPositions(logUuid: String, requestId: Int, positions: Map) = + withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.tracerouteNodePositionDao() + dao.deleteByLogUuid(logUuid) + if (positions.isEmpty()) return@withContext + val entities = + positions.map { (nodeNum, position) -> + TracerouteNodePositionEntity( + logUuid = logUuid, + requestId = requestId, + nodeNum = nodeNum, + position = position, + ) + } + dao.insertAll(entities) + } +} diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/25.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/25.json new file mode 100644 index 000000000..205f6a094 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/25.json @@ -0,0 +1,836 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "2888779b978bd66180464a3cb88903d9", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "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, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + } + ], + "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, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, 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 + }, + { + "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": 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" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "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, '2888779b978bd66180464a3cb88903d9')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 347055aaa..939366c91 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -31,6 +31,7 @@ import org.meshtastic.core.database.dao.MeshLogDao import org.meshtastic.core.database.dao.NodeInfoDao import org.meshtastic.core.database.dao.PacketDao import org.meshtastic.core.database.dao.QuickChatActionDao +import org.meshtastic.core.database.dao.TracerouteNodePositionDao import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.database.entity.DeviceHardwareEntity import org.meshtastic.core.database.entity.FirmwareReleaseEntity @@ -41,6 +42,7 @@ import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.database.entity.ReactionEntity +import org.meshtastic.core.database.entity.TracerouteNodePositionEntity @Database( entities = @@ -55,6 +57,7 @@ import org.meshtastic.core.database.entity.ReactionEntity MetadataEntity::class, DeviceHardwareEntity::class, FirmwareReleaseEntity::class, + TracerouteNodePositionEntity::class, ], autoMigrations = [ @@ -79,8 +82,9 @@ import org.meshtastic.core.database.entity.ReactionEntity AutoMigration(from = 21, to = 22), AutoMigration(from = 22, to = 23), AutoMigration(from = 23, to = 24), + AutoMigration(from = 24, to = 25), ], - version = 24, + version = 25, exportSchema = true, ) @TypeConverters(Converters::class) @@ -97,6 +101,8 @@ abstract class MeshtasticDatabase : RoomDatabase() { abstract fun firmwareReleaseDao(): FirmwareReleaseDao + abstract fun tracerouteNodePositionDao(): TracerouteNodePositionDao + companion object { fun getDatabase(context: Context): MeshtasticDatabase = Room.databaseBuilder(context.applicationContext, MeshtasticDatabase::class.java, "meshtastic_database") diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt new file mode 100644 index 000000000..5d3ebe016 --- /dev/null +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.TracerouteNodePositionEntity + +@Dao +interface TracerouteNodePositionDao { + + @Query("SELECT * FROM traceroute_node_position WHERE log_uuid = :logUuid") + fun getByLogUuid(logUuid: String): Flow> + + @Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid") + suspend fun deleteByLogUuid(logUuid: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(entities: List) +} diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/di/DatabaseModule.kt index c68cb9dd3..b79c7c180 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/di/DatabaseModule.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/di/DatabaseModule.kt @@ -29,6 +29,7 @@ import org.meshtastic.core.database.dao.MeshLogDao import org.meshtastic.core.database.dao.NodeInfoDao import org.meshtastic.core.database.dao.PacketDao import org.meshtastic.core.database.dao.QuickChatActionDao +import org.meshtastic.core.database.dao.TracerouteNodePositionDao import javax.inject.Singleton @InstallIn(SingletonComponent::class) @@ -51,4 +52,8 @@ class DatabaseModule { @Provides fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao = database.firmwareReleaseDao() + + @Provides + fun provideTracerouteNodePositionDao(database: MeshtasticDatabase): TracerouteNodePositionDao = + database.tracerouteNodePositionDao() } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/TracerouteNodePositionEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/TracerouteNodePositionEntity.kt new file mode 100644 index 000000000..72019b2de --- /dev/null +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/TracerouteNodePositionEntity.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.meshtastic.core.database.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import org.meshtastic.proto.MeshProtos + +@Entity( + tableName = "traceroute_node_position", + primaryKeys = ["log_uuid", "node_num"], + foreignKeys = + [ + ForeignKey( + entity = MeshLog::class, + parentColumns = ["uuid"], + childColumns = ["log_uuid"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index(value = ["log_uuid"]), Index(value = ["request_id"])], +) +data class TracerouteNodePositionEntity( + @ColumnInfo(name = "log_uuid") val logUuid: String, + @ColumnInfo(name = "request_id") val requestId: Int, + @ColumnInfo(name = "node_num") val nodeNum: Int, + @ColumnInfo(name = "position", typeAffinity = ColumnInfo.BLOB) val position: MeshProtos.Position, +) diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt index 2d4b4c062..343c15ea1 100644 --- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -78,7 +78,7 @@ object NodeDetailRoutes { @Serializable data class TracerouteLog(val destNum: Int) : Route - @Serializable data class TracerouteMap(val destNum: Int, val requestId: Int) : Route + @Serializable data class TracerouteMap(val destNum: Int, val requestId: Int, val logUuid: String? = null) : Route @Serializable data class HostMetricsLog(val destNum: Int) : Route diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt index fa7162d1c..38e8845b6 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt @@ -35,6 +35,7 @@ data class TracerouteResponse( val requestId: Int, val forwardRoute: List = emptyList(), val returnRoute: List = emptyList(), + val logUuid: String? = null, ) { val hasOverlay: Boolean get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty() diff --git a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt index d0bc42061..e4781766b 100644 --- a/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/fdroid/kotlin/org/meshtastic/feature/map/MapView.kt @@ -132,6 +132,7 @@ import org.meshtastic.feature.map.component.MapButton import org.meshtastic.feature.map.model.CustomTileSource import org.meshtastic.feature.map.model.MarkerWithLabel import org.meshtastic.feature.map.model.TracerouteOverlay +import org.meshtastic.proto.MeshProtos.Position import org.meshtastic.proto.MeshProtos.Waypoint import org.meshtastic.proto.copy import org.meshtastic.proto.waypoint @@ -231,6 +232,7 @@ fun MapView( mapViewModel: MapViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit, tracerouteOverlay: TracerouteOverlay? = null, + tracerouteNodePositions: Map = emptyMap(), onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, ) { var mapFilterExpanded by remember { mutableStateOf(false) } @@ -334,14 +336,17 @@ fun MapView( val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) - val nodeLookup = remember(nodes) { nodes.filter { it.validPosition != null }.associateBy { it.num } } - val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() } - val nodesForMarkers = - if (tracerouteOverlay != null) { - nodes.filter { overlayNodeNums.contains(it.num) } - } else { - nodes + val tracerouteSelection = + remember(tracerouteOverlay, tracerouteNodePositions, nodes) { + mapViewModel.tracerouteNodeSelection( + tracerouteOverlay = tracerouteOverlay, + tracerouteNodePositions = tracerouteNodePositions, + nodes = nodes, + ) } + val overlayNodeNums = tracerouteSelection.overlayNodeNums + val nodeLookup = tracerouteSelection.nodeLookup + val nodesForMarkers = tracerouteSelection.nodesForMarkers val tracerouteForwardPoints = remember(tracerouteOverlay, nodeLookup) { tracerouteOverlay?.forwardRoute?.mapNotNull { diff --git a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt index 4b0833853..1350b9aad 100644 --- a/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt +++ b/feature/map/src/google/kotlin/org/meshtastic/feature/map/MapView.kt @@ -144,6 +144,7 @@ fun MapView( focusedNodeNum: Int? = null, nodeTracks: List? = null, tracerouteOverlay: TracerouteOverlay? = null, + tracerouteNodePositions: Map = emptyMap(), onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current @@ -262,7 +263,14 @@ fun MapView( .collectAsStateWithLifecycle(listOf()) val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } - val overlayNodeNums = remember(tracerouteOverlay) { tracerouteOverlay?.relatedNodeNums ?: emptySet() } + val tracerouteSelection = + remember(tracerouteOverlay, tracerouteNodePositions, allNodes) { + mapViewModel.tracerouteNodeSelection( + tracerouteOverlay = tracerouteOverlay, + tracerouteNodePositions = tracerouteNodePositions, + nodes = allNodes, + ) + } val filteredNodes = allNodes @@ -275,7 +283,7 @@ fun MapView( val displayNodes = if (tracerouteOverlay != null) { - allNodes.filter { overlayNodeNums.contains(it.num) } + tracerouteSelection.nodesForMarkers } else { filteredNodes } diff --git a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 218b3e82c..ef6021799 100644 --- a/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/main/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -42,6 +42,7 @@ import org.meshtastic.core.strings.one_day import org.meshtastic.core.strings.one_hour import org.meshtastic.core.strings.two_days import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed +import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.proto.MeshProtos import timber.log.Timber import java.util.concurrent.TimeUnit @@ -68,7 +69,7 @@ sealed class LastHeardFilter(val seconds: Long, val label: StringResource) { @Suppress("TooManyFunctions") abstract class BaseMapViewModel( protected val mapPrefs: MapPrefs, - nodeRepository: NodeRepository, + private val nodeRepository: NodeRepository, private val packetRepository: PacketRepository, private val serviceRepository: ServiceRepository, ) : ViewModel() { @@ -118,6 +119,12 @@ abstract class BaseMapViewModel( val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo + fun getNodeByNum(nodeNum: Int): Node? = nodeRepository.nodeDBbyNum.value[nodeNum] + + fun getUser(nodeNum: Int): MeshProtos.User = nodeRepository.getUser(nodeNum) + + fun getNodeOrFallback(nodeNum: Int): Node = getNodeByNum(nodeNum) ?: Node(num = nodeNum, user = getUser(nodeNum)) + val isConnected = serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false) @@ -196,3 +203,47 @@ abstract class BaseMapViewModel( ), ) } + +data class TracerouteNodeSelection( + val overlayNodeNums: Set, + val nodesForMarkers: List, + val nodeLookup: Map, +) + +fun BaseMapViewModel.tracerouteNodeSelection( + tracerouteOverlay: TracerouteOverlay?, + tracerouteNodePositions: Map, + nodes: List, +): TracerouteNodeSelection { + val overlayNodeNums = tracerouteOverlay?.relatedNodeNums ?: emptySet() + val tracerouteSnapshotNodes = + if (tracerouteOverlay == null || tracerouteNodePositions.isEmpty()) { + emptyList() + } else { + tracerouteNodePositions.map { (nodeNum, position) -> getNodeOrFallback(nodeNum).copy(position = position) } + } + + val nodesForMarkers = + if (tracerouteOverlay != null) { + if (tracerouteSnapshotNodes.isNotEmpty()) { + tracerouteSnapshotNodes.filter { overlayNodeNums.contains(it.num) } + } else { + nodes.filter { overlayNodeNums.contains(it.num) } + } + } else { + nodes + } + + val nodesForLookup = + if (tracerouteSnapshotNodes.isNotEmpty()) { + tracerouteSnapshotNodes + } else { + nodes.filter { it.validPosition != null } + } + + return TracerouteNodeSelection( + overlayNodeNums = overlayNodeNums, + nodesForMarkers = nodesForMarkers, + nodeLookup = nodesForLookup.associateBy { it.num }, + ) +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 0f5427bbe..fc07fe74f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -43,6 +43,7 @@ import org.meshtastic.core.data.repository.FirmwareReleaseRepository import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.NodeRepository import org.meshtastic.core.data.repository.RadioConfigRepository +import org.meshtastic.core.data.repository.TracerouteSnapshotRepository import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.model.Node import org.meshtastic.core.di.CoroutineDispatchers @@ -87,6 +88,7 @@ constructor( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, private val nodeRepository: NodeRepository, + private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, private val deviceHardwareRepository: DeviceHardwareRepository, private val firmwareReleaseRepository: FirmwareReleaseRepository, ) : ViewModel() { @@ -146,6 +148,8 @@ constructor( return overlay } + fun tracerouteSnapshotPositions(logUuid: String) = tracerouteSnapshotRepository.getSnapshotPositions(logUuid) + fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse() fun tracerouteMapAvailability(forwardRoute: List, returnRoute: List): TracerouteMapAvailability = diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 7be45b9b4..a77af41fd 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -64,6 +64,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.toMessageRes @@ -89,7 +90,12 @@ import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.proto.MeshProtos import java.text.DateFormat -private data class TracerouteDialog(val message: AnnotatedString, val requestId: Int, val overlay: TracerouteOverlay?) +private data class TracerouteDialog( + val message: AnnotatedString, + val requestId: Int, + val responseLogUuid: String, + val overlay: TracerouteOverlay?, +) @OptIn(ExperimentalFoundationApi::class) @Suppress("LongMethod") @@ -98,7 +104,7 @@ fun TracerouteLogScreen( modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit, - onViewOnMap: (requestId: Int) -> Unit = {}, + onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit = { _, _ -> }, ) { val state by viewModel.state.collectAsStateWithLifecycle() val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) } @@ -185,10 +191,12 @@ fun TracerouteLogScreen( AnnotatedString(it) } dialogMessage?.let { + val responseLogUuid = result?.uuid ?: return@combinedClickable showDialog = TracerouteDialog( message = it, requestId = log.fromRadio.packet.id, + responseLogUuid = responseLogUuid, overlay = overlay, ) } @@ -211,23 +219,34 @@ private fun TracerouteLogDialogs( dialog: TracerouteDialog?, errorMessageRes: StringResource?, viewModel: MetricsViewModel, - onViewOnMap: (requestId: Int) -> Unit, + onViewOnMap: (requestId: Int, responseLogUuid: String) -> Unit, onShowErrorMessageRes: (StringResource) -> Unit, onDismissDialog: () -> Unit, onDismissError: () -> Unit, ) { dialog?.let { dialogState -> + val snapshotPositionsFlow = + remember(dialogState.responseLogUuid) { viewModel.tracerouteSnapshotPositions(dialogState.responseLogUuid) } + val snapshotPositions by snapshotPositionsFlow.collectAsStateWithLifecycle(emptyMap()) SimpleAlertDialog( title = Res.string.traceroute, text = { SelectionContainer { Text(text = dialogState.message) } }, confirmText = stringResource(Res.string.view_on_map), onConfirm = { + val positionedNodeNums = + if (snapshotPositions.isNotEmpty()) { + snapshotPositions.keys + } else { + viewModel.positionedNodeNums() + } val availability = - viewModel.tracerouteMapAvailability( + evaluateTracerouteMapAvailability( forwardRoute = dialogState.overlay?.forwardRoute.orEmpty(), returnRoute = dialogState.overlay?.returnRoute.orEmpty(), + positionedNodeNums = positionedNodeNums, ) - availability.toMessageRes()?.let(onShowErrorMessageRes) ?: onViewOnMap(dialogState.requestId) + availability.toMessageRes()?.let(onShowErrorMessageRes) + ?: onViewOnMap(dialogState.requestId, dialogState.responseLogUuid) onDismissDialog() }, onDismiss = onDismissDialog, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt index 25f728666..790f9afab 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteMapScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.flowOf import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.fullRouteDiscovery import org.meshtastic.core.strings.Res @@ -54,37 +55,59 @@ import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.theme.TracerouteColors import org.meshtastic.feature.map.MapView import org.meshtastic.feature.map.model.TracerouteOverlay +import org.meshtastic.proto.MeshProtos @Composable fun TracerouteMapScreen( metricsViewModel: MetricsViewModel = hiltViewModel(), requestId: Int, + logUuid: String? = null, onNavigateUp: () -> Unit, ) { val state by metricsViewModel.state.collectAsStateWithLifecycle() - val nodeTitle = state.node?.user?.longName ?: stringResource(Res.string.traceroute) - val routeDiscovery = - state.tracerouteResults - .find { it.fromRadio.packet.decoded.requestId == requestId } - ?.fromRadio - ?.packet - ?.fullRouteDiscovery + val snapshotPositions by + remember(logUuid) { + logUuid?.let(metricsViewModel::tracerouteSnapshotPositions) + ?: flowOf(emptyMap()) + } + .collectAsStateWithLifecycle(emptyMap()) + val tracerouteResult = + if (logUuid != null) { + state.tracerouteResults.find { it.uuid == logUuid } + } else { + state.tracerouteResults.find { it.fromRadio.packet.decoded.requestId == requestId } + } + val routeDiscovery = tracerouteResult?.fromRadio?.packet?.fullRouteDiscovery val overlayFromLogs = - remember(routeDiscovery) { - routeDiscovery?.let { - TracerouteOverlay(requestId = requestId, forwardRoute = it.routeList, returnRoute = it.routeBackList) - } + remember(routeDiscovery, requestId) { + routeDiscovery?.let { TracerouteOverlay(requestId, it.routeList, it.routeBackList) } } val overlayFromService = remember(requestId) { metricsViewModel.getTracerouteOverlay(requestId) } val overlay = overlayFromLogs ?: overlayFromService - var tracerouteNodesShown by remember { mutableStateOf(0) } - var tracerouteNodesTotal by remember { mutableStateOf(0) } LaunchedEffect(Unit) { metricsViewModel.clearTracerouteResponse() } + TracerouteMapScaffold( + title = state.node?.user?.longName ?: stringResource(Res.string.traceroute), + overlay = overlay, + snapshotPositions = snapshotPositions, + onNavigateUp = onNavigateUp, + ) +} + +@Composable +private fun TracerouteMapScaffold( + title: String, + overlay: TracerouteOverlay?, + snapshotPositions: Map, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + var tracerouteNodesShown by remember { mutableStateOf(0) } + var tracerouteNodesTotal by remember { mutableStateOf(0) } Scaffold( topBar = { MainAppBar( - title = nodeTitle, + title = title, ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -94,10 +117,11 @@ fun TracerouteMapScreen( ) }, ) { paddingValues -> - Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + Box(modifier = modifier.fillMaxSize().padding(paddingValues)) { MapView( navigateToNodeDetails = {}, tracerouteOverlay = overlay, + tracerouteNodePositions = snapshotPositions, onTracerouteMappableCountChanged = { shown, total -> tracerouteNodesShown = shown tracerouteNodesTotal = total