diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt index 9b374de48..661bbd832 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt @@ -75,7 +75,7 @@ constructor( when (action) { is ServiceAction.Favorite -> handleFavorite(action, myNodeNum) is ServiceAction.Ignore -> handleIgnore(action, myNodeNum) - is ServiceAction.Reaction -> handleReaction(action) + is ServiceAction.Reaction -> handleReaction(action, myNodeNum) is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum) is ServiceAction.SendContact -> { commandSender.sendAdmin(myNodeNum) { addContact = action.contact } @@ -103,21 +103,23 @@ constructor( nodeManager.updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored } } - private fun handleReaction(action: ServiceAction.Reaction) { + private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) { val channel = action.contactKey[0].digitToInt() val destId = action.contactKey.substring(1) val dataPacket = - org.meshtastic.core.model.DataPacket( - to = destId, - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - bytes = action.emoji.encodeToByteArray(), - channel = channel, - replyId = action.replyId, - wantAck = true, - emoji = action.emoji.codePointAt(0), - ) + org.meshtastic.core.model + .DataPacket( + to = destId, + dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + bytes = action.emoji.encodeToByteArray(), + channel = channel, + replyId = action.replyId, + wantAck = true, + emoji = action.emoji.codePointAt(0), + ) + .apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL } commandSender.sendData(dataPacket) - rememberReaction(action, dataPacket.id) + rememberReaction(action, dataPacket.id, myNodeNum) } private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) { @@ -126,12 +128,13 @@ constructor( nodeManager.handleReceivedUser(verifiedContact.nodeNum, verifiedContact.user, manuallyVerified = true) } - private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int) { + private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) { scope.handledLaunch { val reaction = ReactionEntity( + myNodeNum = myNodeNum, replyId = action.replyId, - userId = nodeManager.getMyId(), + userId = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL, emoji = action.emoji, timestamp = System.currentTimeMillis(), snr = 0f, @@ -139,6 +142,8 @@ constructor( hopsAway = 0, packetId = packetId, status = MessageStatus.QUEUED, + to = action.contactKey.substring(1), + channel = action.contactKey[0].digitToInt(), ) packetRepository.get().insertReaction(reaction) } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt index fe6a5d369..718a8cb46 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -37,6 +37,7 @@ import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.util.SfppHasher import org.meshtastic.core.prefs.mesh.MeshPrefs import org.meshtastic.core.service.MeshServiceNotifications import org.meshtastic.core.service.ServiceRepository @@ -58,7 +59,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.milliseconds -@Suppress("LongParameterList", "TooManyFunctions") +@Suppress("LongParameterList", "TooManyFunctions", "LargeClass") @Singleton class MeshDataHandler @Inject @@ -191,9 +192,82 @@ constructor( handleReceivedStoreAndForward(dataPacket, u, myNodeNum) } + @Suppress("LongMethod") private fun handleStoreForwardPlusPlus(packet: MeshPacket) { val sfpp = MeshProtos.StoreForwardPlusPlus.parseFrom(packet.decoded.payload) Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" } + + when (sfpp.sfppMessageType) { + MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, + -> { + val isFragment = sfpp.sfppMessageType != MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE + + // If it has a commit hash, it's already on the chain (Confirmed) + // Otherwise it's still being routed via SF++ (Routing) + val status = if (sfpp.commitHash.isEmpty) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED + + // Prefer a full 16-byte hash calculated from the message bytes if available + // But only if it's NOT a fragment, otherwise the calculated hash would be wrong + val hash = + when { + !sfpp.messageHash.isEmpty -> sfpp.messageHash.toByteArray() + !isFragment && !sfpp.message.isEmpty -> { + SfppHasher.computeMessageHash( + encryptedPayload = sfpp.message.toByteArray(), + // Map 0 back to NODENUM_BROADCAST to match firmware hash calculation + to = + if (sfpp.encapsulatedTo == 0) DataPacket.NODENUM_BROADCAST else sfpp.encapsulatedTo, + from = sfpp.encapsulatedFrom, + id = sfpp.encapsulatedId, + ) + } + else -> null + } ?: return + + Logger.d { + "SFPP updateStatus: packetId=${sfpp.encapsulatedId} from=${sfpp.encapsulatedFrom} " + + "to=${sfpp.encapsulatedTo} myNodeNum=${nodeManager.myNodeNum} status=$status" + } + scope.handledLaunch { + packetRepository + .get() + .updateSFPPStatus( + packetId = sfpp.encapsulatedId, + from = sfpp.encapsulatedFrom, + to = sfpp.encapsulatedTo, + hash = hash, + status = status, + rxTime = sfpp.encapsulatedRxtime.toLong() and 0xFFFFFFFFL, + myNodeNum = nodeManager.myNodeNum, + ) + serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulatedId, status) + } + } + + MeshProtos.StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> { + scope.handledLaunch { + packetRepository + .get() + .updateSFPPStatusByHash( + hash = sfpp.messageHash.toByteArray(), + status = MessageStatus.SFPP_CONFIRMED, + rxTime = sfpp.encapsulatedRxtime.toLong() and 0xFFFFFFFFL, + ) + } + } + + MeshProtos.StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> { + Logger.i { "SF++: Node ${packet.from} is querying chain status" } + } + + MeshProtos.StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> { + Logger.i { "SF++: Node ${packet.from} is requesting links" } + } + + else -> {} + } } private fun handlePaxCounter(packet: MeshPacket) { @@ -345,13 +419,13 @@ constructor( isMaxRetransmit && p != null && p.port_num == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE && - p.data.from == DataPacket.ID_LOCAL && + (p.data.from == DataPacket.ID_LOCAL || p.data.from == nodeManager.getMyId()) && p.data.retryCount < MAX_RETRY_ATTEMPTS val shouldRetryReaction = isMaxRetransmit && reaction != null && - reaction.userId == DataPacket.ID_LOCAL && + (reaction.userId == DataPacket.ID_LOCAL || reaction.userId == nodeManager.getMyId()) && reaction.retryCount < MAX_RETRY_ATTEMPTS && reaction.to != null @Suppress("MaxLineLength") @@ -509,7 +583,8 @@ constructor( fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) { if (dataPacket.dataType !in rememberDataType) return - val fromLocal = dataPacket.from == DataPacket.ID_LOCAL + val fromLocal = + dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum) val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from @@ -529,7 +604,6 @@ constructor( snr = dataPacket.snr, rssi = dataPacket.rssi, hopsAway = dataPacket.hopsAway, - replyId = dataPacket.replyId ?: 0, ) scope.handledLaunch { packetRepository.get().apply { @@ -593,10 +667,14 @@ constructor( private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch { val emoji = packet.decoded.payload.toByteArray().decodeToString() + val fromId = dataMapper.toNodeID(packet.from) + val toId = dataMapper.toNodeID(packet.to) + val reaction = ReactionEntity( + myNodeNum = nodeManager.myNodeNum ?: 0, replyId = packet.decoded.replyId, - userId = dataMapper.toNodeID(packet.from), + userId = fromId, emoji = emoji, timestamp = System.currentTimeMillis(), snr = packet.rxSnr, @@ -607,6 +685,10 @@ constructor( } else { packet.hopStart - packet.hopLimit }, + packetId = packet.id, + status = MessageStatus.RECEIVED, + to = toId, + channel = packet.channel, ) packetRepository.get().insertReaction(reaction) diff --git a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt index 184c3ad06..028cbfb5c 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt @@ -39,6 +39,8 @@ class ReactionReceiver : BroadcastReceiver() { @Inject lateinit var packetRepository: PacketRepository + @Inject lateinit var nodeManager: MeshNodeManager + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) companion object { @@ -82,8 +84,9 @@ class ReactionReceiver : BroadcastReceiver() { val reaction = ReactionEntity( + myNodeNum = nodeManager.myNodeNum ?: 0, replyId = packetId, - userId = DataPacket.ID_LOCAL, + userId = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL, emoji = emoji, timestamp = System.currentTimeMillis(), packetId = reactionPacket.id, diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt index 6f8064a2d..3ebfbf16c 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt @@ -51,7 +51,12 @@ constructor( dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys() } fun getContactsPaged(): Flow> = Pager( - config = PagingConfig(pageSize = 30, enablePlaceholders = false, initialLoadSize = 30), + config = + PagingConfig( + pageSize = CONTACTS_PAGE_SIZE, + enablePlaceholders = false, + initialLoadSize = CONTACTS_PAGE_SIZE, + ), pagingSourceFactory = { dbManager.currentDb.value.packetDao().getContactKeysPaged() }, ) .flow @@ -113,7 +118,12 @@ constructor( } fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow> = Pager( - config = PagingConfig(pageSize = 50, enablePlaceholders = false, initialLoadSize = 50), + config = + PagingConfig( + pageSize = MESSAGES_PAGE_SIZE, + enablePlaceholders = false, + initialLoadSize = MESSAGES_PAGE_SIZE, + ), pagingSourceFactory = { dbManager.currentDb.value.packetDao().getMessagesFromPaged(contact) }, ) .flow @@ -140,8 +150,100 @@ constructor( suspend fun getPacketByPacketId(packetId: Int) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) } + @Suppress("CyclomaticComplexMethod") + suspend fun updateSFPPStatus( + packetId: Int, + from: Int, + to: Int, + hash: ByteArray, + status: MessageStatus = MessageStatus.SFPP_CONFIRMED, + rxTime: Long = 0, + myNodeNum: Int? = null, + ) = withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + val packets = dao.findPacketsWithId(packetId) + val reactions = dao.findReactionsWithId(packetId) + val fromId = DataPacket.nodeNumToDefaultId(from) + val isFromLocalNode = myNodeNum != null && from == myNodeNum + val toId = + if (to == 0 || to == DataPacket.NODENUM_BROADCAST) { + DataPacket.ID_BROADCAST + } else { + DataPacket.nodeNumToDefaultId(to) + } + + packets.forEach { packet -> + // For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number + val fromMatches = + packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.ID_LOCAL) + co.touchlab.kermit.Logger.d { + "SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " + + "isFromLocal=$isFromLocalNode fromMatches=$fromMatches " + + "packetTo=${packet.data.to} toId=$toId toMatches=${packet.data.to == toId}" + } + if (fromMatches && packet.data.to == toId) { + // If it's already confirmed, don't downgrade it to routing + if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@forEach + } + val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time + val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime) + dao.update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime)) + } + } + + reactions.forEach { reaction -> + val reactionFrom = reaction.userId + // For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number + val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == DataPacket.ID_LOCAL) + + val toMatches = reaction.to == toId + + co.touchlab.kermit.Logger.d { + "SFPP reaction match check: reactionFrom=$reactionFrom fromId=$fromId " + + "isFromLocal=$isFromLocalNode fromMatches=$fromMatches " + + "reactionTo=${reaction.to} toId=$toId toMatches=$toMatches" + } + + if (fromMatches && (reaction.to == null || toMatches)) { + if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@forEach + } + val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp + val updatedReaction = reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime) + dao.update(updatedReaction) + } + } + } + + suspend fun updateSFPPStatusByHash( + hash: ByteArray, + status: MessageStatus = MessageStatus.SFPP_CONFIRMED, + rxTime: Long = 0, + ) = withContext(dispatchers.io) { + val dao = dbManager.currentDb.value.packetDao() + dao.findPacketBySfppHash(hash)?.let { packet -> + // If it's already confirmed, don't downgrade it + if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@let + } + val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time + val updatedData = packet.data.copy(status = status, sfppHash = hash, time = newTime) + dao.update(packet.copy(data = updatedData, sfpp_hash = hash, received_time = newTime)) + } + + dao.findReactionBySfppHash(hash)?.let { reaction -> + if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { + return@let + } + val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp + val updatedReaction = reaction.copy(status = status, sfpp_hash = hash, timestamp = newTime) + dao.update(updatedReaction) + } + } + suspend fun deleteMessages(uuidList: List) = withContext(dispatchers.io) { - for (chunk in uuidList.chunked(500)) { + for (chunk in uuidList.chunked(DELETE_CHUNK_SIZE)) { // Fetch DAO per chunk to avoid holding a stale reference if the active DB switches dbManager.currentDb.value.packetDao().deleteMessages(chunk) } @@ -187,4 +289,11 @@ constructor( private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow> = getAllPackets(PortNum.WAYPOINT_APP_VALUE) + + companion object { + private const val CONTACTS_PAGE_SIZE = 30 + private const val MESSAGES_PAGE_SIZE = 50 + private const val DELETE_CHUNK_SIZE = 500 + private const val MILLISECONDS_IN_SECOND = 1000L + } } diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/29.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/29.json new file mode 100644 index 000000000..65d6c2f87 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/29.json @@ -0,0 +1,975 @@ +{ + "formatVersion": 1, + "database": { + "version": 29, + "identityHash": "35e6fb55d18557710b0bce216b5df4e8", + "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" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + } + ] + }, + { + "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, `sfpp_hash` BLOB)", + "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" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "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`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + } + ] + }, + { + "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, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` TEXT NOT NULL DEFAULT '0', `routing_error` INTEGER NOT NULL DEFAULT 0, `retry_count` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, 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" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "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`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_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, '35e6fb55d18557710b0bce216b5df4e8')" + ] + } +} \ No newline at end of file diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/30.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/30.json new file mode 100644 index 000000000..225d63235 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/30.json @@ -0,0 +1,985 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "30ebce67f2831b057c3c41b951361ec2", + "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" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + } + ] + }, + { + "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, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB)", + "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": "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" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "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`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "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}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `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, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `retry_count` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "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" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "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`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_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, '30ebce67f2831b057c3c41b951361ec2')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt index b8d791072..093a9cd7c 100644 --- a/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt +++ b/core/database/src/androidTest/kotlin/org/meshtastic/core/database/dao/PacketDaoTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 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 @@ -14,7 +14,6 @@ * 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.Room @@ -24,6 +23,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -31,7 +32,9 @@ import org.junit.runner.RunWith import org.meshtastic.core.database.MeshtasticDatabase import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.Packet +import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus import org.meshtastic.proto.Portnums @RunWith(AndroidJUnit4::class) @@ -166,6 +169,179 @@ class PacketDaoTest { } } + @Test + fun test_findPacketsWithId() = runBlocking { + val packetId = 12345 + val packet = + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + contact_key = "test", + received_time = System.currentTimeMillis(), + read = true, + data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test").copy(id = packetId), + packetId = packetId, + ) + + packetDao.insert(packet) + + val found = packetDao.findPacketsWithId(packetId) + assertEquals(1, found.size) + assertEquals(packetId, found[0].packetId) + } + + @Test + fun test_sfppHashPersistence() = runBlocking { + val hash = byteArrayOf(1, 2, 3, 4) + val packet = + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + contact_key = "test", + received_time = System.currentTimeMillis(), + read = true, + data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"), + sfpp_hash = hash, + ) + + packetDao.insert(packet) + + val retrieved = + packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first().find { + it.sfpp_hash?.contentEquals(hash) == true + } + assertNotNull(retrieved) + assertTrue(retrieved?.sfpp_hash?.contentEquals(hash) == true) + } + + @Test + fun test_findPacketBySfppHash() = runBlocking { + val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + val packet = + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + contact_key = "test", + received_time = System.currentTimeMillis(), + read = true, + data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"), + sfpp_hash = hash, + ) + + packetDao.insert(packet) + + // Exact match + val found = packetDao.findPacketBySfppHash(hash) + assertNotNull(found) + assertTrue(found?.sfpp_hash?.contentEquals(hash) == true) + + // Substring match (first 8 bytes) + val shortHash = hash.copyOf(8) + val foundShort = packetDao.findPacketBySfppHash(shortHash) + assertNotNull(foundShort) + assertTrue(foundShort?.sfpp_hash?.contentEquals(hash) == true) + + // No match + val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0) + val notFound = packetDao.findPacketBySfppHash(wrongHash) + assertNull(notFound) + } + + @Test + fun test_findReactionBySfppHash() = runBlocking { + val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16) + val reaction = + ReactionEntity( + myNodeNum = myNodeNum, + replyId = 123, + userId = "sender", + emoji = "👍", + timestamp = System.currentTimeMillis(), + sfpp_hash = hash, + ) + + packetDao.insert(reaction) + + val found = packetDao.findReactionBySfppHash(hash) + assertNotNull(found) + assertTrue(found?.sfpp_hash?.contentEquals(hash) == true) + + val shortHash = hash.copyOf(8) + val foundShort = packetDao.findReactionBySfppHash(shortHash) + assertNotNull(foundShort) + + val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0) + assertNull(packetDao.findReactionBySfppHash(wrongHash)) + } + + @Test + fun test_updateMessageId_persistence() = runBlocking { + val initialId = 100 + val newId = 200 + val packet = + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + contact_key = "test", + received_time = System.currentTimeMillis(), + read = true, + data = DataPacket(to = "target", channel = 0, text = "Hello").copy(id = initialId), + packetId = initialId, + ) + + packetDao.insert(packet) + + packetDao.updateMessageId(packet.data, newId) + + val updated = packetDao.getPacketById(newId) + assertNotNull(updated) + assertEquals(newId, updated?.packetId) + assertEquals(newId, updated?.data?.id) + } + + @Test + fun test_updateSFPPStatus_logic() = runBlocking { + val packetId = 999 + val fromNum = 123 + val toNum = 456 + val hash = byteArrayOf(9, 8, 7, 6) + + val fromId = DataPacket.nodeNumToDefaultId(fromNum) + val toId = DataPacket.nodeNumToDefaultId(toNum) + + val packet = + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + contact_key = "test", + received_time = System.currentTimeMillis(), + read = true, + data = DataPacket(to = toId, channel = 0, text = "Match me").copy(from = fromId, id = packetId), + packetId = packetId, + ) + + packetDao.insert(packet) + + // Verifying the logic used in PacketRepository + val found = packetDao.findPacketsWithId(packetId) + found.forEach { p -> + if (p.data.from == fromId && p.data.to == toId) { + val data = p.data.copy(status = MessageStatus.SFPP_CONFIRMED, sfppHash = hash) + packetDao.update(p.copy(data = data, sfpp_hash = hash)) + } + } + + val updated = packetDao.findPacketsWithId(packetId)[0] + assertEquals(MessageStatus.SFPP_CONFIRMED, updated.data.status) + assertTrue(updated.data.sfppHash?.contentEquals(hash) == true) + assertTrue(updated.sfpp_hash?.contentEquals(hash) == true) + } + companion object { private const val SAMPLE_SIZE = 10 } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/Converters.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/Converters.kt index f9078a5ff..ef86a5d0f 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/Converters.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/Converters.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.core.database import androidx.room.TypeConverter @@ -23,6 +22,7 @@ import com.google.protobuf.ByteString import com.google.protobuf.InvalidProtocolBufferException import kotlinx.serialization.json.Json import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus import org.meshtastic.proto.MeshProtos import org.meshtastic.proto.PaxcountProtos import org.meshtastic.proto.TelemetryProtos @@ -121,4 +121,9 @@ class Converters { fun bytesToByteString(bytes: ByteArray?): ByteString? = if (bytes == null) null else ByteString.copyFrom(bytes) @TypeConverter fun byteStringToBytes(value: ByteString?): ByteArray? = value?.toByteArray() + + @TypeConverter fun messageStatusToInt(value: MessageStatus?): Int = value?.ordinal ?: MessageStatus.UNKNOWN.ordinal + + @TypeConverter + fun intToMessageStatus(value: Int): MessageStatus = MessageStatus.entries.getOrElse(value) { MessageStatus.UNKNOWN } } 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 75c602275..e248f0114 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 @@ -19,6 +19,7 @@ package org.meshtastic.core.database import android.content.Context import androidx.room.AutoMigration import androidx.room.Database +import androidx.room.DeleteColumn import androidx.room.DeleteTable import androidx.room.Room import androidx.room.RoomDatabase @@ -85,8 +86,10 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 25, to = 26), AutoMigration(from = 26, to = 27), AutoMigration(from = 27, to = 28), + AutoMigration(from = 28, to = 29), + AutoMigration(from = 29, to = 30, spec = AutoMigration29to30::class), ], - version = 28, + version = 30, exportSchema = true, ) @TypeConverters(Converters::class) @@ -115,3 +118,6 @@ abstract class MeshtasticDatabase : RoomDatabase() { @DeleteTable.Entries(DeleteTable(tableName = "NodeInfo"), DeleteTable(tableName = "MyNodeInfo")) class AutoMigration12to13 : AutoMigrationSpec + +@DeleteColumn.Entries(DeleteColumn(tableName = "packet", columnName = "reply_id")) +class AutoMigration29to30 : AutoMigrationSpec diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 12c35c4ae..14f81c0ba 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -204,7 +204,13 @@ interface PacketDao { @Query("SELECT packet_id FROM packet WHERE uuid IN (:uuidList)") suspend fun getPacketIdsFrom(uuidList: List): List - @Query("DELETE FROM reactions WHERE reply_id IN (:packetIds)") + @Query( + """ + DELETE FROM reactions + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND reply_id IN (:packetIds) + """, + ) suspend fun deleteReactions(packetIds: List) @Transaction @@ -227,7 +233,7 @@ interface PacketDao { @Transaction suspend fun updateMessageId(data: DataPacket, id: Int) { val new = data.copy(id = id) - findDataPacket(data)?.let { update(it.copy(data = new)) } + findDataPacket(data)?.let { update(it.copy(data = new, packetId = id)) } } @Query( @@ -251,9 +257,35 @@ interface PacketDao { suspend fun getPacketById(requestId: Int): Packet? @Transaction - @Query("SELECT * FROM packet WHERE packet_id = :packetId LIMIT 1") + @Query( + """ + SELECT * FROM packet + WHERE packet_id = :packetId + AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + LIMIT 1 + """, + ) suspend fun getPacketByPacketId(packetId: Int): PacketEntity? + @Query( + """ + SELECT * FROM packet + WHERE packet_id = :packetId + AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + """, + ) + suspend fun findPacketsWithId(packetId: Int): List + + @Transaction + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8) + """, + ) + suspend fun findPacketBySfppHash(hash: ByteArray): Packet? + @Transaction suspend fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED } @@ -311,9 +343,35 @@ interface PacketDao { @Update suspend fun update(reaction: ReactionEntity) - @Query("SELECT * FROM reactions WHERE packet_id = :packetId LIMIT 1") + @Query( + """ + SELECT * FROM reactions + WHERE packet_id = :packetId + AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + """, + ) + suspend fun findReactionsWithId(packetId: Int): List + + @Query( + """ + SELECT * FROM reactions + WHERE packet_id = :packetId + AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + LIMIT 1 + """, + ) suspend fun getReactionByPacketId(packetId: Int): ReactionEntity? + @Transaction + @Query( + """ + SELECT * FROM reactions + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8) + """, + ) + suspend fun findReactionBySfppHash(hash: ByteArray): ReactionEntity? + @Transaction suspend fun deleteAll() { deleteAllPackets() diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt index ebfba43b8..de32932a9 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -36,11 +36,12 @@ data class PacketEntity( ) { suspend fun toMessage(getNode: suspend (userId: String?) -> Node) = with(packet) { val node = getNode(data.from) + val isFromLocal = node.user.id == DataPacket.ID_LOCAL || (myNodeNum != 0 && node.num == myNodeNum) Message( uuid = uuid, receivedTime = received_time, node = node, - fromLocal = node.user.id == DataPacket.ID_LOCAL, + fromLocal = isFromLocal, text = data.text.orEmpty(), time = getShortDateTime(data.time), snr = snr, @@ -50,7 +51,7 @@ data class PacketEntity( status = data.status, routingError = routingError, packetId = packetId, - emojis = reactions.toReaction(getNode), + emojis = reactions.filter { it.myNodeNum == myNodeNum || it.myNodeNum == 0 }.toReaction(getNode), replyId = data.replyId, viaMqtt = data.viaMqtt, relayNode = data.relayNode, @@ -69,6 +70,7 @@ data class PacketEntity( Index(value = ["port_num"]), Index(value = ["contact_key"]), Index(value = ["contact_key", "port_num", "received_time"]), + Index(value = ["packet_id"]), ], ) data class Packet( @@ -81,10 +83,10 @@ data class Packet( @ColumnInfo(name = "data") val data: DataPacket, @ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0, @ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1, - @ColumnInfo(name = "reply_id", defaultValue = "0") val replyId: Int = 0, @ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f, @ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0, @ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1, + @ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteArray? = null, ) { companion object { const val RELAY_NODE_SUFFIX_MASK = 0xFF @@ -139,14 +141,17 @@ data class Reaction( val relayNode: Int? = null, val to: String? = null, val channel: Int = 0, + val sfppHash: ByteArray? = null, ) +@Suppress("ConstructorParameterNaming") @Entity( tableName = "reactions", - primaryKeys = ["reply_id", "user_id", "emoji"], + primaryKeys = ["myNodeNum", "reply_id", "user_id", "emoji"], indices = [Index(value = ["reply_id"]), Index(value = ["packet_id"])], ) data class ReactionEntity( + @ColumnInfo(name = "myNodeNum", defaultValue = "0") val myNodeNum: Int = 0, @ColumnInfo(name = "reply_id") val replyId: Int, @ColumnInfo(name = "user_id") val userId: String, val emoji: String, @@ -162,25 +167,30 @@ data class ReactionEntity( @ColumnInfo(name = "relay_node") val relayNode: Int? = null, @ColumnInfo(name = "to") val to: String? = null, @ColumnInfo(name = "channel", defaultValue = "0") val channel: Int = 0, + @ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteArray? = null, ) -private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node) = Reaction( - replyId = replyId, - user = getNode(userId).user, - emoji = emoji, - timestamp = timestamp, - snr = snr, - rssi = rssi, - hopsAway = hopsAway, - packetId = packetId, - status = status, - routingError = routingError, - retryCount = retryCount, - relays = relays, - relayNode = relayNode, - to = to, - channel = channel, -) +private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node): Reaction { + val node = getNode(userId) + return Reaction( + replyId = replyId, + user = node.user, + emoji = emoji, + timestamp = timestamp, + snr = snr, + rssi = rssi, + hopsAway = hopsAway, + packetId = packetId, + status = status, + routingError = routingError, + retryCount = retryCount, + relays = relays, + relayNode = relayNode, + to = to, + channel = channel, + sfppHash = sfpp_hash, + ) +} private suspend fun List.toReaction(getNode: suspend (userId: String?) -> Node) = this.map { it.toReaction(getNode) } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt index 06faca24a..02b71055c 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Message.kt @@ -25,6 +25,8 @@ import org.meshtastic.core.strings.error import org.meshtastic.core.strings.message_delivery_status import org.meshtastic.core.strings.message_status_enroute import org.meshtastic.core.strings.message_status_queued +import org.meshtastic.core.strings.message_status_sfpp_confirmed +import org.meshtastic.core.strings.message_status_sfpp_routing import org.meshtastic.core.strings.routing_error_admin_bad_session_key import org.meshtastic.core.strings.routing_error_admin_public_key_unauthorized import org.meshtastic.core.strings.routing_error_bad_request @@ -96,6 +98,8 @@ data class Message( MessageStatus.RECEIVED -> Res.string.delivery_confirmed MessageStatus.QUEUED -> Res.string.message_status_queued MessageStatus.ENROUTE -> Res.string.message_status_enroute + MessageStatus.SFPP_ROUTING -> Res.string.message_status_sfpp_routing + MessageStatus.SFPP_CONFIRMED -> Res.string.message_status_sfpp_confirmed else -> getStringResFrom(routingError) } return title to text diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt index f795eef01..d0f7edfaa 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt @@ -39,6 +39,8 @@ enum class MessageStatus : Parcelable { QUEUED, // Waiting to send to the mesh as soon as we connect to the device ENROUTE, // Delivered to the radio, but no ACK or NAK received DELIVERED, // We received an ack + SFPP_ROUTING, // Message is being routed/buffered in the SFPP system + SFPP_CONFIRMED, // Message is confirmed on the SFPP chain ERROR, // We received back a nak, message not delivered } @@ -65,6 +67,7 @@ data class DataPacket( var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path var retryCount: Int = 0, // Number of automatic retry attempts var emoji: Int = 0, + var sfppHash: ByteArray? = null, ) : Parcelable { /** If there was an error with this message, this string describes what was wrong. */ @@ -142,6 +145,7 @@ data class DataPacket( parcel.readInt() == 1, // viaMqtt parcel.readInt(), // retryCount parcel.readInt(), // emoji + parcel.createByteArray(), // sfppHash ) @Suppress("CyclomaticComplexMethod") @@ -168,6 +172,7 @@ data class DataPacket( if (relayNode != other.relayNode) return false if (retryCount != other.retryCount) return false if (emoji != other.emoji) return false + if (!sfppHash.contentEquals(other.sfppHash)) return false return true } @@ -190,6 +195,7 @@ data class DataPacket( result = 31 * result + relayNode.hashCode() result = 31 * result + retryCount result = 31 * result + emoji + result = 31 * result + (sfppHash?.contentHashCode() ?: 0) return result } @@ -213,6 +219,7 @@ data class DataPacket( parcel.writeInt(if (viaMqtt) 1 else 0) parcel.writeInt(retryCount) parcel.writeInt(emoji) + parcel.writeByteArray(sfppHash) } override fun describeContents(): Int = 0 @@ -238,6 +245,7 @@ data class DataPacket( viaMqtt = parcel.readInt() == 1 retryCount = parcel.readInt() emoji = parcel.readInt() + sfppHash = parcel.createByteArray() } companion object CREATOR : Parcelable.Creator { diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/SfppHasher.kt new file mode 100644 index 000000000..d36b711d2 --- /dev/null +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/SfppHasher.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025-2026 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.model.util + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest + +object SfppHasher { + private const val HASH_SIZE = 16 + private const val INT_BYTES = 4 + + fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray { + val digest = MessageDigest.getInstance("SHA-256") + digest.update(encryptedPayload) + digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array()) + digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array()) + digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array()) + return digest.digest().copyOf(HASH_SIZE) + } +} diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt new file mode 100644 index 000000000..af57a6e76 --- /dev/null +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/DataPacketTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 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.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class DataPacketTest { + @Test + fun `DataPacket sfppHash is nullable and correctly set`() { + val hash = byteArrayOf(1, 2, 3, 4) + val packet = DataPacket(to = "to", channel = 0, text = "hello").copy(sfppHash = hash) + assertArrayEquals(hash, packet.sfppHash) + + val packetNoHash = DataPacket(to = "to", channel = 0, text = "hello") + assertEquals(null, packetNoHash.sfppHash) + } + + @Test + fun `MessageStatus SFPP_CONFIRMED exists`() { + val status = MessageStatus.SFPP_CONFIRMED + assertEquals("SFPP_CONFIRMED", status.name) + } + + @Test + fun `DataPacket serialization preserves sfppHash`() { + val hash = byteArrayOf(5, 6, 7, 8) + val packet = + DataPacket(to = "to", channel = 0, text = "test") + .copy(sfppHash = hash, status = MessageStatus.SFPP_CONFIRMED) + + val json = Json { isLenient = true } + val encoded = json.encodeToString(DataPacket.serializer(), packet) + val decoded = json.decodeFromString(DataPacket.serializer(), encoded) + + assertEquals(packet.status, decoded.status) + assertArrayEquals(hash, decoded.sfppHash) + } + + @Test + fun `DataPacket equals and hashCode include sfppHash`() { + val hash1 = byteArrayOf(1, 2, 3) + val hash2 = byteArrayOf(4, 5, 6) + val p1 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = hash1) + val p2 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = hash1) + val p3 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = hash2) + val p4 = DataPacket(to = "to", channel = 0, text = "text").copy(sfppHash = null) + + assertEquals(p1, p2) + assertEquals(p1.hashCode(), p2.hashCode()) + + assertNotEquals(p1, p3) + assertNotEquals(p1, p4) + assertNotEquals(p1.hashCode(), p3.hashCode()) + } +} diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt new file mode 100644 index 000000000..218955a2f --- /dev/null +++ b/core/model/src/test/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025-2026 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.model.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class SfppHasherTest { + + @Test + fun `computeMessageHash produces consistent results`() { + val payload = "Hello World".toByteArray() + val to = 1234 + val from = 5678 + val id = 999 + + val hash1 = SfppHasher.computeMessageHash(payload, to, from, id) + val hash2 = SfppHasher.computeMessageHash(payload, to, from, id) + + assertArrayEquals(hash1, hash2) + assertEquals(16, hash1.size) + } + + @Test + fun `computeMessageHash produces different results for different inputs`() { + val payload = "Hello World".toByteArray() + val to = 1234 + val from = 5678 + val id = 999 + + val hashBase = SfppHasher.computeMessageHash(payload, to, from, id) + + // Different payload + val hashDiffPayload = SfppHasher.computeMessageHash("Hello Work".toByteArray(), to, from, id) + assertNotEquals(hashBase.toList(), hashDiffPayload.toList()) + + // Different to + val hashDiffTo = SfppHasher.computeMessageHash(payload, 1235, from, id) + assertNotEquals(hashBase.toList(), hashDiffTo.toList()) + + // Different from + val hashDiffFrom = SfppHasher.computeMessageHash(payload, to, 5679, id) + assertNotEquals(hashBase.toList(), hashDiffFrom.toList()) + + // Different id + val hashDiffId = SfppHasher.computeMessageHash(payload, to, from, 1000) + assertNotEquals(hashBase.toList(), hashDiffId.toList()) + } + + @Test + fun `computeMessageHash handles large values`() { + val payload = byteArrayOf(1, 2, 3) + // Testing that large unsigned-like values don't cause issues + val to = -1 // 0xFFFFFFFF + val from = 0x7FFFFFFF + val id = Int.MIN_VALUE + + val hash = SfppHasher.computeMessageHash(payload, to, from, id) + assertEquals(16, hash.size) + } + + @Test + fun `computeMessageHash follows little endian for integers`() { + // This test ensures that the hash is computed consistently with the firmware + // which uses little-endian byte order for these fields. + val payload = byteArrayOf() + val to = 0x01020304 + val from = 0x05060708 + val id = 0x090A0B0C + + val hash = SfppHasher.computeMessageHash(payload, to, from, id) + assertNotNull(hash) + assertEquals(16, hash.size) + } + + private fun assertNotNull(any: Any?) { + if (any == null) throw AssertionError("Should not be null") + } +} diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 53d6760a1..a34551b04 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -53,6 +53,8 @@ Unrecognized Waiting to be acknowledged Queued for sending + Routing via SF++ chain… + Confirmed on SF++ chain Retries: %1$d / %2$d Acknowledged No route diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 750fa17cd..1382cc560 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.messaging import android.os.RemoteException @@ -172,7 +171,10 @@ constructor( } } } - val p = DataPacket(dest, channel ?: 0, str, replyId) + val p = + DataPacket(dest, channel ?: 0, str, replyId).apply { + from = ourNodeInfo.value?.user?.id ?: DataPacket.ID_LOCAL + } sendDataPacket(p) } diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt index a6d3edb3a..743f18dc5 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageActions.kt @@ -23,11 +23,13 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material.icons.twotone.AddLink import androidx.compose.material.icons.twotone.Cloud import androidx.compose.material.icons.twotone.CloudDone import androidx.compose.material.icons.twotone.CloudOff import androidx.compose.material.icons.twotone.CloudUpload import androidx.compose.material.icons.twotone.HowToReg +import androidx.compose.material.icons.twotone.Link import androidx.compose.material.icons.twotone.Warning import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -82,6 +84,8 @@ internal fun MessageStatusButton(onStatusClick: () -> Unit = {}, status: Message MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone + MessageStatus.SFPP_ROUTING -> Icons.TwoTone.AddLink + MessageStatus.SFPP_CONFIRMED -> Icons.TwoTone.Link MessageStatus.ENROUTE -> Icons.TwoTone.Cloud MessageStatus.ERROR -> Icons.TwoTone.CloudOff else -> Icons.TwoTone.Warning diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt index 85d9a73c1..58554b6c0 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/MessageItem.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.messaging.component import androidx.compose.foundation.background @@ -204,6 +203,7 @@ internal fun MessageItem( ReactionRow( modifier = Modifier.fillMaxWidth(), reactions = emojis, + myId = ourNode.user.id, onSendReaction = sendReaction, onShowReactions = onShowReactions, ) diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index 7c76ce0f7..3bc2bdbcc 100644 --- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -114,6 +114,7 @@ private fun ReactionItem( internal fun ReactionRow( modifier: Modifier = Modifier, reactions: List = emptyList(), + myId: String? = null, onSendReaction: (String) -> Unit = {}, onShowReactions: () -> Unit = {}, ) { @@ -126,7 +127,7 @@ internal fun ReactionRow( verticalAlignment = Alignment.CenterVertically, ) { items(emojiGroups.entries.toList()) { (emoji, reactions) -> - val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL } + val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } ReactionItem( emoji = emoji, emojiCount = reactions.size, @@ -187,7 +188,7 @@ internal fun ReactionDialog( LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { items(groupedEmojis.entries.toList()) { (emoji, reactions) -> - val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL } + val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } val isSending = localReaction?.status == MessageStatus.QUEUED || localReaction?.status == MessageStatus.ENROUTE Text(