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