Refactor map layer management and navigation infrastructure (#4921)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-25 19:29:24 -05:00
committed by GitHub
parent b608a04ca4
commit a005231d94
142 changed files with 5408 additions and 3090 deletions

View File

@@ -28,7 +28,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| Directory | Description |
| :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.koin`). |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
| `core/model` | Domain models and common data structures. |
@@ -39,10 +39,10 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for backstack persistence. |
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
@@ -75,8 +75,10 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`.
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **`jvmAndroidMain` source set:** Modules that share JVM-specific code between Android and Desktop apply the `meshtastic.kmp.jvm.android` convention plugin. This creates a `jvmAndroidMain` source set via Kotlin's hierarchy template API. Used in `core:common`, `core:model`, `core:data`, `core:network`, and `core:ui`.
- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.

View File

@@ -239,7 +239,6 @@ dependencies {
implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.material3.navigationSuite)
implementation(libs.material)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.iconsExtended)

View File

@@ -712,6 +712,10 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
var currentLayer by remember { mutableStateOf<com.google.maps.android.data.Layer?>(null) }
MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
// Cleanup old layer if we're reloading
currentLayer?.safeRemoveLayerFromMap()
currentLayer = null
val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
val layer =
try {
@@ -727,7 +731,7 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
layer?.let {
if (layerItem.isVisible) {
it.addLayerToMap()
it.safeAddLayerToMap()
}
currentLayer = it
}
@@ -735,7 +739,7 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
DisposableEffect(layerItem.id) {
onDispose {
currentLayer?.removeLayerFromMap()
currentLayer?.safeRemoveLayerFromMap()
currentLayer = null
}
}
@@ -745,13 +749,33 @@ private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel)
LaunchedEffect(layerItem.isVisible) {
val layer = currentLayer ?: return@LaunchedEffect
if (layerItem.isVisible) {
if (!layer.isLayerOnMap) layer.addLayerToMap()
layer.safeAddLayerToMap()
} else {
if (layer.isLayerOnMap) layer.removeLayerFromMap()
layer.safeRemoveLayerFromMap()
}
}
}
private fun com.google.maps.android.data.Layer.safeRemoveLayerFromMap() {
try {
removeLayerFromMap()
} catch (e: Exception) {
// Log it and ignore. This specifically handles a NullPointerException in
// KmlRenderer.hasNestedContainers which can occur when disposing layers.
Logger.withTag("MapView").e(e) { "Error removing map layer" }
}
}
private fun com.google.maps.android.data.Layer.safeAddLayerToMap() {
try {
if (!isLayerOnMap) {
addLayerToMap()
}
} catch (e: Exception) {
Logger.withTag("MapView").e(e) { "Error adding map layer" }
}
}
internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
String(Character.toChars(unicodeCodePoint))
} catch (e: IllegalArgumentException) {

View File

@@ -34,6 +34,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.core.content.IntentCompat
@@ -129,16 +130,7 @@ class MainActivity : ComponentActivity() {
)
}
CompositionLocalProvider(
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalBarcodeScannerSupported provides true,
LocalNfcScannerSupported provides true,
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
) {
AppCompositionLocals {
AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
@@ -162,6 +154,52 @@ class MainActivity : ComponentActivity() {
handleIntent(intent)
}
@Composable
private fun AppCompositionLocals(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalBarcodeScannerSupported provides true,
LocalNfcScannerSupported provides true,
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp ->
val vm = koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp ->
val metricsViewModel =
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel>(key = "metrics-$destNum") {
org.koin.core.parameter.parametersOf(destNum)
}
metricsViewModel.setNodeId(destNum)
org.meshtastic.feature.node.metrics.TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
},
org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId ->
val viewModel = koinViewModel<org.meshtastic.feature.map.SharedMapViewModel>()
org.meshtastic.feature.map.MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)
},
content = content,
)
}
@Suppress("NestedBlockDepth")
private fun handleIntent(intent: Intent) {
val appLinkAction = intent.action

View File

@@ -18,40 +18,14 @@
package org.meshtastic.app.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -60,28 +34,16 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_too_old
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.must_update
import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
@@ -93,154 +55,20 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerViewModel = koinViewModel()) {
fun MainScreen(uIViewModel: UIViewModel = koinViewModel()) {
val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
LaunchedEffect(uIViewModel) {
uIViewModel.navigationDeepLink.collect { uri ->
val commonUri = org.meshtastic.core.common.util.CommonUri.parse(uri.uriString)
org.meshtastic.core.navigation.DeepLinkRouter.route(commonUri)?.let { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
}
}
}
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
AndroidAppVersionCheck(uIViewModel)
val navSuiteType =
NavigationSuiteScaffoldDefaults.navigationSuiteType(
currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true),
)
val currentKey = backStack.lastOrNull()
val topLevelDestination = TopLevelDestination.fromNavKey(currentKey)
// State for determining the connection type icon to display
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
MeshtasticAppShell(
backStack = backStack,
uiViewModel = uIViewModel,
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
) {
NavigationSuiteScaffold(
org.meshtastic.core.ui.component.MeshtasticNavigationSuite(
backStack = backStack,
uiViewModel = uIViewModel,
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
TopLevelDestination.entries.forEach { destination ->
val isSelected = destination == topLevelDestination
val isConnectionsRoute = destination == TopLevelDestination.Connections
item(
icon = {
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep ->
stringResource(Res.string.device_sleeping)
ConnectionState.Disconnected ->
stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
},
)
}
},
state = rememberTooltipState(),
) {
if (isConnectionsRoute) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice),
meshActivityFlow = uIViewModel.meshActivity,
colorScheme = colorScheme,
)
} else {
BadgedBox(
badge = {
if (destination == TopLevelDestination.Conversations) {
// Keep track of the last non-zero count for display during exit
// animation
var lastNonZeroCount by remember {
mutableIntStateOf(unreadMessageCount)
}
if (unreadMessageCount > 0) {
lastNonZeroCount = unreadMessageCount
}
AnimatedVisibility(
visible = unreadMessageCount > 0,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
) {
Badge { Text(lastNonZeroCount.toString()) }
}
}
},
) {
Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
tint =
if (isSelectedState) {
colorScheme.primary
} else {
LocalContentColor.current
},
)
}
}
}
}
},
selected = isSelected,
label = {
Text(
text = stringResource(destination.label),
modifier =
if (navSuiteType == NavigationSuiteType.ShortNavigationBarCompact) {
Modifier.width(1.dp)
.height(1.dp) // hide on phone - min 1x1 or talkback won't see it.
} else {
Modifier
},
)
},
onClick = {
val isRepress = destination == topLevelDestination
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
else -> Unit
}
} else {
backStack.navigateTopLevel(destination.route)
}
},
)
}
},
) {
val provider =
entryProvider<NavKey> {
@@ -249,14 +77,6 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
backStack = backStack,
scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
onHandleDeepLink = uIViewModel::handleDeepLink,
nodeMapScreen = { destNum, onNavigateUp ->
val vm =
org.koin.compose.viewmodel.koinViewModel<
org.meshtastic.feature.map.node.NodeMapViewModel,
>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
)
mapGraph(backStack)
channelsGraph(backStack)

View File

@@ -47,7 +47,7 @@ class NavigationAssemblyTest {
val backStack = rememberNavBackStack(NodesRoutes.NodesGraph)
entryProvider<NavKey> {
contactsGraph(backStack, emptyFlow())
nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow(), nodeMapScreen = { _, _ -> })
nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow())
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)

View File

@@ -151,6 +151,8 @@ internal fun Project.configureKmpTestDependencies() {
implementation(libs.library("kotest-assertions"))
implementation(libs.library("kotest-property"))
implementation(libs.library("turbine"))
implementation(libs.library("robolectric"))
implementation(libs.library("androidx-test-core"))
}
// Configure jvmTest if it exists

View File

@@ -22,5 +22,5 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facil
## Key Architecture Goals
- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS)
- Ensure offline-first functionality and resilient data persistence (Room 3 KMP with bundled SQLite driver)
- Ensure offline-first functionality and resilient data persistence (Room 3 KMP)
- Decouple UI and navigation logic into shared feature modules (`core:ui`, `feature:*`) using Compose Multiplatform

View File

