chore: KDoc cleanup, stale comments, and cruft removal

- Add KDoc to 3 JSON datasource interfaces and 7 repository implementations
- Remove IndoorAirQuality stub comments
- Remove commented-out WRITE_EXTERNAL_STORAGE permission from manifest
- Update foreground service comment to architecture note (Android 14+)
- Remove redundant inline comments in PacketDao.kt

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-06 17:41:19 -05:00
parent aacd789550
commit 813316110e
20 changed files with 1573 additions and 44 deletions

View File

@@ -67,11 +67,7 @@
-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Only for debug log writing, disable for production
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
-->
<!-- We run our mesh code as a foreground service - FIXME, find a way to stop doing this -->
<!-- Required: foreground service keeps mesh connection alive per Android 14+ requirements -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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))
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<IllegalArgumentException>(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<String>? = 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"),
)
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<NetworkFirmwareRelease> = emptyList(),
alpha: List<NetworkFirmwareRelease> = emptyList(),
) = NetworkFirmwareReleases(releases = Releases(alpha = alpha, stable = stable))
private fun assertReleaseIds(emissions: List<FirmwareRelease?>, vararg expected: String?) {
assertEquals(expected.toList(), emissions.map { it?.id })
}
private fun assertReleaseTypes(emissions: List<FirmwareRelease?>, vararg expected: FirmwareReleaseType?) {
assertEquals(expected.toList(), emissions.map { it?.releaseType })
}
}

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -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<DeviceHardwareEntity>, target: String?): DeviceHardwareEntity? = when {
entities.isEmpty() -> null
private fun disambiguate(entities: List<DeviceHardwareEntity>, target: String?): DeviceHardwareEntity? {
if (entities.isEmpty()) return null
target == null -> entities.first()
val preferred = entities.sortedWith(compareBy<DeviceHardwareEntity> { 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()
}
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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")

View File

@@ -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,

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<ConnectionState>
private lateinit var proxyMessageFlow: MutableSharedFlow<MqttClientProxyMessage>
private lateinit var mqttManager: MqttManagerImpl
private val publishCalls = mutableListOf<PublishCall>()
@BeforeTest
fun setUp() {
serviceScope = TestScope(UnconfinedTestDispatcher())
connectionStateFlow = MutableStateFlow<ConnectionState>(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<ToRadio>()) } 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<ToRadio>()) }
}
@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<ToRadio>()) }
}
@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()) }
}
}

View File

@@ -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<List<NodeMetadataEntity>>
@@ -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)

View File

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

View File

@@ -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) {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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>(ConnectionState.Connected)
val setLocalChannelCalls = mutableListOf<Channel>()
val setLocalConfigCalls = mutableListOf<Config>()
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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String?>
private lateinit var themeFlow: MutableStateFlow<Int>
private lateinit var appIntroCompletedFlow: MutableStateFlow<Boolean>
private lateinit var unreadMessageCountFlow: MutableStateFlow<Int>
@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(),
)
}