mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 08:42:01 -04:00
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:
@@ -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" />
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user