mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2025-12-24 00:07:48 -05:00
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>
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<MeshLogRepository>
|
||||
|
||||
@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<Int, Long>()
|
||||
private val logUuidByPacketId = ConcurrentHashMap<Int, String>()
|
||||
private val logInsertJobByPacketId = ConcurrentHashMap<Int, Job>()
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Map<Int, MeshProtos.Position>> = 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<Int, MeshProtos.Position>) =
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<List<TracerouteNodePositionEntity>>
|
||||
|
||||
@Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid")
|
||||
suspend fun deleteByLogUuid(logUuid: String)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(entities: List<TracerouteNodePositionEntity>)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ data class TracerouteResponse(
|
||||
val requestId: Int,
|
||||
val forwardRoute: List<Int> = emptyList(),
|
||||
val returnRoute: List<Int> = emptyList(),
|
||||
val logUuid: String? = null,
|
||||
) {
|
||||
val hasOverlay: Boolean
|
||||
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
|
||||
|
||||
@@ -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<Int, Position> = 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 {
|
||||
|
||||
@@ -144,6 +144,7 @@ fun MapView(
|
||||
focusedNodeNum: Int? = null,
|
||||
nodeTracks: List<Position>? = null,
|
||||
tracerouteOverlay: TracerouteOverlay? = null,
|
||||
tracerouteNodePositions: Map<Int, Position> = 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
|
||||
}
|
||||
|
||||
@@ -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<Node?> = 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<Int>,
|
||||
val nodesForMarkers: List<Node>,
|
||||
val nodeLookup: Map<Int, Node>,
|
||||
)
|
||||
|
||||
fun BaseMapViewModel.tracerouteNodeSelection(
|
||||
tracerouteOverlay: TracerouteOverlay?,
|
||||
tracerouteNodePositions: Map<Int, MeshProtos.Position>,
|
||||
nodes: List<Node>,
|
||||
): 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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
|
||||
|
||||
@@ -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<Int, MeshProtos.Position>())
|
||||
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,
|
||||
|
||||
@@ -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<Int, MeshProtos.Position>())
|
||||
}
|
||||
.collectAsStateWithLifecycle(emptyMap<Int, MeshProtos.Position>())
|
||||
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<Int, MeshProtos.Position>,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user