@@ -18,7 +18,7 @@
- **Koin 4.2:** Leverages Koin Annotations and the K2 Compiler Plugin for pure compile-time DI, completely replacing Hilt.
## Database & Storage
- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and the `androidx.sqlite` bundled driver across Android, Desktop, and iOS.
- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and platform-appropriate SQLite drivers (e.g., `BundledSQLiteDriver` for JVM/Desktop, Framework driver for Android).
- **Jetpack DataStore:** Shared preferences.
## Networking & Transport
@@ -32,5 +32,7 @@
- **Shared Tests First:** The majority of business logic, ViewModels, and state interactions are tested in the `commonTest` source set using standard `kotlin.test`.
- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows.
- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`.
- **Platform-Specific Verification:** Use **Robolectric** on the Android host target to verify KMP modules that interact with Android framework components (like `Uri` or `Room`).
- **Subclassing Pattern:** Maintain a unified test suite by defining abstract base tests in `commonTest` and platform-specific subclasses in `jvmTest` and `androidHostTest` for initialization (e.g., calling `setupTestContext()`).
- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates.
- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios.

View File

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

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class CommonUriTest {
@Test
fun testParse() {
val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1&param2=true#fragment")
assertEquals("meshtastic.org", uri.host)
assertEquals("fragment", uri.fragment)
assertEquals(listOf("path", "to", "page"), uri.pathSegments)
assertEquals("value1", uri.getQueryParameter("param1"))
assertTrue(uri.getBooleanQueryParameter("param2", false))
}
@Test
fun testBooleanParameters() {
val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
assertTrue(uri.getBooleanQueryParameter("t1", false))
assertTrue(uri.getBooleanQueryParameter("t2", false))
assertTrue(uri.getBooleanQueryParameter("t3", false))
assertTrue(!uri.getBooleanQueryParameter("f1", true))
assertTrue(!uri.getBooleanQueryParameter("f2", true))
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common
import kotlin.test.Test
import kotlin.test.assertEquals
class ByteUtilsTest {
@Test
fun testByteArrayOfInts() {
val bytes = byteArrayOfInts(0x01, 0xFF, 0x80)
assertEquals(3, bytes.size)
assertEquals(1, bytes[0])
assertEquals(-1, bytes[1]) // 0xFF as signed byte
assertEquals(-128, bytes[2].toInt()) // 0x80 as signed byte
}
@Test
fun testXorHash() {
val data = byteArrayOfInts(0x01, 0x02, 0x03)
assertEquals(0 xor 1 xor 2 xor 3, xorHash(data))
val data2 = byteArrayOfInts(0xFF, 0xFF)
assertEquals(0xFF xor 0xFF, xorHash(data2))
assertEquals(0, xorHash(data2))
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class LocationUtilsTest {
@Test
fun testGpsFormat() {
val formatted = GPSFormat.toDec(45.123456, -93.654321)
assertEquals("45.12345, -93.65432", formatted)
}
@Test
fun testLatLongToMeter() {
// Distance from (0,0) to (0,1) at equator should be approx 111.3km
val distance = latLongToMeter(0.0, 0.0, 0.0, 1.0)
assertTrue(distance > 111000 && distance < 112000, "Distance was $distance")
// Distance from (45, -93) to (45, -92)
val distance2 = latLongToMeter(45.0, -93.0, 45.0, -92.0)
assertTrue(distance2 > 78000 && distance2 < 79000, "Distance was $distance2")
}
@Test
fun testBearing() {
// North
assertEquals(0.0, bearing(0.0, 0.0, 1.0, 0.0), 0.1)
// East
assertEquals(90.0, bearing(0.0, 0.0, 0.0, 1.0), 0.1)
// South
assertEquals(180.0, bearing(0.0, 0.0, -1.0, 0.0), 0.1)
// West
assertEquals(270.0, bearing(0.0, 0.0, 0.0, -1.0), 0.1)
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
class NumberFormatterTest {
@Test
fun testFormat() {
assertEquals("1.23", NumberFormatter.format(1.23456, 2))
assertEquals("1.235", NumberFormatter.format(1.23456, 3))
assertEquals("1.00", NumberFormatter.format(1.0, 2))
assertEquals("0.00", NumberFormatter.format(0.0, 2))
assertEquals("-1.23", NumberFormatter.format(-1.23456, 2))
}
@Test
fun testFormatZeroDecimalPlaces() {
assertEquals("1", NumberFormatter.format(1.23, 0))
assertEquals("-1", NumberFormatter.format(-1.23, 0))
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
class UrlUtilsTest {
@Test
fun testEncode() {
assertEquals("Hello%20World", UrlUtils.encode("Hello World"))
assertEquals("abc-123._~", UrlUtils.encode("abc-123._~"))
assertEquals("%21%40%23%24%25", UrlUtils.encode("!@#$%"))
assertEquals("%C3%A1%C3%A9%C3%AD", UrlUtils.encode("áéí"))
}
}

View File

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

View File

@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CommonUriTest {
@Test
fun testParse() {
val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1&param2=true#fragment")
assertEquals("meshtastic.org", uri.host)
assertEquals("fragment", uri.fragment)
assertEquals(listOf("path", "to", "page"), uri.pathSegments)
assertEquals("value1", uri.getQueryParameter("param1"))
assertTrue(uri.getBooleanQueryParameter("param2", false))
}
@Test
fun testBooleanParameters() {
val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
assertTrue(uri.getBooleanQueryParameter("t1", false))
assertTrue(uri.getBooleanQueryParameter("t2", false))
assertTrue(uri.getBooleanQueryParameter("t3", false))
assertTrue(!uri.getBooleanQueryParameter("f1", true))
assertTrue(!uri.getBooleanQueryParameter("f2", true))
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class MeshLogRepositoryTest : CommonMeshLogRepositoryTest() {
@BeforeTest
fun setup() {
setupTestContext()
setupRepo()
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NodeRepositoryTest : CommonNodeRepositoryTest() {
@BeforeTest
fun setup() {
setupTestContext()
setupRepo()
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PacketRepositoryTest : CommonPacketRepositoryTest() {
@BeforeTest
fun setup() {
setupTestContext()
setupRepo()
}
}

View File

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

View File

@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.testing.FakeDatabaseProvider
import org.meshtastic.core.testing.FakeMeshLogPrefs
import org.meshtastic.proto.Data
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class CommonMeshLogRepositoryTest {
protected lateinit var dbProvider: FakeDatabaseProvider
protected lateinit var meshLogPrefs: FakeMeshLogPrefs
protected lateinit var nodeInfoReadDataSource: NodeInfoReadDataSource
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
protected lateinit var repository: MeshLogRepositoryImpl
private val nowMillis = 1000000000L
fun setupRepo() {
dbProvider = FakeDatabaseProvider()
meshLogPrefs = FakeMeshLogPrefs()
meshLogPrefs.setLoggingEnabled(true)
nodeInfoReadDataSource = mock(MockMode.autofill)
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(null)
repository = MeshLogRepositoryImpl(dbProvider, dispatchers, meshLogPrefs, nodeInfoReadDataSource)
}
@AfterTest
fun tearDown() {
dbProvider.close()
}
@Test
fun `parseTelemetryLog preserves zero temperature`() = runTest(testDispatcher) {
val zeroTemp = 0.0f
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = zeroTemp))
val meshPacket =
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
uuid = "123",
message_type = "telemetry",
received_date = nowMillis,
raw_message = "",
fromNum = 0,
portNum = PortNum.TELEMETRY_APP.value,
fromRadio = FromRadio(packet = meshPacket),
)
repository.insert(meshLog)
val result = repository.getTelemetryFrom(0).first()
assertNotNull(result)
assertEquals(1, result.size)
val resultMetrics = result[0].environment_metrics
assertNotNull(resultMetrics)
assertEquals(zeroTemp, resultMetrics.temperature ?: 0f, 0.01f)
}
@Test
fun `deleteLogs redirects local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val localNodeNum = 999
val port = PortNum.TEXT_MESSAGE_APP.value
val myNodeEntity =
MyNodeEntity(
myNodeNum = localNodeNum,
model = "model",
firmwareVersion = "1.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 0,
hasWifi = false,
)
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeEntity)
val log =
MeshLog(
uuid = "123",
message_type = "TEXT",
received_date = nowMillis,
raw_message = "",
fromNum =
0, // asEntity will map it if we pass localNodeNum to asEntity, but here we set it manually
portNum = port,
fromRadio =
FromRadio(
packet = MeshPacket(from = localNodeNum, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)),
),
)
repository.insert(log)
// Verify it's there
assertEquals(1, repository.getAllLogsUnbounded().first().size)
repository.deleteLogs(localNodeNum, port)
val logs = repository.getAllLogsUnbounded().first()
assertTrue(logs.isEmpty())
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.testing.FakeLocalStatsDataSource
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
abstract class CommonNodeRepositoryTest {
protected lateinit var lifecycleOwner: LifecycleOwner
protected lateinit var readDataSource: NodeInfoReadDataSource
protected lateinit var writeDataSource: NodeInfoWriteDataSource
protected lateinit var localStatsDataSource: FakeLocalStatsDataSource
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val myNodeInfoFlow = MutableStateFlow<MyNodeEntity?>(null)
protected lateinit var repository: NodeRepositoryImpl
fun setupRepo() {
Dispatchers.setMain(testDispatcher)
lifecycleOwner =
object : LifecycleOwner {
override val lifecycle = LifecycleRegistry(this)
}
(lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
readDataSource = mock(MockMode.autofill)
writeDataSource = mock(MockMode.autofill)
localStatsDataSource = FakeLocalStatsDataSource()
every { readDataSource.myNodeInfoFlow() } returns myNodeInfoFlow
every { readDataSource.nodeDBbyNumFlow() } returns MutableStateFlow<Map<Int, NodeWithRelations>>(emptyMap())
repository =
NodeRepositoryImpl(
lifecycleOwner.lifecycle,
readDataSource,
writeDataSource,
dispatchers,
localStatsDataSource,
)
}
@AfterTest
fun tearDown() {
// Essential to stop background jobs in NodeRepositoryImpl
(lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
Dispatchers.resetMain()
}
private fun createMyNodeEntity(nodeNum: Int) = MyNodeEntity(
myNodeNum = nodeNum,
model = "model",
firmwareVersion = "1.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 0,
hasWifi = false,
)
@Test
fun `effectiveLogNodeId maps local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val myNodeNum = 12345
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
assertEquals(MeshLog.NODE_NUM_LOCAL, result)
}
@Test
fun `effectiveLogNodeId preserves remote node numbers`() = runTest(testDispatcher) {
val myNodeNum = 12345
val remoteNodeNum = 67890
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
assertEquals(remoteNodeNum, result)
}
}

View File

@@ -0,0 +1,84 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.testing.FakeDatabaseProvider
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
abstract class CommonPacketRepositoryTest {
protected lateinit var dbProvider: FakeDatabaseProvider
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
protected lateinit var repository: PacketRepositoryImpl
fun setupRepo() {
dbProvider = FakeDatabaseProvider()
repository = PacketRepositoryImpl(dbProvider, dispatchers)
}
@AfterTest
fun tearDown() {
dbProvider.close()
}
@Test
fun `savePacket persists and retrieves waypoints`() = runTest(testDispatcher) {
val myNodeNum = 1
val contact = "contact"
// Ensure my_node is present so getMessageCount finds the packet
dbProvider.currentDb.value
.nodeInfoDao()
.setMyNodeInfo(
MyNodeEntity(
myNodeNum = myNodeNum,
model = "model",
firmwareVersion = "1.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 0,
hasWifi = false,
),
)
val packet = DataPacket(to = "0!ffffffff", bytes = okio.ByteString.EMPTY, dataType = 1, id = 123)
repository.savePacket(myNodeNum, contact, packet, 1000L)
// Verify it was saved.
val count = repository.getMessageCount(contact)
assertEquals(1, count)
}
@Test
fun `clearAllUnreadCounts works with real DB`() = runTest(testDispatcher) {
repository.clearAllUnreadCounts()
// No exception thrown
}
}

View File

@@ -1,195 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
class MeshLogRepositoryTest {
/*
private val dbManager: DatabaseProvider = mock()
private val appDatabase: MeshtasticDatabase = mock()
private val meshLogDao: MeshLogDao = mock()
private val meshLogPrefs: MeshLogPrefs = mock()
private val nodeInfoReadDataSource: NodeInfoReadDataSource = mock()
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val repository = MeshLogRepositoryImpl(dbManager, dispatchers, meshLogPrefs, nodeInfoReadDataSource)
init {
every { dbManager.currentDb } returns MutableStateFlow(appDatabase)
every { appDatabase.meshLogDao() } returns meshLogDao
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(null)
}
@Test
fun `parseTelemetryLog preserves zero temperature`() = runTest(testDispatcher) {
val zeroTemp = 0.0f
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = zeroTemp))
val meshPacket =
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
uuid = Uuid.random().toString(),
message_type = "telemetry",
received_date = nowMillis,
raw_message = "",
fromRadio = FromRadio(packet = meshPacket),
)
// Using reflection to test private method parseTelemetryLog
val method = MeshLogRepositoryImpl::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
method.isAccessible = true
val result = method.invoke(repository, meshLog) as Telemetry?
assertNotNull(result)
val resultMetrics = result?.environment_metrics
assertNotNull(resultMetrics)
assertEquals(zeroTemp, resultMetrics?.temperature ?: 0f, 0.01f)
}
@Test
fun `parseTelemetryLog maps missing temperature to NaN`() = runTest(testDispatcher) {
val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = null))
val meshPacket =
MeshPacket(decoded = Data(payload = telemetry.encode().toByteString(), portnum = PortNum.TELEMETRY_APP))
val meshLog =
MeshLog(
uuid = Uuid.random().toString(),
message_type = "telemetry",
received_date = nowMillis,
raw_message = "",
fromRadio = FromRadio(packet = meshPacket),
)
val method = MeshLogRepositoryImpl::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
method.isAccessible = true
val result = method.invoke(repository, meshLog) as Telemetry?
assertNotNull(result)
val resultMetrics = result?.environment_metrics
// Should be NaN as per repository logic for missing fields
assertEquals(Float.NaN, resultMetrics?.temperature ?: 0f, 0.01f)
}
@Test
fun `getRequestLogs filters correctly`() = runTest(testDispatcher) {
val targetNode = 123
val otherNode = 456
val port = PortNum.TRACEROUTE_APP
val logs =
listOf(
// Valid request
MeshLogEntity(
uuid = "1",
message_type = "Packet",
received_date = nowMillis,
raw_message = "",
fromNum = 0,
portNum = port.value,
fromRadio =
FromRadio(
packet =
MeshPacket(to = targetNode, decoded = Data(portnum = port, want_response = true)),
),
),
// Wrong target
MeshLogEntity(
uuid = "2",
message_type = "Packet",
received_date = nowMillis,
raw_message = "",
fromNum = 0,
portNum = port.value,
fromRadio =
FromRadio(
packet =
MeshPacket(to = otherNode, decoded = Data(portnum = port, want_response = true)),
),
),
// Not a request (want_response = false)
MeshLogEntity(
uuid = "3",
message_type = "Packet",
received_date = nowMillis,
raw_message = "",
fromNum = 0,
portNum = port.value,
fromRadio =
FromRadio(
packet =
MeshPacket(to = targetNode, decoded = Data(portnum = port, want_response = false)),
),
),
// Wrong fromNum
MeshLogEntity(
uuid = "4",
message_type = "Packet",
received_date = nowMillis,
raw_message = "",
fromNum = 789,
portNum = port.value,
fromRadio =
FromRadio(
packet =
MeshPacket(to = targetNode, decoded = Data(portnum = port, want_response = true)),
),
),
)
val result = repository.getRequestLogs(targetNode, port).first()
assertEquals(1, result.size)
assertEquals("1", result[0].uuid)
}
@Test
fun `deleteLogs redirects local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val localNodeNum = 999
val port = 100
val myNodeEntity = mock<MyNodeEntity>()
every { myNodeEntity.myNodeNum } returns localNodeNum
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeEntity)
repository.deleteLogs(localNodeNum, port)
verifySuspend { meshLogDao.deleteLogs(MeshLog.NODE_NUM_LOCAL, port) }
}
@Test
fun `deleteLogs preserves remote node numbers`() = runTest(testDispatcher) {
val localNodeNum = 999
val remoteNodeNum = 888
val port = 100
val myNodeEntity = mock<MyNodeEntity>()
every { myNodeEntity.myNodeNum } returns localNodeNum
every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeEntity)
repository.deleteLogs(remoteNodeNum, port)
verifySuspend { meshLogDao.deleteLogs(remoteNodeNum, port) }
}
*/
}

View File

