mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 16:55:02 -04:00
Refactor map layer management and navigation infrastructure (#4921)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user