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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 5408 additions and 3090 deletions

View file

@ -40,6 +40,9 @@ kotlin {
implementation(libs.kermit)
}
androidMain.dependencies { api(libs.androidx.core.ktx) }
val androidHostTest by getting { dependencies { implementation(libs.robolectric) } }
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
}
}

View file

@ -0,0 +1,49 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class CommonUriTest {
@Test
fun testParse() {
val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1&param2=true#fragment")
assertEquals("meshtastic.org", uri.host)
assertEquals("fragment", uri.fragment)
assertEquals(listOf("path", "to", "page"), uri.pathSegments)
assertEquals("value1", uri.getQueryParameter("param1"))
assertTrue(uri.getBooleanQueryParameter("param2", false))
}
@Test
fun testBooleanParameters() {
val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
assertTrue(uri.getBooleanQueryParameter("t1", false))
assertTrue(uri.getBooleanQueryParameter("t2", false))
assertTrue(uri.getBooleanQueryParameter("t3", false))
assertTrue(!uri.getBooleanQueryParameter("f1", true))
assertTrue(!uri.getBooleanQueryParameter("f2", true))
}
}

View file

@ -0,0 +1,42 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common
import kotlin.test.Test
import kotlin.test.assertEquals
class ByteUtilsTest {
@Test
fun testByteArrayOfInts() {
val bytes = byteArrayOfInts(0x01, 0xFF, 0x80)
assertEquals(3, bytes.size)
assertEquals(1, bytes[0])
assertEquals(-1, bytes[1]) // 0xFF as signed byte
assertEquals(-128, bytes[2].toInt()) // 0x80 as signed byte
}
@Test
fun testXorHash() {
val data = byteArrayOfInts(0x01, 0x02, 0x03)
assertEquals(0 xor 1 xor 2 xor 3, xorHash(data))
val data2 = byteArrayOfInts(0xFF, 0xFF)
assertEquals(0xFF xor 0xFF, xorHash(data2))
assertEquals(0, xorHash(data2))
}
}

View file

@ -0,0 +1,53 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class LocationUtilsTest {
@Test
fun testGpsFormat() {
val formatted = GPSFormat.toDec(45.123456, -93.654321)
assertEquals("45.12345, -93.65432", formatted)
}
@Test
fun testLatLongToMeter() {
// Distance from (0,0) to (0,1) at equator should be approx 111.3km
val distance = latLongToMeter(0.0, 0.0, 0.0, 1.0)
assertTrue(distance > 111000 && distance < 112000, "Distance was $distance")
// Distance from (45, -93) to (45, -92)
val distance2 = latLongToMeter(45.0, -93.0, 45.0, -92.0)
assertTrue(distance2 > 78000 && distance2 < 79000, "Distance was $distance2")
}
@Test
fun testBearing() {
// North
assertEquals(0.0, bearing(0.0, 0.0, 1.0, 0.0), 0.1)
// East
assertEquals(90.0, bearing(0.0, 0.0, 0.0, 1.0), 0.1)
// South
assertEquals(180.0, bearing(0.0, 0.0, -1.0, 0.0), 0.1)
// West
assertEquals(270.0, bearing(0.0, 0.0, 0.0, -1.0), 0.1)
}
}

View file

@ -0,0 +1,38 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
class NumberFormatterTest {
@Test
fun testFormat() {
assertEquals("1.23", NumberFormatter.format(1.23456, 2))
assertEquals("1.235", NumberFormatter.format(1.23456, 3))
assertEquals("1.00", NumberFormatter.format(1.0, 2))
assertEquals("0.00", NumberFormatter.format(0.0, 2))
assertEquals("-1.23", NumberFormatter.format(-1.23456, 2))
}
@Test
fun testFormatZeroDecimalPlaces() {
assertEquals("1", NumberFormatter.format(1.23, 0))
assertEquals("-1", NumberFormatter.format(-1.23, 0))
}
}

View file

@ -0,0 +1,31 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
class UrlUtilsTest {
@Test
fun testEncode() {
assertEquals("Hello%20World", UrlUtils.encode("Hello World"))
assertEquals("abc-123._~", UrlUtils.encode("abc-123._~"))
assertEquals("%21%40%23%24%25", UrlUtils.encode("!@#$%"))
assertEquals("%C3%A1%C3%A9%C3%AD", UrlUtils.encode("áéí"))
}
}

View file

@ -32,20 +32,10 @@ actual class CommonUri(private val uri: URI) {
actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull()
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean =
when (getQueryParameter(key)?.lowercase()) {
"1",
"true",
"yes",
"on",
-> true
"0",
"false",
"no",
"off",
-> false
else -> defaultValue
}
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean {
val value = getQueryParameter(key) ?: return defaultValue
return value != "false" && value != "0"
}
actual override fun toString(): String = uri.toString()

View file

@ -0,0 +1,44 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CommonUriTest {
@Test
fun testParse() {
val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1&param2=true#fragment")
assertEquals("meshtastic.org", uri.host)
assertEquals("fragment", uri.fragment)
assertEquals(listOf("path", "to", "page"), uri.pathSegments)
assertEquals("value1", uri.getQueryParameter("param1"))
assertTrue(uri.getBooleanQueryParameter("param2", false))
}
@Test
fun testBooleanParameters() {
val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
assertTrue(uri.getBooleanQueryParameter("t1", false))
assertTrue(uri.getBooleanQueryParameter("t2", false))
assertTrue(uri.getBooleanQueryParameter("t3", false))
assertTrue(!uri.getBooleanQueryParameter("f1", true))
assertTrue(!uri.getBooleanQueryParameter("f2", true))
}
}

View file

@ -0,0 +1,33 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MeshLogRepositoryTest : CommonMeshLogRepositoryTest() {
@BeforeTest
fun setup() {
setupTestContext()
setupRepo()
}
}

View file

@ -0,0 +1,33 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NodeRepositoryTest : CommonNodeRepositoryTest() {
@BeforeTest
fun setup() {
setupTestContext()
setupRepo()
}
}

View file

@ -0,0 +1,33 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PacketRepositoryTest : CommonPacketRepositoryTest() {
@BeforeTest
fun setup() {
setupTestContext()
setupRepo()
}
}

View file

@ -29,13 +29,14 @@ import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource
import org.meshtastic.core.repository.FirmwareReleaseRepository
@Single
class FirmwareReleaseRepository(
open class FirmwareReleaseRepositoryImpl(
private val remoteDataSource: FirmwareReleaseRemoteDataSource,
private val localDataSource: FirmwareReleaseLocalDataSource,
private val jsonDataSource: FirmwareReleaseJsonDataSource,
) {
) : FirmwareReleaseRepository {
/**
* A flow that provides the latest STABLE firmware release. It follows a "cache-then-network" strategy:
@ -44,14 +45,14 @@ class FirmwareReleaseRepository(
* 3. Emits the updated version upon successful fetch. Collectors should use `.distinctUntilChanged()` to avoid
* redundant UI updates.
*/
val stableRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.STABLE)
override val stableRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.STABLE)
/**
* A flow that provides the latest ALPHA firmware release.
*
* @see stableRelease for behavior details.
*/
val alphaRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.ALPHA)
override val alphaRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.ALPHA)
private fun getLatestFirmware(
releaseType: FirmwareReleaseType,
@ -118,7 +119,7 @@ class FirmwareReleaseRepository(
}
}
suspend fun invalidateCache() {
override suspend fun invalidateCache() {
localDataSource.deleteAllFirmwareReleases()
}

View file

@ -0,0 +1,147 @@
/*
* 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.data.repository
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.testing.FakeDatabaseProvider
import org.meshtastic.core.testing.FakeMeshLogPrefs
import org.meshtastic.proto.Data
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class CommonMeshLogRepositoryTest {
protected lateinit var dbProvider: FakeDatabaseProvider
protected lateinit var meshLogPrefs: FakeMeshLogPrefs
protected lateinit var nodeInfoReadDataSource: NodeInfoReadDataSource
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
protected lateinit var repository: MeshLogRepositoryImpl
private val nowMillis = 1000000000L
fun setupRepo() {
dbProvider = FakeDatabaseProvider()
meshLogPrefs = FakeMeshLogPrefs()
meshLogPrefs.setLoggingEnabled(true)
nodeInfoReadDataSource = mock(MockMode.autofill)
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(null)
repository = MeshLogRepositoryImpl(dbProvider, dispatchers, meshLogPrefs, nodeInfoReadDataSource)
}
@AfterTest
fun tearDown() {
dbProvider.close()
}
@Test
fun `parseTelemetryLog preserves zero temperature`() = runTest(testDispatcher) {
val zeroTemp = 0.0f
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = zeroTemp))
val meshPacket =
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
uuid = "123",
message_type = "telemetry",
received_date = nowMillis,
raw_message = "",
fromNum = 0,
portNum = PortNum.TELEMETRY_APP.value,
fromRadio = FromRadio(packet = meshPacket),
)
repository.insert(meshLog)
val result = repository.getTelemetryFrom(0).first()
assertNotNull(result)
assertEquals(1, result.size)
val resultMetrics = result[0].environment_metrics
assertNotNull(resultMetrics)
assertEquals(zeroTemp, resultMetrics.temperature ?: 0f, 0.01f)
}
@Test
fun `deleteLogs redirects local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val localNodeNum = 999
val port = PortNum.TEXT_MESSAGE_APP.value
val myNodeEntity =
MyNodeEntity(
myNodeNum = localNodeNum,
model = "model",
firmwareVersion = "1.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 0,
hasWifi = false,
)
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeEntity)
val log =
MeshLog(
uuid = "123",
message_type = "TEXT",
received_date = nowMillis,
raw_message = "",
fromNum =
0, // asEntity will map it if we pass localNodeNum to asEntity, but here we set it manually
portNum = port,
fromRadio =
FromRadio(
packet = MeshPacket(from = localNodeNum, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)),
),
)
repository.insert(log)
// Verify it's there
assertEquals(1, repository.getAllLogsUnbounded().first().size)
repository.deleteLogs(localNodeNum, port)
val logs = repository.getAllLogsUnbounded().first()
assertTrue(logs.isEmpty())
}
}

View file

@ -0,0 +1,123 @@
/*
* 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.data.repository
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.testing.FakeLocalStatsDataSource
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
abstract class CommonNodeRepositoryTest {
protected lateinit var lifecycleOwner: LifecycleOwner
protected lateinit var readDataSource: NodeInfoReadDataSource
protected lateinit var writeDataSource: NodeInfoWriteDataSource
protected lateinit var localStatsDataSource: FakeLocalStatsDataSource
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val myNodeInfoFlow = MutableStateFlow<MyNodeEntity?>(null)
protected lateinit var repository: NodeRepositoryImpl
fun setupRepo() {
Dispatchers.setMain(testDispatcher)
lifecycleOwner =
object : LifecycleOwner {
override val lifecycle = LifecycleRegistry(this)
}
(lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
readDataSource = mock(MockMode.autofill)
writeDataSource = mock(MockMode.autofill)
localStatsDataSource = FakeLocalStatsDataSource()
every { readDataSource.myNodeInfoFlow() } returns myNodeInfoFlow
every { readDataSource.nodeDBbyNumFlow() } returns MutableStateFlow<Map<Int, NodeWithRelations>>(emptyMap())
repository =
NodeRepositoryImpl(
lifecycleOwner.lifecycle,
readDataSource,
writeDataSource,
dispatchers,
localStatsDataSource,
)
}
@AfterTest
fun tearDown() {
// Essential to stop background jobs in NodeRepositoryImpl
(lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
Dispatchers.resetMain()
}
private fun createMyNodeEntity(nodeNum: Int) = MyNodeEntity(
myNodeNum = nodeNum,
model = "model",
firmwareVersion = "1.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 0,
hasWifi = false,
)
@Test
fun `effectiveLogNodeId maps local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val myNodeNum = 12345
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
assertEquals(MeshLog.NODE_NUM_LOCAL, result)
}
@Test
fun `effectiveLogNodeId preserves remote node numbers`() = runTest(testDispatcher) {
val myNodeNum = 12345
val remoteNodeNum = 67890
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
assertEquals(remoteNodeNum, result)
}
}

View file

@ -0,0 +1,84 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.testing.FakeDatabaseProvider
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
abstract class CommonPacketRepositoryTest {
protected lateinit var dbProvider: FakeDatabaseProvider
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
protected lateinit var repository: PacketRepositoryImpl
fun setupRepo() {
dbProvider = FakeDatabaseProvider()
repository = PacketRepositoryImpl(dbProvider, dispatchers)
}
@AfterTest
fun tearDown() {
dbProvider.close()
}
@Test
fun `savePacket persists and retrieves waypoints`() = runTest(testDispatcher) {
val myNodeNum = 1
val contact = "contact"
// Ensure my_node is present so getMessageCount finds the packet
dbProvider.currentDb.value
.nodeInfoDao()
.setMyNodeInfo(
MyNodeEntity(
myNodeNum = myNodeNum,
model = "model",
firmwareVersion = "1.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 0,
hasWifi = false,
),
)
val packet = DataPacket(to = "0!ffffffff", bytes = okio.ByteString.EMPTY, dataType = 1, id = 123)
repository.savePacket(myNodeNum, contact, packet, 1000L)
// Verify it was saved.
val count = repository.getMessageCount(contact)
assertEquals(1, count)
}
@Test
fun `clearAllUnreadCounts works with real DB`() = runTest(testDispatcher) {
repository.clearAllUnreadCounts()
// No exception thrown
}
}

View file

@ -1,195 +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.data.repository
class MeshLogRepositoryTest {
/*
private val dbManager: DatabaseProvider = mock()
private val appDatabase: MeshtasticDatabase = mock()
private val meshLogDao: MeshLogDao = mock()
private val meshLogPrefs: MeshLogPrefs = mock()
private val nodeInfoReadDataSource: NodeInfoReadDataSource = mock()
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val repository = MeshLogRepositoryImpl(dbManager, dispatchers, meshLogPrefs, nodeInfoReadDataSource)
init {
every { dbManager.currentDb } returns MutableStateFlow(appDatabase)
every { appDatabase.meshLogDao() } returns meshLogDao
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(null)
}
@Test
fun `parseTelemetryLog preserves zero temperature`() = runTest(testDispatcher) {
val zeroTemp = 0.0f
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = zeroTemp))
val meshPacket =
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
uuid = Uuid.random().toString(),
message_type = "telemetry",
received_date = nowMillis,
raw_message = "",
fromRadio = FromRadio(packet = meshPacket),
)
// Using reflection to test private method parseTelemetryLog
val method = MeshLogRepositoryImpl::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
method.isAccessible = true
val result = method.invoke(repository, meshLog) as Telemetry?
assertNotNull(result)
val resultMetrics = result?.environment_metrics
assertNotNull(resultMetrics)
assertEquals(zeroTemp, resultMetrics?.temperature ?: 0f, 0.01f)
}
@Test
fun `parseTelemetryLog maps missing temperature to NaN`() = runTest(testDispatcher) {
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = null))
val meshPacket =
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
uuid = Uuid.random().toString(),
message_type = "telemetry",
received_date = nowMillis,
raw_message = "",
fromRadio = FromRadio(packet = meshPacket),
)
val method = MeshLogRepositoryImpl::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
method.isAccessible = true
val result = method.invoke(repository, meshLog) as Telemetry?
assertNotNull(result)
val resultMetrics = result?.environment_metrics
// Should be NaN as per repository logic for missing fields
assertEquals(Float.NaN, resultMetrics?.temperature ?: 0f, 0.01f)
}
@Test
fun `getRequestLogs filters correctly`() = runTest(testDispatcher) {
val targetNode = 123
val otherNode = 456
val port = PortNum.TRACEROUTE_APP
val logs =
listOf(
// Valid request
MeshLogEntity(
uuid = "1",
message_type = "Packet",
received_date = nowMillis,
raw_message = "",
fromNum = 0,
portNum = port.value,
fromRadio =
FromRadio(
packet =
MeshPacket(to = targetNode, decoded = Data(portnum = port, want_response = true)),
),
),
// Wrong target
MeshLogEntity(
uuid = "2",
message_type = "Packet",
received_date = nowMillis,
raw_message = "",
fromNum = 0,
portNum = port.value,
fromRadio =
FromRadio(
packet =
MeshPacket(to = otherNode, decoded = Data(portnum = port, want_response = true)),
),
),
// Not a request (want_response = false)
MeshLogEntity(
uuid = "3",
message_type = "Packet",
received_date = nowMillis,
raw_message = "",
fromNum = 0,
portNum = port.value,
fromRadio =
FromRadio(
packet =
MeshPacket(to = targetNode, decoded = Data(portnum = port, want_response = false)),
),
),
// Wrong fromNum
MeshLogEntity(
uuid = "4",
message_type = "Packet",
received_date = nowMillis,
raw_message = "",
fromNum = 789,
portNum = port.value,
fromRadio =
FromRadio(
packet =
MeshPacket(to = targetNode, decoded = Data(portnum = port, want_response = true)),
),
),
)
val result = repository.getRequestLogs(targetNode, port).first()
assertEquals(1, result.size)
assertEquals("1", result[0].uuid)
}
@Test
fun `deleteLogs redirects local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val localNodeNum = 999
val port = 100
val myNodeEntity = mock<MyNodeEntity>()
every { myNodeEntity.myNodeNum } returns localNodeNum
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeEntity)
repository.deleteLogs(localNodeNum, port)
verifySuspend { meshLogDao.deleteLogs(MeshLog.NODE_NUM_LOCAL, port) }
}
@Test
fun `deleteLogs preserves remote node numbers`() = runTest(testDispatcher) {
val localNodeNum = 999
val remoteNodeNum = 888
val port = 100
val myNodeEntity = mock<MyNodeEntity>()
every { myNodeEntity.myNodeNum } returns localNodeNum
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeEntity)
repository.deleteLogs(remoteNodeNum, port)
verifySuspend { meshLogDao.deleteLogs(remoteNodeNum, port) }
}
*/
}