@@ -1,119 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import kotlinx.coroutines.ExperimentalCoroutinesApi
@OptIn(ExperimentalCoroutinesApi::class)
class NodeRepositoryTest {
/*
private val lifecycleScope: LifecycleCoroutineScope = mock()
private val testDispatcher = StandardTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val myNodeInfoFlow = MutableStateFlow<MyNodeEntity?>(null)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkStatic("androidx.lifecycle.LifecycleKt")
every { lifecycleScope.coroutineContext } returns testDispatcher + Job()
every { lifecycle.coroutineScope } returns lifecycleScope
every { readDataSource.myNodeInfoFlow() } returns myNodeInfoFlow
every { readDataSource.nodeDBbyNumFlow() } returns MutableStateFlow(emptyMap())
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createMyNodeEntity(nodeNum: Int) = MyNodeEntity(
myNodeNum = nodeNum,
model = "model",
firmwareVersion = "1.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 0,
hasWifi = false,
)
@Test
fun `effectiveLogNodeId maps local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val myNodeNum = 12345
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val repository =
NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
assertEquals(MeshLog.NODE_NUM_LOCAL, result)
}
@Test
fun `effectiveLogNodeId preserves remote node numbers`() = runTest(testDispatcher) {
val myNodeNum = 12345
val remoteNodeNum = 67890
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val repository =
NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
assertEquals(remoteNodeNum, result)
}
@Test
fun `effectiveLogNodeId updates when local node number changes`() = runTest(testDispatcher) {
val firstNodeNum = 111
val secondNodeNum = 222
val targetNodeNum = 111
myNodeInfoFlow.value = createMyNodeEntity(firstNodeNum)
val repository =
NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
// Initially should be mapped to LOCAL because it matches
assertEquals(
MeshLog.NODE_NUM_LOCAL,
repository.effectiveLogNodeId(targetNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first(),
)
// Change local node num
myNodeInfoFlow.value = createMyNodeEntity(secondNodeNum)
testScheduler.runCurrent()
// Now it shouldn't match, so should return the original num
assertEquals(
targetNodeNum,
repository.effectiveLogNodeId(targetNodeNum).filter { it == targetNodeNum }.first(),
)
}
*/
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,11 +14,13 @@
* 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.feature.node.metrics
package org.meshtastic.core.data.repository
import androidx.compose.runtime.Composable
import kotlin.test.BeforeTest
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
// TODO: Implement iOS position log screen
class MeshLogRepositoryTest : CommonMeshLogRepositoryTest() {
@BeforeTest
fun setup() {
setupRepo()
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,11 +14,13 @@
* 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.feature.node.navigation
package org.meshtastic.core.data.repository
import androidx.compose.runtime.Composable
import kotlin.test.BeforeTest
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
// TODO: Implement iOS traceroute map screen
class NodeRepositoryTest : CommonNodeRepositoryTest() {
@BeforeTest
fun setup() {
setupRepo()
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,11 +14,13 @@
* 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.feature.map.navigation
package org.meshtastic.core.data.repository
import androidx.compose.runtime.Composable
import kotlin.test.BeforeTest
@Composable
actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
// TODO: Implement iOS map main screen
class PacketRepositoryTest : CommonPacketRepositoryTest() {
@BeforeTest
fun setup() {
setupRepo()
}
}

View File

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

View File

@@ -1,505 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room3.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.User
@RunWith(AndroidJUnit4::class)
class NodeInfoDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var nodeInfoDao: NodeInfoDao
private val onlineThreshold = onlineTimeThreshold()
private val offlineNodeLastHeard = onlineThreshold - 30
private val onlineNodeLastHeard = onlineThreshold + 20
private val unknownNode =
NodeEntity(
num = 7,
user =
User(
id = "!a1b2c3d4",
long_name = "Meshtastic c3d4",
short_name = "c3d4",
hw_model = HardwareModel.UNSET,
),
longName = "Meshtastic c3d4",
shortName = null, // Dao filter for includeUnknown
)
private val ourNode =
NodeEntity(
num = 8,
user =
User(
id = "+16508765308".format(8),
long_name = "Kevin Mester",
short_name = "KLO",
hw_model = HardwareModel.ANDROID_SIM,
is_licensed = false,
),
longName = "Kevin Mester",
shortName = "KLO",
latitude = 30.267153,
longitude = -97.743057, // Austin
hopsAway = 0,
)
private val onlineNode =
NodeEntity(
num = 9,
user =
User(
id = "!25060801",
long_name = "Meshtastic 0801",
short_name = "0801",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0801",
shortName = "0801",
hopsAway = 0,
lastHeard = onlineNodeLastHeard,
)
private val offlineNode =
NodeEntity(
num = 10,
user =
User(
id = "!25060802",
long_name = "Meshtastic 0802",
short_name = "0802",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0802",
shortName = "0802",
hopsAway = 0,
lastHeard = offlineNodeLastHeard,
)
private val directNode =
NodeEntity(
num = 11,
user =
User(
id = "!25060803",
long_name = "Meshtastic 0803",
short_name = "0803",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0803",
shortName = "0803",
hopsAway = 0,
lastHeard = onlineNodeLastHeard,
)
private val relayedNode =
NodeEntity(
num = 12,
user =
User(
id = "!25060804",
long_name = "Meshtastic 0804",
short_name = "0804",
hw_model = HardwareModel.ANDROID_SIM,
),
longName = "Meshtastic 0804",
shortName = "0804",
hopsAway = 3,
lastHeard = onlineNodeLastHeard,
)
private val myNodeInfo: MyNodeEntity =
MyNodeEntity(
myNodeNum = ourNode.num,
model = null,
firmwareVersion = null,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 5 * 60 * 1000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
)
private val testPositions =
arrayOf(
0.0 to 0.0,
32.776665 to -96.796989, // Dallas
32.960758 to -96.733521, // Richardson
32.912901 to -96.781776, // North Dallas
29.760427 to -95.369804, // Houston
33.748997 to -84.387985, // Atlanta
34.052235 to -118.243683, // Los Angeles
40.712776 to -74.005974, // New York City
41.878113 to -87.629799, // Chicago
39.952583 to -75.165222, // Philadelphia
)
private val testNodes =
listOf(ourNode, unknownNode, onlineNode, offlineNode, directNode, relayedNode) +
testPositions.mapIndexed { index, pos ->
NodeEntity(
num = 1000 + index,
user =
User(
id = "+165087653%02d".format(9 + index),
long_name = "Kevin Mester$index",
short_name = "KM$index",
hw_model = HardwareModel.ANDROID_SIM,
is_licensed = false,
public_key = ByteArray(32) { index.toByte() }.toByteString(),
),
longName = "Kevin Mester$index",
shortName = "KM$index",
latitude = pos.first,
longitude = pos.second,
lastHeard = 9 + index,
)
}
@Before
fun createDb(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
context = context,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.build()
nodeInfoDao = database.nodeInfoDao()
nodeInfoDao.apply {
putAll(testNodes)
setMyNodeInfo(myNodeInfo)
}
}
@After
fun closeDb() {
database.close()
}
/**
* Retrieves a list of nodes based on [sort], [filter] and [includeUnknown] parameters. The list excludes [ourNode]
* to ensure consistency in the results.
*/
private suspend fun getNodes(
sort: NodeSortOption = NodeSortOption.LAST_HEARD,
filter: String = "",
includeUnknown: Boolean = true,
onlyOnline: Boolean = false,
onlyDirect: Boolean = false,
) = nodeInfoDao
.getNodes(
sort = sort.sqlValue,
filter = filter,
includeUnknown = includeUnknown,
hopsAwayMax = if (onlyDirect) 0 else -1,
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
)
.map { list -> list.map { it.toModel() } }
.first()
.filter { it.num != ourNode.num }
@Test // node list size
fun testNodeListSize() = runBlocking {
val nodes = nodeInfoDao.nodeDBbyNum().first()
assertEquals(6 + testPositions.size, nodes.size)
}
@Test // nodeDBbyNum() re-orders our node at the top of the list
fun testOurNodeInfoIsFirst() = runBlocking {
val nodes = nodeInfoDao.nodeDBbyNum().first()
assertEquals(ourNode.num, nodes.values.first().node.num)
}
@Test
fun testSortByLastHeard() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.LAST_HEARD)
val sortedNodes = nodes.sortedByDescending { it.lastHeard }
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByAlpha() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.ALPHABETICAL)
val sortedNodes = nodes.sortedBy { it.user.long_name.uppercase() }
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByDistance() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.DISTANCE)
fun NodeEntity.toNode() = Node(num = num, user = user, position = position)
val sortedNodes =
nodes.sortedWith( // nodes with invalid (null) positions at the end
compareBy<Node> { it.validPosition == null }.thenBy { it.distance(ourNode.toNode()) },
)
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByChannel() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.CHANNEL)
val sortedNodes = nodes.sortedBy { it.channel }
assertEquals(sortedNodes, nodes)
}
@Test
fun testSortByViaMqtt() = runBlocking {
val nodes = getNodes(sort = NodeSortOption.VIA_MQTT)
val sortedNodes = nodes.sortedBy { it.user.long_name.contains("(MQTT)") }
assertEquals(sortedNodes, nodes)
}
@Test
fun testIncludeUnknownIsFalse() = runBlocking {
val nodes = getNodes(includeUnknown = false)
val containsUnsetNode = nodes.any { it.isUnknownUser }
assertFalse(containsUnsetNode)
}
@Test
fun testIncludeUnknownIsTrue() = runBlocking {
val nodes = getNodes(includeUnknown = true)
val containsUnsetNode = nodes.any { it.isUnknownUser }
assertTrue(containsUnsetNode)
}
@Test
fun testUnknownNodesKeepNamesNullAndRemainFiltered() = runBlocking {
val updatedUnknownNode = unknownNode.copy(longName = "Should be cleared", shortName = "SHOULD")
nodeInfoDao.upsert(updatedUnknownNode)
val storedUnknown = nodeInfoDao.getNodeByNum(updatedUnknownNode.num)!!.node
assertEquals(null, storedUnknown.longName)
assertEquals(null, storedUnknown.shortName)
val nodes = getNodes(includeUnknown = false)
assertFalse(nodes.any { it.num == updatedUnknownNode.num })
}
@Test
fun testOfflineNodesIncludedByDefault() = runBlocking {
val nodes = getNodes()
assertTrue(nodes.any { it.lastHeard < onlineTimeThreshold() })
}
@Test
fun testOnlyOnlineExcludesOffline() = runBlocking {
val nodes = getNodes(onlyOnline = true)
assertFalse(nodes.any { it.lastHeard < onlineTimeThreshold() })
}
@Test
fun testRelayedNodesIncludedByDefault() = runBlocking {
val nodes = getNodes()
assertTrue(nodes.any { it.hopsAway > 0 })
}
@Test
fun testOnlyDirectExcludesRelayed() = runBlocking {
val nodes = getNodes(onlyDirect = true)
assertFalse(nodes.any { it.hopsAway > 0 })
}
@Test
fun testPkcMismatch() = runBlocking {
val newNodeNum = 9999
// First, ensure the node is in the DB with Key A
val nodeA =
testNodes[0].copy(
num = newNodeNum,
publicKey = ByteArray(32) { 1 }.toByteString(),
user = testNodes[0].user.copy(id = "!uniqueId1", public_key = ByteArray(32) { 1 }.toByteString()),
)
nodeInfoDao.upsert(nodeA)
// Now upsert with Key B (mismatch)
val nodeB =
nodeA.copy(
publicKey = ByteArray(32) { 2 }.toByteString(),
user = nodeA.user.copy(public_key = ByteArray(32) { 2 }.toByteString()),
)
nodeInfoDao.upsert(nodeB)
val stored = nodeInfoDao.getNodeByNum(nodeA.num)!!.node
assertEquals(NodeEntity.ERROR_BYTE_STRING, stored.publicKey)
assertTrue(stored.toModel().mismatchKey)
}
@Test
fun testRoutineUpdatePreservesKey() = runBlocking {
val newNodeNum = 9998
// First, ensure the node is in the DB with Key A
val keyA = ByteArray(32) { 1 }.toByteString()
val nodeA =
testNodes[0].copy(
num = newNodeNum,
publicKey = keyA,
user = testNodes[0].user.copy(id = "!uniqueId2", public_key = keyA),
)
nodeInfoDao.upsert(nodeA)
// Now upsert with an empty key (common in position/telemetry updates)
val nodeEmpty = nodeA.copy(publicKey = null, user = nodeA.user.copy(public_key = ByteString.EMPTY))
nodeInfoDao.upsert(nodeEmpty)
val stored = nodeInfoDao.getNodeByNum(nodeA.num)!!.node
assertEquals(keyA, stored.publicKey)
assertFalse(stored.toModel().mismatchKey)
}
@Test
fun testRecoveryFromErrorState() = runBlocking {
val newNodeNum = 9997
// Start in Error state
val nodeError =
testNodes[0].copy(
num = newNodeNum,
publicKey = NodeEntity.ERROR_BYTE_STRING,
user = testNodes[0].user.copy(id = "!uniqueId3", public_key = NodeEntity.ERROR_BYTE_STRING),
)
nodeInfoDao.doUpsert(nodeError)
assertTrue(nodeInfoDao.getNodeByNum(nodeError.num)!!.toModel().mismatchKey)
// Now upsert with a valid Key C
val keyC = ByteArray(32) { 3 }.toByteString()
val nodeC = nodeError.copy(publicKey = keyC, user = nodeError.user.copy(public_key = keyC))
nodeInfoDao.upsert(nodeC)
val stored = nodeInfoDao.getNodeByNum(nodeError.num)!!.node
assertEquals(keyC, stored.publicKey)
assertFalse(stored.toModel().mismatchKey)
}
@Test
fun testLicensedUserDoesNotClearKey() = runBlocking {
val newNodeNum = 9996
// Start with a key
val keyA = ByteArray(32) { 1 }.toByteString()
val nodeA =
testNodes[0].copy(
num = newNodeNum,
publicKey = keyA,
user = testNodes[0].user.copy(id = "!uniqueId4", public_key = keyA),
)
nodeInfoDao.upsert(nodeA)
// Upsert as licensed user (without key)
val nodeLicensed =
nodeA.copy(
user = nodeA.user.copy(is_licensed = true, public_key = ByteString.EMPTY),
publicKey = ByteString.EMPTY,
)
nodeInfoDao.upsert(nodeLicensed)
val stored = nodeInfoDao.getNodeByNum(nodeA.num)!!.node
// Should NOT clear key to prevent PKC wipe attack
assertEquals(keyA, stored.publicKey)
assertFalse(stored.toModel().mismatchKey)
}
@Test
fun testValidLicensedUserNoKey() = runBlocking {
val newNodeNum = 9995
// Start with no key and licensed status
val nodeLicensed =
testNodes[0].copy(
num = newNodeNum,
publicKey = null,
user = testNodes[0].user.copy(id = "!uniqueId5", is_licensed = true, public_key = ByteString.EMPTY),
)
nodeInfoDao.upsert(nodeLicensed)
val stored = nodeInfoDao.getNodeByNum(newNodeNum)!!.node
assertEquals(ByteString.EMPTY, stored.publicKey)
assertFalse(stored.toModel().mismatchKey)
}
@Test
fun testPlaceholderUpdatePreservesIdentity() = runBlocking {
val newNodeNum = 9994
val keyA = ByteArray(32) { 5 }.toByteString()
val originalName = "Real Name"
// 1. Create a full node with key and name
val fullNode =
testNodes[0].copy(
num = newNodeNum,
longName = originalName,
publicKey = keyA,
user =
testNodes[0]
.user
.copy(
id = "!uniqueId6",
long_name = originalName,
public_key = keyA,
hw_model = HardwareModel.TLORA_V2, // Set a specific HW model
),
)
nodeInfoDao.upsert(fullNode)
// 2. Simulate receiving a placeholder packet (e.g. from a legacy node or partial info)
// HW Model UNSET, Default Name "Meshtastic XXXX"
val placeholderNode =
fullNode.copy(
user =
fullNode.user.copy(
hw_model = HardwareModel.UNSET,
long_name = "Meshtastic 1234",
public_key = ByteString.EMPTY,
),
longName = "Meshtastic 1234",
publicKey = null,
)
nodeInfoDao.upsert(placeholderNode)
// 3. Verify that the identity (Name and Key) is preserved
val stored = nodeInfoDao.getNodeByNum(newNodeNum)!!.node
assertEquals(originalName, stored.longName)
assertEquals(keyA, stored.publicKey)
// Ensure HW model is NOT overwritten by UNSET if we preserve the user
// Note: The logic in handleExistingNodeUpsertValidation copies the *existing* user back.
assertEquals(HardwareModel.TLORA_V2, stored.user.hw_model)
}
}

View File

@@ -1,501 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room3.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.PortNum
@RunWith(AndroidJUnit4::class)
class PacketDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var nodeInfoDao: NodeInfoDao
private lateinit var packetDao: PacketDao
private val myNodeInfo: MyNodeEntity =
MyNodeEntity(
myNodeNum = 42424242,
model = null,
firmwareVersion = null,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 5 * 60 * 1000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
)
private val myNodeNum: Int
get() = myNodeInfo.myNodeNum
private val testContactKeys = listOf("0${DataPacket.ID_BROADCAST}", "1!test1234")
private fun generateTestPackets(myNodeNum: Int) = testContactKeys.flatMap { contactKey ->
List(SAMPLE_SIZE) {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"),
)
}
}
@Before
fun createDb(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
context = context,
factory = { MeshtasticDatabaseConstructor.initialize() },
)
.build()
nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) }
packetDao =
database.packetDao().apply {
generateTestPackets(42424243).forEach { insert(it) }
generateTestPackets(myNodeNum).forEach { insert(it) }
}
}
@After
fun closeDb() {
database.close()
}
@Test
fun test_myNodeNum() = runBlocking {
val myNodeInfo = nodeInfoDao.getMyNodeInfo().first()
assertEquals(myNodeNum, myNodeInfo?.myNodeNum)
}
@Test
fun test_getAllPackets() = runBlocking {
val packets = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first()
assertEquals(testContactKeys.size * SAMPLE_SIZE, packets.size)
val onlyMyNodeNum = packets.all { it.myNodeNum == myNodeNum }
assertTrue(onlyMyNodeNum)
}
@Test
fun test_getContactKeys() = runBlocking {
val contactKeys = packetDao.getContactKeys().first()
assertEquals(testContactKeys.size, contactKeys.size)
val onlyMyNodeNum = contactKeys.values.all { it.myNodeNum == myNodeNum }
assertTrue(onlyMyNodeNum)
}
@Test
fun test_getMessageCount() = runBlocking {
testContactKeys.forEach { contactKey ->
val messageCount = packetDao.getMessageCount(contactKey)
assertEquals(SAMPLE_SIZE, messageCount)
}
}
@Test
fun test_getMessagesFrom() = runBlocking {
testContactKeys.forEach { contactKey ->
val messages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(SAMPLE_SIZE, messages.size)
val onlyFromContactKey = messages.all { it.packet.contact_key == contactKey }
assertTrue(onlyFromContactKey)
val onlyMyNodeNum = messages.all { it.packet.myNodeNum == myNodeNum }
assertTrue(onlyMyNodeNum)
}
}
@Test
fun test_getUnreadCount() = runBlocking {
testContactKeys.forEach { contactKey ->
val unreadCount = packetDao.getUnreadCount(contactKey)
assertEquals(SAMPLE_SIZE, unreadCount)
}
}
@Test
fun test_getUnreadCount_excludesFiltered() = runBlocking {
val filteredContactKey = "0!filteredonly"
val filteredPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = 1,
contact_key = filteredContactKey,
received_time = nowMillis,
read = false,
filtered = true,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered message"),
)
packetDao.insert(filteredPacket)
val unreadCount = packetDao.getUnreadCount(filteredContactKey)
assertEquals(0, unreadCount)
}
@Test
fun test_clearUnreadCount() = runBlocking {
val timestamp = nowMillis
testContactKeys.forEach { contactKey ->
packetDao.clearUnreadCount(contactKey, timestamp)
val unreadCount = packetDao.getUnreadCount(contactKey)
assertEquals(0, unreadCount)
}
}
@Test
fun test_deleteContacts() = runBlocking {
packetDao.deleteContacts(testContactKeys)
testContactKeys.forEach { contactKey ->
val messages = packetDao.getMessagesFrom(contactKey).first()
assertTrue(messages.isEmpty())
}
}
@Test
fun test_findPacketsWithId() = runBlocking {
val packetId = 12345
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test").copy(id = packetId),
packetId = packetId,
)
packetDao.insert(packet)
val found = packetDao.findPacketsWithId(packetId)
assertEquals(1, found.size)
assertEquals(packetId, found[0].packetId)
}
@Test
fun test_sfppHashPersistence() = runBlocking {
val hash = byteArrayOf(1, 2, 3, 4)
val hashByteString = hash.toByteString()
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hashByteString,
)
packetDao.insert(packet)
val retrieved =
packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first().find { it.sfpp_hash == hashByteString }
assertNotNull(retrieved)
assertEquals(hashByteString, retrieved?.sfpp_hash)
}
@Test
fun test_findPacketBySfppHash() = runBlocking {
val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
val hashByteString = hash.toByteString()
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
read = true,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = 0, text = "Test"),
sfpp_hash = hashByteString,
)
packetDao.insert(packet)
// Exact match
val found = packetDao.findPacketBySfppHash(hashByteString)
assertNotNull(found)
assertEquals(hashByteString, found?.sfpp_hash)
// Substring match (first 8 bytes)
val shortHash = hash.copyOf(8).toByteString()
val foundShort = packetDao.findPacketBySfppHash(shortHash)
assertNotNull(foundShort)
assertEquals(hashByteString, foundShort?.sfpp_hash)
// No match
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString()
val notFound = packetDao.findPacketBySfppHash(wrongHash)
assertNull(notFound)
}
@Test
fun test_findReactionBySfppHash() = runBlocking {
val hash = byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16)
val hashByteString = hash.toByteString()
val reaction =
ReactionEntity(
myNodeNum = myNodeNum,
replyId = 123,
userId = "sender",
emoji = "👍",
timestamp = nowMillis,
sfpp_hash = hashByteString,
)
packetDao.insert(reaction)
val found = packetDao.findReactionBySfppHash(hashByteString)
assertNotNull(found)
assertEquals(hashByteString, found?.sfpp_hash)
val shortHash = hash.copyOf(8).toByteString()
val foundShort = packetDao.findReactionBySfppHash(shortHash)
assertNotNull(foundShort)
val wrongHash = byteArrayOf(0, 0, 0, 0, 0, 0, 0, 0).toByteString()
assertNull(packetDao.findReactionBySfppHash(wrongHash))
}
@Test
fun test_updateMessageId_persistence() = runBlocking {
val initialId = 100
val newId = 200
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
read = true,
data = DataPacket(to = "target", channel = 0, text = "Hello").copy(id = initialId),
packetId = initialId,
)
packetDao.insert(packet)
packetDao.updateMessageId(packet.data, newId)
val updated = packetDao.getPacketById(newId)
assertNotNull(updated)
assertEquals(newId, updated?.packetId)
assertEquals(newId, updated?.data?.id)
}
@Test
fun test_updateSFPPStatus_logic() = runBlocking {
val packetId = 999
val fromNum = 123
val toNum = 456
val hash = byteArrayOf(9, 8, 7, 6).toByteString()
val fromId = DataPacket.nodeNumToDefaultId(fromNum)
val toId = DataPacket.nodeNumToDefaultId(toNum)
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "test",
received_time = nowMillis,
read = true,
data = DataPacket(to = toId, channel = 0, text = "Match me").copy(from = fromId, id = packetId),
packetId = packetId,
)
packetDao.insert(packet)
// Verifying the logic used in PacketRepository
val found = packetDao.findPacketsWithId(packetId)
found.forEach { p ->
if (p.data.from == fromId && p.data.to == toId) {
val data = p.data.copy(status = MessageStatus.SFPP_CONFIRMED, sfppHash = hash)
packetDao.update(p.copy(data = data, sfpp_hash = hash))
}
}
val updated = packetDao.findPacketsWithId(packetId)[0]
assertEquals(MessageStatus.SFPP_CONFIRMED, updated.data.status)
assertEquals(hash, updated.data.sfppHash)
assertEquals(hash, updated.sfpp_hash)
}
@Test
fun test_filteredMessages_excludedFromContactKeys(): Unit = runBlocking {
// Create a new contact with only filtered messages
val filteredContactKey = "0!filteredonly"
val filteredPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = filteredContactKey,
received_time = nowMillis,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered message"),
filtered = true,
)
packetDao.insert(filteredPacket)
// getContactKeys should not include contacts with only filtered messages
val contactKeys = packetDao.getContactKeys().first()
assertFalse(contactKeys.containsKey(filteredContactKey))
}
@Test
fun test_getFilteredCount_returnsCorrectCount(): Unit = runBlocking {
val contactKey = "0${DataPacket.ID_BROADCAST}"
// Insert filtered messages
repeat(3) { i ->
val filteredPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis + i,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, "Filtered $i"),
filtered = true,
)
packetDao.insert(filteredPacket)
}
val filteredCount = packetDao.getFilteredCount(contactKey)
assertEquals(3, filteredCount)
}
@Test
fun test_contactFilteringDisabled_persistence(): Unit = runBlocking {
val contactKey = "0!testcontact"
// Initially should be null or false
val initial = packetDao.getContactFilteringDisabled(contactKey)
assertTrue(initial == null || initial == false)
// Set filtering disabled
packetDao.setContactFilteringDisabled(contactKey, true)
val disabled = packetDao.getContactFilteringDisabled(contactKey)
assertEquals(true, disabled)
// Re-enable filtering
packetDao.setContactFilteringDisabled(contactKey, false)
val enabled = packetDao.getContactFilteringDisabled(contactKey)
assertEquals(false, enabled)
}
@Test
fun test_getMessagesFrom_excludesFilteredMessages(): Unit = runBlocking {
val contactKey = "0!notificationtest"
// Insert mix of filtered and non-filtered messages
val normalMessages = listOf("Hello", "How are you?", "Good morning")
val filteredMessages = listOf("Filtered message 1", "Filtered message 2")
normalMessages.forEachIndexed { index, text ->
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis + index,
read = false,
data = DataPacket(DataPacket.ID_BROADCAST, 0, text),
filtered = false,
)
packetDao.insert(packet)
}
filteredMessages.forEachIndexed { index, text ->
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis + normalMessages.size + index,
read = true, // Filtered messages are marked as read
data = DataPacket(DataPacket.ID_BROADCAST, 0, text),
filtered = true,
)
packetDao.insert(packet)
}
// Without filter - should return all messages
val allMessages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(normalMessages.size + filteredMessages.size, allMessages.size)
// With includeFiltered = true - should return all messages
val includingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = true).first()
assertEquals(normalMessages.size + filteredMessages.size, includingFiltered.size)
// With includeFiltered = false - should only return non-filtered messages
val excludingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = false).first()
assertEquals(normalMessages.size, excludingFiltered.size)
// Verify none of the returned messages are filtered
val hasFilteredMessages = excludingFiltered.any { it.packet.filtered }
assertFalse(hasFilteredMessages)
}
companion object {
private const val SAMPLE_SIZE = 10
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.test.runTest
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NodeInfoDaoTest : CommonNodeInfoDaoTest() {
@BeforeTest
fun setup() = runTest {
setupTestContext()
createDb()
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.test.runTest
import org.junit.runner.RunWith
import org.meshtastic.core.testing.setupTestContext
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PacketDaoTest : CommonPacketDaoTest() {
@BeforeTest
fun setup() = runTest {
setupTestContext()
createDb()
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,115 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class CommonNodeInfoDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var dao: NodeInfoDao
private val myNodeInfo: MyNodeEntity =
MyNodeEntity(
myNodeNum = 42424242,
model = "TBEAM",
firmwareVersion = "2.5.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 300000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
)
suspend fun createDb() {
database = getInMemoryDatabaseBuilder().build()
dao = database.nodeInfoDao()
dao.setMyNodeInfo(myNodeInfo)
}
@AfterTest
fun closeDb() {
database.close()
}
@Test
fun testGetMyNodeInfo() = runTest {
val info = dao.getMyNodeInfo().first()
assertNotNull(info)
assertEquals(myNodeInfo.myNodeNum, info.myNodeNum)
}
@Test
fun testUpsertNode() = runTest {
val node =
NodeEntity(
num = 1234,
user = User(long_name = "Test Node", id = "!test", hw_model = org.meshtastic.proto.HardwareModel.TBEAM),
lastHeard = (nowMillis / 1000).toInt(),
)
dao.upsert(node)
val result = dao.getNodeByNum(1234)
assertNotNull(result)
assertEquals("Test Node", result.node.longName)
}
@Test
fun testNodeDBbyNum() = runTest {
val node1 = NodeEntity(num = 1, user = User(id = "!1"))
val node2 = NodeEntity(num = 2, user = User(id = "!2"))
dao.putAll(listOf(node1, node2))
val nodes = dao.nodeDBbyNum().first()
assertEquals(2, nodes.size)
assertTrue(nodes.containsKey(1))
assertTrue(nodes.containsKey(2))
}
@Test
fun testDeleteNode() = runTest {
val node = NodeEntity(num = 1, user = User(id = "!1"))
dao.upsert(node)
dao.deleteNode(1)
val result = dao.getNodeByNum(1)
assertEquals(null, result)
}
@Test
fun testClearNodeInfo() = runTest {
val node1 = NodeEntity(num = 1, user = User(id = "!1"), isFavorite = true)
val node2 = NodeEntity(num = 2, user = User(id = "!2"), isFavorite = false)
dao.putAll(listOf(node1, node2))
dao.clearNodeInfo(preserveFavorites = true)
val nodes = dao.nodeDBbyNum().first()
assertEquals(1, nodes.size)
assertTrue(nodes.containsKey(1))
}
}

View File

@@ -0,0 +1,277 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.PortNum
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
abstract class CommonPacketDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var nodeInfoDao: NodeInfoDao
private lateinit var packetDao: PacketDao
private val myNodeInfo: MyNodeEntity =
MyNodeEntity(
myNodeNum = 42424242,
model = null,
firmwareVersion = null,
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 5 * 60 * 1000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
)
private val myNodeNum: Int
get() = myNodeInfo.myNodeNum
private val testContactKeys = listOf("0${DataPacket.ID_BROADCAST}", "1!test1234")
private fun generateTestPackets(myNodeNum: Int) = testContactKeys.flatMap { contactKey ->
List(SAMPLE_SIZE) {
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis + it,
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Message $it!".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
)
}
}
suspend fun createDb() {
database = getInMemoryDatabaseBuilder().build()
nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) }
packetDao =
database.packetDao().apply {
generateTestPackets(42424243).forEach { insert(it) }
generateTestPackets(myNodeNum).forEach { insert(it) }
}
}
@AfterTest
fun closeDb() {
database.close()
}
@Test
fun testGetMessagesFrom() = runTest {
val contactKey = testContactKeys.first()
val messages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(SAMPLE_SIZE, messages.size)
assertTrue(messages.all { it.packet.myNodeNum == myNodeNum })
assertTrue(messages.all { it.packet.contact_key == contactKey })
}
@Test
fun testGetMessageCount() = runTest {
val contactKey = testContactKeys.first()
assertEquals(SAMPLE_SIZE, packetDao.getMessageCount(contactKey))
}
@Test
fun testGetUnreadCount() = runTest {
val contactKey = testContactKeys.first()
assertEquals(SAMPLE_SIZE, packetDao.getUnreadCount(contactKey))
}
@Test
fun testClearUnreadCount() = runTest {
val contactKey = testContactKeys.first()
packetDao.clearUnreadCount(contactKey, nowMillis + SAMPLE_SIZE)
assertEquals(0, packetDao.getUnreadCount(contactKey))
}
@Test
fun testClearAllUnreadCounts() = runTest {
packetDao.clearAllUnreadCounts()
testContactKeys.forEach { assertEquals(0, packetDao.getUnreadCount(it)) }
}
@Test
fun testUpdateMessageStatus() = runTest {
val contactKey = testContactKeys.first()
val messages = packetDao.getMessagesFrom(contactKey).first()
val packet = messages.first().packet.data
val originalStatus = packet.status
// Ensure packet has a valid ID for updating
val packetWithId = packet.copy(id = 999, from = "!$myNodeNum")
val updatedRoomPacket = messages.first().packet.copy(data = packetWithId, packetId = 999)
packetDao.update(updatedRoomPacket)
packetDao.updateMessageStatus(packetWithId, MessageStatus.DELIVERED)
val updatedMessages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(MessageStatus.DELIVERED, updatedMessages.first { it.packet.data.id == 999 }.packet.data.status)
}
@Test
fun testGetQueuedPackets() = runTest {
val queuedPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = "queued",
received_time = nowMillis,
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Queued".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
status = MessageStatus.QUEUED,
),
)
packetDao.insert(queuedPacket)
val queued = packetDao.getQueuedPackets()
assertNotNull(queued)
assertEquals(1, queued.size)
assertEquals("Queued", queued.first().text)
}
@Test
fun testDeleteMessages() = runTest {
val contactKey = testContactKeys.first()
packetDao.deleteContacts(listOf(contactKey))
assertEquals(0, packetDao.getMessageCount(contactKey))
}
@Test
fun testGetContactKeys() = runTest {
val contacts = packetDao.getContactKeys().first()
assertEquals(testContactKeys.size, contacts.size)
testContactKeys.forEach { assertTrue(contacts.containsKey(it)) }
}
@Test
fun testGetWaypoints() = runTest {
val waypointPacket =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.WAYPOINT_APP.value,
contact_key = "0${DataPacket.ID_BROADCAST}",
received_time = nowMillis,
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = "Waypoint".encodeToByteArray().toByteString(),
dataType = PortNum.WAYPOINT_APP.value,
),
)
packetDao.insert(waypointPacket)
val waypoints = packetDao.getAllWaypoints()
assertEquals(1, waypoints.size)
// Waypoints aren't text messages, so they don't resolve a string text.
}
@Test
fun testUpsertReaction() = runTest {
val reaction =
ReactionEntity(myNodeNum = myNodeNum, replyId = 123, userId = "!test", emoji = "👍", timestamp = nowMillis)
packetDao.insert(reaction)
}
@Test
fun testGetMessagesFromWithIncludeFiltered() = runTest {
val contactKey = "filter-test"
val normalMessages = listOf("Msg 1", "Msg 2")
val filteredMessages = listOf("Filtered 1")
normalMessages.forEachIndexed { index, text ->
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis + index,
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
filtered = false,
)
packetDao.insert(packet)
}
filteredMessages.forEachIndexed { index, text ->
val packet =
Packet(
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = contactKey,
received_time = nowMillis + normalMessages.size + index,
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
filtered = true,
)
packetDao.insert(packet)
}
val allMessages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(normalMessages.size + filteredMessages.size, allMessages.size)
val includingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = true).first()
assertEquals(normalMessages.size + filteredMessages.size, includingFiltered.size)
val excludingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = false).first()
assertEquals(normalMessages.size, excludingFiltered.size)
assertFalse(excludingFiltered.any { it.packet.filtered })
}
companion object {
private const val SAMPLE_SIZE = 10
}
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,12 +14,11 @@
* 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.feature.node.metrics
package org.meshtastic.core.database.dao
import androidx.compose.runtime.Composable
import org.meshtastic.core.ui.component.PlaceholderScreen
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
PlaceholderScreen(name = "Position Log")
class NodeInfoDaoTest : CommonNodeInfoDaoTest() {
@BeforeTest fun setup() = runTest { createDb() }
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
class PacketDaoTest : CommonPacketDaoTest() {
@BeforeTest fun setup() = runTest { createDb() }
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,49 +16,52 @@
*/
package org.meshtastic.core.domain.usecase.settings
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.Node
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AdminActionsUseCaseTest {
/*
private lateinit var radioController: RadioController
private lateinit var nodeRepository: NodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var useCase: AdminActionsUseCase
@BeforeTest
fun setUp() {
radioController = FakeRadioController()
nodeRepository = FakeNodeRepository()
useCase = AdminActionsUseCase(radioController, nodeRepository)
every { radioController.getPacketId() } returns 42
}
@Test
fun `reboot calls radioController and returns packetId`() = runTest {
val result = useCase.reboot(123)
verifySuspend { radioController.reboot(123, 42) }
assertEquals(42, result)
fun `reboot calls radioController`() = runTest {
val packetId = useCase.reboot(1234)
assertEquals(1, packetId)
}
@Test
fun `shutdown calls radioController and returns packetId`() = runTest {
val result = useCase.shutdown(123)
verifySuspend { radioController.shutdown(123, 42) }
assertEquals(42, result)
fun `shutdown calls radioController`() = runTest {
val packetId = useCase.shutdown(1234)
assertEquals(1, packetId)
}
@Test
fun `factoryReset calls radioController and clears DB if local`() = runTest {
val result = useCase.factoryReset(123, isLocal = true)
verifySuspend { radioController.factoryReset(123, 42) }
verifySuspend { nodeRepository.clearNodeDB() }
assertEquals(42, result)
fun `factoryReset local node clears local NodeDB`() = runTest {
nodeRepository.upsert(Node(num = 1))
useCase.factoryReset(1234, isLocal = true)
assertTrue(nodeRepository.nodeDBbyNum.value.isEmpty())
}
@Test
fun `nodedbReset calls radioController and clears DB if local`() = runTest {
val result = useCase.nodedbReset(123, preserveFavorites = true, isLocal = true)
verifySuspend { radioController.nodedbReset(123, 42, true) }
verifySuspend { nodeRepository.clearNodeDB(true) }
assertEquals(42, result)
fun `nodedbReset local node clears local NodeDB with preserveFavorites`() = runTest {
nodeRepository.setNodes(listOf(Node(num = 1, isFavorite = true), Node(num = 2, isFavorite = false)))
useCase.nodedbReset(1234, preserveFavorites = true, isLocal = true)
assertEquals(1, nodeRepository.nodeDBbyNum.value.size)
assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(1))
}
*/
}

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,34 +16,67 @@
*/
package org.meshtastic.core.domain.usecase.settings
//
import kotlinx.coroutines.test.runTest
import okio.Buffer
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.testing.FakeMeshLogRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
class ExportDataUseCaseTest {
/*
private lateinit var nodeRepository: NodeRepository
private lateinit var meshLogRepository: MeshLogRepository
private lateinit var nodeRepository: FakeNodeRepository
private lateinit var meshLogRepository: FakeMeshLogRepository
private lateinit var useCase: ExportDataUseCase
@BeforeTest
fun setUp() {
nodeRepository = mock(MockMode.autofill)
meshLogRepository = mock(MockMode.autofill)
nodeRepository = FakeNodeRepository()
meshLogRepository = FakeMeshLogRepository()
useCase = ExportDataUseCase(nodeRepository, meshLogRepository)
}
@Test
fun `invoke calls repositories`() = runTest {
// Arrange
fun `invoke writes header to sink`() = runTest {
val buffer = Buffer()
useCase(buffer, 1)
// Act
useCase(buffer, 123, null)
// Assert
verifySuspend { nodeRepository.getNodes() }
val output = buffer.readUtf8()
assertTrue(output.startsWith("\"date\",\"time\",\"from\""))
}
*/
@Test
fun `invoke writes packet data to sink`() = runTest {
val buffer = Buffer()
val log =
MeshLog(
uuid = "1",
message_type = "TEXT",
received_date = 1000000000L,
raw_message = "",
fromRadio =
FromRadio(
packet =
MeshPacket(
from = 1234,
rx_snr = 5.0f,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "Hello".encodeUtf8()),
),
),
)
meshLogRepository.setLogs(listOf(log))
useCase(buffer, 1)
val output = buffer.readUtf8()
assertTrue(output.contains("\"1234\""))
assertTrue(output.contains("Hello"))
}
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,32 +16,31 @@
*/
package org.meshtastic.core.domain.usecase.settings
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.testing.FakeRadioController
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
class MeshLocationUseCaseTest {
private lateinit var radioController: RadioController
private lateinit var radioController: FakeRadioController
private lateinit var useCase: MeshLocationUseCase
@BeforeTest
fun setUp() {
radioController = mock(dev.mokkery.MockMode.autofill)
radioController = FakeRadioController()
useCase = MeshLocationUseCase(radioController)
}
@Test
fun `startProvidingLocation calls radioController`() {
useCase.startProvidingLocation()
verify { radioController.startProvideLocation() }
assertTrue(radioController.startProvideLocationCalled)
}
@Test
fun `stopProvidingLocation calls radioController`() {
useCase.stopProvidingLocation()
verify { radioController.stopProvideLocation() }
assertTrue(radioController.stopProvideLocationCalled)
}
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,51 +16,45 @@
*/
package org.meshtastic.core.domain.usecase.settings
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.testing.FakeMeshLogPrefs
import org.meshtastic.core.testing.FakeMeshLogRepository
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class SetMeshLogSettingsUseCaseTest {
/*
private lateinit var meshLogRepository: MeshLogRepository
private lateinit var meshLogPrefs: MeshLogPrefs
private lateinit var meshLogRepository: FakeMeshLogRepository
private lateinit var meshLogPrefs: FakeMeshLogPrefs
private lateinit var useCase: SetMeshLogSettingsUseCase
@BeforeTest
fun setUp() {
meshLogRepository = FakeMeshLogRepository()
meshLogPrefs = FakeMeshLogPrefs()
useCase = SetMeshLogSettingsUseCase(meshLogRepository, meshLogPrefs)
}
@Test
fun `setRetentionDays clamps and updates prefs and repository`() = runTest {
// Act
useCase.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS - 1)
// Assert
verify { meshLogPrefs.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS) }
verifySuspend { meshLogRepository.deleteLogsOlderThan(MeshLogPrefs.MIN_RETENTION_DAYS) }
fun `setRetentionDays clamps value and deletes old logs`() = runTest {
useCase.setRetentionDays(500) // Max is 365
assertEquals(365, meshLogPrefs.retentionDays.value)
assertEquals(365, meshLogRepository.lastDeletedOlderThan)
}
@Test
fun `setLoggingEnabled true triggers cleanup`() = runTest {
// Arrange
every { meshLogPrefs.retentionDays.value } returns 30
// Act
useCase.setLoggingEnabled(true)
// Assert
verify { meshLogPrefs.setLoggingEnabled(true) }
verifySuspend { meshLogRepository.deleteLogsOlderThan(30) }
}
@Test
fun `setLoggingEnabled false triggers deletion`() = runTest {
// Act
fun `setLoggingEnabled false deletes all logs`() = runTest {
useCase.setLoggingEnabled(false)
// Assert
verify { meshLogPrefs.setLoggingEnabled(false) }
verifySuspend { meshLogRepository.deleteAll() }
assertEquals(false, meshLogPrefs.loggingEnabled.value)
assertEquals(true, meshLogRepository.deleteAllCalled)
}
*/
@Test
fun `setLoggingEnabled true deletes logs older than retention`() = runTest {
meshLogPrefs.setRetentionDays(15)
useCase.setLoggingEnabled(true)
assertEquals(true, meshLogPrefs.loggingEnabled.value)
assertEquals(15, meshLogRepository.lastDeletedOlderThan)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,41 +16,33 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.testing.FakeAnalyticsPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ToggleAnalyticsUseCaseTest {
/*
private lateinit var analyticsPrefs: AnalyticsPrefs
private lateinit var analyticsPrefs: FakeAnalyticsPrefs
private lateinit var useCase: ToggleAnalyticsUseCase
@BeforeTest
fun setUp() {
analyticsPrefs = FakeAnalyticsPrefs()
useCase = ToggleAnalyticsUseCase(analyticsPrefs)
}
@Test
fun `invoke toggles analytics from false to true`() {
// Arrange
every { analyticsPrefs.analyticsAllowed.value } returns false
// Act
fun `invoke toggles from false to true`() {
analyticsPrefs.setAnalyticsAllowed(false)
useCase()
// Assert
verify { analyticsPrefs.setAnalyticsAllowed(true) }
assertEquals(true, analyticsPrefs.analyticsAllowed.value)
}
@Test
fun `invoke toggles analytics from true to false`() {
// Arrange
every { analyticsPrefs.analyticsAllowed.value } returns true
// Act
fun `invoke toggles from true to false`() {
analyticsPrefs.setAnalyticsAllowed(true)
useCase()
// Assert
verify { analyticsPrefs.setAnalyticsAllowed(false) }
assertEquals(false, analyticsPrefs.analyticsAllowed.value)
}
*/
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,41 +16,33 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.testing.FakeHomoglyphPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ToggleHomoglyphEncodingUseCaseTest {
/*
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
private lateinit var homoglyphPrefs: FakeHomoglyphPrefs
private lateinit var useCase: ToggleHomoglyphEncodingUseCase
@BeforeTest
fun setUp() {
useCase = ToggleHomoglyphEncodingUseCase(homoglyphEncodingPrefs)
homoglyphPrefs = FakeHomoglyphPrefs()
useCase = ToggleHomoglyphEncodingUseCase(homoglyphPrefs)
}
@Test
fun `invoke toggles homoglyph encoding from false to true`() {
// Arrange
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
// Act
fun `invoke toggles from false to true`() {
homoglyphPrefs.setHomoglyphEncodingEnabled(false)
useCase()
// Assert
verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(true) }
assertEquals(true, homoglyphPrefs.homoglyphEncodingEnabled.value)
}
@Test
fun `invoke toggles homoglyph encoding from true to false`() {
// Arrange
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns true
// Act
fun `invoke toggles from true to false`() {
homoglyphPrefs.setHomoglyphEncodingEnabled(true)
useCase()
// Assert
verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(false) }
assertEquals(false, homoglyphPrefs.homoglyphEncodingEnabled.value)
}
*/
}

View File

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

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.radio
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import kotlin.test.Test
import kotlin.test.assertEquals
class TCPInterfaceTest {
@Test
fun testHeartbeatFraming() = runTest {
val sentBytes = mutableListOf<ByteArray>()
val codec = StreamFrameCodec(onPacketReceived = {}, logTag = "Test")
val heartbeat = ToRadio(heartbeat = Heartbeat()).encode()
codec.frameAndSend(heartbeat, { sentBytes.add(it) })
// First sent bytes are the 4-byte header, second is the payload
assertEquals(2, sentBytes.size)
val header = sentBytes[0]
assertEquals(4, header.size)
assertEquals(0x94.toByte(), header[0])
assertEquals(0xc3.toByte(), header[1])
val payload = sentBytes[1]
assertEquals(heartbeat.toList(), payload.toList())
}
@Test
fun testServicePort() {
assertEquals(4403, TCPInterface.SERVICE_PORT)
}
}

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,20 +14,18 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.navigation
package org.meshtastic.core.repository
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.database.entity.FirmwareRelease
@Composable
actual fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String?,
initialMessage: String,
) {
// TODO: Implement iOS contacts screen
interface FirmwareReleaseRepository {
/** A flow that provides the latest STABLE firmware release. */
val stableRelease: Flow<FirmwareRelease?>
/** A flow that provides the latest ALPHA firmware release. */
val alphaRelease: Flow<FirmwareRelease?>
/** Invalidates the local cache of firmware releases. */
suspend fun invalidateCache()
}

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import org.meshtastic.core.testing.FakeRadioPrefs
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AppPreferencesTest {
@Test
fun `RadioPrefs isBle returns true for x prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("x12345678")
assertTrue(prefs.isBle())
}
@Test
fun `RadioPrefs isBle returns false for other prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("s12345678")
assertFalse(prefs.isBle())
}
@Test
fun `RadioPrefs isSerial returns true for s prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("s12345678")
assertTrue(prefs.isSerial())
}
@Test
fun `RadioPrefs isTcp returns true for t prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("t192.168.1.1")
assertTrue(prefs.isTcp())
}
@Test
fun `RadioPrefs isMock returns true for m prefix`() {
val prefs = FakeRadioPrefs()
prefs.setDevAddr("m12345678")
assertTrue(prefs.isMock())
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import kotlin.test.Test
import kotlin.test.assertEquals
class DataPairTest {
@Test
fun `DataPair with non-null value retains value`() {
val pair = DataPair("key", "value")
assertEquals("value", pair.value)
}
@Test
fun `DataPair with null value becomes string null`() {
val pair = DataPair("key", null)
assertEquals("null", pair.value)
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import kotlin.test.Test
import kotlin.test.assertEquals
class NotificationTest {
@Test
fun `Notification creation works with defaults`() {
val notification = Notification("Title", "Message")
assertEquals("Title", notification.title)
assertEquals("Message", notification.message)
assertEquals(Notification.Type.Info, notification.type)
assertEquals(Notification.Category.Message, notification.category)
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.repository.Location
/** Creates an Android [Location] for testing. */
actual fun createLocation(latitude: Double, longitude: Double, altitude: Double): Location = Location("fake").apply {
this.latitude = latitude
this.longitude = longitude
this.altitude = altitude
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import androidx.test.core.app.ApplicationProvider
import org.meshtastic.core.common.ContextServices
actual fun setupTestContext() {
ContextServices.app = ApplicationProvider.getApplicationContext()
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
/** Base class for fakes that provides common utilities for state management and reset capabilities. */
abstract class BaseFake {
private val resetActions = mutableListOf<() -> Unit>()
/** Creates a [MutableStateFlow] and registers it for automatic reset. */
protected fun <T> mutableStateFlow(initialValue: T): MutableStateFlow<T> {
val flow = MutableStateFlow(initialValue)
resetActions.add { flow.value = initialValue }
return flow
}
/** Creates a [MutableSharedFlow] and registers it for automatic reset (replay cache cleared). */
protected fun <T> mutableSharedFlow(replay: Int = 0): MutableSharedFlow<T> {
val flow = MutableSharedFlow<T>(replay = replay)
resetActions.add { flow.resetReplayCache() }
return flow
}
/** Registers a custom reset action (e.g. clearing a list of recorded calls). */
protected fun registerResetAction(action: () -> Unit) {
resetActions.add(action)
}
/** Resets all registered state flows and custom actions to their initial state. */
open fun reset() {
resetActions.forEach { it() }
}
}

View File

@@ -0,0 +1,265 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.AppPreferences
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MapTileProviderPrefs
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.UiPrefs
class FakeAnalyticsPrefs : AnalyticsPrefs {
override val analyticsAllowed = MutableStateFlow(false)
override fun setAnalyticsAllowed(allowed: Boolean) {
analyticsAllowed.value = allowed
}
override val installId = MutableStateFlow("fake-install-id")
}
class FakeHomoglyphPrefs : HomoglyphPrefs {
override val homoglyphEncodingEnabled = MutableStateFlow(false)
override fun setHomoglyphEncodingEnabled(enabled: Boolean) {
homoglyphEncodingEnabled.value = enabled
}
}
class FakeFilterPrefs : FilterPrefs {
override val filterEnabled = MutableStateFlow(false)
override fun setFilterEnabled(enabled: Boolean) {
filterEnabled.value = enabled
}
override val filterWords = MutableStateFlow(emptySet<String>())
override fun setFilterWords(words: Set<String>) {
filterWords.value = words
}
}
class FakeCustomEmojiPrefs : CustomEmojiPrefs {
override val customEmojiFrequency = MutableStateFlow<String?>(null)
override fun setCustomEmojiFrequency(frequency: String?) {
customEmojiFrequency.value = frequency
}
}
@Suppress("TooManyFunctions")
class FakeUiPrefs : UiPrefs {
override val appIntroCompleted = MutableStateFlow(false)
override fun setAppIntroCompleted(completed: Boolean) {
appIntroCompleted.value = completed
}
override val theme = MutableStateFlow(0)
override fun setTheme(value: Int) {
theme.value = value
}
override val locale = MutableStateFlow("en")
override fun setLocale(languageTag: String) {
locale.value = languageTag
}
override val nodeSort = MutableStateFlow(0)
override fun setNodeSort(value: Int) {
nodeSort.value = value
}
override val includeUnknown = MutableStateFlow(true)
override fun setIncludeUnknown(value: Boolean) {
includeUnknown.value = value
}
override val excludeInfrastructure = MutableStateFlow(false)
override fun setExcludeInfrastructure(value: Boolean) {
excludeInfrastructure.value = value
}
override val onlyOnline = MutableStateFlow(false)
override fun setOnlyOnline(value: Boolean) {
onlyOnline.value = value
}
override val onlyDirect = MutableStateFlow(false)
override fun setOnlyDirect(value: Boolean) {
onlyDirect.value = value
}
override val showIgnored = MutableStateFlow(false)
override fun setShowIgnored(value: Boolean) {
showIgnored.value = value
}
override val excludeMqtt = MutableStateFlow(false)
override fun setExcludeMqtt(value: Boolean) {
excludeMqtt.value = value
}
override val hasShownNotPairedWarning = MutableStateFlow(false)
override fun setHasShownNotPairedWarning(shown: Boolean) {
hasShownNotPairedWarning.value = shown
}
override val showQuickChat = MutableStateFlow(true)
override fun setShowQuickChat(show: Boolean) {
showQuickChat.value = show
}
private val nodeLocationEnabled = mutableMapOf<Int, MutableStateFlow<Boolean>>()
override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean> =
nodeLocationEnabled.getOrPut(nodeNum) { MutableStateFlow(true) }
override fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) {
nodeLocationEnabled.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide
}
}
class FakeMapPrefs : MapPrefs {
override val mapStyle = MutableStateFlow(0)
override fun setMapStyle(style: Int) {
mapStyle.value = style
}
override val showOnlyFavorites = MutableStateFlow(false)
override fun setShowOnlyFavorites(show: Boolean) {
showOnlyFavorites.value = show
}
override val showWaypointsOnMap = MutableStateFlow(true)
override fun setShowWaypointsOnMap(show: Boolean) {
showWaypointsOnMap.value = show
}
override val showPrecisionCircleOnMap = MutableStateFlow(true)
override fun setShowPrecisionCircleOnMap(show: Boolean) {
showPrecisionCircleOnMap.value = show
}
override val lastHeardFilter = MutableStateFlow(0L)
override fun setLastHeardFilter(seconds: Long) {
lastHeardFilter.value = seconds
}
override val lastHeardTrackFilter = MutableStateFlow(0L)
override fun setLastHeardTrackFilter(seconds: Long) {
lastHeardTrackFilter.value = seconds
}
}
class FakeMapConsentPrefs : MapConsentPrefs {
private val consent = mutableMapOf<Int?, MutableStateFlow<Boolean>>()
override fun shouldReportLocation(nodeNum: Int?): StateFlow<Boolean> =
consent.getOrPut(nodeNum) { MutableStateFlow(false) }
override fun setShouldReportLocation(nodeNum: Int?, report: Boolean) {
consent.getOrPut(nodeNum) { MutableStateFlow(report) }.value = report
}
}
class FakeMapTileProviderPrefs : MapTileProviderPrefs {
override val customTileProviders = MutableStateFlow<String?>(null)
override fun setCustomTileProviders(providers: String?) {
customTileProviders.value = providers
}
}
class FakeRadioPrefs : RadioPrefs {
override val devAddr = MutableStateFlow<String?>(null)
override val devName = MutableStateFlow<String?>(null)
override fun setDevAddr(address: String?) {
devAddr.value = address
}
override fun setDevName(name: String?) {
devName.value = name
}
}
class FakeMeshPrefs : MeshPrefs {
override val deviceAddress = MutableStateFlow<String?>(null)
override fun setDeviceAddress(address: String?) {
deviceAddress.value = address
}
private val provideLocation = mutableMapOf<Int?, MutableStateFlow<Boolean>>()
override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow<Boolean> =
provideLocation.getOrPut(nodeNum) { MutableStateFlow(true) }
override fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean) {
provideLocation.getOrPut(nodeNum) { MutableStateFlow(provide) }.value = provide
}
private val lastRequest = mutableMapOf<String?, MutableStateFlow<Int>>()
override fun getStoreForwardLastRequest(address: String?): StateFlow<Int> =
lastRequest.getOrPut(address) { MutableStateFlow(0) }
override fun setStoreForwardLastRequest(address: String?, timestamp: Int) {
lastRequest.getOrPut(address) { MutableStateFlow(timestamp) }.value = timestamp
}
}
class FakeAppPreferences : AppPreferences {
override val analytics = FakeAnalyticsPrefs()
override val homoglyph = FakeHomoglyphPrefs()
override val filter = FakeFilterPrefs()
override val meshLog = FakeMeshLogPrefs()
override val emoji = FakeCustomEmojiPrefs()
override val ui = FakeUiPrefs()
override val map = FakeMapPrefs()
override val mapConsent = FakeMapConsentPrefs()
override val mapTileProvider = FakeMapTileProviderPrefs()
override val radio = FakeRadioPrefs()
override val mesh = FakeMeshPrefs()
}

View File

@@ -0,0 +1,173 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import org.meshtastic.core.ble.BleConnection
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleService
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.ble.BluetoothState
import kotlin.time.Duration
import kotlin.uuid.Uuid
class FakeBleDevice(
override val address: String,
override val name: String? = "Fake Device",
initialState: BleConnectionState = BleConnectionState.Disconnected,
) : BaseFake(),
BleDevice {
private val _state = mutableStateFlow(initialState)
override val state: StateFlow<BleConnectionState> = _state.asStateFlow()
private val _isBonded = mutableStateFlow(false)
override val isBonded: Boolean
get() = _isBonded.value
override val isConnected: Boolean
get() = _state.value == BleConnectionState.Connected
override suspend fun readRssi(): Int = DEFAULT_RSSI
override suspend fun bond() {
_isBonded.value = true
}
fun setState(newState: BleConnectionState) {
_state.value = newState
}
companion object {
private const val DEFAULT_RSSI = -60
}
}
class FakeBleScanner :
BaseFake(),
BleScanner {
private val foundDevices = mutableSharedFlow<BleDevice>(replay = 10)
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow<BleDevice> = flow {
emitAll(foundDevices)
}
fun emitDevice(device: BleDevice) {
foundDevices.tryEmit(device)
}
}
class FakeBleConnection :
BaseFake(),
BleConnection {
private val _device = mutableStateFlow<BleDevice?>(null)
override val device: BleDevice?
get() = _device.value
private val _deviceFlow = mutableSharedFlow<BleDevice?>(replay = 1)
override val deviceFlow: SharedFlow<BleDevice?> = _deviceFlow.asSharedFlow()
private val _connectionState = mutableSharedFlow<BleConnectionState>(replay = 1)
override val connectionState: SharedFlow<BleConnectionState> = _connectionState.asSharedFlow()
override suspend fun connect(device: BleDevice) {
_device.value = device
_deviceFlow.emit(device)
_connectionState.emit(BleConnectionState.Connecting)
if (device is FakeBleDevice) {
device.setState(BleConnectionState.Connecting)
}
_connectionState.emit(BleConnectionState.Connected)
if (device is FakeBleDevice) {
device.setState(BleConnectionState.Connected)
}
}
override suspend fun connectAndAwait(
device: BleDevice,
timeoutMs: Long,
onRegister: suspend () -> Unit,
): BleConnectionState {
connect(device)
onRegister()
return BleConnectionState.Connected
}
override suspend fun disconnect() {
val currentDevice = _device.value
_connectionState.emit(BleConnectionState.Disconnected)
if (currentDevice is FakeBleDevice) {
currentDevice.setState(BleConnectionState.Disconnected)
}
_device.value = null
_deviceFlow.emit(null)
}
override suspend fun <T> profile(
serviceUuid: Uuid,
timeout: Duration,
setup: suspend CoroutineScope.(BleService) -> T,
): T = CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined).setup(FakeBleService())
override fun maximumWriteValueLength(writeType: BleWriteType): Int = 512
}
class FakeBleService : BleService
class FakeBleConnectionFactory(private val fakeConnection: FakeBleConnection = FakeBleConnection()) :
BleConnectionFactory {
override fun create(scope: CoroutineScope, tag: String): BleConnection = fakeConnection
}
@Suppress("EmptyFunctionBlock")
class FakeBluetoothRepository :
BaseFake(),
BluetoothRepository {
private val _state = mutableStateFlow(BluetoothState(hasPermissions = true, enabled = true))
override val state: StateFlow<BluetoothState> = _state.asStateFlow()
override fun refreshState() {}
override fun isValid(bleAddress: String): Boolean = bleAddress.isNotBlank()
override fun isBonded(address: String): Boolean = _state.value.bondedDevices.any { it.address == address }
override suspend fun bond(device: BleDevice) {
val currentState = _state.value
if (!currentState.bondedDevices.contains(device)) {
_state.value = currentState.copy(bondedDevices = currentState.bondedDevices + device)
}
}
fun setBluetoothEnabled(enabled: Boolean) {
_state.value = _state.value.copy(enabled = enabled)
}
fun setHasPermissions(hasPermissions: Boolean) {
_state.value = _state.value.copy(hasPermissions = hasPermissions)
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.common.database.DatabaseManager
/** A test double for [DatabaseManager] that provides a simple implementation and tracks calls. */
class FakeDatabaseManager :
BaseFake(),
DatabaseManager {
private val _cacheLimit = mutableStateFlow(DEFAULT_CACHE_LIMIT)
override val cacheLimit: StateFlow<Int> = _cacheLimit
var lastSwitchedAddress: String? = null
val existingDatabases = mutableSetOf<String>()
init {
registerResetAction {
_cacheLimit.value = DEFAULT_CACHE_LIMIT
lastSwitchedAddress = null
existingDatabases.clear()
}
}
override fun getCurrentCacheLimit(): Int = _cacheLimit.value
override fun setCacheLimit(limit: Int) {
_cacheLimit.value = limit
}
override suspend fun switchActiveDatabase(address: String?) {
lastSwitchedAddress = address
}
override fun hasDatabaseFor(address: String?): Boolean = address != null && existingDatabases.contains(address)
companion object {
private const val DEFAULT_CACHE_LIMIT = 100
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
/** A real [DatabaseProvider] that uses an in-memory database for testing. */
class FakeDatabaseProvider : DatabaseProvider {
private val db: MeshtasticDatabase = getInMemoryDatabaseBuilder().build()
private val _currentDb = MutableStateFlow(db)
override val currentDb: StateFlow<MeshtasticDatabase> = _currentDb
override suspend fun <T> withDb(block: suspend (MeshtasticDatabase) -> T): T? = block(db)
fun close() {
db.close()
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.proto.LocalStats
/** A test double for [LocalStatsDataSource] that provides an in-memory implementation. */
class FakeLocalStatsDataSource :
BaseFake(),
LocalStatsDataSource {
private val _localStatsFlow = mutableStateFlow(LocalStats())
override val localStatsFlow: StateFlow<LocalStats> = _localStatsFlow
override suspend fun setLocalStats(stats: LocalStats) {
_localStatsFlow.value = stats
}
override suspend fun clearLocalStats() {
_localStatsFlow.value = LocalStats()
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.repository.Location
import org.meshtastic.core.repository.LocationRepository
/** A test double for [LocationRepository] that provides a manual location emission mechanism. */
class FakeLocationRepository : LocationRepository {
private val _receivingLocationUpdates = MutableStateFlow(false)
override val receivingLocationUpdates: StateFlow<Boolean> = _receivingLocationUpdates
private val _locations = MutableSharedFlow<Location>(replay = 1)
override fun getLocations(): Flow<Location> = _locations
fun setReceivingLocationUpdates(receiving: Boolean) {
_receivingLocationUpdates.value = receiving
}
suspend fun emitLocation(location: Location) {
_locations.emit(location)
}
}
/** Platform-specific factory for creating [Location] objects in tests. */
expect fun createLocation(latitude: Double, longitude: Double, altitude: Double = 0.0): Location

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
/**
* A container for all mesh-related fakes to simplify test setup.
*
* Instead of manually instantiating and wiring multiple fakes, you can use [FakeMeshService] to get a consistent set of
* test doubles.
*/
class FakeMeshService {
val nodeRepository = FakeNodeRepository()
val serviceRepository = FakeServiceRepository()
val radioController = FakeRadioController()
val radioInterfaceService = FakeRadioInterfaceService()
val notifications = FakeMeshServiceNotifications()
val transport = FakeRadioTransport()
val logRepository = FakeMeshLogRepository()
val packetRepository = FakePacketRepository()
val contactRepository = FakeContactRepository()
val locationRepository = FakeLocationRepository()
// Add more as they are implemented
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
/** A test double for [MeshServiceNotifications] that provides a no-op implementation. */
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun clearNotifications() {}
override fun initChannels() {}
override fun updateServiceStateNotification(
state: org.meshtastic.core.model.ConnectionState,
telemetry: Telemetry?,
): Any = Any()
override suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {}
override suspend fun updateWaypointNotification(
contactKey: String,
name: String,
message: String,
waypointId: Int,
isSilent: Boolean,
) {}
override suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
isSilent: Boolean,
) {}
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
override fun showNewNodeSeenNotification(node: Node) {}
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
override fun showClientNotification(clientNotification: ClientNotification) {}
override fun cancelMessageNotification(contactKey: String) {}
override fun cancelLowBatteryNotification(node: Node) {}
override fun clearClientNotification(notification: ClientNotification) {}
}

View File

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

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.repository.NotificationPrefs
class FakeNotificationPrefs : NotificationPrefs {
override val messagesEnabled = MutableStateFlow(true)
override fun setMessagesEnabled(enabled: Boolean) {
messagesEnabled.value = enabled
}
override val nodeEventsEnabled = MutableStateFlow(true)
override fun setNodeEventsEnabled(enabled: Boolean) {
nodeEventsEnabled.value = enabled
}
override val lowBatteryEnabled = MutableStateFlow(true)
override fun setLowBatteryEnabled(enabled: Boolean) {
lowBatteryEnabled.value = enabled
}
}

View File

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

View File

@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.repository.RadioInterfaceService
/** A test double for [RadioInterfaceService] that provides an in-memory implementation. */
@Suppress("TooManyFunctions")
class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = MainScope()) : RadioInterfaceService {
override val supportedDeviceTypes: List<DeviceType> = emptyList()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
private val _currentDeviceAddressFlow = MutableStateFlow<String?>(null)
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow
private val _receivedData = MutableSharedFlow<ByteArray>()
override val receivedData: SharedFlow<ByteArray> = _receivedData
private val _meshActivity = MutableSharedFlow<MeshActivity>()
override val meshActivity: SharedFlow<MeshActivity> = _meshActivity
val sentToRadio = mutableListOf<ByteArray>()
var connectCalled = false
override fun isMockInterface(): Boolean = true
override fun sendToRadio(bytes: ByteArray) {
sentToRadio.add(bytes)
}
override fun connect() {
connectCalled = true
}
override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value
override fun setDeviceAddress(deviceAddr: String?): Boolean {
_currentDeviceAddressFlow.value = deviceAddr
return true
}
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "$interfaceId:$rest"
override fun onConnect() {
_connectionState.value = ConnectionState.Connected
}
override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {
_connectionState.value = ConnectionState.Disconnected
}
override fun handleFromRadio(bytes: ByteArray) {
// In a real implementation, this would emit to receivedData
}
// --- Helper methods for testing ---
suspend fun emitFromRadio(bytes: ByteArray) {
_receivedData.emit(bytes)
}
fun setConnectionState(state: ConnectionState) {
_connectionState.value = state
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.repository.RadioTransport
/** A test double for [RadioTransport] that tracks sent data. */
class FakeRadioTransport : RadioTransport {
val sentData = mutableListOf<ByteArray>()
var closeCalled = false
var keepAliveCalled = false
override fun handleSendToRadio(p: ByteArray) {
sentData.add(p)
}
override fun keepAlive() {
keepAliveCalled = true
}
override fun close() {
closeCalled = true
}
}

View File

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

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
/** Initializes platform-specific test context (e.g., Robolectric on Android). */
expect fun setupTestContext()

View File

@@ -0,0 +1,140 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import app.cash.turbine.test
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FakeNodeRepositoryTest {
private val repository = FakeNodeRepository()
@Test
fun `getNodes sorting by name`() = runTest {
val nodes =
listOf(
Node(num = 1, user = User(long_name = "Charlie")),
Node(num = 2, user = User(long_name = "Alice")),
Node(num = 3, user = User(long_name = "Bob")),
)
repository.setNodes(nodes)
repository.getNodes(sort = NodeSortOption.ALPHABETICAL).test {
val result = awaitItem()
assertEquals("Alice", result[0].user.long_name)
assertEquals("Bob", result[1].user.long_name)
assertEquals("Charlie", result[2].user.long_name)
}
}
@Test
fun `getUnknownNodes returns nodes with UNSET hw_model`() = runTest {
val node1 = Node(num = 1, user = User(hw_model = org.meshtastic.proto.HardwareModel.UNSET))
val node2 = Node(num = 2, user = User(hw_model = org.meshtastic.proto.HardwareModel.TLORA_V2))
repository.setNodes(listOf(node1, node2))
val result = repository.getUnknownNodes()
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
@Test
fun `getNodes filtering by onlyOnline`() = runTest {
val node1 = Node(num = 1, lastHeard = 2000000000) // Online
val node2 = Node(num = 2, lastHeard = 0) // Offline
repository.setNodes(listOf(node1, node2))
repository.getNodes(onlyOnline = true).test {
val result = awaitItem()
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
}
@Test
fun `getNodes filtering by onlyDirect`() = runTest {
val node1 = Node(num = 1, hopsAway = 0) // Direct
val node2 = Node(num = 2, hopsAway = 1) // Indirect
repository.setNodes(listOf(node1, node2))
repository.getNodes(onlyDirect = true).test {
val result = awaitItem()
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
}
@Test
fun `insertMetadata updates node metadata`() = runTest {
val nodeNum = 1234
repository.upsert(Node(num = nodeNum))
val metadata = org.meshtastic.proto.DeviceMetadata(firmware_version = "2.5.0")
repository.insertMetadata(nodeNum, metadata)
val node = repository.nodeDBbyNum.value[nodeNum]
assertEquals("2.5.0", node?.metadata?.firmware_version)
}
@Test
fun `deleteNodes removes multiple nodes`() = runTest {
repository.setNodes(listOf(Node(num = 1), Node(num = 2), Node(num = 3)))
repository.deleteNodes(listOf(1, 2))
assertEquals(1, repository.nodeDBbyNum.value.size)
assertTrue(repository.nodeDBbyNum.value.containsKey(3))
}
@Test
fun `reset clears all state`() = runTest {
repository.setNodes(listOf(Node(num = 1)))
repository.setMyId("my-id")
repository.setNodeNotes(1, "note")
repository.reset()
assertTrue(repository.nodeDBbyNum.value.isEmpty())
assertEquals(null, repository.myId.value)
}
@Test
fun `setNodeNotes persists notes`() = runTest {
val nodeNum = 1234
repository.upsert(Node(num = nodeNum))
repository.setNodeNotes(nodeNum, "My Note")
val node = repository.nodeDBbyNum.value[nodeNum]
assertEquals("My Note", node?.notes)
}
@Test
fun `clearNodeDB preserves favorites`() = runTest {
val node1 = Node(num = 1, isFavorite = true)
val node2 = Node(num = 2, isFavorite = false)
repository.setNodes(listOf(node1, node2))
repository.clearNodeDB(preserveFavorites = true)
assertEquals(1, repository.nodeDBbyNum.value.size)
assertTrue(repository.nodeDBbyNum.value.containsKey(1))
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.repository.Location
/** Creates a placeholder iOS [Location] for testing. */
actual fun createLocation(latitude: Double, longitude: Double, altitude: Double): Location = Location()

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
actual fun setupTestContext() {}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
import org.meshtastic.core.repository.Location
/** Creates a placeholder JVM [Location] for testing. */
actual fun createLocation(latitude: Double, longitude: Double, altitude: Double): Location = Location()

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.testing
@Suppress("EmptyFunctionBlock")
actual fun setupTestContext() {}

View File

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

View File

@@ -14,13 +14,12 @@
* 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.feature.connections.ui.components
package org.meshtastic.core.ui.component
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -49,9 +48,9 @@ fun AnimatedConnectionsNavIcon(
connectionState: ConnectionState,
deviceType: DeviceType?,
meshActivityFlow: Flow<MeshActivity>,
colorScheme: ColorScheme,
modifier: Modifier = Modifier,
) {
val colorScheme = androidx.compose.material3.MaterialTheme.colorScheme
var currentGlowColor by remember { mutableStateOf(Color.Transparent) }
val animatedGlowAlpha = remember { Animatable(0f) }

View File

@@ -14,7 +14,7 @@
* 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.feature.connections.ui.components
package org.meshtastic.core.ui.component
import androidx.compose.animation.Crossfade
import androidx.compose.material.icons.Icons

View File

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

View File

@@ -0,0 +1,293 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.window.core.layout.WindowWidthSizeClass
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Shared adaptive navigation shell. Provides a Bottom Navigation bar on phones, and a Navigation Rail on tablets and
* desktop targets.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MeshtasticNavigationSuite(
backStack: NavBackStack<NavKey>,
uiViewModel: UIViewModel,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val connectionState by uiViewModel.connectionState.collectAsStateWithLifecycle()
val unreadMessageCount by uiViewModel.unreadMessageCount.collectAsStateWithLifecycle()
val selectedDevice by uiViewModel.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val adaptiveInfo = currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)
val isCompact = adaptiveInfo.windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
val currentKey = backStack.lastOrNull()
val rootKey = backStack.firstOrNull()
val topLevelDestination = TopLevelDestination.fromNavKey(rootKey)
val onNavigate = { destination: TopLevelDestination ->
handleNavigation(destination, topLevelDestination, currentKey, backStack, uiViewModel)
}
if (isCompact) {
Scaffold(
modifier = modifier,
bottomBar = {
MeshtasticNavigationBar(
topLevelDestination = topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
onNavigate = onNavigate,
)
},
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) { content() }
}
} else {
Row(modifier = modifier.fillMaxSize()) {
MeshtasticNavigationRail(
topLevelDestination = topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
onNavigate = onNavigate,
)
Box(modifier = Modifier.weight(1f).fillMaxSize()) { content() }
}
}
}
private fun handleNavigation(
destination: TopLevelDestination,
topLevelDestination: TopLevelDestination?,
currentKey: NavKey?,
backStack: NavBackStack<NavKey>,
uiViewModel: UIViewModel,
) {
val isRepress = destination == topLevelDestination
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.NodesGraph || currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.navigateTopLevel(destination.route)
} else {
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
}
TopLevelDestination.Conversations -> {
val onConversationsList =
currentKey is ContactsRoutes.ContactsGraph || currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.navigateTopLevel(destination.route)
} else {
uiViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
}
else -> {
if (currentKey != destination.route) {
backStack.navigateTopLevel(destination.route)
}
}
}
} else {
backStack.navigateTopLevel(destination.route)
}
}
@Composable
private fun MeshtasticNavigationBar(
topLevelDestination: TopLevelDestination?,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
onNavigate: (TopLevelDestination) -> Unit,
) {
NavigationBar {
TopLevelDestination.entries.forEach { destination ->
NavigationBarItem(
selected = destination == topLevelDestination,
onClick = { onNavigate(destination) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = destination == topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
label = { Text(stringResource(destination.label)) },
)
}
}
}
@Composable
private fun MeshtasticNavigationRail(
topLevelDestination: TopLevelDestination?,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
onNavigate: (TopLevelDestination) -> Unit,
) {
NavigationRail {
TopLevelDestination.entries.forEach { destination ->
NavigationRailItem(
selected = destination == topLevelDestination,
onClick = { onNavigate(destination) },
icon = {
NavigationIconContent(
destination = destination,
isSelected = destination == topLevelDestination,
connectionState = connectionState,
unreadMessageCount = unreadMessageCount,
selectedDevice = selectedDevice,
uiViewModel = uiViewModel,
)
},
label = { Text(stringResource(destination.label)) },
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NavigationIconContent(
destination: TopLevelDestination,
isSelected: Boolean,
connectionState: ConnectionState,
unreadMessageCount: Int,
selectedDevice: String?,
uiViewModel: UIViewModel,
) {
val isConnectionsRoute = destination == TopLevelDestination.Connections
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping)
ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
},
)
}
},
state = rememberTooltipState(),
) {
if (isConnectionsRoute) {
AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
meshActivityFlow = uiViewModel.meshActivity,
)
} else {
BadgedBox(
badge = {
if (destination == TopLevelDestination.Conversations) {
var lastNonZeroCount by remember { mutableIntStateOf(unreadMessageCount) }
if (unreadMessageCount > 0) {
lastNonZeroCount = unreadMessageCount
}
AnimatedVisibility(
visible = unreadMessageCount > 0,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
) {
Badge { Text(lastNonZeroCount.toString()) }
}
}
},
) {
Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
tint = if (isSelectedState) colorScheme.primary else LocalContentColor.current,
)
}
}
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.core.ui.component.PlaceholderScreen
/**
* Provides the platform-specific Map Main Screen. On Desktop or JVM targets where native maps aren't available yet, it
* falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalMapMainScreenProvider =
compositionLocalOf<
@Composable (onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) -> Unit,
> {
{ _, _, _ -> PlaceholderScreen("Map") }
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.core.ui.component.PlaceholderScreen
/**
* Provides the platform-specific Map Screen for a Node (e.g. Google Maps or OSMDroid on Android). On Desktop or JVM
* targets where native maps aren't available yet, it falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping")
val LocalNodeMapScreenProvider =
compositionLocalOf<@Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit> {
{ destNum, _ -> PlaceholderScreen("Node Map ($destNum)") }
}

Some files were not shown because too many files have changed in this diff Show More