Modularize database classes (#3192)

This commit is contained in:
Phil Oliver 2025-09-24 16:23:05 -04:00 committed by GitHub
parent 989a6bc820
commit 613714cdb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 384 additions and 431 deletions

View file

@ -1,61 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.geeksville.mesh.database.MeshtasticDatabase
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class MeshtasticDatabaseTest {
companion object {
private const val TEST_DB = "migration-test"
}
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
MeshtasticDatabase::class.java,
)
@Test
@Throws(IOException::class)
fun migrateAll() {
// Create earliest version of the database.
helper.createDatabase(TEST_DB, 3).apply {
close()
}
// Open latest version of the database. Room validates the schema
// once all migrations execute.
Room.databaseBuilder(
InstrumentationRegistry.getInstrumentation().targetContext,
MeshtasticDatabase::class.java,
TEST_DB
).build().apply {
openHelper.writableDatabase.close()
}
}
}

View file

@ -1,333 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.geeksville.mesh.database.MeshtasticDatabase
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.NodeSortOption
import com.google.protobuf.ByteString
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
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.model.util.onlineTimeThreshold
@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"
longName = "Meshtastic c3d4"
shortName = "c3d4"
hwModel = MeshProtos.HardwareModel.UNSET
},
longName = "Meshtastic c3d4",
shortName = null, // Dao filter for includeUnknown
)
private val ourNode =
NodeEntity(
num = 8,
user =
user {
id = "+16508765308".format(8)
longName = "Kevin Mester"
shortName = "KLO"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
isLicensed = 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"
longName = "Meshtastic 0801"
shortName = "0801"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
longName = "Meshtastic 0801",
shortName = "0801",
hopsAway = 0,
lastHeard = onlineNodeLastHeard,
)
private val offlineNode =
NodeEntity(
num = 10,
user =
user {
id = "!25060802"
longName = "Meshtastic 0802"
shortName = "0802"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
longName = "Meshtastic 0802",
shortName = "0802",
hopsAway = 0,
lastHeard = offlineNodeLastHeard,
)
private val directNode =
NodeEntity(
num = 11,
user =
user {
id = "!25060803"
longName = "Meshtastic 0803"
shortName = "0803"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
},
longName = "Meshtastic 0803",
shortName = "0803",
hopsAway = 0,
lastHeard = onlineNodeLastHeard,
)
private val relayedNode =
NodeEntity(
num = 12,
user =
user {
id = "!25060804"
longName = "Meshtastic 0804"
shortName = "0804"
hwModel = MeshProtos.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)
longName = "Kevin Mester$index"
shortName = "KM$index"
hwModel = MeshProtos.HardwareModel.ANDROID_SIM
isLicensed = false
publicKey = ByteString.copyFrom(ByteArray(32) { index.toByte() })
},
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(context, MeshtasticDatabase::class.java).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.longName.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.longName.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 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 newNode =
testNodes[1].copy(user = testNodes[1].user.copy { publicKey = ByteString.copyFrom(ByteArray(32) { 99 }) })
nodeInfoDao.putAll(listOf(newNode))
val nodes = getNodes()
val containsMismatchNode = nodes.any { it.mismatchKey }
assertTrue(containsMismatchNode)
}
}

View file

@ -1,173 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.geeksville.mesh.database.MeshtasticDatabase
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.Packet
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.DataPacket
@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 = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
contact_key = contactKey,
received_time = System.currentTimeMillis(),
read = false,
DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"),
)
}
}
@Before
fun createDb(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
database = Room.inMemoryDatabaseBuilder(context, MeshtasticDatabase::class.java).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(Portnums.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_clearUnreadCount() = runBlocking {
val timestamp = System.currentTimeMillis()
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())
}
}
companion object {
private const val SAMPLE_SIZE = 10
}
}

View file

@ -21,12 +21,12 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
import com.geeksville.mesh.ui.message.components.MessageItem
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.MessageStatus
@RunWith(AndroidJUnit4::class)