View file

@ -1,119 +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.data.repository
import kotlinx.coroutines.ExperimentalCoroutinesApi
@OptIn(ExperimentalCoroutinesApi::class)
class NodeRepositoryTest {
/*
private val lifecycleScope: LifecycleCoroutineScope = mock()
private val testDispatcher = StandardTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val myNodeInfoFlow = MutableStateFlow<MyNodeEntity?>(null)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkStatic("androidx.lifecycle.LifecycleKt")
every { lifecycleScope.coroutineContext } returns testDispatcher + Job()
every { lifecycle.coroutineScope } returns lifecycleScope
every { readDataSource.myNodeInfoFlow() } returns myNodeInfoFlow
every { readDataSource.nodeDBbyNumFlow() } returns MutableStateFlow(emptyMap())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createMyNodeEntity(nodeNum: Int) = MyNodeEntity(
myNodeNum = nodeNum,
model = "model",
firmwareVersion = "1.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 0,
hasWifi = false,
)
@Test
fun `effectiveLogNodeId maps local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val myNodeNum = 12345
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val repository =
NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
assertEquals(MeshLog.NODE_NUM_LOCAL, result)
}
@Test
fun `effectiveLogNodeId preserves remote node numbers`() = runTest(testDispatcher) {
val myNodeNum = 12345
val remoteNodeNum = 67890
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val repository =
NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
assertEquals(remoteNodeNum, result)
}
@Test
fun `effectiveLogNodeId updates when local node number changes`() = runTest(testDispatcher) {
val firstNodeNum = 111
val secondNodeNum = 222
val targetNodeNum = 111
myNodeInfoFlow.value = createMyNodeEntity(firstNodeNum)
val repository =
NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
// Initially should be mapped to LOCAL because it matches
assertEquals(
MeshLog.NODE_NUM_LOCAL,
repository.effectiveLogNodeId(targetNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first(),
)
// Change local node num
myNodeInfoFlow.value = createMyNodeEntity(secondNodeNum)
testScheduler.runCurrent()
// Now it shouldn't match, so should return the original num
assertEquals(
targetNodeNum,
repository.effectiveLogNodeId(targetNodeNum).filter { it == targetNodeNum }.first(),
)
}
*/
}

View file

@ -0,0 +1,26 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import kotlin.test.BeforeTest
class MeshLogRepositoryTest : CommonMeshLogRepositoryTest() {
@BeforeTest
fun setup() {
setupRepo()
}
}

View file

@ -0,0 +1,26 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import kotlin.test.BeforeTest
class NodeRepositoryTest : CommonNodeRepositoryTest() {
@BeforeTest
fun setup() {
setupRepo()
}
}

View file

@ -0,0 +1,26 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import kotlin.test.BeforeTest
class PacketRepositoryTest : CommonPacketRepositoryTest() {
@BeforeTest
fun setup() {
setupRepo()
}
}

View file

@ -48,13 +48,16 @@ kotlin {
implementation(libs.kermit)
}
commonTest.dependencies {
implementation(projects.core.testing)
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.room.testing)
implementation(libs.turbine)
}
val androidHostTest by getting {
dependencies {
implementation(libs.androidx.sqlite.bundled)
implementation(libs.androidx.room.testing)
implementation(libs.androidx.test.core)
implementation(libs.androidx.test.ext.junit)
@ -74,6 +77,7 @@ kotlin {
dependencies {
"kspJvm"(libs.androidx.room.compiler)
"kspJvmTest"(libs.androidx.room.compiler)
"kspAndroidHostTest"(libs.androidx.room.compiler)
"kspAndroidDeviceTest"(libs.androidx.room.compiler)
}

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
}
}

View file

@ -0,0 +1,34 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.test.runTest
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NodeInfoDaoTest : CommonNodeInfoDaoTest() {
@BeforeTest
fun setup() = runTest {
setupTestContext()
createDb()
}
}

View file

@ -0,0 +1,34 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.test.runTest
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PacketDaoTest : CommonPacketDaoTest() {
@BeforeTest
fun setup() = runTest {
setupTestContext()
createDb()
}
}

View file

@ -40,6 +40,14 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDa
.configureCommon()
}
/** Returns a [RoomDatabase.Builder] configured for an in-memory Android database. */
actual fun getInMemoryDatabaseBuilder(): RoomDatabase.Builder<MeshtasticDatabase> =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
context = ContextServices.app.applicationContext,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.configureCommon()
/** Returns the Android directory where database files are stored. */
actual fun getDatabaseDirectory(): Path {
val app = ContextServices.app

View file

@ -25,6 +25,9 @@ import okio.Path
/** Returns a [RoomDatabase.Builder] configured for the current platform with the given [dbName]. */
expect fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDatabase>
/** Returns a [RoomDatabase.Builder] configured for an in-memory database on the current platform. */
expect fun getInMemoryDatabaseBuilder(): RoomDatabase.Builder<MeshtasticDatabase>
/** Returns the platform-specific directory where database files are stored. */
expect fun getDatabaseDirectory(): Path

View file

@ -23,7 +23,6 @@ import androidx.room3.DeleteTable
import androidx.room3.RoomDatabase
import androidx.room3.TypeConverters
import androidx.room3.migration.AutoMigrationSpec
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
@ -120,9 +119,7 @@ abstract class MeshtasticDatabase : RoomDatabase() {
companion object {
/** Configures a [RoomDatabase.Builder] with standard settings for this project. */
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(): RoomDatabase.Builder<T> =
this.fallbackToDestructiveMigration(dropAllTables = false)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(ioDispatcher)
this.fallbackToDestructiveMigration(dropAllTables = false).setQueryCoroutineContext(ioDispatcher)
}
}

View file

@ -0,0 +1,115 @@
/*
* 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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class CommonNodeInfoDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var dao: NodeInfoDao
private val myNodeInfo: MyNodeEntity =
MyNodeEntity(
myNodeNum = 42424242,
model = "TBEAM",
firmwareVersion = "2.5.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 300000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
)
suspend fun createDb() {
database = getInMemoryDatabaseBuilder().build()
dao = database.nodeInfoDao()
dao.setMyNodeInfo(myNodeInfo)
}
@AfterTest
fun closeDb() {
database.close()
}
@Test
fun testGetMyNodeInfo() = runTest {
val info = dao.getMyNodeInfo().first()
assertNotNull(info)
assertEquals(myNodeInfo.myNodeNum, info.myNodeNum)
}
@Test
fun testUpsertNode() = runTest {
val node =
NodeEntity(
num = 1234,
user = User(long_name = "Test Node", id = "!test", hw_model = org.meshtastic.proto.HardwareModel.TBEAM),
lastHeard = (nowMillis / 1000).toInt(),
)
dao.upsert(node)
val result = dao.getNodeByNum(1234)
assertNotNull(result)
assertEquals("Test Node", result.node.longName)
}
@Test
fun testNodeDBbyNum() = runTest {
val node1 = NodeEntity(num = 1, user = User(id = "!1"))
val node2 = NodeEntity(num = 2, user = User(id = "!2"))
dao.putAll(listOf(node1, node2))
val nodes = dao.nodeDBbyNum().first()
assertEquals(2, nodes.size)
assertTrue(nodes.containsKey(1))
assertTrue(nodes.containsKey(2))
}
@Test
fun testDeleteNode() = runTest {
val node = NodeEntity(num = 1, user = User(id = "!1"))
dao.upsert(node)
dao.deleteNode(1)
val result = dao.getNodeByNum(1)
assertEquals(null, result)
}
@Test
fun testClearNodeInfo() = runTest {
val node1 = NodeEntity(num = 1, user = User(id = "!1"), isFavorite = true)
val node2 = NodeEntity(num = 2, user = User(id = "!2"), isFavorite = false)
dao.putAll(listOf(node1, node2))
dao.clearNodeInfo(preserveFavorites = true)
val nodes = dao.nodeDBbyNum().first()
assertEquals(1, nodes.size)
assertTrue(nodes.containsKey(1))
}
}

View file

@ -0,0 +1,277 @@
/*
* 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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
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.database.getInMemoryDatabaseBuilder
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.PortNum
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class CommonPacketDaoTest {
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 + it,
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Message $it!".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
)
}
}
suspend fun createDb() {
database = getInMemoryDatabaseBuilder().build()
nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) }
packetDao =
database.packetDao().apply {
generateTestPackets(42424243).forEach { insert(it) }
generateTestPackets(myNodeNum).forEach { insert(it) }
}
}
@AfterTest
fun closeDb() {
database.close()
}
@Test
fun testGetMessagesFrom() = runTest {
val contactKey = testContactKeys.first()
val messages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(SAMPLE_SIZE, messages.size)
assertTrue(messages.all { it.packet.myNodeNum == myNodeNum })
assertTrue(messages.all { it.packet.contact_key == contactKey })
}
@Test
fun testGetMessageCount() = runTest {
val contactKey = testContactKeys.first()
assertEquals(SAMPLE_SIZE, packetDao.getMessageCount(contactKey))
}
@Test
fun testGetUnreadCount() = runTest {
val contactKey = testContactKeys.first()
assertEquals(SAMPLE_SIZE, packetDao.getUnreadCount(contactKey))
}
@Test
fun testClearUnreadCount() = runTest {
val contactKey = testContactKeys.first()
packetDao.clearUnreadCount(contactKey, nowMillis + SAMPLE_SIZE)
assertEquals(0, packetDao.getUnreadCount(contactKey))
}
@Test
fun testClearAllUnreadCounts() = runTest {
packetDao.clearAllUnreadCounts()
testContactKeys.forEach { assertEquals(0, packetDao.getUnreadCount(it)) }
}
@Test
fun testUpdateMessageStatus() = runTest {
val contactKey = testContactKeys.first()
val messages = packetDao.getMessagesFrom(contactKey).first()
val packet = messages.first().packet.data
val originalStatus = packet.status
// Ensure packet has a valid ID for updating
val packetWithId = packet.copy(id = 999, from = "!$myNodeNum")
val updatedRoomPacket = messages.first().packet.copy(data = packetWithId, packetId = 999)
packetDao.update(updatedRoomPacket)
packetDao.updateMessageStatus(packetWithId, MessageStatus.DELIVERED)
val updatedMessages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(MessageStatus.DELIVERED, updatedMessages.first { it.packet.data.id == 999 }.packet.data.status)
}
@Test
fun testGetQueuedPackets() = runTest {
val queuedPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "queued",
received_time = nowMillis,
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Queued".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
status = MessageStatus.QUEUED,
),
)
packetDao.insert(queuedPacket)
val queued = packetDao.getQueuedPackets()
assertNotNull(queued)
assertEquals(1, queued.size)
assertEquals("Queued", queued.first().text)
}
@Test
fun testDeleteMessages() = runTest {
val contactKey = testContactKeys.first()
packetDao.deleteContacts(listOf(contactKey))
assertEquals(0, packetDao.getMessageCount(contactKey))
}
@Test
fun testGetContactKeys() = runTest {
val contacts = packetDao.getContactKeys().first()
assertEquals(testContactKeys.size, contacts.size)
testContactKeys.forEach { assertTrue(contacts.containsKey(it)) }
}
@Test
fun testGetWaypoints() = runTest {
val waypointPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.WAYPOINT_APP.value,
contact_key = "0${DataPacket.ID_BROADCAST}",
received_time = nowMillis,
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Waypoint".encodeToByteArray().toByteString(),
dataType = PortNum.WAYPOINT_APP.value,
),
)
packetDao.insert(waypointPacket)
val waypoints = packetDao.getAllWaypoints()
assertEquals(1, waypoints.size)
// Waypoints aren't text messages, so they don't resolve a string text.
}
@Test
fun testUpsertReaction() = runTest {
val reaction =
ReactionEntity(myNodeNum = myNodeNum, replyId = 123, userId = "!test", emoji = "👍", timestamp = nowMillis)
packetDao.insert(reaction)
}
@Test
fun testGetMessagesFromWithIncludeFiltered() = runTest {
val contactKey = "filter-test"
val normalMessages = listOf("Msg 1", "Msg 2")
val filteredMessages = listOf("Filtered 1")
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(
to = DataPacket.ID_BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
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,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
filtered = true,
)
packetDao.insert(packet)
}
val allMessages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(normalMessages.size + filteredMessages.size, allMessages.size)
val includingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = true).first()
assertEquals(normalMessages.size + filteredMessages.size, includingFiltered.size)
val excludingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = false).first()
assertEquals(normalMessages.size, excludingFiltered.size)
assertFalse(excludingFiltered.any { it.packet.filtered })
}
companion object {
private const val SAMPLE_SIZE = 10
}
}

View file

@ -24,6 +24,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.cinterop.ExperimentalForeignApi
import okio.BufferedSink
import okio.BufferedSource
@ -44,8 +45,15 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDa
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.configureCommon()
.setDriver(BundledSQLiteDriver())
}
/** Returns a [RoomDatabase.Builder] configured for an in-memory iOS database. */
actual fun getInMemoryDatabaseBuilder(): RoomDatabase.Builder<MeshtasticDatabase> =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(factory = { MeshtasticDatabaseConstructor.initialize() })
.configureCommon()
.setDriver(BundledSQLiteDriver())
/** Returns the iOS directory where database files are stored. */
actual fun getDatabaseDirectory(): Path = documentDirectory().toPath()

View file

@ -21,6 +21,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
@ -46,8 +47,15 @@ actual fun getDatabaseBuilder(dbName: String): RoomDatabase.Builder<MeshtasticDa
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.configureCommon()
.setDriver(BundledSQLiteDriver())
}
/** Returns a [RoomDatabase.Builder] configured for an in-memory JVM database. */
actual fun getInMemoryDatabaseBuilder(): RoomDatabase.Builder<MeshtasticDatabase> =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(factory = { MeshtasticDatabaseConstructor.initialize() })
.configureCommon()
.setDriver(BundledSQLiteDriver())
/** Returns the JVM/Desktop directory where database files are stored. */
actual fun getDatabaseDirectory(): Path = desktopDataDir().toPath()

