Refactor map layer management and navigation infrastructure (#4921)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-25 19:29:24 -05:00
committed by GitHub
parent b608a04ca4
commit a005231d94
142 changed files with 5408 additions and 3090 deletions

View File

@@ -1,505 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room3.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.User
@RunWith(AndroidJUnit4::class)
class NodeInfoDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var nodeInfoDao: NodeInfoDao
private val onlineThreshold = onlineTimeThreshold()
private val offlineNodeLastHeard = onlineThreshold - 30
private val onlineNodeLastHeard = onlineThreshold + 20
private val unknownNode =
NodeEntity(
num = 7,
user =
User(
id = "!a1b2c3d4",
long_name = "Meshtastic c3d4",
short_name = "c3d4",
hw_model = HardwareModel.UNSET,
),
longName = "Meshtastic c3d4",
shortName = null, // Dao filter for includeUnknown
)
private val ourNode =
NodeEntity(
num = 8,
user =
User(
id = "+16508765308".format(8),
long_name = "Kevin Mester",
short_name = "KLO",
hw_model = HardwareModel.ANDROID_SIM,
is_licensed = false,
),
longName = "Kevin Mester",
shortName = "KLO",
latitude = 30.267153,
longitude = -97.743057, // Austin
hopsAway = 0,
)
private val onlineNode =
NodeEntity(
num = 9,
user =
User(
id = "!25060801",
long_name = "Meshtastic 0801",
short_name = "0801",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0801",
shortName = "0801",
hopsAway = 0,
lastHeard = onlineNodeLastHeard,
)
private val offlineNode =
NodeEntity(
num = 10,
user =
User(
id = "!25060802",
long_name = "Meshtastic 0802",
short_name = "0802",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0802",
shortName = "0802",
hopsAway = 0,
lastHeard = offlineNodeLastHeard,
)
private val directNode =
NodeEntity(
num = 11,
user =
User(
id = "!25060803",
long_name = "Meshtastic 0803",
short_name = "0803",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0803",
shortName = "0803",
hopsAway = 0,
lastHeard = onlineNodeLastHeard,
)
private val relayedNode =
NodeEntity(
num = 12,
user =
User(
id = "!25060804",
long_name = "Meshtastic 0804",
short_name = "0804",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0804",
shortName = "0804",
hopsAway = 3,
lastHeard = onlineNodeLastHeard,
)
private val myNodeInfo: MyNodeEntity =
MyNodeEntity(
myNodeNum = ourNode.num,
model = null,
firmwareVersion = null,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 5 * 60 * 1000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
)
private val testPositions =
arrayOf(
0.0 to 0.0,
32.776665 to -96.796989, // Dallas
32.960758 to -96.733521, // Richardson
32.912901 to -96.781776, // North Dallas
29.760427 to -95.369804, // Houston
33.748997 to -84.387985, // Atlanta
34.052235 to -118.243683, // Los Angeles
40.712776 to -74.005974, // New York City
41.878113 to -87.629799, // Chicago
39.952583 to -75.165222, // Philadelphia
)
private val testNodes =
listOf(ourNode, unknownNode, onlineNode, offlineNode, directNode, relayedNode) +
testPositions.mapIndexed { index, pos ->
NodeEntity(
num = 1000 + index,
user =
User(
id = "+165087653%02d".format(9 + index),
long_name = "Kevin Mester$index",
short_name = "KM$index",
hw_model = HardwareModel.ANDROID_SIM,
is_licensed = false,
public_key = ByteArray(32) { index.toByte() }.toByteString(),
),
longName = "Kevin Mester$index",
shortName = "KM$index",
latitude = pos.first,
longitude = pos.second,
lastHeard = 9 + index,
)
}
@Before
fun createDb(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
context = context,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.build()
nodeInfoDao = database.nodeInfoDao()
nodeInfoDao.apply {
putAll(testNodes)
setMyNodeInfo(myNodeInfo)
}
}
@After
fun closeDb() {
database.close()
}
/**
* Retrieves a list of nodes based on [sort], [filter] and [includeUnknown] parameters. The list excludes [ourNode]
* to ensure consistency in the results.
*/
private suspend fun getNodes(
sort: NodeSortOption = NodeSortOption.LAST_HEARD,
filter: String = "",
includeUnknown: Boolean = true,
onlyOnline: Boolean = false,
onlyDirect: Boolean = false,
) = nodeInfoDao
.getNodes(
sort = sort.sqlValue,
filter = filter,
includeUnknown = includeUnknown,
hopsAwayMax = if (onlyDirect) 0 else -1,
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
)
.map { list -> list.map { it.toModel() } }
.first()
.filter { it.num != ourNode.num }
@Test // node list size
fun testNodeListSize() = runBlocking {
val nodes = nodeInfoDao.nodeDBbyNum().first()
assertEquals(6 + testPositions.size, nodes.size)
}
@Test // nodeDBbyNum() re-orders our node at the top of the list
fun testOurNodeInfoIsFirst() = runBlocking {
val nodes = nodeInfoDao.nodeDBbyNum().first()
assertEquals(ourNode.num, nodes.values.first().node.num)
}
@Test
fun testSortByLastHeard() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.LAST_HEARD)
val sortedNodes = nodes.sortedByDescending { it.lastHeard }
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByAlpha() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.ALPHABETICAL)
val sortedNodes = nodes.sortedBy { it.user.long_name.uppercase() }
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByDistance() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.DISTANCE)
fun NodeEntity.toNode() = Node(num = num, user = user, position = position)
val sortedNodes =
nodes.sortedWith( // nodes with invalid (null) positions at the end
compareBy<Node> { it.validPosition == null }.thenBy { it.distance(ourNode.toNode()) },
)
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByChannel() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.CHANNEL)
val sortedNodes = nodes.sortedBy { it.channel }
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByViaMqtt() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.VIA_MQTT)
val sortedNodes = nodes.sortedBy { it.user.long_name.contains("(MQTT)") }
assertEquals(sortedNodes, nodes)
}
@Test
fun testIncludeUnknownIsFalse() = runBlocking {
val nodes = getNodes(includeUnknown = false)
val containsUnsetNode = nodes.any { it.isUnknownUser }
assertFalse(containsUnsetNode)
}
@Test
fun testIncludeUnknownIsTrue() = runBlocking {
val nodes = getNodes(includeUnknown = true)
val containsUnsetNode = nodes.any { it.isUnknownUser }
assertTrue(containsUnsetNode)
}
@Test
fun testUnknownNodesKeepNamesNullAndRemainFiltered() = runBlocking {
val updatedUnknownNode = unknownNode.copy(longName = "Should be cleared", shortName = "SHOULD")
nodeInfoDao.upsert(updatedUnknownNode)
val storedUnknown = nodeInfoDao.getNodeByNum(updatedUnknownNode.num)!!.node
assertEquals(null, storedUnknown.longName)
assertEquals(null, storedUnknown.shortName)
val nodes = getNodes(includeUnknown = false)
assertFalse(nodes.any { it.num == updatedUnknownNode.num })
}
@Test
fun testOfflineNodesIncludedByDefault() = runBlocking {
val nodes = getNodes()
assertTrue(nodes.any { it.lastHeard < onlineTimeThreshold() })
}
@Test
fun testOnlyOnlineExcludesOffline() = runBlocking {
val nodes = getNodes(onlyOnline = true)
assertFalse(nodes.any { it.lastHeard < onlineTimeThreshold() })
}
@Test
fun testRelayedNodesIncludedByDefault() = runBlocking {
val nodes = getNodes()
assertTrue(nodes.any { it.hopsAway > 0 })
}
@Test
fun testOnlyDirectExcludesRelayed() = runBlocking {
val nodes = getNodes(onlyDirect = true)
assertFalse(nodes.any { it.hopsAway > 0 })
}
@Test
fun testPkcMismatch() = runBlocking {
val newNodeNum = 9999
// First, ensure the node is in the DB with Key A
val nodeA =
testNodes[0].copy(
num = newNodeNum,
publicKey = ByteArray(32) { 1 }.toByteString(),
user = testNodes[0].user.copy(id = "!uniqueId1", public_key = ByteArray(32) { 1 }.toByteString()),
)
nodeInfoDao.upsert(nodeA)
// Now upsert with Key B (mismatch)
val nodeB =
nodeA.copy(
publicKey = ByteArray(32) { 2 }.toByteString(),
user = nodeA.user.copy(public_key = ByteArray(32) { 2 }.toByteString()),
)
nodeInfoDao.upsert(nodeB)
val stored = nodeInfoDao.getNodeByNum(nodeA.num)!!.node
assertEquals(NodeEntity.ERROR_BYTE_STRING, stored.publicKey)
assertTrue(stored.toModel().mismatchKey)
}
@Test
fun testRoutineUpdatePreservesKey() = runBlocking {
val newNodeNum = 9998
// First, ensure the node is in the DB with Key A
val keyA = ByteArray(32) { 1 }.toByteString()
val nodeA =
testNodes[0].copy(
num = newNodeNum,
publicKey = keyA,
user = testNodes[0].user.copy(id = "!uniqueId2", public_key = keyA),
)
nodeInfoDao.upsert(nodeA)
// Now upsert with an empty key (common in position/telemetry updates)
val nodeEmpty = nodeA.copy(publicKey = null, user = nodeA.user.copy(public_key = ByteString.EMPTY))
nodeInfoDao.upsert(nodeEmpty)
val stored = nodeInfoDao.getNodeByNum(nodeA.num)!!.node
assertEquals(keyA, stored.publicKey)
assertFalse(stored.toModel().mismatchKey)
}
@Test
fun testRecoveryFromErrorState() = runBlocking {
val newNodeNum = 9997
// Start in Error state
val nodeError =
testNodes[0].copy(
num = newNodeNum,
publicKey = NodeEntity.ERROR_BYTE_STRING,
user = testNodes[0].user.copy(id = "!uniqueId3", public_key = NodeEntity.ERROR_BYTE_STRING),
)
nodeInfoDao.doUpsert(nodeError)
assertTrue(nodeInfoDao.getNodeByNum(nodeError.num)!!.toModel().mismatchKey)
// Now upsert with a valid Key C
val keyC = ByteArray(32) { 3 }.toByteString()
val nodeC = nodeError.copy(publicKey = keyC, user = nodeError.user.copy(public_key = keyC))
nodeInfoDao.upsert(nodeC)
val stored = nodeInfoDao.getNodeByNum(nodeError.num)!!.node
assertEquals(keyC, stored.publicKey)
assertFalse(stored.toModel().mismatchKey)
}
@Test
fun testLicensedUserDoesNotClearKey() = runBlocking {
val newNodeNum = 9996
// Start with a key
val keyA = ByteArray(32) { 1 }.toByteString()
val nodeA =
testNodes[0].copy(
num = newNodeNum,
publicKey = keyA,
user = testNodes[0].user.copy(id = "!uniqueId4", public_key = keyA),
)
nodeInfoDao.upsert(nodeA)
// Upsert as licensed user (without key)
val nodeLicensed =
nodeA.copy(
user = nodeA.user.copy(is_licensed = true, public_key = ByteString.EMPTY),
publicKey = ByteString.EMPTY,
)
nodeInfoDao.upsert(nodeLicensed)
val stored = nodeInfoDao.getNodeByNum(nodeA.num)!!.node
// Should NOT clear key to prevent PKC wipe attack
assertEquals(keyA, stored.publicKey)
assertFalse(stored.toModel().mismatchKey)
}
@Test
fun testValidLicensedUserNoKey() = runBlocking {
val newNodeNum = 9995
// Start with no key and licensed status
val nodeLicensed =
testNodes[0].copy(
num = newNodeNum,
publicKey = null,
user = testNodes[0].user.copy(id = "!uniqueId5", is_licensed = true, public_key = ByteString.EMPTY),
)
nodeInfoDao.upsert(nodeLicensed)
val stored = nodeInfoDao.getNodeByNum(newNodeNum)!!.node
assertEquals(ByteString.EMPTY, stored.publicKey)
assertFalse(stored.toModel().mismatchKey)
}
@Test
fun testPlaceholderUpdatePreservesIdentity() = runBlocking {
val newNodeNum = 9994
val keyA = ByteArray(32) { 5 }.toByteString()
val originalName = "Real Name"
// 1. Create a full node with key and name
val fullNode =
testNodes[0].copy(
num = newNodeNum,
longName = originalName,
publicKey = keyA,
user =
testNodes[0]
.user
.copy(
id = "!uniqueId6",
long_name = originalName,
public_key = keyA,
hw_model = HardwareModel.TLORA_V2, // Set a specific HW model
),
)
nodeInfoDao.upsert(fullNode)
// 2. Simulate receiving a placeholder packet (e.g. from a legacy node or partial info)
// HW Model UNSET, Default Name "Meshtastic XXXX"
val placeholderNode =
fullNode.copy(
user =
fullNode.user.copy(
hw_model = HardwareModel.UNSET,
long_name = "Meshtastic 1234",
public_key = ByteString.EMPTY,
),
longName = "Meshtastic 1234",
publicKey = null,
)
nodeInfoDao.upsert(placeholderNode)
// 3. Verify that the identity (Name and Key) is preserved
val stored = nodeInfoDao.getNodeByNum(newNodeNum)!!.node
assertEquals(originalName, stored.longName)
assertEquals(keyA, stored.publicKey)
// Ensure HW model is NOT overwritten by UNSET if we preserve the user
// Note: The logic in handleExistingNodeUpsertValidation copies the *existing* user back.
assertEquals(HardwareModel.TLORA_V2, stored.user.hw_model)
}
}

