mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-31 12:11:15 -04:00
Refactor map layer management and navigation infrastructure (#4921)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
12
AGENTS.md
12
AGENTS.md
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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¶m2=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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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("áéí"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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¶m2=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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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() {}
|
||||
@@ -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()
|
||||
@@ -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() {}
|
||||
@@ -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)) } }
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user