View file

@ -0,0 +1,24 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
class NodeInfoDaoTest : CommonNodeInfoDaoTest() {
@BeforeTest fun setup() = runTest { createDb() }
}

View file

@ -0,0 +1,24 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
class PacketDaoTest : CommonPacketDaoTest() {
@BeforeTest fun setup() = runTest { createDb() }
}

View file

@ -29,7 +29,9 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
@Single
class BootloaderWarningDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) {
open class BootloaderWarningDataSource(
@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>,
) {
private object PreferencesKeys {
val DISMISSED_BOOTLOADER_ADDRESSES = stringPreferencesKey("dismissed-bootloader-addresses")
@ -51,10 +53,10 @@ class BootloaderWarningDataSource(@Named("CorePreferencesDataStore") private val
}
/** Returns true if the bootloader warning has been dismissed for the given [address]. */
suspend fun isDismissed(address: String): Boolean = dismissedAddressesFlow.first().contains(address)
open suspend fun isDismissed(address: String): Boolean = dismissedAddressesFlow.first().contains(address)
/** Marks the bootloader warning as dismissed for the given [address]. */
suspend fun dismiss(address: String) {
open suspend fun dismiss(address: String) {
val current = dismissedAddressesFlow.first()
if (current.contains(address)) return

View file

@ -25,10 +25,21 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.proto.LocalStats
/** Class that handles saving and retrieving [LocalStats] data. */
/** Interface that handles saving and retrieving [LocalStats] data. */
interface LocalStatsDataSource {
val localStatsFlow: Flow<LocalStats>
suspend fun setLocalStats(stats: LocalStats)
suspend fun clearLocalStats()
}
/** Implementation of [LocalStatsDataSource] using DataStore. */
@Single
open class LocalStatsDataSource(@Named("CoreLocalStatsDataStore") private val localStatsStore: DataStore<LocalStats>) {
val localStatsFlow: Flow<LocalStats> =
open class LocalStatsDataSourceImpl(
@Named("CoreLocalStatsDataStore") private val localStatsStore: DataStore<LocalStats>,
) : LocalStatsDataSource {
override val localStatsFlow: Flow<LocalStats> =
localStatsStore.data.catch { exception ->
if (exception is IOException) {
Logger.e { "Error reading LocalStats: ${exception.message}" }
@ -38,11 +49,11 @@ open class LocalStatsDataSource(@Named("CoreLocalStatsDataStore") private val lo
}
}
open suspend fun setLocalStats(stats: LocalStats) {
override suspend fun setLocalStats(stats: LocalStats) {
localStatsStore.updateData { stats }
}
open suspend fun clearLocalStats() {
override suspend fun clearLocalStats() {
localStatsStore.updateData { LocalStats() }
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -16,49 +16,52 @@
*/
package org.meshtastic.core.domain.usecase.settings
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.Node
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AdminActionsUseCaseTest {
/*
private lateinit var radioController: RadioController
private lateinit var nodeRepository: NodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var useCase: AdminActionsUseCase
@BeforeTest
fun setUp() {
radioController = FakeRadioController()
nodeRepository = FakeNodeRepository()
useCase = AdminActionsUseCase(radioController, nodeRepository)
every { radioController.getPacketId() } returns 42
}
@Test
fun `reboot calls radioController and returns packetId`() = runTest {
val result = useCase.reboot(123)
verifySuspend { radioController.reboot(123, 42) }
assertEquals(42, result)
fun `reboot calls radioController`() = runTest {
val packetId = useCase.reboot(1234)
assertEquals(1, packetId)
}
@Test
fun `shutdown calls radioController and returns packetId`() = runTest {
val result = useCase.shutdown(123)
verifySuspend { radioController.shutdown(123, 42) }
assertEquals(42, result)
fun `shutdown calls radioController`() = runTest {
val packetId = useCase.shutdown(1234)
assertEquals(1, packetId)
}
@Test
fun `factoryReset calls radioController and clears DB if local`() = runTest {
val result = useCase.factoryReset(123, isLocal = true)
verifySuspend { radioController.factoryReset(123, 42) }
verifySuspend { nodeRepository.clearNodeDB() }
assertEquals(42, result)
fun `factoryReset local node clears local NodeDB`() = runTest {
nodeRepository.upsert(Node(num = 1))
useCase.factoryReset(1234, isLocal = true)
assertTrue(nodeRepository.nodeDBbyNum.value.isEmpty())
}
@Test
fun `nodedbReset calls radioController and clears DB if local`() = runTest {
val result = useCase.nodedbReset(123, preserveFavorites = true, isLocal = true)
verifySuspend { radioController.nodedbReset(123, 42, true) }
verifySuspend { nodeRepository.clearNodeDB(true) }
assertEquals(42, result)
fun `nodedbReset local node clears local NodeDB with preserveFavorites`() = runTest {
nodeRepository.setNodes(listOf(Node(num = 1, isFavorite = true), Node(num = 2, isFavorite = false)))
useCase.nodedbReset(1234, preserveFavorites = true, isLocal = true)
assertEquals(1, nodeRepository.nodeDBbyNum.value.size)
assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(1))
}
*/
}

View file

@ -16,27 +16,62 @@
*/
package org.meshtastic.core.domain.usecase.settings
//
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.Node
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.days
class CleanNodeDatabaseUseCaseTest {
/*
private lateinit var nodeRepository: NodeRepository
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var useCase: CleanNodeDatabaseUseCase
@BeforeTest
fun setUp() {
nodeRepository = mock(MockMode.autofill)
nodeRepository = FakeNodeRepository()
radioController = FakeRadioController()
useCase = CleanNodeDatabaseUseCase(nodeRepository, radioController)
}
@Test
fun `invoke calls clearNodeDB on repository`() = runTest {
// Act
useCase(true)
fun `getNodesToClean returns nodes older than threshold`() = runTest {
val now = 1000000000L
val olderThan = now - 30.days.inWholeSeconds
val node1 = Node(num = 1, lastHeard = (olderThan - 100).toInt())
val node2 = Node(num = 2, lastHeard = (olderThan + 100).toInt())
nodeRepository.setNodes(listOf(node1, node2))
// Assert
val result = useCase.getNodesToClean(30f, false, now)
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
*/
@Test
fun `getNodesToClean filters out favorites and ignored`() = runTest {
val now = 1000000000L
val olderThan = now - 30.days.inWholeSeconds
val node1 = Node(num = 1, lastHeard = (olderThan - 100).toInt(), isFavorite = true)
val node2 = Node(num = 2, lastHeard = (olderThan - 100).toInt(), isIgnored = true)
nodeRepository.setNodes(listOf(node1, node2))
val result = useCase.getNodesToClean(30f, false, now)
assertTrue(result.isEmpty())
}
@Test
fun `cleanNodes deletes from repo and controller`() = runTest {
nodeRepository.setNodes(listOf(Node(num = 1), Node(num = 2)))
useCase.cleanNodes(listOf(1))
assertEquals(1, nodeRepository.nodeDBbyNum.value.size)
assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(2))
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -16,34 +16,67 @@
*/
package org.meshtastic.core.domain.usecase.settings
//
import kotlinx.coroutines.test.runTest
import okio.Buffer
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.testing.FakeMeshLogRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
class ExportDataUseCaseTest {
/*
private lateinit var nodeRepository: NodeRepository
private lateinit var meshLogRepository: MeshLogRepository
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var meshLogRepository: FakeMeshLogRepository
private lateinit var useCase: ExportDataUseCase
@BeforeTest
fun setUp() {
nodeRepository = mock(MockMode.autofill)
meshLogRepository = mock(MockMode.autofill)
nodeRepository = FakeNodeRepository()
meshLogRepository = FakeMeshLogRepository()
useCase = ExportDataUseCase(nodeRepository, meshLogRepository)
}
@Test
fun `invoke calls repositories`() = runTest {
// Arrange
fun `invoke writes header to sink`() = runTest {
val buffer = Buffer()
useCase(buffer, 1)
// Act
useCase(buffer, 123, null)
// Assert
verifySuspend { nodeRepository.getNodes() }
val output = buffer.readUtf8()
assertTrue(output.startsWith("\"date\",\"time\",\"from\""))
}
*/
@Test
fun `invoke writes packet data to sink`() = runTest {
val buffer = Buffer()
val log =
MeshLog(
uuid = "1",
message_type = "TEXT",
received_date = 1000000000L,
raw_message = "",
fromRadio =
FromRadio(
packet =
MeshPacket(
from = 1234,
rx_snr = 5.0f,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "Hello".encodeUtf8()),
),
),
)
meshLogRepository.setLogs(listOf(log))
useCase(buffer, 1)
val output = buffer.readUtf8()
assertTrue(output.contains("\"1234\""))
assertTrue(output.contains("Hello"))
}
}

View file

@ -16,68 +16,99 @@
*/
package org.meshtastic.core.domain.usecase.settings
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.proto.Config.BluetoothConfig
import org.meshtastic.proto.Config.DeviceConfig
import org.meshtastic.proto.Config.DisplayConfig
import org.meshtastic.proto.Config.LoRaConfig
import org.meshtastic.proto.Config.NetworkConfig
import org.meshtastic.proto.Config.PositionConfig
import org.meshtastic.proto.Config.PowerConfig
import org.meshtastic.proto.Config.SecurityConfig
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.ModuleConfig.AmbientLightingConfig
import org.meshtastic.proto.ModuleConfig.AudioConfig
import org.meshtastic.proto.ModuleConfig.CannedMessageConfig
import org.meshtastic.proto.ModuleConfig.DetectionSensorConfig
import org.meshtastic.proto.ModuleConfig.ExternalNotificationConfig
import org.meshtastic.proto.ModuleConfig.MQTTConfig
import org.meshtastic.proto.ModuleConfig.NeighborInfoConfig
import org.meshtastic.proto.ModuleConfig.PaxcounterConfig
import org.meshtastic.proto.ModuleConfig.RangeTestConfig
import org.meshtastic.proto.ModuleConfig.RemoteHardwareConfig
import org.meshtastic.proto.ModuleConfig.SerialConfig
import org.meshtastic.proto.ModuleConfig.StatusMessageConfig
import org.meshtastic.proto.ModuleConfig.StoreForwardConfig
import org.meshtastic.proto.ModuleConfig.TAKConfig
import org.meshtastic.proto.ModuleConfig.TelemetryConfig
import org.meshtastic.proto.ModuleConfig.TrafficManagementConfig
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
class InstallProfileUseCaseTest {
/*
private lateinit var radioController: RadioController
private lateinit var radioController: FakeRadioController
private lateinit var useCase: InstallProfileUseCase
@BeforeTest
fun setUp() {
radioController = FakeRadioController()
useCase = InstallProfileUseCase(radioController)
every { radioController.getPacketId() } returns 1
}
@Test
fun `invoke with names updates owner`() = runTest {
// Arrange
val profile = DeviceProfile(long_name = "New Long", short_name = "NL")
val currentUser = User(long_name = "Old Long", short_name = "OL")
fun `invoke calls begin and commit edit settings`() = runTest {
useCase(1234, DeviceProfile(), User())
// Act
useCase(123, profile, currentUser)
// Assert
verifySuspend { radioController.beginEditSettings(123) }
verifySuspend { radioController.commitEditSettings(123) }
assertTrue(radioController.beginEditSettingsCalled)
assertTrue(radioController.commitEditSettingsCalled)
}
@Test
fun `invoke with config sets config`() = runTest {
// Arrange
val loraConfig = Config.LoRaConfig(region = Config.LoRaConfig.RegionCode.US)
val profile = DeviceProfile(config = LocalConfig(lora = loraConfig))
fun `invoke installs all sections of a full profile`() = runTest {
val profile =
DeviceProfile(
long_name = "Full Node",
short_name = "FULL",
config =
org.meshtastic.proto.LocalConfig(
device = DeviceConfig(),
position = PositionConfig(),
power = PowerConfig(),
network = NetworkConfig(),
display = DisplayConfig(),
lora = LoRaConfig(),
bluetooth = BluetoothConfig(),
security = SecurityConfig(),
),
module_config =
org.meshtastic.proto.LocalModuleConfig(
mqtt = MQTTConfig(),
serial = SerialConfig(),
external_notification = ExternalNotificationConfig(),
store_forward = StoreForwardConfig(),
range_test = RangeTestConfig(),
telemetry = TelemetryConfig(),
canned_message = CannedMessageConfig(),
audio = AudioConfig(),
remote_hardware = RemoteHardwareConfig(),
neighbor_info = NeighborInfoConfig(),
ambient_lighting = AmbientLightingConfig(),
detection_sensor = DetectionSensorConfig(),
paxcounter = PaxcounterConfig(),
statusmessage = StatusMessageConfig(),
traffic_management = TrafficManagementConfig(),
tak = TAKConfig(),
),
fixed_position = org.meshtastic.proto.Position(),
)
// Act
useCase(456, profile, null)
useCase(1234, profile, org.meshtastic.proto.User(long_name = "Old"))
// Assert
assertTrue(radioController.beginEditSettingsCalled)
assertTrue(radioController.commitEditSettingsCalled)
}
@Test
fun `invoke with module_config sets module config`() = runTest {
// Arrange
val mqttConfig = ModuleConfig.MQTTConfig(enabled = true, address = "broker.local")
val profile = DeviceProfile(module_config = LocalModuleConfig(mqtt = mqttConfig))
// Act
useCase(789, profile, null)
// Assert
}
@Test
fun `invoke with module_config part 2 sets module config`() = runTest {
// Arrange
val neighborInfoConfig = ModuleConfig.NeighborInfoConfig(enabled = true)
val profile = DeviceProfile(module_config = LocalModuleConfig(neighbor_info = neighborInfoConfig))
// Act
useCase(789, profile, null)
// Assert
}
*/
}

View file

@ -145,18 +145,39 @@ class IsOtaCapableUseCaseTest {
}
@Test
fun `invoke returns true when hardware lookup fails but model is set`() = runTest {
// Arrange
fun `invoke returns false when disconnected`() = runTest {
dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(Node(num = 123))
dev.mokkery.every { radioController.connectionState } returns
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Disconnected)
useCase().test {
assertFalse(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `invoke returns false when node is null`() = runTest {
dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
dev.mokkery.every { radioController.connectionState } returns
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
useCase().test {
assertFalse(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `invoke returns false when address is not ota capable`() = runTest {
val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM))
dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node)
dev.mokkery.every { radioController.connectionState } returns
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE
everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.failure(Exception())
dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("mqtt://example.com")
useCase().test {
assertTrue(awaitItem())
assertFalse(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -16,32 +16,31 @@
*/
package org.meshtastic.core.domain.usecase.settings
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.testing.FakeRadioController
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
class MeshLocationUseCaseTest {
private lateinit var radioController: RadioController
private lateinit var radioController: FakeRadioController
private lateinit var useCase: MeshLocationUseCase
@BeforeTest
fun setUp() {
radioController = mock(dev.mokkery.MockMode.autofill)
radioController = FakeRadioController()
useCase = MeshLocationUseCase(radioController)
}
@Test
fun `startProvidingLocation calls radioController`() {
useCase.startProvidingLocation()
verify { radioController.startProvideLocation() }
assertTrue(radioController.startProvideLocationCalled)
}
@Test
fun `stopProvidingLocation calls radioController`() {
useCase.stopProvidingLocation()
verify { radioController.stopProvideLocation() }
assertTrue(radioController.stopProvideLocationCalled)
}
}

View file

@ -102,5 +102,92 @@ class ProcessRadioResponseUseCaseTest {
assertEquals("Hello World", (result as RadioResponseResult.CannedMessages).messages)
}
@Test
fun `invoke with unexpected sender returns error`() {
val adminMsg = AdminMessage()
val packet =
MeshPacket(
from = 456,
decoded = Data(
portnum = PortNum.ADMIN_APP,
request_id = 42,
payload = adminMsg.encode().toByteString(),
),
)
val result = useCase(packet, 123, setOf(42))
assertTrue(result is RadioResponseResult.Error)
}
@Test
fun `invoke with owner response returns owner result`() {
val owner = org.meshtastic.proto.User(long_name = "Owner")
val adminMsg = AdminMessage(get_owner_response = owner)
val packet =
MeshPacket(
from = 123,
decoded = Data(
portnum = PortNum.ADMIN_APP,
request_id = 42,
payload = adminMsg.encode().toByteString(),
),
)
val result = useCase(packet, 123, setOf(42))
assertTrue(result is RadioResponseResult.Owner)
assertEquals("Owner", (result as RadioResponseResult.Owner).user.long_name)
}
@Test
fun `invoke with config response returns config result`() {
val config = org.meshtastic.proto.Config(lora = org.meshtastic.proto.Config.LoRaConfig(use_preset = true))
val adminMsg = AdminMessage(get_config_response = config)
val packet =
MeshPacket(
from = 123,
decoded = Data(
portnum = PortNum.ADMIN_APP,
request_id = 42,
payload = adminMsg.encode().toByteString(),
),
)
val result = useCase(packet, 123, setOf(42))
assertTrue(result is RadioResponseResult.ConfigResponse)
}
@Test
fun `invoke with module config response returns module config result`() {
val config =
org.meshtastic.proto.ModuleConfig(mqtt = org.meshtastic.proto.ModuleConfig.MQTTConfig(enabled = true))
val adminMsg = AdminMessage(get_module_config_response = config)
val packet =
MeshPacket(
from = 123,
decoded = Data(
portnum = PortNum.ADMIN_APP,
request_id = 42,
payload = adminMsg.encode().toByteString(),
),
)
val result = useCase(packet, 123, setOf(42))
assertTrue(result is RadioResponseResult.ModuleConfigResponse)
}
@Test
fun `invoke with channel response returns channel result`() {
val channel = org.meshtastic.proto.Channel(settings = org.meshtastic.proto.ChannelSettings(name = "Main"))
val adminMsg = AdminMessage(get_channel_response = channel)
val packet =
MeshPacket(
from = 123,
decoded = Data(
portnum = PortNum.ADMIN_APP,
request_id = 42,
payload = adminMsg.encode().toByteString(),
),
)
val result = useCase(packet, 123, setOf(42))
assertTrue(result is RadioResponseResult.ChannelResponse)
assertEquals("Main", (result as RadioResponseResult.ChannelResponse).channel.settings?.name)
}
private fun ByteArray.toByteString() = okio.ByteString.of(*this)
}

View file

@ -16,33 +16,78 @@
*/
package org.meshtastic.core.domain.usecase.settings
//
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.Position
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class RadioConfigUseCaseTest {
/*
private lateinit var radioController: RadioController
private lateinit var radioController: FakeRadioController
private lateinit var useCase: RadioConfigUseCase
@BeforeTest
fun setUp() {
radioController = mock(MockMode.autofill)
radioController = FakeRadioController()
useCase = RadioConfigUseCase(radioController)
}
@Test
fun `setConfig calls radioController`() = runTest {
// Arrange
val config = Config()
// Act
val result = useCase.setConfig(123, config)
// Assert
// result is Unit
verifySuspend { radioController.setConfig(123, config, 1) }
fun `setOwner calls radioController`() = runTest {
val user = User(long_name = "New Name")
useCase.setOwner(1234, user)
// Verify call implicitly or by adding tracking to FakeRadioController if needed.
// FakeRadioController already has getPacketId returning 1.
}
*/
@Test
fun `getOwner calls radioController`() = runTest {
val packetId = useCase.getOwner(1234)
assertEquals(1, packetId)
}
@Test
fun `setConfig calls radioController`() = runTest {
val config = Config(lora = Config.LoRaConfig(use_preset = true))
useCase.setConfig(1234, config)
}
@Test
fun `setModuleConfig calls radioController`() = runTest {
val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
useCase.setModuleConfig(1234, config)
}
@Test
fun `setFixedPosition calls radioController`() = runTest {
val position = Position(1.0, 2.0, 3)
useCase.setFixedPosition(1234, position)
}
@Test
fun `removeFixedPosition calls radioController with zero position`() = runTest { useCase.removeFixedPosition(1234) }
@Test fun `setRingtone calls radioController`() = runTest { useCase.setRingtone(1234, "ringtone.mp3") }
@Test fun `setCannedMessages calls radioController`() = runTest { useCase.setCannedMessages(1234, "messages") }
@Test fun `getConfig calls radioController`() = runTest { useCase.getConfig(1234, 1) }
@Test fun `getModuleConfig calls radioController`() = runTest { useCase.getModuleConfig(1234, 1) }
@Test fun `getChannel calls radioController`() = runTest { useCase.getChannel(1234, 1) }
@Test
fun `setRemoteChannel calls radioController`() = runTest {
useCase.setRemoteChannel(1234, org.meshtastic.proto.Channel())
}
@Test fun `getRingtone calls radioController`() = runTest { useCase.getRingtone(1234) }
@Test fun `getCannedMessages calls radioController`() = runTest { useCase.getCannedMessages(1234) }
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -16,51 +16,45 @@
*/
package org.meshtastic.core.domain.usecase.settings
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.testing.FakeMeshLogPrefs
import org.meshtastic.core.testing.FakeMeshLogRepository
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class SetMeshLogSettingsUseCaseTest {
/*
private lateinit var meshLogRepository: MeshLogRepository
private lateinit var meshLogPrefs: MeshLogPrefs
private lateinit var meshLogRepository: FakeMeshLogRepository
private lateinit var meshLogPrefs: FakeMeshLogPrefs
private lateinit var useCase: SetMeshLogSettingsUseCase
@BeforeTest
fun setUp() {
meshLogRepository = FakeMeshLogRepository()
meshLogPrefs = FakeMeshLogPrefs()
useCase = SetMeshLogSettingsUseCase(meshLogRepository, meshLogPrefs)
}
@Test
fun `setRetentionDays clamps and updates prefs and repository`() = runTest {
// Act
useCase.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS - 1)
// Assert
verify { meshLogPrefs.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS) }
verifySuspend { meshLogRepository.deleteLogsOlderThan(MeshLogPrefs.MIN_RETENTION_DAYS) }
fun `setRetentionDays clamps value and deletes old logs`() = runTest {
useCase.setRetentionDays(500) // Max is 365
assertEquals(365, meshLogPrefs.retentionDays.value)
assertEquals(365, meshLogRepository.lastDeletedOlderThan)
}
@Test
fun `setLoggingEnabled true triggers cleanup`() = runTest {
// Arrange
every { meshLogPrefs.retentionDays.value } returns 30
// Act
useCase.setLoggingEnabled(true)
// Assert
verify { meshLogPrefs.setLoggingEnabled(true) }
verifySuspend { meshLogRepository.deleteLogsOlderThan(30) }
}
@Test
fun `setLoggingEnabled false triggers deletion`() = runTest {
// Act
fun `setLoggingEnabled false deletes all logs`() = runTest {
useCase.setLoggingEnabled(false)
// Assert
verify { meshLogPrefs.setLoggingEnabled(false) }
verifySuspend { meshLogRepository.deleteAll() }
assertEquals(false, meshLogPrefs.loggingEnabled.value)
assertEquals(true, meshLogRepository.deleteAllCalled)
}
*/
@Test
fun `setLoggingEnabled true deletes logs older than retention`() = runTest {
meshLogPrefs.setRetentionDays(15)
useCase.setLoggingEnabled(true)
assertEquals(true, meshLogPrefs.loggingEnabled.value)
assertEquals(15, meshLogRepository.lastDeletedOlderThan)
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -16,41 +16,33 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.testing.FakeAnalyticsPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ToggleAnalyticsUseCaseTest {
/*
private lateinit var analyticsPrefs: AnalyticsPrefs
private lateinit var analyticsPrefs: FakeAnalyticsPrefs
private lateinit var useCase: ToggleAnalyticsUseCase
@BeforeTest
fun setUp() {
analyticsPrefs = FakeAnalyticsPrefs()
useCase = ToggleAnalyticsUseCase(analyticsPrefs)
}
@Test
fun `invoke toggles analytics from false to true`() {
// Arrange
every { analyticsPrefs.analyticsAllowed.value } returns false
// Act
fun `invoke toggles from false to true`() {
analyticsPrefs.setAnalyticsAllowed(false)
useCase()
// Assert
verify { analyticsPrefs.setAnalyticsAllowed(true) }
assertEquals(true, analyticsPrefs.analyticsAllowed.value)
}
@Test
fun `invoke toggles analytics from true to false`() {
// Arrange
every { analyticsPrefs.analyticsAllowed.value } returns true
// Act
fun `invoke toggles from true to false`() {
analyticsPrefs.setAnalyticsAllowed(true)
useCase()
// Assert
verify { analyticsPrefs.setAnalyticsAllowed(false) }
assertEquals(false, analyticsPrefs.analyticsAllowed.value)
}
*/
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* 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
@ -16,41 +16,33 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.testing.FakeHomoglyphPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ToggleHomoglyphEncodingUseCaseTest {
/*
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
private lateinit var homoglyphPrefs: FakeHomoglyphPrefs
private lateinit var useCase: ToggleHomoglyphEncodingUseCase
@BeforeTest
fun setUp() {
useCase = ToggleHomoglyphEncodingUseCase(homoglyphEncodingPrefs)
homoglyphPrefs = FakeHomoglyphPrefs()
useCase = ToggleHomoglyphEncodingUseCase(homoglyphPrefs)
}
@Test
fun `invoke toggles homoglyph encoding from false to true`() {
// Arrange
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
// Act
fun `invoke toggles from false to true`() {
homoglyphPrefs.setHomoglyphEncodingEnabled(false)
useCase()
// Assert
verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(true) }
assertEquals(true, homoglyphPrefs.homoglyphEncodingEnabled.value)
}
@Test
fun `invoke toggles homoglyph encoding from true to false`() {
// Arrange
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns true
// Act
fun `invoke toggles from true to false`() {
homoglyphPrefs.setHomoglyphEncodingEnabled(true)
useCase()
// Assert
verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(false) }
assertEquals(false, homoglyphPrefs.homoglyphEncodingEnabled.value)
}
*/
}

View file

@ -61,6 +61,7 @@ kotlin {
androidMain.dependencies { implementation(libs.usb.serial.android) }
commonTest.dependencies {
implementation(projects.core.testing)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.kotest.assertions)

View file

@ -17,26 +17,16 @@
package org.meshtastic.core.network.radio
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.ble.BluetoothState
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.testing.FakeBleConnection
import org.meshtastic.core.testing.FakeBleConnectionFactory
import org.meshtastic.core.testing.FakeBleDevice
import org.meshtastic.core.testing.FakeBleScanner
import org.meshtastic.core.testing.FakeBluetoothRepository
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@ -45,33 +35,23 @@ import kotlin.test.assertEquals
class BleRadioInterfaceTest {
private val testScope = TestScope()
private val scanner: BleScanner = mock()
private val bluetoothRepository: BluetoothRepository = mock()
private val connectionFactory: BleConnectionFactory = mock()
private val connection: BleConnection = mock()
private val scanner = FakeBleScanner()
private val bluetoothRepository = FakeBluetoothRepository()
private val connection = FakeBleConnection()
private val connectionFactory = FakeBleConnectionFactory(connection)
private val service: RadioInterfaceService = mock(MockMode.autofill)
private val address = "00:11:22:33:44:55"
private val connectionStateFlow = MutableSharedFlow<BleConnectionState>(replay = 1)
private val bluetoothStateFlow = MutableStateFlow(BluetoothState())
@BeforeTest
fun setup() {
every { connectionFactory.create(any(), any()) } returns connection
every { connection.connectionState } returns connectionStateFlow
every { bluetoothRepository.state } returns bluetoothStateFlow.asStateFlow()
bluetoothStateFlow.value = BluetoothState(enabled = true, hasPermissions = true)
bluetoothRepository.setHasPermissions(true)
bluetoothRepository.setBluetoothEnabled(true)
}
@Test
fun `connect attempts to scan and connect via init`() = runTest {
val device: BleDevice = mock()
every { device.address } returns address
every { device.name } returns "Test Device"
every { scanner.scan(any(), any()) } returns flowOf(device)
everySuspend { connection.connectAndAwait(any(), any(), any()) } returns BleConnectionState.Connected
val device = FakeBleDevice(address = address, name = "Test Device")
scanner.emitDevice(device)
val bleInterface =
BleRadioInterface(
@ -84,8 +64,9 @@ class BleRadioInterfaceTest {
)
// init starts connect() which is async
// We can wait for the coEvery to be triggered if needed,
// but for a basic test this confirms it doesn't crash on init.
// In a real test we'd verify the connection state,
// but for now this confirms it works with the fakes.
assertEquals(address, bleInterface.address)
}
@Test

View file

@ -154,6 +154,26 @@ class StreamFrameCodecTest {
assertEquals(listOf(0xAA.toByte()), receivedPackets[0].toList())
}
@Test
fun `frameAndSend produces correct header for 1-byte payload`() = runTest {
val payload = byteArrayOf(0x42.toByte())
val sentBytes = mutableListOf<ByteArray>()
codec.frameAndSend(payload, sendBytes = { sentBytes.add(it) })
// First sent bytes are the 4-byte header, second is the payload
assertEquals(2, sentBytes.size)
val header = sentBytes[0]
assertEquals(4, header.size)
assertEquals(0x94.toByte(), header[0])
assertEquals(0xc3.toByte(), header[1])
assertEquals(0x00.toByte(), header[2])
assertEquals(0x01.toByte(), header[3])
val sentPayload = sentBytes[1]
assertEquals(payload.toList(), sentPayload.toList())
}
@Test
fun `WAKE_BYTES is four START1 bytes`() {
assertEquals(4, StreamFrameCodec.WAKE_BYTES.size)

View file

@ -1,52 +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.network.radio
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import kotlin.test.Test
import kotlin.test.assertEquals
class TCPInterfaceTest {
@Test
fun testHeartbeatFraming() = runTest {
val sentBytes = mutableListOf<ByteArray>()
val codec = StreamFrameCodec(onPacketReceived = {}, logTag = "Test")
val heartbeat = ToRadio(heartbeat = Heartbeat()).encode()
codec.frameAndSend(heartbeat, { sentBytes.add(it) })
// First sent bytes are the 4-byte header, second is the payload
assertEquals(2, sentBytes.size)
val header = sentBytes[0]
assertEquals(4, header.size)
assertEquals(0x94.toByte(), header[0])
assertEquals(0xc3.toByte(), header[1])
val payload = sentBytes[1]
assertEquals(heartbeat.toList(), payload.toList())
}
@Test
fun testServicePort() {
assertEquals(4403, TCPInterface.SERVICE_PORT)
}
}

View file

@ -36,8 +36,11 @@ kotlin {
implementation(libs.androidx.paging.common)
}
commonTest.dependencies {
implementation(projects.core.testing)
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.kotest.assertions)
}
}
}

View file

@ -0,0 +1,31 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.FirmwareRelease
interface FirmwareReleaseRepository {
/** A flow that provides the latest STABLE firmware release. */
val stableRelease: Flow<FirmwareRelease?>
/** A flow that provides the latest ALPHA firmware release. */
val alphaRelease: Flow<FirmwareRelease?>
/** Invalidates the local cache of firmware releases. */
suspend fun invalidateCache()
}

View file

@ -0,0 +1,60 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import org.meshtastic.core.testing.FakeRadioPrefs
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AppPreferencesTest {
@Test
fun `RadioPrefs isBle returns true for x prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("x12345678")
assertTrue(prefs.isBle())
}
@Test
fun `RadioPrefs isBle returns false for other prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("s12345678")
assertFalse(prefs.isBle())
}
@Test
fun `RadioPrefs isSerial returns true for s prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("s12345678")
assertTrue(prefs.isSerial())
}
@Test
fun `RadioPrefs isTcp returns true for t prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("t192.168.1.1")
assertTrue(prefs.isTcp())
}
@Test
fun `RadioPrefs isMock returns true for m prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("m12345678")
assertTrue(prefs.isMock())
}
}

View file

@ -0,0 +1,35 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import kotlin.test.Test
import kotlin.test.assertEquals
class DataPairTest {
@Test
fun `DataPair with non-null value retains value`() {
val pair = DataPair("key", "value")
assertEquals("value", pair.value)
}
@Test
fun `DataPair with null value becomes string null`() {
val pair = DataPair("key", null)
assertEquals("null", pair.value)
}
}

View file

@ -0,0 +1,32 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import kotlin.test.Test
import kotlin.test.assertEquals
class NotificationTest {
@Test
fun `Notification creation works with defaults`() {
val notification = Notification("Title", "Message")
assertEquals("Title", notification.title)
assertEquals("Message", notification.message)
assertEquals(Notification.Type.Info, notification.type)
assertEquals(Notification.Category.Message, notification.category)
}
}

View file

@ -14,23 +14,18 @@
* 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.domain.usecase
package org.meshtastic.core.repository.usecase
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.core.repository.usecase.SendMessageUseCaseImpl
import org.meshtastic.core.testing.FakeAppPreferences
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
@ -40,20 +35,19 @@ import kotlin.test.Test
class SendMessageUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var packetRepository: PacketRepository
private lateinit var radioController: FakeRadioController
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
private lateinit var appPreferences: FakeAppPreferences
private lateinit var messageQueue: MessageQueue
private lateinit var useCase: SendMessageUseCase
@BeforeTest
fun setUp() {
nodeRepository = mock(MockMode.autofill)
nodeRepository = FakeNodeRepository()
packetRepository = mock(MockMode.autofill)
radioController = FakeRadioController()
homoglyphEncodingPrefs =
mock(MockMode.autofill) { every { homoglyphEncodingEnabled } returns MutableStateFlow(false) }
appPreferences = FakeAppPreferences()
messageQueue = mock(MockMode.autofill)
useCase =
@ -61,7 +55,7 @@ class SendMessageUseCaseTest {
nodeRepository = nodeRepository,
packetRepository = packetRepository,
radioController = radioController,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
homoglyphEncodingPrefs = appPreferences.homoglyph,
messageQueue = messageQueue,
)
}
@ -70,8 +64,8 @@ class SendMessageUseCaseTest {
fun `invoke with broadcast message simply sends data packet`() = runTest {
// Arrange
val ourNode = Node(num = 1, user = User(id = "!1234"))
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
nodeRepository.setOurNode(ourNode)
appPreferences.homoglyph.setHomoglyphEncodingEnabled(false)
// Act
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
@ -90,12 +84,12 @@ class SendMessageUseCaseTest {
user = User(id = "!local", role = Config.DeviceConfig.Role.CLIENT),
metadata = DeviceMetadata(firmware_version = "2.0.0"),
)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
nodeRepository.setOurNode(ourNode)
val destNode = Node(num = 12345, isFavorite = false)
every { nodeRepository.getNode("!dest") } returns destNode
val destNode = Node(num = 12345, user = User(id = "!dest"))
nodeRepository.upsert(destNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
appPreferences.homoglyph.setHomoglyphEncodingEnabled(false)
// Act
useCase("Direct message", "!dest", null)
@ -114,12 +108,12 @@ class SendMessageUseCaseTest {
user = User(id = "!local", role = Config.DeviceConfig.Role.CLIENT),
metadata = DeviceMetadata(firmware_version = "2.7.12"),
)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
nodeRepository.setOurNode(ourNode)
val destNode = Node(num = 67890)
every { nodeRepository.getNode("!dest") } returns destNode
val destNode = Node(num = 67890, user = User(id = "!dest"))
nodeRepository.upsert(destNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false)
appPreferences.homoglyph.setHomoglyphEncodingEnabled(false)
// Act
useCase("Direct message", "!dest", null)
@ -133,8 +127,8 @@ class SendMessageUseCaseTest {
fun `invoke with homoglyph enabled transforms text`() = runTest {
// Arrange
val ourNode = Node(num = 1)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(true)
nodeRepository.setOurNode(ourNode)
appPreferences.homoglyph.setHomoglyphEncodingEnabled(true)
val originalText = "\u0410pple" // Cyrillic A
@ -142,8 +136,6 @@ class SendMessageUseCaseTest {
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
// Assert
// The packet is saved to packetRepository. Verify that savePacket was called with transformed text?
// Since we didn't mock savePacket specifically, it will just work due to MockMode.autofill.
// If we want to verify transformed text, we'd need to capture the packet.
// Verified by observing that no exception is thrown and coverage is hit.
}
}

View file

@ -45,6 +45,54 @@ The `:core:testing` module is a dedicated **Kotlin Multiplatform (KMP)** library
By centralizing fakes and mocking utilities here, we prevent duplication of test setups and enforce a standard approach to testing ViewModels, Repositories, and pure domain logic.
## Handling Platform-Specific Setup (Robolectric)
Some KMP modules interact with Android framework components (e.g., `android.net.Uri`, `androidx.room`, `DataStore`) that require Robolectric to run on the JVM. To maintain a unified test suite while providing platform-specific initialization, follow the **Subclassing Pattern**:
### 1. Create an Abstract Base Test in `commonTest`
Place your test logic in an abstract class in `src/commonTest`. Do NOT use `@BeforeTest` for setup that requires platform-specific context.
```kotlin
abstract class CommonMyViewModelTest {
protected lateinit var viewModel: MyViewModel
// Call this from subclasses
fun setupRepo() {
// ... common setup logic
}
@Test
fun testLogic() { /* ... */ }
}
```
### 2. Implement the JVM Subclass in `jvmTest`
A simple subclass is usually enough for pure JVM targets.
```kotlin
class MyViewModelTest : CommonMyViewModelTest() {
@BeforeTest
fun setup() {
setupRepo()
}
}
```
### 3. Implement the Android Subclass in `androidHostTest`
Use `@RunWith(RobolectricTestRunner::class)` and call `setupTestContext()` to initialize `ContextServices.app`.
```kotlin
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MyViewModelTest : CommonMyViewModelTest() {
@BeforeTest
fun setup() {
setupTestContext() // From :core:testing, initializes Robolectric context
setupRepo()
}
}
```
## Key Components
- **Test Doubles / Fakes**: Provides in-memory implementations of core repositories (e.g., `FakeNodeRepository`, `FakeMeshLogRepository`) to isolate components under test.

View file

@ -31,6 +31,11 @@ kotlin {
// Heavy modules (database, data, domain) should depend on core:testing, not vice versa.
api(projects.core.model)
api(projects.core.repository)
api(projects.core.database)
api(projects.core.ble)
implementation(projects.core.datastore)
implementation(libs.androidx.room.runtime)
implementation(libs.jetbrains.lifecycle.runtime)
api(libs.kermit)
// Testing libraries - these are public API for all test consumers
@ -39,5 +44,9 @@ kotlin {
api(libs.turbine)
api(libs.junit)
}
androidMain.dependencies {
api(libs.androidx.test.core)
api(libs.robolectric)
}
}
}

View file

@ -0,0 +1,26 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.repository.Location
/** Creates an Android [Location] for testing. */
actual fun createLocation(latitude: Double, longitude: Double, altitude: Double): Location = Location("fake").apply {
this.latitude = latitude
this.longitude = longitude
this.altitude = altitude
}

View file

@ -0,0 +1,24 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import androidx.test.core.app.ApplicationProvider
import org.meshtastic.core.common.ContextServices
actual fun setupTestContext() {
ContextServices.app = ApplicationProvider.getApplicationContext()
}

View file

@ -0,0 +1,49 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
/** Base class for fakes that provides common utilities for state management and reset capabilities. */
abstract class BaseFake {
private val resetActions = mutableListOf<() -> Unit>()
/** Creates a [MutableStateFlow] and registers it for automatic reset. */
protected fun <T> mutableStateFlow(initialValue: T): MutableStateFlow<T> {
val flow = MutableStateFlow(initialValue)
resetActions.add { flow.value = initialValue }
return flow
}
/** Creates a [MutableSharedFlow] and registers it for automatic reset (replay cache cleared). */
protected fun <T> mutableSharedFlow(replay: Int = 0): MutableSharedFlow<T> {
val flow = MutableSharedFlow<T>(replay = replay)
resetActions.add { flow.resetReplayCache() }
return flow
}
/** Registers a custom reset action (e.g. clearing a list of recorded calls). */
protected fun registerResetAction(action: () -> Unit) {
resetActions.add(action)
}
/** Resets all registered state flows and custom actions to their initial state. */
open fun reset() {
resetActions.forEach { it() }
}
}

View file

@ -0,0 +1,265 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.AppPreferences
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MapTileProviderPrefs
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.UiPrefs
class FakeAnalyticsPrefs : AnalyticsPrefs {
override val analyticsAllowed = MutableStateFlow(false)
override fun setAnalyticsAllowed(allowed: Boolean) {
analyticsAllowed.value = allowed
}
override val installId = MutableStateFlow("fake-install-id")
}
class FakeHomoglyphPrefs : HomoglyphPrefs {
override val homoglyphEncodingEnabled = MutableStateFlow(false)
override fun setHomoglyphEncodingEnabled(enabled: Boolean) {
homoglyphEncodingEnabled.value = enabled
}
}
class FakeFilterPrefs : FilterPrefs {
override val filterEnabled = MutableStateFlow(false)
override fun setFilterEnabled(enabled: Boolean) {
filterEnabled.value = enabled
}
override val filterWords = MutableStateFlow(emptySet<String>())
override fun setFilterWords(words: Set<String>) {
filterWords.value = words
}
}
class FakeCustomEmojiPrefs : CustomEmojiPrefs {
override val customEmojiFrequency = MutableStateFlow<String?>(null)
override fun setCustomEmojiFrequency(frequency: String?) {
customEmojiFrequency.value = frequency
}
}
@Suppress("TooManyFunctions")
class FakeUiPrefs : UiPrefs {
override val appIntroCompleted = MutableStateFlow(false)
override fun setAppIntroCompleted(completed: Boolean) {
appIntroCompleted.value = completed
}
override val theme = MutableStateFlow(0)
override fun setTheme(value: Int) {
theme.value = value
}
override val locale = MutableStateFlow("en")
override fun setLocale(languageTag: String) {
locale.value = languageTag
}
override val nodeSort = MutableStateFlow(0)
override fun setNodeSort(value: Int) {
nodeSort.value = value
}
override val includeUnknown = MutableStateFlow(true)
override fun setIncludeUnknown(value: Boolean) {
includeUnknown.value = value
}
override val excludeInfrastructure = MutableStateFlow(false)
override fun setExcludeInfrastructure(value: Boolean) {
excludeInfrastructure.value = value
}
override val onlyOnline = MutableStateFlow(false)
override fun setOnlyOnline(value: Boolean) {
onlyOnline.value = value
}
override val onlyDirect = MutableStateFlow(false)
override fun setOnlyDirect(value: Boolean) {
onlyDirect.value = value
}
override val showIgnored = MutableStateFlow(false)
override fun setShowIgnored(value: Boolean) {
showIgnored.value = value
}
override val excludeMqtt = MutableStateFlow(false)
override fun setExcludeMqtt(value: Boolean) {
excludeMqtt.value = value
}
override val hasShownNotPairedWarning = MutableStateFlow(false)
override fun setHasShownNotPairedWarning(shown: Boolean) {
hasShownNotPairedWarning.value = shown
}
override val showQuickChat = MutableStateFlow(true)
override fun setShowQuickChat(show: Boolean) {
showQuickChat.value = show
}
private val nodeLocationEnabled = mutableMapOf<Int, MutableStateFlow<Boolean>>()
override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean> =
nodeLocationEnabled.getOrPut(nodeNum) { MutableStateFlow(true) }
override fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) {
nodeLocationEnabled.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide
}
}
class FakeMapPrefs : MapPrefs {
override val mapStyle = MutableStateFlow(0)
override fun setMapStyle(style: Int) {
mapStyle.value = style
}
override val showOnlyFavorites = MutableStateFlow(false)
override fun setShowOnlyFavorites(show: Boolean) {
showOnlyFavorites.value = show
}
override val showWaypointsOnMap = MutableStateFlow(true)
override fun setShowWaypointsOnMap(show: Boolean) {
showWaypointsOnMap.value = show
}
override val showPrecisionCircleOnMap = MutableStateFlow(true)
override fun setShowPrecisionCircleOnMap(show: Boolean) {
showPrecisionCircleOnMap.value = show
}
override val lastHeardFilter = MutableStateFlow(0L)
override fun setLastHeardFilter(seconds: Long) {
lastHeardFilter.value = seconds
}
override val lastHeardTrackFilter = MutableStateFlow(0L)
override fun setLastHeardTrackFilter(seconds: Long) {
lastHeardTrackFilter.value = seconds
}
}
class FakeMapConsentPrefs : MapConsentPrefs {
private val consent = mutableMapOf<Int?, MutableStateFlow<Boolean>>()
override fun shouldReportLocation(nodeNum: Int?): StateFlow<Boolean> =
consent.getOrPut(nodeNum) { MutableStateFlow(false) }
override fun setShouldReportLocation(nodeNum: Int?, report: Boolean) {
consent.getOrPut(nodeNum) { MutableStateFlow(report) }.value = report
}
}
class FakeMapTileProviderPrefs : MapTileProviderPrefs {
override val customTileProviders = MutableStateFlow<String?>(null)
override fun setCustomTileProviders(providers: String?) {
customTileProviders.value = providers
}
}
class FakeRadioPrefs : RadioPrefs {
override val devAddr = MutableStateFlow<String?>(null)
override val devName = MutableStateFlow<String?>(null)
override fun setDevAddr(address: String?) {
devAddr.value = address
}
override fun setDevName(name: String?) {
devName.value = name
}
}
class FakeMeshPrefs : MeshPrefs {
override val deviceAddress = MutableStateFlow<String?>(null)
override fun setDeviceAddress(address: String?) {
deviceAddress.value = address
}
private val provideLocation = mutableMapOf<Int?, MutableStateFlow<Boolean>>()
override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow<Boolean> =
provideLocation.getOrPut(nodeNum) { MutableStateFlow(true) }
override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) {
provideLocation.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide
}
private val lastRequest = mutableMapOf<String?, MutableStateFlow<Int>>()
override fun getStoreForwardLastRequest(address: String?): StateFlow<Int> =
lastRequest.getOrPut(address) { MutableStateFlow(0) }
override fun setStoreForwardLastRequest(address: String?, timestamp: Int) {
lastRequest.getOrPut(address) { MutableStateFlow(timestamp) }.value = timestamp
}
}
class FakeAppPreferences : AppPreferences {
override val analytics = FakeAnalyticsPrefs()
override val homoglyph = FakeHomoglyphPrefs()
override val filter = FakeFilterPrefs()
override val meshLog = FakeMeshLogPrefs()
override val emoji = FakeCustomEmojiPrefs()
override val ui = FakeUiPrefs()
override val map = FakeMapPrefs()
override val mapConsent = FakeMapConsentPrefs()
override val mapTileProvider = FakeMapTileProviderPrefs()
override val radio = FakeRadioPrefs()
override val mesh = FakeMeshPrefs()
}

View file

@ -0,0 +1,173 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleService
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.ble.BluetoothState
import kotlin.time.Duration
import kotlin.uuid.Uuid
class FakeBleDevice(
override val address: String,
override val name: String? = "Fake Device",
initialState: BleConnectionState = BleConnectionState.Disconnected,
) : BaseFake(),
BleDevice {
private val _state = mutableStateFlow(initialState)
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
private val _isBonded = mutableStateFlow(false)
override val isBonded: Boolean
get() = _isBonded.value
override val isConnected: Boolean
get() = _state.value == BleConnectionState.Connected
override suspend fun readRssi(): Int = DEFAULT_RSSI
override suspend fun bond() {
_isBonded.value = true
}
fun setState(newState: BleConnectionState) {
_state.value = newState
}
companion object {
private const val DEFAULT_RSSI = -60
}
}
class FakeBleScanner :
BaseFake(),
BleScanner {
private val foundDevices = mutableSharedFlow<BleDevice>(replay = 10)
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> = flow {
emitAll(foundDevices)
}
fun emitDevice(device: BleDevice) {
foundDevices.tryEmit(device)
}
}
class FakeBleConnection :
BaseFake(),
BleConnection {
private val _device = mutableStateFlow<BleDevice?>(null)
override val device: BleDevice?
get() = _device.value
private val _deviceFlow = mutableSharedFlow<BleDevice?>(replay = 1)
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
private val _connectionState = mutableSharedFlow<BleConnectionState>(replay = 1)
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
override suspend fun connect(device: BleDevice) {
_device.value = device
_deviceFlow.emit(device)
_connectionState.emit(BleConnectionState.Connecting)
if (device is FakeBleDevice) {
device.setState(BleConnectionState.Connecting)
}
_connectionState.emit(BleConnectionState.Connected)
if (device is FakeBleDevice) {
device.setState(BleConnectionState.Connected)
}
}
override suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit,
): BleConnectionState {
connect(device)
onRegister()
return BleConnectionState.Connected
}
override suspend fun disconnect() {
val currentDevice = _device.value
_connectionState.emit(BleConnectionState.Disconnected)
if (currentDevice is FakeBleDevice) {
currentDevice.setState(BleConnectionState.Disconnected)
}
_device.value = null
_deviceFlow.emit(null)
}
override suspend fun <T> profile(
serviceUuid: Uuid,
timeout: Duration,
setup: suspend CoroutineScope.(BleService) -> T,
): T = CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined).setup(FakeBleService())
override fun maximumWriteValueLength(writeType: BleWriteType): Int = 512
}
class FakeBleService : BleService
class FakeBleConnectionFactory(private val fakeConnection: FakeBleConnection = FakeBleConnection()) :
BleConnectionFactory {
override fun create(scope: CoroutineScope, tag: String): BleConnection = fakeConnection
}
@Suppress("EmptyFunctionBlock")
class FakeBluetoothRepository :
BaseFake(),
BluetoothRepository {
private val _state = mutableStateFlow(BluetoothState(hasPermissions = true, enabled = true))
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
override fun refreshState() {}
override fun isValid(bleAddress: String): Boolean = bleAddress.isNotBlank()
override fun isBonded(address: String): Boolean = _state.value.bondedDevices.any { it.address == address }
override suspend fun bond(device: BleDevice) {
val currentState = _state.value
if (!currentState.bondedDevices.contains(device)) {
_state.value = currentState.copy(bondedDevices = currentState.bondedDevices + device)
}
}
fun setBluetoothEnabled(enabled: Boolean) {
_state.value = _state.value.copy(enabled = enabled)
}
fun setHasPermissions(hasPermissions: Boolean) {
_state.value = _state.value.copy(hasPermissions = hasPermissions)
}
}

View file

@ -0,0 +1,55 @@
/*
* 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.testing
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.common.database.DatabaseManager
/** A test double for [DatabaseManager] that provides a simple implementation and tracks calls. */
class FakeDatabaseManager :
BaseFake(),
DatabaseManager {
private val _cacheLimit = mutableStateFlow(DEFAULT_CACHE_LIMIT)
override val cacheLimit: StateFlow<Int> = _cacheLimit
var lastSwitchedAddress: String? = null
val existingDatabases = mutableSetOf<String>()
init {
registerResetAction {
_cacheLimit.value = DEFAULT_CACHE_LIMIT
lastSwitchedAddress = null
existingDatabases.clear()
}
}
override fun getCurrentCacheLimit(): Int = _cacheLimit.value
override fun setCacheLimit(limit: Int) {
_cacheLimit.value = limit
}
override suspend fun switchActiveDatabase(address: String?) {
lastSwitchedAddress = address
}
override fun hasDatabaseFor(address: String?): Boolean = address != null && existingDatabases.contains(address)
companion object {
private const val DEFAULT_CACHE_LIMIT = 100
}
}

View file

@ -0,0 +1,36 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
/** A real [DatabaseProvider] that uses an in-memory database for testing. */
class FakeDatabaseProvider : DatabaseProvider {
private val db: MeshtasticDatabase = getInMemoryDatabaseBuilder().build()
private val _currentDb = MutableStateFlow(db)
override val currentDb: StateFlow<MeshtasticDatabase> = _currentDb
override suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = block(db)
fun close() {
db.close()
}
}

View file

@ -0,0 +1,37 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.proto.LocalStats
/** A test double for [LocalStatsDataSource] that provides an in-memory implementation. */
class FakeLocalStatsDataSource :
BaseFake(),
LocalStatsDataSource {
private val _localStatsFlow = mutableStateFlow(LocalStats())
override val localStatsFlow: StateFlow<LocalStats> = _localStatsFlow
override suspend fun setLocalStats(stats: LocalStats) {
_localStatsFlow.value = stats
}
override suspend fun clearLocalStats() {
_localStatsFlow.value = LocalStats()
}
}

View file

@ -0,0 +1,45 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.repository.Location
import org.meshtastic.core.repository.LocationRepository
/** A test double for [LocationRepository] that provides a manual location emission mechanism. */
class FakeLocationRepository : LocationRepository {
private val _receivingLocationUpdates = MutableStateFlow(false)
override val receivingLocationUpdates: StateFlow<Boolean> = _receivingLocationUpdates
private val _locations = MutableSharedFlow<Location>(replay = 1)
override fun getLocations(): Flow<Location> = _locations
fun setReceivingLocationUpdates(receiving: Boolean) {
_receivingLocationUpdates.value = receiving
}
suspend fun emitLocation(location: Location) {
_locations.emit(location)
}
}
/** Platform-specific factory for creating [Location] objects in tests. */
expect fun createLocation(latitude: Double, longitude: Double, altitude: Double = 0.0): Location

View file

@ -16,18 +16,19 @@
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.repository.MeshLogPrefs
class FakeMeshLogPrefs : MeshLogPrefs {
private val _retentionDays = MutableStateFlow(MeshLogPrefs.DEFAULT_RETENTION_DAYS)
class FakeMeshLogPrefs :
BaseFake(),
MeshLogPrefs {
private val _retentionDays = mutableStateFlow(MeshLogPrefs.DEFAULT_RETENTION_DAYS)
override val retentionDays = _retentionDays
override fun setRetentionDays(days: Int) {
_retentionDays.value = days
}
private val _loggingEnabled = MutableStateFlow(true)
private val _loggingEnabled = mutableStateFlow(true)
override val loggingEnabled = _loggingEnabled
override fun setLoggingEnabled(enabled: Boolean) {

View file

@ -26,13 +26,26 @@ import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
/** A test double for [MeshLogRepository] that provides in-memory log storage. */
@Suppress("TooManyFunctions")
class FakeMeshLogRepository : MeshLogRepository {
private val logsFlow = MutableStateFlow<List<MeshLog>>(emptyList())
class FakeMeshLogRepository :
BaseFake(),
MeshLogRepository {
private val logsFlow = mutableStateFlow<List<MeshLog>>(emptyList())
val currentLogs: List<MeshLog>
get() = logsFlow.value
var deleteLogsOlderThanCalledDays: Int? = null
var lastDeletedOlderThan: Int? = null
private set
var deleteAllCalled = false
private set
override fun reset() {
super.reset()
lastDeletedOlderThan = null
deleteAllCalled = false
}
override fun getAllLogs(maxItem: Int): Flow<List<MeshLog>> = logsFlow.map { it.take(maxItem) }
@ -59,6 +72,7 @@ class FakeMeshLogRepository : MeshLogRepository {
override suspend fun deleteAll() {
logsFlow.value = emptyList()
deleteAllCalled = true
}
override suspend fun deleteLog(uuid: String) {
@ -70,7 +84,7 @@ class FakeMeshLogRepository : MeshLogRepository {
}
override suspend fun deleteLogsOlderThan(retentionDays: Int) {
deleteLogsOlderThanCalledDays = retentionDays
lastDeletedOlderThan = retentionDays
}
fun setLogs(logs: List<MeshLog>) {

View file

@ -0,0 +1,38 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
/**
* A container for all mesh-related fakes to simplify test setup.
*
* Instead of manually instantiating and wiring multiple fakes, you can use [FakeMeshService] to get a consistent set of
* test doubles.
*/
class FakeMeshService {
val nodeRepository = FakeNodeRepository()
val serviceRepository = FakeServiceRepository()
val radioController = FakeRadioController()
val radioInterfaceService = FakeRadioInterfaceService()
val notifications = FakeMeshServiceNotifications()
val transport = FakeRadioTransport()
val logRepository = FakeMeshLogRepository()
val packetRepository = FakePacketRepository()
val contactRepository = FakeContactRepository()
val locationRepository = FakeLocationRepository()
// Add more as they are implemented
}

View file

@ -0,0 +1,75 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
/** A test double for [MeshServiceNotifications] that provides a no-op implementation. */
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun clearNotifications() {}
override fun initChannels() {}
override fun updateServiceStateNotification(
state: org.meshtastic.core.model.ConnectionState,
telemetry: Telemetry?,
): Any = Any()
override suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {}
override suspend fun updateWaypointNotification(
contactKey: String,
name: String,
message: String,
waypointId: Int,
isSilent: Boolean,
) {}
override suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {}
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
override fun showNewNodeSeenNotification(node: Node) {}
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
override fun showClientNotification(clientNotification: ClientNotification) {}
override fun cancelMessageNotification(contactKey: String) {}
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
}

View file

@ -41,21 +41,23 @@ import org.meshtastic.proto.User
* ```
*/
@Suppress("TooManyFunctions")
class FakeNodeRepository : NodeRepository {
class FakeNodeRepository :
BaseFake(),
NodeRepository {
private val _myNodeInfo = MutableStateFlow<MyNodeInfo?>(null)
private val _myNodeInfo = mutableStateFlow<MyNodeInfo?>(null)
override val myNodeInfo: StateFlow<MyNodeInfo?> = _myNodeInfo
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
private val _ourNodeInfo = mutableStateFlow<Node?>(null)
override val ourNodeInfo: StateFlow<Node?> = _ourNodeInfo
private val _myId = MutableStateFlow<String?>(null)
private val _myId = mutableStateFlow<String?>(null)
override val myId: StateFlow<String?> = _myId
private val _localStats = MutableStateFlow(LocalStats())
private val _localStats = mutableStateFlow(LocalStats())
override val localStats: StateFlow<LocalStats> = _localStats
private val _nodeDBbyNum = MutableStateFlow<Map<Int, Node>>(emptyMap())
private val _nodeDBbyNum = mutableStateFlow<Map<Int, Node>>(emptyMap())
override val nodeDBbyNum: StateFlow<Map<Int, Node>> = _nodeDBbyNum
override val onlineNodeCount: Flow<Int> = _nodeDBbyNum.map { it.size }
@ -82,18 +84,51 @@ class FakeNodeRepository : NodeRepository {
onlyDirect: Boolean,
): Flow<List<Node>> = _nodeDBbyNum.map { db ->
db.values
.asSequence()
.filter { filterNode(it, filter, includeUnknown, onlyOnline, onlyDirect) }
.toList()
.let { nodes -> if (filter.isBlank()) nodes else nodes.filter { it.user.long_name.contains(filter) } }
.sortedBy { it.num }
.let { nodes ->
when (sort) {
NodeSortOption.ALPHABETICAL -> nodes.sortedBy { it.user.long_name.lowercase() }
NodeSortOption.LAST_HEARD -> nodes.sortedByDescending { it.lastHeard }
NodeSortOption.DISTANCE -> nodes.sortedBy { it.position.latitude_i } // Simplified
NodeSortOption.HOPS_AWAY -> nodes.sortedBy { it.hopsAway }
NodeSortOption.CHANNEL -> nodes.sortedBy { it.channel }
NodeSortOption.VIA_MQTT -> nodes.sortedBy { if (it.viaMqtt) 0 else 1 }
NodeSortOption.VIA_FAVORITE -> nodes.sortedBy { if (it.isFavorite) 0 else 1 }
}
}
}
private fun filterNode(
node: Node,
filter: String,
includeUnknown: Boolean,
onlyOnline: Boolean,
onlyDirect: Boolean,
): Boolean {
val matchesFilter =
filter.isBlank() ||
node.user.long_name.contains(filter, ignoreCase = true) ||
node.user.id.contains(filter, ignoreCase = true)
val matchesUnknown = includeUnknown || !node.isUnknownUser
val matchesOnline = !onlyOnline || node.isOnline
val matchesDirect = !onlyDirect || node.hopsAway == 0
return matchesFilter && matchesUnknown && matchesOnline && matchesDirect
}
override suspend fun getNodesOlderThan(lastHeard: Int): List<Node> =
_nodeDBbyNum.value.values.filter { it.lastHeard < lastHeard }
override suspend fun getUnknownNodes(): List<Node> = emptyList()
override suspend fun getUnknownNodes(): List<Node> = _nodeDBbyNum.value.values.filter { it.isUnknownUser }
override suspend fun clearNodeDB(preserveFavorites: Boolean) {
_nodeDBbyNum.value = emptyMap()
if (preserveFavorites) {
_nodeDBbyNum.value = _nodeDBbyNum.value.filter { it.value.isFavorite }
} else {
_nodeDBbyNum.value = emptyMap()
}
}
override suspend fun clearMyNodeInfo() {
@ -108,7 +143,10 @@ class FakeNodeRepository : NodeRepository {
_nodeDBbyNum.value = _nodeDBbyNum.value - nodeNums.toSet()
}
override suspend fun setNodeNotes(num: Int, notes: String) = Unit
override suspend fun setNodeNotes(num: Int, notes: String) {
val node = _nodeDBbyNum.value[num] ?: return
_nodeDBbyNum.value = _nodeDBbyNum.value + (num to node.copy(notes = notes))
}
override suspend fun upsert(node: Node) {
_nodeDBbyNum.value = _nodeDBbyNum.value + (node.num to node)
@ -119,7 +157,10 @@ class FakeNodeRepository : NodeRepository {
_nodeDBbyNum.value = nodes.associateBy { it.num }
}
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) = Unit
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {
val node = _nodeDBbyNum.value[nodeNum] ?: return
_nodeDBbyNum.value = _nodeDBbyNum.value + (nodeNum to node.copy(metadata = metadata))
}
// --- Helper methods for testing ---
@ -134,4 +175,8 @@ class FakeNodeRepository : NodeRepository {
fun setOurNode(node: Node?) {
_ourNodeInfo.value = node
}
fun setMyNodeInfo(info: MyNodeInfo?) {
_myNodeInfo.value = info
}
}

View file

@ -0,0 +1,40 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.repository.NotificationPrefs
class FakeNotificationPrefs : NotificationPrefs {
override val messagesEnabled = MutableStateFlow(true)
override fun setMessagesEnabled(enabled: Boolean) {
messagesEnabled.value = enabled
}
override val nodeEventsEnabled = MutableStateFlow(true)
override fun setNodeEventsEnabled(enabled: Boolean) {
nodeEventsEnabled.value = enabled
}
override val lowBatteryEnabled = MutableStateFlow(true)
override fun setLowBatteryEnabled(enabled: Boolean) {
lowBatteryEnabled.value = enabled
}
}

View file

@ -16,7 +16,6 @@
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
@ -25,34 +24,41 @@ import org.meshtastic.proto.ClientNotification
/**
* A test double for [RadioController] that provides a no-op implementation and tracks calls for assertions in tests.
*
* Use this in place of mocking the entire RadioController interface when you need fine-grained control over connection
* state and packet tracking.
*
* Example:
* ```kotlin
* val radioController = FakeRadioController()
* radioController.setConnectionState(ConnectionState.Connected)
* // ... perform test ...
* assertEquals(1, radioController.sentPackets.size)
* ```
*/
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
class FakeRadioController : RadioController {
class FakeRadioController :
BaseFake(),
RadioController {
// Mutable state flows so we can manipulate them in our tests
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Connected)
private val _connectionState = mutableStateFlow<ConnectionState>(ConnectionState.Connected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
private val _clientNotification = MutableStateFlow<ClientNotification?>(null)
private val _clientNotification = mutableStateFlow<ClientNotification?>(null)
override val clientNotification: StateFlow<ClientNotification?> = _clientNotification
// Track sent packets to assert in tests
val sentPackets = mutableListOf<DataPacket>()
val favoritedNodes = mutableListOf<Int>()
val sentSharedContacts = mutableListOf<Int>()
var throwOnSend: Boolean = false
var lastSetDeviceAddress: String? = null
var beginEditSettingsCalled = false
var commitEditSettingsCalled = false
var startProvideLocationCalled = false
var stopProvideLocationCalled = false
init {
registerResetAction {
sentPackets.clear()
favoritedNodes.clear()
sentSharedContacts.clear()
throwOnSend = false
lastSetDeviceAddress = null
beginEditSettingsCalled = false
commitEditSettingsCalled = false
startProvideLocationCalled = false
stopProvideLocationCalled = false
}
}
override suspend fun sendMessage(packet: DataPacket) {
if (throwOnSend) error("Fake send failure")
@ -127,15 +133,23 @@ class FakeRadioController : RadioController {
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {}
override suspend fun beginEditSettings(destNum: Int) {}
override suspend fun beginEditSettings(destNum: Int) {
beginEditSettingsCalled = true
}
override suspend fun commitEditSettings(destNum: Int) {}
override suspend fun commitEditSettings(destNum: Int) {
commitEditSettingsCalled = true
}
override fun getPacketId(): Int = 1
override fun startProvideLocation() {}
override fun startProvideLocation() {
startProvideLocationCalled = true
}
override fun stopProvideLocation() {}
override fun stopProvideLocation() {
stopProvideLocationCalled = true
}
override fun setDeviceAddress(address: String) {
lastSetDeviceAddress = address

View file

@ -0,0 +1,92 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.repository.RadioInterfaceService
/** A test double for [RadioInterfaceService] that provides an in-memory implementation. */
@Suppress("TooManyFunctions")
class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = MainScope()) : RadioInterfaceService {
override val supportedDeviceTypes: List<DeviceType> = emptyList()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(null)
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow
private val _receivedData = MutableSharedFlow<ByteArray>()
override val receivedData: SharedFlow<ByteArray> = _receivedData
private val _meshActivity = MutableSharedFlow<MeshActivity>()
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity
val sentToRadio = mutableListOf<ByteArray>()
var connectCalled = false
override fun isMockInterface(): Boolean = true
override fun sendToRadio(bytes: ByteArray) {
sentToRadio.add(bytes)
}
override fun connect() {
connectCalled = true
}
override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
override fun setDeviceAddress(deviceAddr: String?): Boolean {
_currentDeviceAddressFlow.value = deviceAddr
return true
}
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "$interfaceId:$rest"
override fun onConnect() {
_connectionState.value = ConnectionState.Connected
}
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
_connectionState.value = ConnectionState.Disconnected
}
override fun handleFromRadio(bytes: ByteArray) {
// In a real implementation, this would emit to receivedData
}
// --- Helper methods for testing ---
suspend fun emitFromRadio(bytes: ByteArray) {
_receivedData.emit(bytes)
}
fun setConnectionState(state: ConnectionState) {
_connectionState.value = state
}
}

View file

@ -0,0 +1,38 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.repository.RadioTransport
/** A test double for [RadioTransport] that tracks sent data. */
class FakeRadioTransport : RadioTransport {
val sentData = mutableListOf<ByteArray>()
var closeCalled = false
var keepAliveCalled = false
override fun handleSendToRadio(p: ByteArray) {
sentData.add(p)
}
override fun keepAlive() {
keepAliveCalled = true
}
override fun close() {
closeCalled = true
}
}

View file

@ -17,6 +17,7 @@
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.proto.User
@ -36,6 +37,7 @@ object TestDataFactory {
* @param longName User long name (default: "Test User")
* @param shortName User short name (default: "T")
* @param lastHeard Last heard timestamp in seconds (default: 0)
* @param hwModel Hardware model (default: UNSET)
* @return A Node instance with provided or default values
*/
fun createTestNode(
@ -44,18 +46,31 @@ object TestDataFactory {
longName: String = "Test User",
shortName: String = "T",
lastHeard: Int = 0,
hwModel: org.meshtastic.proto.HardwareModel = org.meshtastic.proto.HardwareModel.UNSET,
batteryLevel: Int? = 100,
): Node {
val user = User(id = userId, long_name = longName, short_name = shortName)
return Node(num = num, user = user, lastHeard = lastHeard, snr = 0f, rssi = 0, channel = 0)
val user = User(id = userId, long_name = longName, short_name = shortName, hw_model = hwModel)
val metrics = org.meshtastic.proto.DeviceMetrics(battery_level = batteryLevel)
return Node(
num = num,
user = user,
lastHeard = lastHeard,
snr = 0f,
rssi = 0,
channel = 0,
deviceMetrics = metrics,
)
}
/**
* Creates multiple test nodes with sequential IDs.
*
* @param count Number of nodes to create
* @param baseNum Starting node number (default: 1)
* @return A list of Node instances
*/
/** Creates a test [org.meshtastic.proto.MeshPacket] with default values. */
fun createTestPacket(
from: Int = 1,
to: Int = 0xffffffff.toInt(),
decoded: org.meshtastic.proto.Data? = null,
relayNode: Int = 0,
) = org.meshtastic.proto.MeshPacket(from = from, to = to, decoded = decoded, relay_node = relayNode)
/** Creates multiple test nodes with sequential IDs. */
fun createTestNodes(count: Int, baseNum: Int = 1): List<Node> = (0 until count).map { i ->
createTestNode(
num = baseNum + i,
@ -64,6 +79,32 @@ object TestDataFactory {
shortName = "T$i",
)
}
/** Creates a test [MyNodeInfo] with default values. */
fun createMyNodeInfo(
myNodeNum: Int = 1,
hasGPS: Boolean = false,
model: String? = "TBEAM",
firmwareVersion: String? = "2.5.0",
hasWifi: Boolean = false,
pioEnv: String? = null,
) = MyNodeInfo(
myNodeNum = myNodeNum,
hasGPS = hasGPS,
model = model,
firmwareVersion = firmwareVersion,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 300000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = hasWifi,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = "!$myNodeNum",
pioEnv = pioEnv,
)
}
/**

View file

@ -0,0 +1,20 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
/** Initializes platform-specific test context (e.g., Robolectric on Android). */
expect fun setupTestContext()

View file

@ -0,0 +1,140 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import app.cash.turbine.test
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FakeNodeRepositoryTest {
private val repository = FakeNodeRepository()
@Test
fun `getNodes sorting by name`() = runTest {
val nodes =
listOf(
Node(num = 1, user = User(long_name = "Charlie")),
Node(num = 2, user = User(long_name = "Alice")),
Node(num = 3, user = User(long_name = "Bob")),
)
repository.setNodes(nodes)
repository.getNodes(sort = NodeSortOption.ALPHABETICAL).test {
val result = awaitItem()
assertEquals("Alice", result[0].user.long_name)
assertEquals("Bob", result[1].user.long_name)
assertEquals("Charlie", result[2].user.long_name)
}
}
@Test
fun `getUnknownNodes returns nodes with UNSET hw_model`() = runTest {
val node1 = Node(num = 1, user = User(hw_model = org.meshtastic.proto.HardwareModel.UNSET))
val node2 = Node(num = 2, user = User(hw_model = org.meshtastic.proto.HardwareModel.TLORA_V2))
repository.setNodes(listOf(node1, node2))
val result = repository.getUnknownNodes()
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
@Test
fun `getNodes filtering by onlyOnline`() = runTest {
val node1 = Node(num = 1, lastHeard = 2000000000) // Online
val node2 = Node(num = 2, lastHeard = 0) // Offline
repository.setNodes(listOf(node1, node2))
repository.getNodes(onlyOnline = true).test {
val result = awaitItem()
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
}
@Test
fun `getNodes filtering by onlyDirect`() = runTest {
val node1 = Node(num = 1, hopsAway = 0) // Direct
val node2 = Node(num = 2, hopsAway = 1) // Indirect
repository.setNodes(listOf(node1, node2))
repository.getNodes(onlyDirect = true).test {
val result = awaitItem()
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
}
@Test
fun `insertMetadata updates node metadata`() = runTest {
val nodeNum = 1234
repository.upsert(Node(num = nodeNum))
val metadata = org.meshtastic.proto.DeviceMetadata(firmware_version = "2.5.0")
repository.insertMetadata(nodeNum, metadata)
val node = repository.nodeDBbyNum.value[nodeNum]
assertEquals("2.5.0", node?.metadata?.firmware_version)
}
@Test
fun `deleteNodes removes multiple nodes`() = runTest {
repository.setNodes(listOf(Node(num = 1), Node(num = 2), Node(num = 3)))
repository.deleteNodes(listOf(1, 2))
assertEquals(1, repository.nodeDBbyNum.value.size)
assertTrue(repository.nodeDBbyNum.value.containsKey(3))
}
@Test
fun `reset clears all state`() = runTest {
repository.setNodes(listOf(Node(num = 1)))
repository.setMyId("my-id")
repository.setNodeNotes(1, "note")
repository.reset()
assertTrue(repository.nodeDBbyNum.value.isEmpty())
assertEquals(null, repository.myId.value)
}
@Test
fun `setNodeNotes persists notes`() = runTest {
val nodeNum = 1234
repository.upsert(Node(num = nodeNum))
repository.setNodeNotes(nodeNum, "My Note")
val node = repository.nodeDBbyNum.value[nodeNum]
assertEquals("My Note", node?.notes)
}
@Test
fun `clearNodeDB preserves favorites`() = runTest {
val node1 = Node(num = 1, isFavorite = true)
val node2 = Node(num = 2, isFavorite = false)
repository.setNodes(listOf(node1, node2))
repository.clearNodeDB(preserveFavorites = true)
assertEquals(1, repository.nodeDBbyNum.value.size)
assertTrue(repository.nodeDBbyNum.value.containsKey(1))
}
}

View file

@ -0,0 +1,22 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.repository.Location
/** Creates a placeholder iOS [Location] for testing. */
actual fun createLocation(latitude: Double, longitude: Double, altitude: Double): Location = Location()

View file

@ -0,0 +1,19 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
actual fun setupTestContext() {}

View file

@ -0,0 +1,22 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.repository.Location
/** Creates a placeholder JVM [Location] for testing. */
actual fun createLocation(latitude: Double, longitude: Double, altitude: Double): Location = Location()

View file

@ -0,0 +1,20 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
@Suppress("EmptyFunctionBlock")
actual fun setupTestContext() {}

View file

@ -86,3 +86,66 @@ actual fun rememberOpenUrl(): (url: String) -> Unit {
}
}
}
@Composable
@Suppress("Wrapping")
actual fun rememberSaveFileLauncher(
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit {
val launcher =
androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode == android.app.Activity.RESULT_OK) {
result.data?.data?.let { uri ->
onUriReceived(uri.toString().let { org.meshtastic.core.common.util.MeshtasticUri(it) })
}
}
}
return remember(launcher) {
{ defaultFilename, mimeType ->
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, defaultFilename)
}
launcher.launch(intent)
}
}
}
@Composable
actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit {
val launcher =
androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(),
) { permissions ->
if (permissions.values.any { it }) {
onGranted()
} else {
onDenied()
}
}
return remember(launcher) {
{
launcher.launch(
arrayOf(
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
),
)
}
}
}
@Composable
actual fun rememberOpenLocationSettings(): () -> Unit {
val launcher =
androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult(),
) { _ ->
}
return remember(launcher) { { launcher.launch(Intent(android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS)) } }
}

View file

@ -0,0 +1,107 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
/**
* A wrapper around [ConnectionsNavIcon] that adds a blinking glow effect when there is mesh activity (Send/Receive).
*/
@Composable
fun AnimatedConnectionsNavIcon(
connectionState: ConnectionState,
deviceType: DeviceType?,
meshActivityFlow: Flow<MeshActivity>,
modifier: Modifier = Modifier,
) {
val colorScheme = androidx.compose.material3.MaterialTheme.colorScheme
var currentGlowColor by remember { mutableStateOf(Color.Transparent) }
val animatedGlowAlpha = remember { Animatable(0f) }
val sendColor = colorScheme.StatusGreen
val receiveColor = colorScheme.StatusBlue
LaunchedEffect(meshActivityFlow, colorScheme) {
meshActivityFlow.conflate().collect { activity ->
val newTargetColor =
when (activity) {
is MeshActivity.Send -> sendColor
is MeshActivity.Receive -> receiveColor
}
currentGlowColor = newTargetColor
// Suspend the collection until the animation finishes.
// conflate() will drop any fast events that arrive during this 1-second animation.
animatedGlowAlpha.stop()
animatedGlowAlpha.snapTo(1.0f)
animatedGlowAlpha.animateTo(
targetValue = 0.0f,
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
)
}
}
Box(
modifier =
modifier.drawWithCache {
val glowRadius = size.minDimension
val glowBrush =
Brush.radialGradient(
colors =
listOf(
currentGlowColor.copy(alpha = 0.8f),
currentGlowColor.copy(alpha = 0.4f),
Color.Transparent,
),
center = Offset(size.width / 2, size.height / 2),
radius = glowRadius,
)
onDrawWithContent {
drawContent()
val alpha = animatedGlowAlpha.value
if (alpha > 0f) {
drawCircle(brush = glowBrush, radius = glowRadius, alpha = alpha, blendMode = BlendMode.Screen)
}
}
},
) {
ConnectionsNavIcon(connectionState = connectionState, deviceType = deviceType)
}
}

View file

@ -0,0 +1,99 @@
/*
* 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.ui.component
import androidx.compose.animation.Crossfade
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bluetooth
import androidx.compose.material.icons.rounded.Cached
import androidx.compose.material.icons.rounded.Snooze
import androidx.compose.material.icons.rounded.Usb
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.ui.icon.Device
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
@Composable
fun ConnectionsNavIcon(
modifier: Modifier = Modifier,
connectionState: ConnectionState,
deviceType: DeviceType?,
contentDescription: String? = null,
) {
val tint = getTint(connectionState)
val (backgroundIcon, connectionTypeIcon) = getIconPair(deviceType = deviceType, connectionState = connectionState)
val foregroundPainter = connectionTypeIcon?.let { rememberVectorPainter(it) }
Crossfade(targetState = backgroundIcon, label = "ConnectionIcon") {
Icon(
imageVector = it,
contentDescription = contentDescription,
tint = tint,
modifier =
modifier.drawWithContent {
drawContent()
foregroundPainter?.let {
@Suppress("MagicNumber")
val badgeSize = size.width * .45f
with(it) { draw(Size(badgeSize, badgeSize), colorFilter = ColorFilter.tint(tint)) }
}
},
)
}
}
@Composable
private fun getTint(connectionState: ConnectionState): Color = when (connectionState) {
ConnectionState.Connecting -> colorScheme.StatusOrange
ConnectionState.Disconnected -> colorScheme.StatusRed
ConnectionState.DeviceSleep -> colorScheme.StatusYellow
else -> colorScheme.StatusGreen
}
@Composable
fun getIconPair(connectionState: ConnectionState, deviceType: DeviceType? = null): Pair<ImageVector, ImageVector?> =
when (connectionState) {
ConnectionState.Disconnected -> MeshtasticIcons.NoDevice to null
ConnectionState.DeviceSleep -> MeshtasticIcons.Device to Icons.Rounded.Snooze
ConnectionState.Connecting -> MeshtasticIcons.Device to Icons.Rounded.Cached
else ->
MeshtasticIcons.Device to
when (deviceType) {
DeviceType.BLE -> Icons.Rounded.Bluetooth
DeviceType.TCP -> Icons.Rounded.Wifi
DeviceType.USB -> Icons.Rounded.Usb
else -> null
}
}

View file

@ -23,8 +23,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.navigation.DeepLinkRouter
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.ui.viewmodel.UIViewModel
@ -42,12 +40,9 @@ fun MeshtasticAppShell(
content: @Composable () -> Unit,
) {
LaunchedEffect(uiViewModel) {
uiViewModel.navigationDeepLink.collect { uri ->
val commonUri = CommonUri.parse(uri.uriString)
DeepLinkRouter.route(commonUri)?.let { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
}
uiViewModel.navigationDeepLink.collect { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
}
}

View file

@ -0,0 +1,293 @@
/*
* 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.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.window.core.layout.WindowWidthSizeClass
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Shared adaptive navigation shell. Provides a Bottom Navigation bar on phones, and a Navigation Rail on tablets and
* desktop targets.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MeshtasticNavigationSuite(
backStack: NavBackStack<NavKey>,
uiViewModel: UIViewModel,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val connectionState by uiViewModel.connectionState.collectAsStateWithLifecycle()
val unreadMessageCount by uiViewModel.unreadMessageCount.collectAsStateWithLifecycle()
val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
val isCompact = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
val currentKey = backStack.lastOrNull()
val rootKey = backStack.firstOrNull()
val topLevelDestination = TopLevelDestination.fromNavKey(rootKey)
val onNavigate = { destination: TopLevelDestination ->
handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel)
}
if (isCompact) {
Scaffold(
modifier = modifier,
bottomBar = {
MeshtasticNavigationBar(
topLevelDestination = topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
onNavigate = onNavigate,
)
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) { content() }
}
} else {
Row(modifier = modifier.fillMaxSize()) {
MeshtasticNavigationRail(
topLevelDestination = topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
onNavigate = onNavigate,
)
Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() }
}
}
}
private fun handleNavigation(
destination: TopLevelDestination,
topLevelDestination: TopLevelDestination?,
currentKey: NavKey?,
backStack: NavBackStack<NavKey>,
uiViewModel: UIViewModel,
) {
val isRepress = destination == topLevelDestination
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.navigateTopLevel(destination.route)
} else {
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
}
TopLevelDestination.Conversations -> {
val onConversationsList =
currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.navigateTopLevel(destination.route)
} else {
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
}
else -> {
if (currentKey != destination.route) {
backStack.navigateTopLevel(destination.route)
}
}
}
} else {
backStack.navigateTopLevel(destination.route)
}
}
@Composable
private fun MeshtasticNavigationBar(
topLevelDestination: TopLevelDestination?,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
onNavigate: (TopLevelDestination) -> Unit,
) {
NavigationBar {
TopLevelDestination.entries.forEach { destination ->
NavigationBarItem(
selected = destination == topLevelDestination,
onClick = { onNavigate(destination) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = destination == topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
label = { Text(stringResource(destination.label)) },
)
}
}
}
@Composable
private fun MeshtasticNavigationRail(
topLevelDestination: TopLevelDestination?,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
onNavigate: (TopLevelDestination) -> Unit,
) {
NavigationRail {
TopLevelDestination.entries.forEach { destination ->
NavigationRailItem(
selected = destination == topLevelDestination,
onClick = { onNavigate(destination) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = destination == topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
label = { Text(stringResource(destination.label)) },
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NavigationIconContent(
destination: TopLevelDestination,
isSelected: Boolean,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
) {
val isConnectionsRoute = destination == TopLevelDestination.Connections
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping)
ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
},
)
}
},
state = rememberTooltipState(),
) {
if (isConnectionsRoute) {
AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
meshActivityFlow = uiViewModel.meshActivity,
)
} else {
BadgedBox(
badge = {
if (destination == TopLevelDestination.Conversations) {
var lastNonZeroCount by remember { mutableIntStateOf(unreadMessageCount) }
if (unreadMessageCount > 0) {
lastNonZeroCount = unreadMessageCount
}
AnimatedVisibility(
visible = unreadMessageCount > 0,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
) {
Badge { Text(lastNonZeroCount.toString()) }
}
}
},
) {
Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
tint = if (isSelectedState) colorScheme.primary else LocalContentColor.current,
)
}
}
}
}
}

View file

@ -0,0 +1,33 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.core.ui.component.PlaceholderScreen
/**
* Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it
* falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalMapMainScreenProvider =
compositionLocalOf<
@Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit,
> {
{ _, _, _ -> PlaceholderScreen("Map") }
}

View file

@ -0,0 +1,31 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.core.ui.component.PlaceholderScreen
/**
* Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM
* targets where native maps aren't available yet, it falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalNodeMapScreenProvider =
compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> {
{ destNum, _ -> PlaceholderScreen("Node Map ($destNum)") }
}

View file

@ -0,0 +1,31 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.core.ui.component.PlaceholderScreen
/**
* Provides the platform-specific Traceroute Map Screen. On Desktop or JVM targets where native maps aren't available
* yet, it falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalTracerouteMapScreenProvider =
compositionLocalOf<@Composable (destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) -> Unit> {
{ _, _, _, _ -> PlaceholderScreen("Traceroute Map") }
}

View file

@ -33,3 +33,15 @@ import org.jetbrains.compose.resources.StringResource
/** Returns a function to open the platform's browser with the given URL. */
@Composable expect fun rememberOpenUrl(): (url: String) -> Unit
/** Returns a launcher function to prompt the user to save a file. The callback receives the saved file URI. */
@Composable
expect fun rememberSaveFileLauncher(
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit
/** Returns a launcher to request location permissions. */
@Composable expect fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit = {}): () -> Unit
/** Returns a launcher to open the platform's location settings. */
@Composable expect fun rememberOpenLocationSettings(): () -> Unit

View file

@ -35,7 +35,6 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MyNodeInfo
@ -44,6 +43,7 @@ import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
@ -84,7 +84,7 @@ class UIViewModel(
val snackbarManager: SnackbarManager,
) : ViewModel() {
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
private val _navigationDeepLink = MutableSharedFlow<List<androidx.navigation3.runtime.NavKey>>(replay = 1)
val navigationDeepLink = _navigationDeepLink.asSharedFlow()
/**
@ -100,8 +100,9 @@ class UIViewModel(
val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
// Try navigation routing first
if (org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri) != null) {
_navigationDeepLink.tryEmit(uri)
val navKeys = org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri)
if (navKeys != null) {
_navigationDeepLink.tryEmit(navKeys)
return
}
@ -127,6 +128,8 @@ class UIViewModel(
/** Emits events for mesh network send/receive activity. */
val meshActivity: Flow<MeshActivity> = radioInterfaceService.meshActivity
val currentDeviceAddressFlow: StateFlow<String?> = radioInterfaceService.currentDeviceAddressFlow
private val _scrollToTopEventFlow =
MutableSharedFlow<ScrollToTopEvent>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val scrollToTopEventFlow: Flow<ScrollToTopEvent> = _scrollToTopEventFlow.asSharedFlow()

View file

@ -37,4 +37,13 @@ actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): A
@Composable actual fun rememberOpenUrl(): (url: String) -> Unit = { _ -> }
@Composable
actual fun rememberSaveFileLauncher(
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> }
@Composable actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {}
@Composable actual fun rememberOpenLocationSettings(): () -> Unit = {}
@Composable actual fun SetScreenBrightness(brightness: Float) {}

View file

@ -46,3 +46,20 @@ actual fun rememberOpenUrl(): (url: String) -> Unit = { url ->
Logger.w(e) { "Failed to open URL: $url" }
}
}
/** JVM stub — Save file launcher is a no-op on desktop until implemented. */
@Composable
actual fun rememberSaveFileLauncher(
onUriReceived: (org.meshtastic.core.common.util.MeshtasticUri) -> Unit,
): (defaultFilename: String, mimeType: String) -> Unit = { _, _ ->
Logger.w { "File saving not implemented on Desktop" }
}
@Composable
actual fun rememberRequestLocationPermission(onGranted: () -> Unit, onDenied: () -> Unit): () -> Unit = {
Logger.w { "Location permissions not implemented on Desktop" }
onDenied()
}
@Composable
actual fun rememberOpenLocationSettings(): () -> Unit = { Logger.w { "Location settings not implemented on Desktop" } }