View File

@@ -1,501 +0,0 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room3.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
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.PortNum
@RunWith(AndroidJUnit4::class)
class PacketDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var nodeInfoDao: NodeInfoDao
private lateinit var packetDao: PacketDao
private val myNodeInfo: MyNodeEntity =
MyNodeEntity(
myNodeNum = 42424242,
model = null,
firmwareVersion = null,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 5 * 60 * 1000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
)
private val myNodeNum: Int
get() = myNodeInfo.myNodeNum
private val testContactKeys = listOf("0${DataPacket.ID_BROADCAST}", "1!test1234")
private fun generateTestPackets(myNodeNum: Int) = testContactKeys.flatMap { contactKey ->
List(SAMPLE_SIZE) {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"),
)
}
}
@Before
fun createDb(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
context = context,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.build()
nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) }
packetDao =
database.packetDao().apply {
generateTestPackets(42424243).forEach { insert(it) }
generateTestPackets(myNodeNum).forEach { insert(it) }
}
}
@After
fun closeDb() {
database.close()
}
@Test
fun test_myNodeNum() = runBlocking {
val myNodeInfo = nodeInfoDao.getMyNodeInfo().first()
assertEquals(myNodeNum, myNodeInfo?.myNodeNum)
}
@Test
fun test_getAllPackets() = runBlocking {
val packets = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first()
assertEquals(testContactKeys.size * SAMPLE_SIZE, packets.size)
val onlyMyNodeNum = packets.all { it.myNodeNum == myNodeNum }
assertTrue(onlyMyNodeNum)
}
@Test
fun test_getContactKeys() = runBlocking {
val contactKeys = packetDao.getContactKeys().first()
assertEquals(testContactKeys.size, contactKeys.size)
val onlyMyNodeNum = contactKeys.values.all { it.myNodeNum == myNodeNum }
assertTrue(onlyMyNodeNum)
}
@Test
fun test_getMessageCount() = runBlocking {
testContactKeys.forEach { contactKey ->
val messageCount = packetDao.getMessageCount(contactKey)
assertEquals(SAMPLE_SIZE, messageCount)
}
}
@Test
fun test_getMessagesFrom() = runBlocking {
testContactKeys.forEach { contactKey ->
val messages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(SAMPLE_SIZE, messages.size)
val onlyFromContactKey = messages.all { it.packet.contact_key == contactKey }
assertTrue(onlyFromContactKey)
val onlyMyNodeNum = messages.all { it.packet.myNodeNum == myNodeNum }
assertTrue(onlyMyNodeNum)
}
}
@Test
fun test_getUnreadCount() = runBlocking {
testContactKeys.forEach { contactKey ->
val unreadCount = packetDao.getUnreadCount(contactKey)
assertEquals(SAMPLE_SIZE, unreadCount)
}
}
@Test
fun test_getUnreadCount_excludesFiltered() = runBlocking {
val filteredContactKey = "0!filteredonly"
val filteredPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = 1,
contact_key = filteredContactKey,
received_time = nowMillis,
read = false,
filtered = true,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered message"),
)
packetDao.insert(filteredPacket)
val unreadCount = packetDao.getUnreadCount(filteredContactKey)
assertEquals(0, unreadCount)
}
@Test
fun test_clearUnreadCount() = runBlocking {
val timestamp = nowMillis
testContactKeys.forEach { contactKey ->
packetDao.clearUnreadCount(contactKey, timestamp)
val unreadCount = packetDao.getUnreadCount(contactKey)
assertEquals(0, unreadCount)
}
}
@Test
fun test_deleteContacts() = runBlocking {
packetDao.deleteContacts(testContactKeys)
testContactKeys.forEach { contactKey ->
val messages = packetDao.getMessagesFrom(contactKey).first()
assertTrue(messages.isEmpty())
}
}
@Test
fun test_findPacketsWithId() = runBlocking {
val packetId = 12345
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
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 hashByteString = hash.toByteString()
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hashByteString,
)
packetDao.insert(packet)
val retrieved =
packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first().find { it.sfpp_hash == hashByteString }
assertNotNull(retrieved)
assertEquals(hashByteString, retrieved?.sfpp_hash)
}
@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 hashByteString = hash.toByteString()
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hashByteString,
)
packetDao.insert(packet)
// Exact match
val found = packetDao.findPacketBySfppHash(hashByteString)
assertNotNull(found)
assertEquals(hashByteString, found?.sfpp_hash)
// Substring match (first 8 bytes)
val shortHash = hash.copyOf(8).toByteString()
val foundShort = packetDao.findPacketBySfppHash(shortHash)
assertNotNull(foundShort)
assertEquals(hashByteString, foundShort?.sfpp_hash)
// No match
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString()
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 hashByteString = hash.toByteString()
val reaction =
ReactionEntity(
myNodeNum = myNodeNum,
replyId = 123,
userId = "sender",
emoji = "👍",
timestamp = nowMillis,
sfpp_hash = hashByteString,
)
packetDao.insert(reaction)
val found = packetDao.findReactionBySfppHash(hashByteString)
assertNotNull(found)
assertEquals(hashByteString, found?.sfpp_hash)
val shortHash = hash.copyOf(8).toByteString()
val foundShort = packetDao.findReactionBySfppHash(shortHash)
assertNotNull(foundShort)
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString()
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 = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
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).toByteString()
val fromId = DataPacket.nodeNumToDefaultId(fromNum)
val toId = DataPacket.nodeNumToDefaultId(toNum)
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
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)
assertEquals(hash, updated.data.sfppHash)
assertEquals(hash, updated.sfpp_hash)
}
@Test
fun test_filteredMessages_excludedFromContactKeys(): Unit = runBlocking {
// Create a new contact with only filtered messages
val filteredContactKey = "0!filteredonly"
val filteredPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = filteredContactKey,
received_time = nowMillis,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered message"),
filtered = true,
)
packetDao.insert(filteredPacket)
// getContactKeys should not include contacts with only filtered messages
val contactKeys = packetDao.getContactKeys().first()
assertFalse(contactKeys.containsKey(filteredContactKey))
}
@Test
fun test_getFilteredCount_returnsCorrectCount(): Unit = runBlocking {
val contactKey = "0${DataPacket.ID_BROADCAST}"
// Insert filtered messages
repeat(3) { i ->
val filteredPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis + i,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered $i"),
filtered = true,
)
packetDao.insert(filteredPacket)
}
val filteredCount = packetDao.getFilteredCount(contactKey)
assertEquals(3, filteredCount)
}
@Test
fun test_contactFilteringDisabled_persistence(): Unit = runBlocking {
val contactKey = "0!testcontact"
// Initially should be null or false
val initial = packetDao.getContactFilteringDisabled(contactKey)
assertTrue(initial == null || initial == false)
// Set filtering disabled
packetDao.setContactFilteringDisabled(contactKey, true)
val disabled = packetDao.getContactFilteringDisabled(contactKey)
assertEquals(true, disabled)
// Re-enable filtering
packetDao.setContactFilteringDisabled(contactKey, false)
val enabled = packetDao.getContactFilteringDisabled(contactKey)
assertEquals(false, enabled)
}
@Test
fun test_getMessagesFrom_excludesFilteredMessages(): Unit = runBlocking {
val contactKey = "0!notificationtest"
// Insert mix of filtered and non-filtered messages
val normalMessages = listOf("Hello", "How are you?", "Good morning")
val filteredMessages = listOf("Filtered message 1", "Filtered message 2")
normalMessages.forEachIndexed { index, text ->
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis + index,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, text),
filtered = false,
)
packetDao.insert(packet)
}
filteredMessages.forEachIndexed { index, text ->
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis + normalMessages.size + index,
read = true, // Filtered messages are marked as read
data = DataPacket(DataPacket.ID_BROADCAST, 0, text),
filtered = true,
)
packetDao.insert(packet)
}
// Without filter - should return all messages
val allMessages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(normalMessages.size + filteredMessages.size, allMessages.size)
// With includeFiltered = true - should return all messages
val includingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = true).first()
assertEquals(normalMessages.size + filteredMessages.size, includingFiltered.size)
// With includeFiltered = false - should only return non-filtered messages
val excludingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = false).first()
assertEquals(normalMessages.size, excludingFiltered.size)
// Verify none of the returned messages are filtered
val hasFilteredMessages = excludingFiltered.any { it.packet.filtered }
assertFalse(hasFilteredMessages)
}
companion object {
private const val SAMPLE_SIZE = 10
}
}