diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bc5dab9d3..c3e1bab7a 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -67,11 +67,7 @@
-->
-
-
-
+
diff --git a/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt
new file mode 100644
index 000000000..adf943844
--- /dev/null
+++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.repository
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.repository.NodeMetadata
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import org.meshtastic.core.testing.setupTestContext
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+@OptIn(ExperimentalCoroutinesApi::class)
+class AppMetadataRepositoryImplTest {
+
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var repository: AppMetadataRepositoryImpl
+
+ @BeforeTest
+ fun setUp() {
+ setupTestContext()
+ dbProvider = FakeDatabaseProvider()
+ repository = AppMetadataRepositoryImpl(dbProvider)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ dbProvider.close()
+ }
+
+ @Test
+ fun `metadataByNum starts empty`() = runTest {
+ assertTrue(repository.metadataByNum.first().isEmpty())
+ }
+
+ @Test
+ fun `setFavorite creates missing metadata row`() = runTest {
+ repository.setFavorite(nodeNum = 101, isFavorite = true)
+ advanceUntilIdle()
+
+ assertEquals(
+ NodeMetadata(num = 101, isFavorite = true, notes = ""),
+ repository.metadataByNum.first().getValue(101),
+ )
+ }
+
+ @Test
+ fun `setIgnored creates missing metadata row`() = runTest {
+ repository.setIgnored(nodeNum = 102, isIgnored = true)
+ advanceUntilIdle()
+
+ assertEquals(
+ NodeMetadata(num = 102, isIgnored = true, notes = ""),
+ repository.metadataByNum.first().getValue(102),
+ )
+ }
+
+ @Test
+ fun `setMuted and setNotes preserve existing flags`() = runTest {
+ repository.setFavorite(nodeNum = 103, isFavorite = true)
+ repository.setMuted(nodeNum = 103, isMuted = true)
+ repository.setNotes(nodeNum = 103, notes = "Portable node")
+ advanceUntilIdle()
+
+ assertEquals(
+ NodeMetadata(num = 103, isFavorite = true, isMuted = true, notes = "Portable node"),
+ repository.metadataByNum.first().getValue(103),
+ )
+ }
+
+ @Test
+ fun `setManuallyVerified updates verification flag`() = runTest {
+ repository.setManuallyVerified(nodeNum = 104, verified = true)
+ advanceUntilIdle()
+
+ assertEquals(
+ NodeMetadata(num = 104, manuallyVerified = true, notes = ""),
+ repository.metadataByNum.first().getValue(104),
+ )
+ }
+
+ @Test
+ fun `repeated updates keep a single metadata entry per node`() = runTest {
+ repository.setFavorite(nodeNum = 105, isFavorite = true)
+ repository.setFavorite(nodeNum = 105, isFavorite = false)
+ repository.setNotes(nodeNum = 105, notes = "Updated")
+ advanceUntilIdle()
+
+ val metadata = repository.metadataByNum.first()
+ assertEquals(1, metadata.size)
+ assertEquals(NodeMetadata(num = 105, notes = "Updated"), metadata.getValue(105))
+ }
+
+ @Test
+ fun `delete removes existing metadata`() = runTest {
+ repository.setFavorite(nodeNum = 106, isFavorite = true)
+ advanceUntilIdle()
+
+ repository.delete(106)
+ advanceUntilIdle()
+
+ assertTrue(repository.metadataByNum.first().isEmpty())
+ }
+
+ @Test
+ fun `delete missing metadata is a no-op`() = runTest {
+ repository.delete(999)
+ advanceUntilIdle()
+
+ assertTrue(repository.metadataByNum.first().isEmpty())
+ }
+
+ @Test
+ fun `metadataByNum flow reflects create update and delete changes`() = runTest {
+ val created = backgroundScope.async(UnconfinedTestDispatcher(testScheduler)) {
+ repository.metadataByNum.drop(1).first { it[107]?.isFavorite == true }
+ }
+
+ repository.setFavorite(nodeNum = 107, isFavorite = true)
+ advanceUntilIdle()
+ assertEquals(NodeMetadata(num = 107, isFavorite = true, notes = ""), created.await().getValue(107))
+
+ val updated = backgroundScope.async(UnconfinedTestDispatcher(testScheduler)) {
+ repository.metadataByNum.drop(1).first { it[107]?.notes == "Flow note" }
+ }
+
+ repository.setNotes(nodeNum = 107, notes = "Flow note")
+ advanceUntilIdle()
+ assertEquals("Flow note", updated.await().getValue(107).notes)
+
+ val deleted = backgroundScope.async(UnconfinedTestDispatcher(testScheduler)) {
+ repository.metadataByNum.drop(1).first { it.isEmpty() }
+ }
+
+ repository.delete(107)
+ advanceUntilIdle()
+ assertTrue(deleted.await().isEmpty())
+ }
+
+ @Test
+ fun `concurrent updates on same missing node produce one merged row`() = runTest {
+ coroutineScope {
+ launch { repository.setFavorite(nodeNum = 108, isFavorite = true) }
+ launch { repository.setIgnored(nodeNum = 108, isIgnored = true) }
+ launch { repository.setMuted(nodeNum = 108, isMuted = true) }
+ launch { repository.setNotes(nodeNum = 108, notes = "Concurrent") }
+ launch { repository.setManuallyVerified(nodeNum = 108, verified = true) }
+ }
+ advanceUntilIdle()
+
+ val metadata = repository.metadataByNum.first()
+ assertEquals(1, metadata.size)
+ assertEquals(
+ NodeMetadata(
+ num = 108,
+ isFavorite = true,
+ isIgnored = true,
+ isMuted = true,
+ notes = "Concurrent",
+ manuallyVerified = true,
+ ),
+ metadata.getValue(108),
+ )
+ }
+
+ @Test
+ fun `updates for multiple nodes stay isolated`() = runTest {
+ repository.setFavorite(nodeNum = 201, isFavorite = true)
+ repository.setIgnored(nodeNum = 202, isIgnored = true)
+ repository.setNotes(nodeNum = 203, notes = "Third")
+ advanceUntilIdle()
+
+ val metadata = repository.metadataByNum.first()
+ assertEquals(3, metadata.size)
+ assertEquals(NodeMetadata(num = 201, isFavorite = true, notes = ""), metadata.getValue(201))
+ assertEquals(NodeMetadata(num = 202, isIgnored = true, notes = ""), metadata.getValue(202))
+ assertEquals(NodeMetadata(num = 203, notes = "Third"), metadata.getValue(203))
+ }
+}
diff --git a/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt
new file mode 100644
index 000000000..519026dbd
--- /dev/null
+++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt
@@ -0,0 +1,308 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.repository
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
+import dev.mokkery.answering.returns
+import dev.mokkery.answering.throws
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.mock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
+import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
+import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
+import org.meshtastic.core.database.entity.asEntity
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.BootloaderOtaQuirk
+import org.meshtastic.core.model.NetworkDeviceHardware
+import org.meshtastic.core.model.util.TimeConstants
+import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
+import org.meshtastic.core.network.service.ApiService
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import org.meshtastic.core.testing.setupTestContext
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+@OptIn(ExperimentalCoroutinesApi::class)
+class DeviceHardwareRepositoryImplTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
+
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var apiService: ApiService
+ private lateinit var jsonDataSource: DeviceHardwareJsonDataSource
+ private lateinit var quirksJsonDataSource: BootloaderOtaQuirksJsonDataSource
+ private lateinit var repository: DeviceHardwareRepositoryImpl
+
+ private var remoteCallCount = 0
+ private var jsonCallCount = 0
+
+ @BeforeTest
+ fun setUp() {
+ setupTestContext()
+ dbProvider = FakeDatabaseProvider()
+ apiService = mock(MockMode.autofill)
+ jsonDataSource = mock(MockMode.autofill)
+ quirksJsonDataSource = mock(MockMode.autofill)
+
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ emptyList()
+ }
+ every { jsonDataSource.loadDeviceHardwareFromJsonAsset() } calls {
+ jsonCallCount += 1
+ emptyList()
+ }
+ every { quirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns emptyList()
+
+ repository = DeviceHardwareRepositoryImpl(
+ remoteDataSource = DeviceHardwareRemoteDataSource(apiService, dispatchers),
+ localDataSource = DeviceHardwareLocalDataSource(dbProvider, dispatchers),
+ jsonDataSource = jsonDataSource,
+ bootloaderOtaQuirksJsonDataSource = quirksJsonDataSource,
+ dispatchers = dispatchers,
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ dbProvider.close()
+ }
+
+ @Test
+ fun `returns fresh cached hardware without hitting remote sources`() = runTest(testDispatcher) {
+ cacheHardware(hardware(hwModel = 1, target = "t-echo", displayName = "Cached"))
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 1)
+
+ assertEquals("Cached", result.getOrNull()?.displayName)
+ assertEquals(0, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `disambiguates cached variants by target ignoring case and preserves reported target`() = runTest(testDispatcher) {
+ cacheHardware(
+ hardware(hwModel = 7, target = "t-beam", displayName = "Beam"),
+ hardware(hwModel = 7, target = "t-deck", displayName = "Deck"),
+ )
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 7, target = "T-DECK")
+ val device = result.getOrNull()
+
+ assertNotNull(device)
+ assertEquals("Deck", device.displayName)
+ assertEquals("T-DECK", device.platformioTarget)
+ assertEquals(0, remoteCallCount)
+ }
+
+ @Test
+ fun `falls back to cached target lookup when model cache is empty`() = runTest(testDispatcher) {
+ cacheHardware(hardware(hwModel = 42, target = "target-only", displayName = "Target Match"))
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 999, target = "target-only")
+ val device = result.getOrNull()
+
+ assertNotNull(device)
+ assertEquals(42, device.hwModel)
+ assertEquals("Target Match", device.displayName)
+ }
+
+ @Test
+ fun `force refresh clears cache and replaces it with remote data`() = runTest(testDispatcher) {
+ cacheHardware(hardware(hwModel = 5, target = "old-target", displayName = "Old Cache"))
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ listOf(hardware(hwModel = 5, target = "new-target", displayName = "Remote Fresh"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 5, forceRefresh = true)
+ val cachedAfterRefresh = dbProvider.currentDb.value.deviceHardwareDao().getByHwModel(5)
+
+ assertEquals("Remote Fresh", result.getOrNull()?.displayName)
+ assertEquals(listOf("new-target"), cachedAfterRefresh.map { it.platformioTarget })
+ assertEquals(1, remoteCallCount)
+ }
+
+ @Test
+ fun `stale cache refreshes from remote and returns updated hardware`() = runTest(testDispatcher) {
+ cacheHardware(
+ hardware(hwModel = 9, target = "stale", displayName = "Stale Cache"),
+ lastUpdated = nowMillis - TimeConstants.ONE_DAY.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ listOf(hardware(hwModel = 9, target = "fresh", displayName = "Fresh Remote"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 9)
+
+ assertEquals("Fresh Remote", result.getOrNull()?.displayName)
+ assertEquals(1, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `returns stale cache when remote fails and stale data is still complete`() = runTest(testDispatcher) {
+ cacheHardware(
+ hardware(hwModel = 10, target = "complete", displayName = "Stale Complete"),
+ lastUpdated = nowMillis - TimeConstants.ONE_DAY.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 10)
+
+ assertEquals("Stale Complete", result.getOrNull()?.displayName)
+ assertTrue(result.isSuccess)
+ assertEquals(1, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `falls back to bundled json when stale cache is incomplete`() = runTest(testDispatcher) {
+ cacheHardware(
+ hardware(hwModel = 11, target = "broken", displayName = "", images = emptyList()),
+ lastUpdated = nowMillis - TimeConstants.ONE_DAY.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+ every { jsonDataSource.loadDeviceHardwareFromJsonAsset() } calls {
+ jsonCallCount += 1
+ listOf(hardware(hwModel = 11, target = "json-target", displayName = "Bundled Json"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 11)
+
+ assertEquals("Bundled Json", result.getOrNull()?.displayName)
+ assertEquals(1, remoteCallCount)
+ assertEquals(1, jsonCallCount)
+ }
+
+ @Test
+ fun `applies bootloader quirks to cached hardware`() = runTest(testDispatcher) {
+ cacheHardware(hardware(hwModel = 12, target = "quirky", displayName = "Quirky"))
+ every { quirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns listOf(
+ BootloaderOtaQuirk(
+ hwModel = 12,
+ requiresBootloaderUpgradeForOta = true,
+ infoUrl = "https://example.invalid/bootloader",
+ ),
+ )
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 12)
+ val device = result.getOrNull()
+
+ assertNotNull(device)
+ assertTrue(device.requiresBootloaderUpgradeForOta == true)
+ assertEquals("https://example.invalid/bootloader", device.bootloaderInfoUrl)
+ }
+
+ @Test
+ fun `returns success null when remote data does not contain requested model`() = runTest(testDispatcher) {
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ listOf(hardware(hwModel = 99, target = "other", displayName = "Other"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 13)
+
+ assertTrue(result.isSuccess)
+ assertNull(result.getOrNull())
+ assertEquals(1, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `uses target lookup after remote fetch when requested model is absent`() = runTest(testDispatcher) {
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ listOf(hardware(hwModel = 77, target = "shared-target", displayName = "Remote Target Match"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 14, target = "shared-target")
+ val device = result.getOrNull()
+
+ assertNotNull(device)
+ assertEquals(77, device.hwModel)
+ assertEquals("shared-target", device.platformioTarget)
+ assertEquals(1, remoteCallCount)
+ }
+
+ @Test
+ fun `returns failure when both remote and bundled json sources fail`() = runTest(testDispatcher) {
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+ every { jsonDataSource.loadDeviceHardwareFromJsonAsset() } calls {
+ jsonCallCount += 1
+ throw IllegalArgumentException("missing asset")
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 15)
+
+ assertTrue(result.isFailure)
+ assertIs(result.exceptionOrNull())
+ assertEquals(1, remoteCallCount)
+ assertEquals(1, jsonCallCount)
+ }
+
+ private suspend fun cacheHardware(vararg hardware: NetworkDeviceHardware, lastUpdated: Long = nowMillis) {
+ dbProvider.currentDb.value.deviceHardwareDao().insertAll(hardware.map { it.asEntity().copy(lastUpdated = lastUpdated) })
+ }
+
+ private fun hardware(
+ hwModel: Int,
+ target: String,
+ displayName: String,
+ images: List? = listOf("$target.png"),
+ ) = NetworkDeviceHardware(
+ activelySupported = true,
+ architecture = "esp32s3",
+ displayName = displayName,
+ hwModel = hwModel,
+ hwModelSlug = "hw-$hwModel",
+ images = images,
+ platformioTarget = target,
+ requiresDfu = false,
+ supportLevel = 3,
+ tags = listOf("portable"),
+ )
+}
diff --git a/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
new file mode 100644
index 000000000..74b21dc7d
--- /dev/null
+++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
@@ -0,0 +1,297 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.repository
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.mock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
+import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
+import org.meshtastic.core.database.entity.FirmwareRelease
+import org.meshtastic.core.database.entity.FirmwareReleaseType
+import org.meshtastic.core.database.entity.asEntity
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.NetworkFirmwareRelease
+import org.meshtastic.core.model.NetworkFirmwareReleases
+import org.meshtastic.core.model.Releases
+import org.meshtastic.core.model.util.TimeConstants
+import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource
+import org.meshtastic.core.network.service.ApiService
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import org.meshtastic.core.testing.setupTestContext
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+@OptIn(ExperimentalCoroutinesApi::class)
+class FirmwareReleaseRepositoryImplTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
+
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var apiService: ApiService
+ private lateinit var jsonDataSource: FirmwareReleaseJsonDataSource
+ private lateinit var repository: FirmwareReleaseRepositoryImpl
+
+ private var remoteCallCount = 0
+ private var jsonCallCount = 0
+
+ @BeforeTest
+ fun setUp() {
+ setupTestContext()
+ dbProvider = FakeDatabaseProvider()
+ apiService = mock(MockMode.autofill)
+ jsonDataSource = mock(MockMode.autofill)
+
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ NetworkFirmwareReleases()
+ }
+ every { jsonDataSource.loadFirmwareReleaseFromJsonAsset() } calls {
+ jsonCallCount += 1
+ NetworkFirmwareReleases()
+ }
+
+ repository = FirmwareReleaseRepositoryImpl(
+ remoteDataSource = FirmwareReleaseRemoteDataSource(apiService, dispatchers),
+ localDataSource = FirmwareReleaseLocalDataSource(dbProvider, dispatchers),
+ jsonDataSource = jsonDataSource,
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ dbProvider.close()
+ }
+
+ @Test
+ fun `empty cache emits null then latest stable from remote`() = runTest(testDispatcher) {
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(
+ stable = listOf(release("v2.9.0.abc"), release("v2.10.0.abc")),
+ alpha = listOf(release("v2.11.0.alpha.1")),
+ )
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, null, "v2.10.0.abc")
+ assertReleaseTypes(emissions, null, FirmwareReleaseType.STABLE)
+ assertEquals(1, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `fresh stable cache emits once and skips remote refresh`() = runTest(testDispatcher) {
+ cacheRelease(release("v2.8.0.abc"), FirmwareReleaseType.STABLE)
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.8.0.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE)
+ assertEquals(0, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `stale stable cache emits stale value then refreshed remote value`() = runTest(testDispatcher) {
+ cacheRelease(
+ release("v2.7.0.abc"),
+ FirmwareReleaseType.STABLE,
+ lastUpdated = nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(stable = listOf(release("v2.10.1.abc")))
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.7.0.abc", "v2.10.1.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE, FirmwareReleaseType.STABLE)
+ }
+
+ @Test
+ fun `stale cache falls back to bundled json when remote fetch fails`() = runTest(testDispatcher) {
+ cacheRelease(
+ release("v2.6.0.abc"),
+ FirmwareReleaseType.STABLE,
+ lastUpdated = nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+ every { jsonDataSource.loadFirmwareReleaseFromJsonAsset() } calls {
+ jsonCallCount += 1
+ releases(stable = listOf(release("v2.11.0.abc")))
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.6.0.abc", "v2.11.0.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE, FirmwareReleaseType.STABLE)
+ assertEquals(1, remoteCallCount)
+ assertEquals(1, jsonCallCount)
+ }
+
+ @Test
+ fun `stale cache is re-emitted when both remote and json refreshes fail`() = runTest(testDispatcher) {
+ cacheRelease(
+ release("v2.5.0.abc"),
+ FirmwareReleaseType.STABLE,
+ lastUpdated = nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+ every { jsonDataSource.loadFirmwareReleaseFromJsonAsset() } calls {
+ jsonCallCount += 1
+ throw IllegalArgumentException("missing asset")
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.5.0.abc", "v2.5.0.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE, FirmwareReleaseType.STABLE)
+ }
+
+ @Test
+ fun `alpha release emits the newest alpha version only`() = runTest(testDispatcher) {
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(
+ stable = listOf(release("v2.9.0.abc")),
+ alpha = listOf(release("v2.11.0.alpha.1"), release("v2.12.0.alpha.1")),
+ )
+ }
+
+ val emissions = repository.alphaRelease.toList()
+
+ assertReleaseIds(emissions, null, "v2.12.0.alpha.1")
+ assertReleaseTypes(emissions, null, FirmwareReleaseType.ALPHA)
+ }
+
+ @Test
+ fun `stable collection warms alpha cache for subsequent alpha collectors`() = runTest(testDispatcher) {
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(
+ stable = listOf(release("v2.9.9.abc")),
+ alpha = listOf(release("v2.12.0.alpha.2")),
+ )
+ }
+
+ val stableEmissions = repository.stableRelease.toList()
+ val alphaEmissions = repository.alphaRelease.toList()
+
+ assertReleaseIds(stableEmissions, null, "v2.9.9.abc")
+ assertReleaseTypes(stableEmissions, null, FirmwareReleaseType.STABLE)
+ assertReleaseIds(alphaEmissions, "v2.12.0.alpha.2")
+ assertReleaseTypes(alphaEmissions, FirmwareReleaseType.ALPHA)
+ assertEquals(1, remoteCallCount)
+ }
+
+ @Test
+ fun `invalidateCache clears database and next collection refetches`() = runTest(testDispatcher) {
+ cacheRelease(release("v2.8.0.abc"), FirmwareReleaseType.STABLE)
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(stable = listOf(release("v2.10.2.abc")))
+ }
+
+ repository.invalidateCache()
+ val emissions = repository.stableRelease.toList()
+
+ assertTrue(dbProvider.currentDb.value.firmwareReleaseDao().getAllReleases().isNotEmpty())
+ assertReleaseIds(emissions, null, "v2.10.2.abc")
+ assertReleaseTypes(emissions, null, FirmwareReleaseType.STABLE)
+ assertEquals(1, remoteCallCount)
+ }
+
+ @Test
+ fun `selects highest semantic version from fresh cached releases`() = runTest(testDispatcher) {
+ cacheRelease(release("v2.9.9.abc"), FirmwareReleaseType.STABLE)
+ cacheRelease(release("v2.10.0.abc"), FirmwareReleaseType.STABLE)
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.10.0.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE)
+ assertEquals(0, remoteCallCount)
+ }
+
+ @Test
+ fun `empty remote release list emits null twice`() = runTest(testDispatcher) {
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ NetworkFirmwareReleases()
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertEquals(listOf(null, null), emissions)
+ assertEquals(1, remoteCallCount)
+ }
+
+ private suspend fun cacheRelease(
+ release: NetworkFirmwareRelease,
+ type: FirmwareReleaseType,
+ lastUpdated: Long = nowMillis,
+ ) {
+ dbProvider.currentDb.value.firmwareReleaseDao().insert(release.asEntity(type).copy(lastUpdated = lastUpdated))
+ }
+
+ private fun release(id: String) = NetworkFirmwareRelease(
+ id = id,
+ pageUrl = "https://example.invalid/$id",
+ releaseNotes = "notes for $id",
+ title = id,
+ zipUrl = "https://example.invalid/$id.zip",
+ )
+
+ private fun releases(
+ stable: List = emptyList(),
+ alpha: List = emptyList(),
+ ) = NetworkFirmwareReleases(releases = Releases(alpha = alpha, stable = stable))
+
+ private fun assertReleaseIds(emissions: List, vararg expected: String?) {
+ assertEquals(expected.toList(), emissions.map { it?.id })
+ }
+
+ private fun assertReleaseTypes(emissions: List, vararg expected: FirmwareReleaseType?) {
+ assertEquals(expected.toList(), emissions.map { it?.releaseType })
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
index 58673c879..e4048cdde 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
@@ -18,6 +18,10 @@ package org.meshtastic.core.data.datasource
import org.meshtastic.core.model.BootloaderOtaQuirk
+/**
+ * Loads bundled bootloader OTA quirk metadata for hardware-specific update handling.
+ */
interface BootloaderOtaQuirksJsonDataSource {
+ /** Returns bootloader OTA quirks parsed from the packaged JSON asset. */
fun loadBootloaderOtaQuirksFromJsonAsset(): List
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt
index 6c53b3367..864a7d59c 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt
@@ -18,6 +18,10 @@ package org.meshtastic.core.data.datasource
import org.meshtastic.core.model.NetworkDeviceHardware
+/**
+ * Loads bundled device hardware metadata used when cached or remote data is unavailable.
+ */
interface DeviceHardwareJsonDataSource {
+ /** Returns device hardware entries parsed from the packaged JSON asset. */
fun loadDeviceHardwareFromJsonAsset(): List
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt
index 043d165e1..7612a41ca 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt
@@ -18,6 +18,10 @@ package org.meshtastic.core.data.datasource
import org.meshtastic.core.model.NetworkFirmwareReleases
+/**
+ * Loads bundled firmware release metadata used as a local fallback source.
+ */
interface FirmwareReleaseJsonDataSource {
+ /** Returns firmware release metadata parsed from the packaged JSON asset. */
fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt
index b69d04db0..faf593446 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt
@@ -25,6 +25,9 @@ import org.meshtastic.core.database.entity.NodeMetadataEntity
import org.meshtastic.core.repository.AppMetadataRepository
import org.meshtastic.core.repository.NodeMetadata
+/**
+ * Stores app-managed node metadata such as favorites, mute state, and notes.
+ */
@Single(binds = [AppMetadataRepository::class])
class AppMetadataRepositoryImpl(
private val dbManager: DatabaseProvider,
@@ -35,41 +38,28 @@ class AppMetadataRepositoryImpl(
.map { list -> list.associate { it.num to it.toModel() } }
override suspend fun setFavorite(nodeNum: Int, isFavorite: Boolean) {
- ensureExists(nodeNum)
- dbManager.withDb { it.nodeMetadataDao().setFavorite(nodeNum, isFavorite) }
+ dbManager.withDb { it.nodeMetadataDao().setFavoriteEnsuringExists(nodeNum, isFavorite) }
}
override suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean) {
- ensureExists(nodeNum)
- dbManager.withDb { it.nodeMetadataDao().setIgnored(nodeNum, isIgnored) }
+ dbManager.withDb { it.nodeMetadataDao().setIgnoredEnsuringExists(nodeNum, isIgnored) }
}
override suspend fun setMuted(nodeNum: Int, isMuted: Boolean) {
- ensureExists(nodeNum)
- dbManager.withDb { it.nodeMetadataDao().setMuted(nodeNum, isMuted) }
+ dbManager.withDb { it.nodeMetadataDao().setMutedEnsuringExists(nodeNum, isMuted) }
}
override suspend fun setNotes(nodeNum: Int, notes: String) {
- ensureExists(nodeNum)
- dbManager.withDb { it.nodeMetadataDao().setNotes(nodeNum, notes) }
+ dbManager.withDb { it.nodeMetadataDao().setNotesEnsuringExists(nodeNum, notes) }
}
override suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean) {
- ensureExists(nodeNum)
- dbManager.withDb { it.nodeMetadataDao().setManuallyVerified(nodeNum, verified) }
+ dbManager.withDb { it.nodeMetadataDao().setManuallyVerifiedEnsuringExists(nodeNum, verified) }
}
override suspend fun delete(nodeNum: Int) {
dbManager.withDb { it.nodeMetadataDao().delete(nodeNum) }
}
-
- private suspend fun ensureExists(nodeNum: Int) {
- dbManager.withDb { db ->
- if (db.nodeMetadataDao().getByNum(nodeNum) == null) {
- db.nodeMetadataDao().upsert(NodeMetadataEntity(num = nodeNum))
- }
- }
- }
}
private fun NodeMetadataEntity.toModel() = NodeMetadata(
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index 1ff565704..d72e669ad 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -33,7 +33,9 @@ import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import org.meshtastic.core.repository.DeviceHardwareRepository
-// Annotating with Singleton to ensure a single instance manages the cache
+/**
+ * Resolves device hardware metadata from cache, remote data, and bundled JSON fallbacks.
+ */
@Single
class DeviceHardwareRepositoryImpl(
private val remoteDataSource: DeviceHardwareRemoteDataSource,
@@ -189,15 +191,17 @@ class DeviceHardwareRepositoryImpl(
}
}
- private fun disambiguate(entities: List, target: String?): DeviceHardwareEntity? = when {
- entities.isEmpty() -> null
+ private fun disambiguate(entities: List, target: String?): DeviceHardwareEntity? {
+ if (entities.isEmpty()) return null
- target == null -> entities.first()
+ val preferred = entities.sortedWith(compareBy { it.isIncomplete() }.thenByDescending { it.lastUpdated })
- else -> {
+ return if (target == null) {
+ preferred.first()
+ } else {
entities.find { it.platformioTarget == target }
?: entities.find { it.platformioTarget.equals(target, ignoreCase = true) }
- ?: entities.first()
+ ?: preferred.first()
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
index cfb045227..73dad222e 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
@@ -32,6 +32,9 @@ import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource
import org.meshtastic.core.repository.FirmwareReleaseRepository
+/**
+ * Serves firmware release data from local cache, bundled assets, and remote updates.
+ */
@Single
open class FirmwareReleaseRepositoryImpl(
private val remoteDataSource: FirmwareReleaseRemoteDataSource,
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
index 22c9a0c57..46c941527 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
@@ -46,6 +46,9 @@ import org.meshtastic.core.database.entity.Packet as RoomPacket
import org.meshtastic.core.database.entity.ReactionEntity as RoomReaction
import org.meshtastic.core.repository.PacketRepository as SharedPacketRepository
+/**
+ * Provides reactive access to packets, messages, contacts, and related packet metadata.
+ */
@Suppress("TooManyFunctions", "LongParameterList")
@Single
class PacketRepositoryImpl(
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepositoryImpl.kt
index 6546b6504..298855607 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepositoryImpl.kt
@@ -26,6 +26,9 @@ import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.QuickChatActionRepository
+/**
+ * Manages persisted quick chat actions and their ordering.
+ */
@Single
class QuickChatActionRepositoryImpl(
private val dbManager: DatabaseProvider,
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt
index 5914593f8..6bbdefc55 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt
@@ -58,15 +58,7 @@ import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
/**
- * Unified node repository and manager — single source of truth for all mesh node state.
- *
- * Replaces the previous split between a write-operation layer (in-memory atomicfu maps)
- * and `SdkNodeRepositoryImpl` (repository interface, StateFlows). Now uses a single StateFlow
- * with metadata enrichment on every write.
- *
- * The SDK manages node persistence via its SqlDelight storage. This class stores the live node
- * database in-memory, populated by SdkStateBridge from the SDK's NodeChange flow.
- * Node metadata (favorites, notes, ignored, muted) persists via Room's node_metadata table.
+ * Maintains live mesh node state and exposes reactive node data for the app layer.
*/
@Single(binds = [NodeRepository::class, NodeIdLookup::class])
@Suppress("TooManyFunctions", "LongParameterList")
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt
index 4ad7afc24..931f6b35c 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt
@@ -30,6 +30,9 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.proto.Position
+/**
+ * Persists and exposes traceroute snapshot positions for a traceroute log entry.
+ */
@Single
class TracerouteSnapshotRepositoryImpl(
private val dbManager: DatabaseProvider,
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MqttManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MqttManagerImplTest.kt
new file mode 100644
index 000000000..66b1a6d2d
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MqttManagerImplTest.kt
@@ -0,0 +1,256 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.manager
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode.Companion.exactly
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.model.MqttConnectionState
+import org.meshtastic.core.network.repository.MQTTRepository
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.testing.FakeServiceRepository
+import org.meshtastic.mqtt.ConnectionState
+import org.meshtastic.mqtt.MqttException
+import org.meshtastic.mqtt.ReasonCode
+import org.meshtastic.proto.MqttClientProxyMessage
+import org.meshtastic.proto.ToRadio
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class MqttManagerImplTest {
+
+ private data class PublishCall(
+ val topic: String,
+ val data: ByteArray,
+ val retained: Boolean,
+ )
+
+ private lateinit var mqttRepository: MQTTRepository
+ private lateinit var packetHandler: PacketHandler
+ private lateinit var serviceRepository: FakeServiceRepository
+ private lateinit var serviceScope: TestScope
+ private lateinit var connectionStateFlow: MutableStateFlow
+ private lateinit var proxyMessageFlow: MutableSharedFlow
+ private lateinit var mqttManager: MqttManagerImpl
+
+ private val publishCalls = mutableListOf()
+
+ @BeforeTest
+ fun setUp() {
+ serviceScope = TestScope(UnconfinedTestDispatcher())
+ connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected.Idle)
+ proxyMessageFlow = MutableSharedFlow(extraBufferCapacity = 1)
+ mqttRepository = mock(MockMode.autofill)
+ packetHandler = mock(MockMode.autofill)
+ serviceRepository = FakeServiceRepository()
+ publishCalls.clear()
+
+ every { mqttRepository.connectionState } returns connectionStateFlow
+ every { mqttRepository.proxyMessageFlow } returns proxyMessageFlow
+ every { mqttRepository.publish(any(), any(), any()) } calls { args ->
+ publishCalls +=
+ PublishCall(
+ topic = args.arg(0),
+ data = args.arg(1),
+ retained = args.arg(2),
+ )
+ }
+ every { packetHandler.sendToRadio(any()) } returns Unit
+
+ mqttManager = MqttManagerImpl(mqttRepository, packetHandler, serviceRepository, serviceScope)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ serviceScope.cancel()
+ }
+
+ @Test
+ fun mqttConnectionState_whenInactive_emitsInactive() = runTest {
+ connectionStateFlow.value = ConnectionState.Connected
+
+ assertEquals(MqttConnectionState.Inactive, mqttManager.mqttConnectionState.value)
+ }
+
+ @Test
+ fun mqttConnectionState_whenActive_mapsConnecting() = runTest {
+ connectionStateFlow.value = ConnectionState.Connecting
+
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+
+ assertEquals(MqttConnectionState.Connecting, mqttManager.mqttConnectionState.value)
+ }
+
+ @Test
+ fun mqttConnectionState_whenActive_mapsConnected() = runTest {
+ connectionStateFlow.value = ConnectionState.Connected
+
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+
+ assertEquals(MqttConnectionState.Connected, mqttManager.mqttConnectionState.value)
+ }
+
+ @Test
+ fun mqttConnectionState_whenActive_mapsReconnecting() = runTest {
+ val error = MqttException.ConnectionLost(ReasonCode.SERVER_UNAVAILABLE, "network down")
+ connectionStateFlow.value = ConnectionState.Reconnecting(attempt = 3, lastError = error)
+
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+
+ assertEquals(
+ MqttConnectionState.Reconnecting(attempt = 3, lastError = "network down"),
+ mqttManager.mqttConnectionState.value,
+ )
+ }
+
+ @Test
+ fun mqttConnectionState_whenActive_mapsDisconnectedWithReason() = runTest {
+ val reason = MqttException.ConnectionLost(ReasonCode.KEEP_ALIVE_TIMEOUT, "timed out")
+ connectionStateFlow.value = ConnectionState.Disconnected(reason = reason)
+
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+
+ assertEquals(
+ MqttConnectionState.Disconnected(reason = "timed out"),
+ mqttManager.mqttConnectionState.value,
+ )
+ }
+
+ @Test
+ fun mqttConnectionState_whenActive_mapsDisconnectedIdle() = runTest {
+ connectionStateFlow.value = ConnectionState.Disconnected.Idle
+
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+
+ assertEquals(MqttConnectionState.Disconnected.Idle, mqttManager.mqttConnectionState.value)
+ }
+
+ @Test
+ fun startProxy_whenAlreadyRunning_doesNotDuplicate() = runTest {
+ val message = MqttClientProxyMessage(topic = "msh/test", text = "hello")
+
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+ proxyMessageFlow.emit(message)
+
+ verify(exactly(1)) { packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
+ }
+
+ @Test
+ fun startProxy_whenNotEnabled_doesNotStart() = runTest {
+ val message = MqttClientProxyMessage(topic = "msh/test", text = "hello")
+ connectionStateFlow.value = ConnectionState.Connected
+
+ mqttManager.startProxy(enabled = false, proxyToClientEnabled = true)
+ assertTrue(proxyMessageFlow.tryEmit(message))
+
+ assertEquals(MqttConnectionState.Inactive, mqttManager.mqttConnectionState.value)
+ verify(exactly(0)) { packetHandler.sendToRadio(any()) }
+ }
+
+ @Test
+ fun startProxy_collectsProxyMessages_sendsToRadio() = runTest {
+ val message = MqttClientProxyMessage(topic = "msh/test", text = "hello")
+
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+ proxyMessageFlow.emit(message)
+
+ verify(exactly(1)) { packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
+ }
+
+ @Test
+ fun startProxy_onConnectionRejected_setsErrorMessage() = runTest {
+ every { mqttRepository.proxyMessageFlow } returns
+ flow {
+ throw MqttException.ConnectionRejected(
+ reasonCode = ReasonCode.NOT_AUTHORIZED,
+ message = "bad credentials",
+ )
+ }
+
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+
+ assertEquals(
+ "MQTT: connection rejected (check credentials)",
+ serviceRepository.errorMessage.value,
+ )
+ assertEquals(MqttConnectionState.Inactive, mqttManager.mqttConnectionState.value)
+ }
+
+ @Test
+ fun stop_cancelsJobAndSetsInactive() = runTest {
+ val message = MqttClientProxyMessage(topic = "msh/test", text = "hello")
+
+ mqttManager.startProxy(enabled = true, proxyToClientEnabled = true)
+ mqttManager.stop()
+ assertTrue(proxyMessageFlow.tryEmit(message))
+
+ assertEquals(MqttConnectionState.Inactive, mqttManager.mqttConnectionState.value)
+ verify(exactly(0)) { packetHandler.sendToRadio(any()) }
+ }
+
+ @Test
+ fun handleMqttProxyMessage_withText_publishesText() = runTest {
+ val message = MqttClientProxyMessage(topic = "msh/json/test", text = "hello world", retained = true)
+
+ mqttManager.handleMqttProxyMessage(message)
+
+ assertEquals(1, publishCalls.size)
+ assertEquals("msh/json/test", publishCalls.single().topic)
+ assertTrue("hello world".encodeToByteArray().contentEquals(publishCalls.single().data))
+ assertEquals(true, publishCalls.single().retained)
+ verify(exactly(1)) { mqttRepository.publish(any(), any(), any()) }
+ }
+
+ @Test
+ fun handleMqttProxyMessage_withData_publishesData() = runTest {
+ val payload = byteArrayOf(1, 2, 3, 4)
+ val message = MqttClientProxyMessage(topic = "msh/data/test", data_ = payload.toByteString(), retained = false)
+
+ mqttManager.handleMqttProxyMessage(message)
+
+ assertEquals(1, publishCalls.size)
+ assertEquals("msh/data/test", publishCalls.single().topic)
+ assertTrue(payload.contentEquals(publishCalls.single().data))
+ assertEquals(false, publishCalls.single().retained)
+ verify(exactly(1)) { mqttRepository.publish(any(), any(), any()) }
+ }
+
+ @Test
+ fun handleMqttProxyMessage_withNeither_doesNotPublish() = runTest {
+ mqttManager.handleMqttProxyMessage(MqttClientProxyMessage(topic = "msh/empty/test"))
+
+ assertTrue(publishCalls.isEmpty())
+ verify(exactly(0)) { mqttRepository.publish(any(), any(), any()) }
+ }
+}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt
index 35f1f06e6..0fdaa9946 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt
@@ -18,6 +18,7 @@ package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Query
+import androidx.room3.Transaction
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.NodeMetadataEntity
@@ -28,6 +29,9 @@ interface NodeMetadataDao {
@Upsert
suspend fun upsert(metadata: NodeMetadataEntity)
+ @Query("INSERT OR IGNORE INTO node_metadata(num) VALUES (:num)")
+ suspend fun ensureExists(num: Int)
+
@Query("SELECT * FROM node_metadata")
fun getAllFlow(): Flow>
@@ -37,18 +41,48 @@ interface NodeMetadataDao {
@Query("UPDATE node_metadata SET is_favorite = :isFavorite WHERE num = :num")
suspend fun setFavorite(num: Int, isFavorite: Boolean)
+ @Transaction
+ suspend fun setFavoriteEnsuringExists(num: Int, isFavorite: Boolean) {
+ ensureExists(num)
+ setFavorite(num, isFavorite)
+ }
+
@Query("UPDATE node_metadata SET is_ignored = :isIgnored WHERE num = :num")
suspend fun setIgnored(num: Int, isIgnored: Boolean)
+ @Transaction
+ suspend fun setIgnoredEnsuringExists(num: Int, isIgnored: Boolean) {
+ ensureExists(num)
+ setIgnored(num, isIgnored)
+ }
+
@Query("UPDATE node_metadata SET is_muted = :isMuted WHERE num = :num")
suspend fun setMuted(num: Int, isMuted: Boolean)
+ @Transaction
+ suspend fun setMutedEnsuringExists(num: Int, isMuted: Boolean) {
+ ensureExists(num)
+ setMuted(num, isMuted)
+ }
+
@Query("UPDATE node_metadata SET notes = :notes WHERE num = :num")
suspend fun setNotes(num: Int, notes: String)
+ @Transaction
+ suspend fun setNotesEnsuringExists(num: Int, notes: String) {
+ ensureExists(num)
+ setNotes(num, notes)
+ }
+
@Query("UPDATE node_metadata SET manually_verified = :verified WHERE num = :num")
suspend fun setManuallyVerified(num: Int, verified: Boolean)
+ @Transaction
+ suspend fun setManuallyVerifiedEnsuringExists(num: Int, verified: Boolean) {
+ ensureExists(num)
+ setManuallyVerified(num, verified)
+ }
+
@Query("DELETE FROM node_metadata WHERE num = :num")
suspend fun delete(num: Int)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
index c7e136f9e..0c4ebdc7e 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
@@ -263,7 +263,6 @@ interface PacketDao {
@Transaction
suspend fun updateMessageStatus(myNodeNum: Int, data: DataPacket, m: MessageStatus) {
val new = data.copy(status = m)
- // Match on key fields that identify the packet, rather than the entire data object
findPacketsWithId(myNodeNum, data.id)
.find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to }
?.let { update(it.copy(data = new)) }
@@ -272,7 +271,6 @@ interface PacketDao {
@Transaction
suspend fun updateMessageId(myNodeNum: Int, data: DataPacket, id: Int) {
val new = data.copy(id = id)
- // Match on key fields that identify the packet
findPacketsWithId(myNodeNum, data.id)
.find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to }
?.let { update(it.copy(data = new, packetId = id)) }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt
index 7504c048a..0cc03c97c 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt
@@ -232,10 +232,6 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
}
}
-// Assuming Iaq is an enum class with color and description properties
-// and that it conforms to CaseIterable.
-// Replace with your actual implementation
-
@Composable
fun IAQScale(modifier: Modifier = Modifier) {
Column(modifier = modifier.padding(16.dp), horizontalAlignment = Alignment.Start) {
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModelTest.kt
new file mode 100644
index 000000000..bac51bc75
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModelTest.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.qr
+
+import app.cash.turbine.test
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceAdmin
+import org.meshtastic.core.model.DeviceAdminEdit
+import org.meshtastic.core.testing.FakeRadioConfigRepository
+import org.meshtastic.proto.Channel
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ScannedQrCodeViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ private lateinit var radioConfigRepository: FakeRadioConfigRepository
+ private lateinit var deviceAdmin: TestDeviceAdmin
+ private lateinit var viewModel: ScannedQrCodeViewModel
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ radioConfigRepository = FakeRadioConfigRepository()
+ deviceAdmin = TestDeviceAdmin()
+ viewModel = ScannedQrCodeViewModel(radioConfigRepository, deviceAdmin)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun channels_emitsFromRadioConfigRepository() = runTest {
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Primary")))
+ radioConfigRepository.setChannelSet(channelSet)
+ viewModel = ScannedQrCodeViewModel(radioConfigRepository, deviceAdmin)
+
+ viewModel.channels.test {
+ advanceUntilIdle()
+ assertEquals(channelSet, expectMostRecentItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun setChannels_updatesChannelsViaRadioController() = runTest {
+ val first = ChannelSettings(name = "Primary")
+ val second = ChannelSettings(name = "Secondary")
+ val channelSet = ChannelSet(settings = listOf(first, second))
+
+ viewModel.setChannels(channelSet)
+ advanceUntilIdle()
+
+ assertEquals(
+ listOf(
+ Channel(role = Channel.Role.PRIMARY, index = 0, settings = first),
+ Channel(role = Channel.Role.SECONDARY, index = 1, settings = second),
+ ),
+ deviceAdmin.setLocalChannelCalls,
+ )
+ }
+
+ @Test
+ fun setChannels_updatesLoraConfig_whenDifferent() = runTest {
+ val loraConfig = Config.LoRaConfig(hop_limit = 5)
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Primary")), lora_config = loraConfig)
+
+ viewModel.setChannels(channelSet)
+ advanceUntilIdle()
+
+ assertEquals(listOf(Config(lora = loraConfig)), deviceAdmin.setLocalConfigCalls)
+ }
+
+ @Test
+ fun setChannels_skipsLoraConfig_whenSame() = runTest {
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Primary")))
+
+ viewModel.setChannels(channelSet)
+ advanceUntilIdle()
+
+ assertTrue(deviceAdmin.setLocalConfigCalls.isEmpty())
+ }
+
+ @Test
+ fun setChannels_replacesAllSettings() = runTest {
+ val settings = listOf(ChannelSettings(name = "Primary"), ChannelSettings(name = "Secondary"))
+
+ viewModel.setChannels(ChannelSet(settings = settings))
+ advanceUntilIdle()
+
+ assertEquals(settings, radioConfigRepository.currentChannelSet.settings)
+ }
+
+ private class TestDeviceAdmin : DeviceAdmin {
+ override val connectionState = MutableStateFlow(ConnectionState.Connected)
+ val setLocalChannelCalls = mutableListOf()
+ val setLocalConfigCalls = mutableListOf()
+
+ override suspend fun setLocalConfig(config: Config) {
+ setLocalConfigCalls += config
+ }
+
+ override suspend fun setLocalChannel(channel: Channel) {
+ setLocalChannelCalls += channel
+ }
+
+ override suspend fun editSettings(destNum: Int, block: suspend DeviceAdminEdit.() -> Unit) = Unit
+ }
+}
diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelTest.kt
new file mode 100644
index 000000000..32cef2f34
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelTest.kt
@@ -0,0 +1,285 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.viewmodel
+
+import app.cash.turbine.test
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+import dev.mokkery.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.TracerouteMapAvailability
+import org.meshtastic.core.model.util.getSharedContactUrl
+import org.meshtastic.core.navigation.ContactsRoute
+import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
+import org.meshtastic.core.repository.NotificationManager
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioPrefs
+import org.meshtastic.core.repository.UiPrefs
+import org.meshtastic.core.testing.FakeFirmwareReleaseRepository
+import org.meshtastic.core.testing.FakeMeshLogRepository
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeRadioController
+import org.meshtastic.core.testing.FakeServiceRepository
+import org.meshtastic.core.testing.TestDataFactory
+import org.meshtastic.core.ui.util.AlertManager
+import org.meshtastic.core.ui.util.SnackbarManager
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.ClientNotification
+import org.meshtastic.proto.Position
+import org.meshtastic.proto.SharedContact
+import org.meshtastic.proto.User
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class UIViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ private lateinit var viewModel: UIViewModel
+ private lateinit var nodeRepository: FakeNodeRepository
+ private lateinit var serviceRepository: FakeServiceRepository
+ private lateinit var radioController: FakeRadioController
+ private lateinit var meshLogRepository: FakeMeshLogRepository
+ private lateinit var firmwareReleaseRepository: FakeFirmwareReleaseRepository
+ private lateinit var radioPrefs: RadioPrefs
+ private lateinit var uiPrefs: UiPrefs
+ private lateinit var notificationManager: NotificationManager
+ private lateinit var packetRepository: PacketRepository
+
+ private lateinit var devAddrFlow: MutableStateFlow
+ private lateinit var themeFlow: MutableStateFlow
+ private lateinit var appIntroCompletedFlow: MutableStateFlow
+ private lateinit var unreadMessageCountFlow: MutableStateFlow
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ nodeRepository = FakeNodeRepository()
+ serviceRepository = FakeServiceRepository()
+ radioController = FakeRadioController()
+ meshLogRepository = FakeMeshLogRepository()
+ firmwareReleaseRepository = FakeFirmwareReleaseRepository()
+ radioPrefs = mock(MockMode.autofill)
+ uiPrefs = mock(MockMode.autofill)
+ notificationManager = mock(MockMode.autofill)
+ packetRepository = mock(MockMode.autofill)
+
+ devAddrFlow = MutableStateFlow(null)
+ themeFlow = MutableStateFlow(1)
+ appIntroCompletedFlow = MutableStateFlow(false)
+ unreadMessageCountFlow = MutableStateFlow(0)
+
+ every { radioPrefs.devAddr } returns devAddrFlow
+ every { uiPrefs.theme } returns themeFlow
+ every { uiPrefs.appIntroCompleted } returns appIntroCompletedFlow
+ every { uiPrefs.setAppIntroCompleted(any()) } returns Unit
+ every { notificationManager.cancel(any()) } returns Unit
+ every { packetRepository.getUnreadCountTotal() } returns unreadMessageCountFlow
+
+ viewModel = createViewModel()
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun handleDeepLink_triesDeepLinkRouterFirst() = runTest {
+ viewModel.navigationDeepLink.test {
+ viewModel.handleDeepLink(CommonUri.parse("$DEEP_LINK_BASE_URI/messages/contact1"))
+
+ assertEquals(
+ listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "contact1", message = "")),
+ awaitItem(),
+ )
+ assertNull(viewModel.sharedContactRequested.value)
+ assertNull(viewModel.requestChannelSet.value)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun handleDeepLink_fallsBackToDispatchMeshtasticUri() {
+ val sharedContact = SharedContact(user = User(long_name = "Suzume", short_name = "SZ"), node_num = 12345)
+
+ viewModel.handleDeepLink(sharedContact.getSharedContactUrl())
+
+ assertEquals(sharedContact, viewModel.sharedContactRequested.value)
+ assertNull(viewModel.requestChannelSet.value)
+ }
+
+ @Test
+ fun sharedContactRequested_setAndClear() = runTest {
+ val sharedContact = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
+
+ viewModel.sharedContactRequested.test {
+ assertNull(awaitItem())
+
+ viewModel.setSharedContactRequested(sharedContact)
+ assertEquals(sharedContact, awaitItem())
+
+ viewModel.clearSharedContactRequested()
+ assertNull(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun requestChannelSet_setAndClear() = runTest {
+ val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "LongFast")))
+
+ viewModel.requestChannelSet.test {
+ assertNull(awaitItem())
+
+ viewModel.setRequestChannelSet(channelSet)
+ assertEquals(channelSet, awaitItem())
+
+ viewModel.clearRequestChannelUrl()
+ assertNull(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun connectionState_delegatesToServiceRepository() = runTest {
+ viewModel.connectionState.test {
+ assertEquals(ConnectionState.Disconnected, awaitItem())
+
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ assertEquals(ConnectionState.Connected, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun unreadMessageCount_coercesToZero() = runTest {
+ unreadMessageCountFlow.value = -1
+
+ viewModel.unreadMessageCount.test {
+ advanceUntilIdle()
+ assertEquals(0, expectMostRecentItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun unreadMessageCount_emitsPositiveValues() = runTest {
+ unreadMessageCountFlow.value = 5
+
+ viewModel.unreadMessageCount.test {
+ advanceUntilIdle()
+ assertEquals(5, expectMostRecentItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun appIntroCompleted_readsFromPrefs() {
+ appIntroCompletedFlow.value = true
+ viewModel = createViewModel()
+
+ assertTrue(viewModel.appIntroCompleted.value)
+ }
+
+ @Test
+ fun onAppIntroCompleted_setsPrefs() {
+ viewModel.onAppIntroCompleted()
+
+ verify { uiPrefs.setAppIntroCompleted(true) }
+ }
+
+ @Test
+ fun clearClientNotification_clearsServiceRepoAndCancelsNotification() {
+ val notification = ClientNotification(message = "Check me")
+ serviceRepository.setClientNotification(notification)
+
+ viewModel.clearClientNotification(notification)
+
+ assertNull(serviceRepository.clientNotification.value)
+ verify { notificationManager.cancel(notification.toString().hashCode()) }
+ }
+
+ @Test
+ fun myNodeInfo_delegatesToNodeDB() {
+ val myNodeInfo = TestDataFactory.createMyNodeInfo(myNodeNum = 42)
+ nodeRepository.setMyNodeInfo(myNodeInfo)
+
+ assertEquals(myNodeInfo, viewModel.myNodeInfo.value)
+ }
+
+ @Test
+ fun theme_delegatesToUiPrefs() {
+ themeFlow.value = 2
+ viewModel = createViewModel()
+
+ assertEquals(2, viewModel.theme.value)
+
+ themeFlow.value = 4
+ assertEquals(4, viewModel.theme.value)
+ }
+
+ @Test
+ fun tracerouteMapAvailability_correctlyEvaluatesForwardAndReturnRoutes() {
+ nodeRepository.setNodes(
+ listOf(
+ Node(num = 1, position = Position(latitude_i = 100000000, longitude_i = 200000000)),
+ Node(num = 2),
+ Node(num = 3, position = Position(latitude_i = 300000000, longitude_i = 400000000)),
+ ),
+ )
+
+ val result = viewModel.tracerouteMapAvailability(forwardRoute = listOf(1, 2, 3), returnRoute = listOf(3, 2, 1))
+
+ assertEquals(TracerouteMapAvailability.Ok, result)
+ }
+
+ private fun createViewModel() =
+ UIViewModel(
+ nodeDB = nodeRepository,
+ serviceRepository = serviceRepository,
+ radioController = radioController,
+ radioPrefs = radioPrefs,
+ meshLogRepository = meshLogRepository,
+ firmwareReleaseRepository = firmwareReleaseRepository,
+ uiPrefs = uiPrefs,
+ notificationManager = notificationManager,
+ packetRepository = packetRepository,
+ alertManager = AlertManager(),
+ snackbarManager = SnackbarManager(),
+ )
+}