mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor command handling, enhance tests, and improve discovery logic (#4878)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
d136b162a4
commit
c38bfc64de
76 changed files with 2220 additions and 1277 deletions
10
.github/copilot-instructions.md
vendored
10
.github/copilot-instructions.md
vendored
|
|
@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
| `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`). |
|
||||||
| `core:di` | Common DI qualifiers and dispatchers. |
|
| `core:di` | Common DI qualifiers and dispatchers. |
|
||||||
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
||||||
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
||||||
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
||||||
| `core:api` | Public AIDL/API integration module for external clients. |
|
| `core:api` | Public AIDL/API integration module for external clients. |
|
||||||
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
||||||
|
|
@ -60,6 +60,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
- **Material 3:** The app uses Material 3.
|
- **Material 3:** The app uses Material 3.
|
||||||
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
|
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
|
||||||
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
|
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
|
||||||
|
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
|
||||||
|
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
|
||||||
|
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
|
||||||
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
|
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
|
||||||
|
|
||||||
### B. Logic & Data Layer
|
### B. Logic & Data Layer
|
||||||
|
|
@ -69,8 +72,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
- `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
|
- `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
|
||||||
- `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`.
|
- `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`.
|
||||||
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
|
- `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`.
|
||||||
|
- **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`.
|
||||||
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
||||||
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`.
|
- **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`.
|
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
|
||||||
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
||||||
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
|
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -52,3 +52,4 @@ wireless-install.sh
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
/firebase-debug.log.jdk/
|
/firebase-debug.log.jdk/
|
||||||
|
firebase-debug.log
|
||||||
|
|
|
||||||
10
AGENTS.md
10
AGENTS.md
|
|
@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
| `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`). |
|
||||||
| `core:di` | Common DI qualifiers and dispatchers. |
|
| `core:di` | Common DI qualifiers and dispatchers. |
|
||||||
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
||||||
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
||||||
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
||||||
| `core:api` | Public AIDL/API integration module for external clients. |
|
| `core:api` | Public AIDL/API integration module for external clients. |
|
||||||
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
||||||
|
|
@ -60,6 +60,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
- **Material 3:** The app uses Material 3.
|
- **Material 3:** The app uses Material 3.
|
||||||
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
|
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
|
||||||
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
|
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
|
||||||
|
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
|
||||||
|
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
|
||||||
|
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
|
||||||
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
|
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
|
||||||
|
|
||||||
### B. Logic & Data Layer
|
### B. Logic & Data Layer
|
||||||
|
|
@ -69,8 +72,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
- `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
|
- `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
|
||||||
- `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`.
|
- `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`.
|
||||||
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
|
- `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`.
|
||||||
|
- **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`.
|
||||||
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
||||||
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`.
|
- **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`.
|
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
|
||||||
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
||||||
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
|
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
|
||||||
|
|
|
||||||
10
GEMINI.md
10
GEMINI.md
|
|
@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
| `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`). |
|
||||||
| `core:di` | Common DI qualifiers and dispatchers. |
|
| `core:di` | Common DI qualifiers and dispatchers. |
|
||||||
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
|
||||||
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
|
||||||
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
|
||||||
| `core:api` | Public AIDL/API integration module for external clients. |
|
| `core:api` | Public AIDL/API integration module for external clients. |
|
||||||
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
|
||||||
|
|
@ -60,6 +60,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
- **Material 3:** The app uses Material 3.
|
- **Material 3:** The app uses Material 3.
|
||||||
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
|
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
|
||||||
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
|
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
|
||||||
|
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
|
||||||
|
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
|
||||||
|
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
|
||||||
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
|
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
|
||||||
|
|
||||||
### B. Logic & Data Layer
|
### B. Logic & Data Layer
|
||||||
|
|
@ -69,8 +72,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
|
||||||
- `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
|
- `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
|
||||||
- `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`.
|
- `java.util.concurrent.locks.*` → `kotlinx.coroutines.sync.Mutex`.
|
||||||
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
|
- `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`.
|
||||||
|
- **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`.
|
||||||
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
- **Concurrency:** Use Kotlin Coroutines and Flow.
|
||||||
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`.
|
- **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`.
|
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
|
||||||
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
|
||||||
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
|
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
|
||||||
|
|
|
||||||
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
|
|
@ -20,6 +20,9 @@
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
# Room KMP: preserve generated database constructor (required for R8/ProGuard)
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||||
|
|
||||||
# Needed for protobufs
|
# Needed for protobufs
|
||||||
-keep class com.google.protobuf.** { *; }
|
-keep class com.google.protobuf.** { *; }
|
||||||
-keep class org.meshtastic.proto.** { *; }
|
-keep class org.meshtastic.proto.** { *; }
|
||||||
|
|
|
||||||
|
|
@ -90,11 +90,10 @@ import org.meshtastic.core.resources.should_update_firmware
|
||||||
import org.meshtastic.core.resources.traceroute
|
import org.meshtastic.core.resources.traceroute
|
||||||
import org.meshtastic.core.resources.view_on_map
|
import org.meshtastic.core.resources.view_on_map
|
||||||
import org.meshtastic.core.service.MeshService
|
import org.meshtastic.core.service.MeshService
|
||||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
import org.meshtastic.core.ui.component.AlertHost
|
||||||
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
import org.meshtastic.core.ui.component.ScrollToTopEvent
|
||||||
|
import org.meshtastic.core.ui.component.SharedDialogs
|
||||||
import org.meshtastic.core.ui.navigation.icon
|
import org.meshtastic.core.ui.navigation.icon
|
||||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
|
||||||
import org.meshtastic.core.ui.share.SharedContactDialog
|
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||||
|
|
@ -122,39 +121,17 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
|
||||||
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||||
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
|
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (connectionState == ConnectionState.Connected) {
|
SharedDialogs(
|
||||||
sharedContactRequested?.let {
|
connectionState = connectionState,
|
||||||
SharedContactDialog(sharedContact = it, onDismiss = { uIViewModel.clearSharedContactRequested() })
|
sharedContactRequested = sharedContactRequested,
|
||||||
}
|
requestChannelSet = requestChannelSet,
|
||||||
|
onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
|
||||||
requestChannelSet?.let { newChannelSet ->
|
onDismissChannelSet = { uIViewModel.clearRequestChannelUrl() },
|
||||||
ScannedQrCodeDialog(newChannelSet, onDismiss = { uIViewModel.clearRequestChannelUrl() })
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VersionChecks(uIViewModel)
|
VersionChecks(uIViewModel)
|
||||||
|
|
||||||
val alertDialogState by uIViewModel.currentAlert.collectAsStateWithLifecycle()
|
AlertHost(uIViewModel.alertManager)
|
||||||
alertDialogState?.let { state ->
|
|
||||||
val title = state.title ?: state.titleRes?.let { stringResource(it) } ?: ""
|
|
||||||
val message = state.message ?: state.messageRes?.let { stringResource(it) }
|
|
||||||
val confirmText = state.confirmText ?: state.confirmTextRes?.let { stringResource(it) }
|
|
||||||
val dismissText = state.dismissText ?: state.dismissTextRes?.let { stringResource(it) }
|
|
||||||
|
|
||||||
MeshtasticDialog(
|
|
||||||
title = title,
|
|
||||||
message = message,
|
|
||||||
html = state.html,
|
|
||||||
icon = state.icon,
|
|
||||||
text = state.composableMessage?.let { msg -> { msg.Content() } },
|
|
||||||
confirmText = confirmText,
|
|
||||||
onConfirm = state.onConfirm,
|
|
||||||
dismissText = dismissText,
|
|
||||||
onDismiss = state.onDismiss,
|
|
||||||
choices = state.choices,
|
|
||||||
dismissable = state.dismissable,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
|
val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
|
||||||
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
|
var dismissedTracerouteRequestId by remember { mutableStateOf<Int?>(null) }
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
|
|
||||||
import org.gradle.api.Plugin
|
import org.gradle.api.Plugin
|
||||||
import org.gradle.api.Project
|
import org.gradle.api.Project
|
||||||
import org.gradle.api.provider.Property
|
|
||||||
import org.gradle.kotlin.dsl.apply
|
import org.gradle.kotlin.dsl.apply
|
||||||
import org.gradle.kotlin.dsl.dependencies
|
import org.gradle.kotlin.dsl.dependencies
|
||||||
|
import org.koin.compiler.plugin.KoinGradleExtension
|
||||||
import org.meshtastic.buildlogic.libs
|
import org.meshtastic.buildlogic.libs
|
||||||
import org.meshtastic.buildlogic.plugin
|
import org.meshtastic.buildlogic.plugin
|
||||||
|
|
||||||
|
|
@ -28,28 +28,14 @@ class KoinConventionPlugin : Plugin<Project> {
|
||||||
with(target) {
|
with(target) {
|
||||||
apply(plugin = libs.plugin("koin-compiler").get().pluginId)
|
apply(plugin = libs.plugin("koin-compiler").get().pluginId)
|
||||||
|
|
||||||
// Configure Koin Compiler Plugin (0.4.0+)
|
// Configure Koin K2 Compiler Plugin (0.4.0+)
|
||||||
extensions.configure<Any>("koinCompiler") {
|
extensions.configure(KoinGradleExtension::class.java) {
|
||||||
val extension = this
|
// Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1
|
||||||
val clazz = extension.javaClass
|
// per-module safety checks strictly enforce that all dependencies must be explicitly
|
||||||
try {
|
// provided or included locally. This breaks decoupled Clean Architecture designs.
|
||||||
// Meshtastic heavily utilizes dependency inversion across KMP modules. Koin 0.4.0's A1
|
// We disable compile safety globally to properly rely on Koin's A3 full-graph
|
||||||
// per-module safety checks strictly enforce that all dependencies must be explicitly
|
// validation which perfectly handles inverted dependencies at the composition root.
|
||||||
// provided or included locally. This breaks decoupled Clean Architecture designs.
|
compileSafety.set(false)
|
||||||
// We disable A1 compile safety globally to properly rely on Koin's A3 full-graph
|
|
||||||
// validation which perfectly handles inverted dependencies at the composition root.
|
|
||||||
try {
|
|
||||||
clazz.getMethod("setCompileSafety", Boolean::class.java).invoke(extension, false)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val prop = clazz.getMethod("getCompileSafety").invoke(extension)
|
|
||||||
if (prop is Property<*>) {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
(prop as Property<Boolean>).set(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Ignore gracefully if Koin DSL changes in the future
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val koinAnnotations = libs.findLibrary("koin-annotations").get()
|
val koinAnnotations = libs.findLibrary("koin-annotations").get()
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,21 @@ internal fun Project.configureKotlinMultiplatform() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable iOS native test link & run tasks.
|
||||||
|
// iOS targets exist only for compile-time validation; linking test
|
||||||
|
// executables is extremely slow and causes `./gradlew test` to hang.
|
||||||
|
tasks.configureEach {
|
||||||
|
val taskName = name.lowercase()
|
||||||
|
if (taskName.contains("iosarm64") || taskName.contains("iossimulatorarm64")) {
|
||||||
|
if (taskName.startsWith("link") && taskName.contains("test") ||
|
||||||
|
taskName == "iosarm64test" || taskName == "iossimulatorarm64test" ||
|
||||||
|
taskName.endsWith("testbinaries")
|
||||||
|
) {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
configureMokkery()
|
configureMokkery()
|
||||||
configureKotlin<KotlinMultiplatformExtension>()
|
configureKotlin<KotlinMultiplatformExtension>()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2026 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -19,15 +19,16 @@ package org.meshtastic.core.ble
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import kotlin.test.Test
|
||||||
import org.junit.Assert.assertTrue
|
import kotlin.test.assertEquals
|
||||||
import org.junit.Test
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class BleRetryTest {
|
class BleRetryTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `retryBleOperation returns immediately on success`() = runTest {
|
fun retryBleOperation_returns_immediately_on_success() = runTest {
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
val result =
|
val result =
|
||||||
retryBleOperation(count = 3, delayMs = 10L) {
|
retryBleOperation(count = 3, delayMs = 10L) {
|
||||||
|
|
@ -39,7 +40,7 @@ class BleRetryTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `retryBleOperation retries on exception and succeeds`() = runTest {
|
fun retryBleOperation_retries_on_exception_and_succeeds() = runTest {
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
val result =
|
val result =
|
||||||
retryBleOperation(count = 3, delayMs = 10L) {
|
retryBleOperation(count = 3, delayMs = 10L) {
|
||||||
|
|
@ -54,32 +55,30 @@ class BleRetryTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `retryBleOperation throws exception after max attempts`() = runTest {
|
fun retryBleOperation_throws_exception_after_max_attempts() = runTest {
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
var caughtException: Exception? = null
|
val ex =
|
||||||
try {
|
assertFailsWith<RuntimeException> {
|
||||||
retryBleOperation(count = 3, delayMs = 10L) {
|
retryBleOperation(count = 3, delayMs = 10L) {
|
||||||
attempts++
|
attempts++
|
||||||
throw RuntimeException("Persistent error")
|
throw RuntimeException("Persistent error")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
caughtException = e
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTrue(caughtException is RuntimeException)
|
assertTrue(ex is RuntimeException)
|
||||||
assertEquals("Persistent error", caughtException?.message)
|
assertEquals("Persistent error", ex.message)
|
||||||
assertEquals(3, attempts)
|
assertEquals(3, attempts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(expected = CancellationException::class)
|
@Test
|
||||||
fun `retryBleOperation does not retry CancellationException`() = runTest {
|
fun retryBleOperation_does_not_retry_CancellationException() = runTest {
|
||||||
var attempts = 0
|
var attempts = 0
|
||||||
retryBleOperation(count = 3, delayMs = 10L) {
|
assertFailsWith<CancellationException> {
|
||||||
attempts++
|
retryBleOperation(count = 3, delayMs = 10L) {
|
||||||
throw CancellationException("Cancelled")
|
attempts++
|
||||||
|
throw CancellationException("Cancelled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Test fails if it catches and doesn't rethrow, or if it retries.
|
|
||||||
// It shouldn't reach the assertion below because the exception should be thrown immediately.
|
|
||||||
assertEquals(1, attempts)
|
assertEquals(1, attempts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -34,9 +34,11 @@ import org.meshtastic.core.model.Position
|
||||||
import org.meshtastic.core.model.TelemetryType
|
import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.model.util.isWithinSizeLimit
|
import org.meshtastic.core.model.util.isWithinSizeLimit
|
||||||
import org.meshtastic.core.repository.CommandSender
|
import org.meshtastic.core.repository.CommandSender
|
||||||
|
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||||
import org.meshtastic.core.repository.NodeManager
|
import org.meshtastic.core.repository.NodeManager
|
||||||
import org.meshtastic.core.repository.PacketHandler
|
import org.meshtastic.core.repository.PacketHandler
|
||||||
import org.meshtastic.core.repository.RadioConfigRepository
|
import org.meshtastic.core.repository.RadioConfigRepository
|
||||||
|
import org.meshtastic.core.repository.TracerouteHandler
|
||||||
import org.meshtastic.proto.AdminMessage
|
import org.meshtastic.proto.AdminMessage
|
||||||
import org.meshtastic.proto.ChannelSet
|
import org.meshtastic.proto.ChannelSet
|
||||||
import org.meshtastic.proto.Constants
|
import org.meshtastic.proto.Constants
|
||||||
|
|
@ -57,18 +59,16 @@ class CommandSenderImpl(
|
||||||
private val packetHandler: PacketHandler,
|
private val packetHandler: PacketHandler,
|
||||||
private val nodeManager: NodeManager,
|
private val nodeManager: NodeManager,
|
||||||
private val radioConfigRepository: RadioConfigRepository,
|
private val radioConfigRepository: RadioConfigRepository,
|
||||||
|
private val tracerouteHandler: TracerouteHandler,
|
||||||
|
private val neighborInfoHandler: NeighborInfoHandler,
|
||||||
) : CommandSender {
|
) : CommandSender {
|
||||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||||
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
|
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
|
||||||
private val sessionPasskey = atomic(ByteString.EMPTY)
|
private val sessionPasskey = atomic(ByteString.EMPTY)
|
||||||
override val tracerouteStartTimes = mutableMapOf<Int, Long>()
|
|
||||||
override val neighborInfoStartTimes = mutableMapOf<Int, Long>()
|
|
||||||
|
|
||||||
private val localConfig = MutableStateFlow(LocalConfig())
|
private val localConfig = MutableStateFlow(LocalConfig())
|
||||||
private val channelSet = MutableStateFlow(ChannelSet())
|
private val channelSet = MutableStateFlow(ChannelSet())
|
||||||
|
|
||||||
override var lastNeighborInfo: NeighborInfo? = null
|
|
||||||
|
|
||||||
// We'll need a way to track connection state in shared code,
|
// We'll need a way to track connection state in shared code,
|
||||||
// maybe via ServiceRepository or similar.
|
// maybe via ServiceRepository or similar.
|
||||||
// For now I'll assume it's injected or available.
|
// For now I'll assume it's injected or available.
|
||||||
|
|
@ -251,7 +251,7 @@ class CommandSenderImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun requestTraceroute(requestId: Int, destNum: Int) {
|
override fun requestTraceroute(requestId: Int, destNum: Int) {
|
||||||
tracerouteStartTimes[requestId] = nowMillis
|
tracerouteHandler.recordStartTime(requestId)
|
||||||
packetHandler.sendToRadio(
|
packetHandler.sendToRadio(
|
||||||
buildMeshPacket(
|
buildMeshPacket(
|
||||||
to = destNum,
|
to = destNum,
|
||||||
|
|
@ -302,11 +302,11 @@ class CommandSenderImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
|
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
|
||||||
neighborInfoStartTimes[requestId] = nowMillis
|
neighborInfoHandler.recordStartTime(requestId)
|
||||||
val myNum = nodeManager.myNodeNum ?: 0
|
val myNum = nodeManager.myNodeNum ?: 0
|
||||||
if (destNum == myNum) {
|
if (destNum == myNum) {
|
||||||
val neighborInfoToSend =
|
val neighborInfoToSend =
|
||||||
lastNeighborInfo
|
neighborInfoHandler.lastNeighborInfo
|
||||||
?: run {
|
?: run {
|
||||||
val oneHour = 1.hours.inWholeMinutes.toInt()
|
val oneHour = 1.hours.inWholeMinutes.toInt()
|
||||||
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
|
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import org.meshtastic.core.common.util.handledLaunch
|
||||||
import org.meshtastic.core.common.util.ioDispatcher
|
import org.meshtastic.core.common.util.ioDispatcher
|
||||||
import org.meshtastic.core.model.ConnectionState
|
import org.meshtastic.core.model.ConnectionState
|
||||||
import org.meshtastic.core.repository.CommandSender
|
import org.meshtastic.core.repository.CommandSender
|
||||||
|
import org.meshtastic.core.repository.HandshakeConstants
|
||||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||||
import org.meshtastic.core.repository.MeshConnectionManager
|
import org.meshtastic.core.repository.MeshConnectionManager
|
||||||
import org.meshtastic.core.repository.NodeManager
|
import org.meshtastic.core.repository.NodeManager
|
||||||
|
|
@ -57,8 +58,6 @@ class MeshConfigFlowManagerImpl(
|
||||||
private val packetHandler: PacketHandler,
|
private val packetHandler: PacketHandler,
|
||||||
) : MeshConfigFlowManager {
|
) : MeshConfigFlowManager {
|
||||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||||
private val configOnlyNonce = 69420
|
|
||||||
private val nodeInfoNonce = 69421
|
|
||||||
private val wantConfigDelay = 100L
|
private val wantConfigDelay = 100L
|
||||||
|
|
||||||
override fun start(scope: CoroutineScope) {
|
override fun start(scope: CoroutineScope) {
|
||||||
|
|
@ -76,8 +75,8 @@ class MeshConfigFlowManagerImpl(
|
||||||
|
|
||||||
override fun handleConfigComplete(configCompleteId: Int) {
|
override fun handleConfigComplete(configCompleteId: Int) {
|
||||||
when (configCompleteId) {
|
when (configCompleteId) {
|
||||||
configOnlyNonce -> handleConfigOnlyComplete()
|
HandshakeConstants.CONFIG_NONCE -> handleConfigOnlyComplete()
|
||||||
nodeInfoNonce -> handleNodeInfoComplete()
|
HandshakeConstants.NODE_INFO_NONCE -> handleNodeInfoComplete()
|
||||||
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
|
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import org.meshtastic.core.model.TelemetryType
|
||||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||||
import org.meshtastic.core.repository.CommandSender
|
import org.meshtastic.core.repository.CommandSender
|
||||||
import org.meshtastic.core.repository.DataPair
|
import org.meshtastic.core.repository.DataPair
|
||||||
|
import org.meshtastic.core.repository.HandshakeConstants
|
||||||
import org.meshtastic.core.repository.HistoryManager
|
import org.meshtastic.core.repository.HistoryManager
|
||||||
import org.meshtastic.core.repository.MeshConnectionManager
|
import org.meshtastic.core.repository.MeshConnectionManager
|
||||||
import org.meshtastic.core.repository.MeshLocationManager
|
import org.meshtastic.core.repository.MeshLocationManager
|
||||||
|
|
@ -253,13 +254,13 @@ class MeshConnectionManagerImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startConfigOnly() {
|
override fun startConfigOnly() {
|
||||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE)) }
|
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
|
||||||
startHandshakeStallGuard(1, action)
|
startHandshakeStallGuard(1, action)
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startNodeInfoOnly() {
|
override fun startNodeInfoOnly() {
|
||||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE)) }
|
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
|
||||||
startHandshakeStallGuard(2, action)
|
startHandshakeStallGuard(2, action)
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
|
|
@ -340,8 +341,6 @@ class MeshConnectionManagerImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CONFIG_ONLY_NONCE = 69420
|
|
||||||
private const val NODE_INFO_NONCE = 69421
|
|
||||||
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
|
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
|
||||||
private val HANDSHAKE_TIMEOUT = 30.seconds
|
private val HANDSHAKE_TIMEOUT = 30.seconds
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import okio.ByteString.Companion.toByteString
|
|
||||||
import okio.IOException
|
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.common.util.handledLaunch
|
import org.meshtastic.core.common.util.handledLaunch
|
||||||
import org.meshtastic.core.common.util.ioDispatcher
|
import org.meshtastic.core.common.util.ioDispatcher
|
||||||
|
|
@ -37,12 +35,10 @@ import org.meshtastic.core.model.MessageStatus
|
||||||
import org.meshtastic.core.model.Node
|
import org.meshtastic.core.model.Node
|
||||||
import org.meshtastic.core.model.Reaction
|
import org.meshtastic.core.model.Reaction
|
||||||
import org.meshtastic.core.model.util.MeshDataMapper
|
import org.meshtastic.core.model.util.MeshDataMapper
|
||||||
import org.meshtastic.core.model.util.SfppHasher
|
|
||||||
import org.meshtastic.core.model.util.decodeOrNull
|
import org.meshtastic.core.model.util.decodeOrNull
|
||||||
import org.meshtastic.core.model.util.toOneLiner
|
import org.meshtastic.core.model.util.toOneLiner
|
||||||
import org.meshtastic.core.repository.CommandSender
|
import org.meshtastic.core.repository.CommandSender
|
||||||
import org.meshtastic.core.repository.DataPair
|
import org.meshtastic.core.repository.DataPair
|
||||||
import org.meshtastic.core.repository.HistoryManager
|
|
||||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||||
import org.meshtastic.core.repository.MeshConfigHandler
|
import org.meshtastic.core.repository.MeshConfigHandler
|
||||||
import org.meshtastic.core.repository.MeshConnectionManager
|
import org.meshtastic.core.repository.MeshConnectionManager
|
||||||
|
|
@ -59,6 +55,7 @@ import org.meshtastic.core.repository.PlatformAnalytics
|
||||||
import org.meshtastic.core.repository.RadioConfigRepository
|
import org.meshtastic.core.repository.RadioConfigRepository
|
||||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||||
import org.meshtastic.core.repository.ServiceRepository
|
import org.meshtastic.core.repository.ServiceRepository
|
||||||
|
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||||
import org.meshtastic.core.repository.TracerouteHandler
|
import org.meshtastic.core.repository.TracerouteHandler
|
||||||
import org.meshtastic.core.resources.Res
|
import org.meshtastic.core.resources.Res
|
||||||
import org.meshtastic.core.resources.critical_alert
|
import org.meshtastic.core.resources.critical_alert
|
||||||
|
|
@ -75,8 +72,6 @@ import org.meshtastic.proto.PortNum
|
||||||
import org.meshtastic.proto.Position
|
import org.meshtastic.proto.Position
|
||||||
import org.meshtastic.proto.Routing
|
import org.meshtastic.proto.Routing
|
||||||
import org.meshtastic.proto.StatusMessage
|
import org.meshtastic.proto.StatusMessage
|
||||||
import org.meshtastic.proto.StoreAndForward
|
|
||||||
import org.meshtastic.proto.StoreForwardPlusPlus
|
|
||||||
import org.meshtastic.proto.Telemetry
|
import org.meshtastic.proto.Telemetry
|
||||||
import org.meshtastic.proto.User
|
import org.meshtastic.proto.User
|
||||||
import org.meshtastic.proto.Waypoint
|
import org.meshtastic.proto.Waypoint
|
||||||
|
|
@ -107,17 +102,21 @@ class MeshDataHandlerImpl(
|
||||||
private val configHandler: Lazy<MeshConfigHandler>,
|
private val configHandler: Lazy<MeshConfigHandler>,
|
||||||
private val configFlowManager: Lazy<MeshConfigFlowManager>,
|
private val configFlowManager: Lazy<MeshConfigFlowManager>,
|
||||||
private val commandSender: CommandSender,
|
private val commandSender: CommandSender,
|
||||||
private val historyManager: HistoryManager,
|
|
||||||
private val connectionManager: Lazy<MeshConnectionManager>,
|
private val connectionManager: Lazy<MeshConnectionManager>,
|
||||||
private val tracerouteHandler: TracerouteHandler,
|
private val tracerouteHandler: TracerouteHandler,
|
||||||
private val neighborInfoHandler: NeighborInfoHandler,
|
private val neighborInfoHandler: NeighborInfoHandler,
|
||||||
private val radioConfigRepository: RadioConfigRepository,
|
private val radioConfigRepository: RadioConfigRepository,
|
||||||
private val messageFilter: MessageFilter,
|
private val messageFilter: MessageFilter,
|
||||||
|
private val storeForwardHandler: StoreForwardPacketHandler,
|
||||||
) : MeshDataHandler {
|
) : MeshDataHandler {
|
||||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||||
|
|
||||||
|
private val batteryMutex = Mutex()
|
||||||
|
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
|
||||||
|
|
||||||
override fun start(scope: CoroutineScope) {
|
override fun start(scope: CoroutineScope) {
|
||||||
this.scope = scope
|
this.scope = scope
|
||||||
|
storeForwardHandler.start(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val rememberDataType =
|
private val rememberDataType =
|
||||||
|
|
@ -191,11 +190,11 @@ class MeshDataHandlerImpl(
|
||||||
}
|
}
|
||||||
|
|
||||||
PortNum.STORE_FORWARD_APP -> {
|
PortNum.STORE_FORWARD_APP -> {
|
||||||
handleStoreAndForward(packet, dataPacket, myNodeNum)
|
storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
|
PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
|
||||||
handleStoreForwardPlusPlus(packet)
|
storeForwardHandler.handleStoreForwardPlusPlus(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
PortNum.ADMIN_APP -> {
|
PortNum.ADMIN_APP -> {
|
||||||
|
|
@ -235,98 +234,6 @@ class MeshDataHandlerImpl(
|
||||||
rememberDataPacket(u, myNodeNum)
|
rememberDataPacket(u, myNodeNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
|
||||||
val payload = packet.decoded?.payload ?: return
|
|
||||||
val u = StoreAndForward.ADAPTER.decode(payload)
|
|
||||||
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("LongMethod", "ReturnCount")
|
|
||||||
private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
|
|
||||||
val payload = packet.decoded?.payload ?: return
|
|
||||||
val sfpp =
|
|
||||||
try {
|
|
||||||
StoreForwardPlusPlus.ADAPTER.decode(payload)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
|
|
||||||
|
|
||||||
when (sfpp.sfpp_message_type) {
|
|
||||||
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
|
|
||||||
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
|
|
||||||
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
|
|
||||||
-> {
|
|
||||||
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
|
|
||||||
|
|
||||||
// If it has a commit hash, it's already on the chain (Confirmed)
|
|
||||||
// Otherwise it's still being routed via SF++ (Routing)
|
|
||||||
val status =
|
|
||||||
if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
|
|
||||||
|
|
||||||
// Prefer a full 16-byte hash calculated from the message bytes if available
|
|
||||||
// But only if it's NOT a fragment, otherwise the calculated hash would be wrong
|
|
||||||
val hash =
|
|
||||||
when {
|
|
||||||
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
|
|
||||||
!isFragment && sfpp.message.size != 0 -> {
|
|
||||||
SfppHasher.computeMessageHash(
|
|
||||||
encryptedPayload = sfpp.message.toByteArray(),
|
|
||||||
// Map 0 back to NODENUM_BROADCAST to match firmware hash calculation
|
|
||||||
to =
|
|
||||||
if (sfpp.encapsulated_to == 0) {
|
|
||||||
DataPacket.NODENUM_BROADCAST
|
|
||||||
} else {
|
|
||||||
sfpp.encapsulated_to
|
|
||||||
},
|
|
||||||
from = sfpp.encapsulated_from,
|
|
||||||
id = sfpp.encapsulated_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
} ?: return
|
|
||||||
|
|
||||||
Logger.d {
|
|
||||||
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
|
|
||||||
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
|
|
||||||
}
|
|
||||||
scope.handledLaunch {
|
|
||||||
packetRepository.value.updateSFPPStatus(
|
|
||||||
packetId = sfpp.encapsulated_id,
|
|
||||||
from = sfpp.encapsulated_from,
|
|
||||||
to = sfpp.encapsulated_to,
|
|
||||||
hash = hash,
|
|
||||||
status = status,
|
|
||||||
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
|
||||||
myNodeNum = nodeManager.myNodeNum ?: 0,
|
|
||||||
)
|
|
||||||
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> {
|
|
||||||
scope.handledLaunch {
|
|
||||||
sfpp.message_hash.let {
|
|
||||||
packetRepository.value.updateSFPPStatusByHash(
|
|
||||||
hash = it.toByteArray(),
|
|
||||||
status = MessageStatus.SFPP_CONFIRMED,
|
|
||||||
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
|
|
||||||
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
|
|
||||||
}
|
|
||||||
|
|
||||||
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
|
|
||||||
Logger.i { "SF++: Node ${packet.from} is requesting links" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handlePaxCounter(packet: MeshPacket) {
|
private fun handlePaxCounter(packet: MeshPacket) {
|
||||||
val payload = packet.decoded?.payload ?: return
|
val payload = packet.decoded?.payload ?: return
|
||||||
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return
|
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return
|
||||||
|
|
@ -559,52 +466,6 @@ class MeshDataHandlerImpl(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
|
|
||||||
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
|
|
||||||
// For now, we don't have meshPrefs in commonMain, so we use a simplified transport check or abstract it.
|
|
||||||
// In the original, it was used for logging.
|
|
||||||
val h = s.history
|
|
||||||
val lastRequest = h?.last_request ?: 0
|
|
||||||
Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
|
|
||||||
when {
|
|
||||||
s.stats != null -> {
|
|
||||||
val text = s.stats.toString()
|
|
||||||
val u =
|
|
||||||
dataPacket.copy(
|
|
||||||
bytes = text.encodeToByteArray().toByteString(),
|
|
||||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
|
||||||
)
|
|
||||||
rememberDataPacket(u, myNodeNum)
|
|
||||||
}
|
|
||||||
h != null -> {
|
|
||||||
val text =
|
|
||||||
"Total messages: ${h.history_messages}\n" +
|
|
||||||
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
|
|
||||||
"Last request: ${h.last_request}"
|
|
||||||
val u =
|
|
||||||
dataPacket.copy(
|
|
||||||
bytes = text.encodeToByteArray().toByteString(),
|
|
||||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
|
||||||
)
|
|
||||||
rememberDataPacket(u, myNodeNum)
|
|
||||||
// historyManager call remains same
|
|
||||||
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
|
|
||||||
}
|
|
||||||
s.heartbeat != null -> {
|
|
||||||
val hb = s.heartbeat!!
|
|
||||||
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
|
|
||||||
}
|
|
||||||
s.text != null -> {
|
|
||||||
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
|
|
||||||
dataPacket.to = DataPacket.ID_BROADCAST
|
|
||||||
}
|
|
||||||
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
|
|
||||||
rememberDataPacket(u, myNodeNum)
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
|
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
|
||||||
if (dataPacket.dataType !in rememberDataType) return
|
if (dataPacket.dataType !in rememberDataType) return
|
||||||
val fromLocal =
|
val fromLocal =
|
||||||
|
|
@ -807,7 +668,5 @@ class MeshDataHandlerImpl(
|
||||||
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
|
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
|
||||||
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
|
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
|
||||||
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
|
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
|
||||||
private val batteryMutex = Mutex()
|
|
||||||
private val batteryPercentCooldowns = mutableMapOf<Int, Long>()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,15 @@
|
||||||
package org.meshtastic.core.data.manager
|
package org.meshtastic.core.data.manager
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import kotlinx.atomicfu.atomic
|
||||||
|
import kotlinx.atomicfu.update
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.common.util.NumberFormatter
|
import org.meshtastic.core.common.util.NumberFormatter
|
||||||
import org.meshtastic.core.common.util.ioDispatcher
|
import org.meshtastic.core.common.util.ioDispatcher
|
||||||
import org.meshtastic.core.common.util.nowMillis
|
import org.meshtastic.core.common.util.nowMillis
|
||||||
import org.meshtastic.core.repository.CommandSender
|
|
||||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||||
import org.meshtastic.core.repository.NodeManager
|
import org.meshtastic.core.repository.NodeManager
|
||||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||||
|
|
@ -35,15 +37,22 @@ import org.meshtastic.proto.NeighborInfo
|
||||||
class NeighborInfoHandlerImpl(
|
class NeighborInfoHandlerImpl(
|
||||||
private val nodeManager: NodeManager,
|
private val nodeManager: NodeManager,
|
||||||
private val serviceRepository: ServiceRepository,
|
private val serviceRepository: ServiceRepository,
|
||||||
private val commandSender: CommandSender,
|
|
||||||
private val serviceBroadcasts: ServiceBroadcasts,
|
private val serviceBroadcasts: ServiceBroadcasts,
|
||||||
) : NeighborInfoHandler {
|
) : NeighborInfoHandler {
|
||||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||||
|
|
||||||
|
private val startTimes = atomic(persistentMapOf<Int, Long>())
|
||||||
|
|
||||||
|
override var lastNeighborInfo: NeighborInfo? = null
|
||||||
|
|
||||||
override fun start(scope: CoroutineScope) {
|
override fun start(scope: CoroutineScope) {
|
||||||
this.scope = scope
|
this.scope = scope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun recordStartTime(requestId: Int) {
|
||||||
|
startTimes.update { it.put(requestId, nowMillis) }
|
||||||
|
}
|
||||||
|
|
||||||
override fun handleNeighborInfo(packet: MeshPacket) {
|
override fun handleNeighborInfo(packet: MeshPacket) {
|
||||||
val payload = packet.decoded?.payload ?: return
|
val payload = packet.decoded?.payload ?: return
|
||||||
val ni = NeighborInfo.ADAPTER.decode(payload)
|
val ni = NeighborInfo.ADAPTER.decode(payload)
|
||||||
|
|
@ -51,7 +60,7 @@ class NeighborInfoHandlerImpl(
|
||||||
// Store the last neighbor info from our connected radio
|
// Store the last neighbor info from our connected radio
|
||||||
val from = packet.from
|
val from = packet.from
|
||||||
if (from == nodeManager.myNodeNum) {
|
if (from == nodeManager.myNodeNum) {
|
||||||
commandSender.lastNeighborInfo = ni
|
lastNeighborInfo = ni
|
||||||
Logger.d { "Stored last neighbor info from connected radio" }
|
Logger.d { "Stored last neighbor info from connected radio" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +69,8 @@ class NeighborInfoHandlerImpl(
|
||||||
|
|
||||||
// Format for UI response
|
// Format for UI response
|
||||||
val requestId = packet.decoded?.request_id ?: 0
|
val requestId = packet.decoded?.request_id ?: 0
|
||||||
val start = commandSender.neighborInfoStartTimes.remove(requestId)
|
val start = startTimes.value[requestId]
|
||||||
|
startTimes.update { it.remove(requestId) }
|
||||||
|
|
||||||
val neighbors =
|
val neighbors =
|
||||||
ni.neighbors.joinToString("\n") { n ->
|
ni.neighbors.joinToString("\n") { n ->
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
/*
|
||||||
|
* 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.manager
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import okio.ByteString.Companion.toByteString
|
||||||
|
import okio.IOException
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
import org.meshtastic.core.common.util.handledLaunch
|
||||||
|
import org.meshtastic.core.common.util.ioDispatcher
|
||||||
|
import org.meshtastic.core.model.DataPacket
|
||||||
|
import org.meshtastic.core.model.MessageStatus
|
||||||
|
import org.meshtastic.core.model.util.SfppHasher
|
||||||
|
import org.meshtastic.core.repository.HistoryManager
|
||||||
|
import org.meshtastic.core.repository.MeshDataHandler
|
||||||
|
import org.meshtastic.core.repository.NodeManager
|
||||||
|
import org.meshtastic.core.repository.PacketRepository
|
||||||
|
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||||
|
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||||
|
import org.meshtastic.proto.MeshPacket
|
||||||
|
import org.meshtastic.proto.PortNum
|
||||||
|
import org.meshtastic.proto.StoreAndForward
|
||||||
|
import org.meshtastic.proto.StoreForwardPlusPlus
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/** Implementation of [StoreForwardPacketHandler] that handles both legacy S&F and SF++ packets. */
|
||||||
|
@Single
|
||||||
|
class StoreForwardPacketHandlerImpl(
|
||||||
|
private val nodeManager: NodeManager,
|
||||||
|
private val packetRepository: Lazy<PacketRepository>,
|
||||||
|
private val serviceBroadcasts: ServiceBroadcasts,
|
||||||
|
private val historyManager: HistoryManager,
|
||||||
|
private val dataHandler: Lazy<MeshDataHandler>,
|
||||||
|
) : StoreForwardPacketHandler {
|
||||||
|
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||||
|
|
||||||
|
override fun start(scope: CoroutineScope) {
|
||||||
|
this.scope = scope
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||||
|
val payload = packet.decoded?.payload ?: return
|
||||||
|
val u = StoreAndForward.ADAPTER.decode(payload)
|
||||||
|
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod", "ReturnCount")
|
||||||
|
override fun handleStoreForwardPlusPlus(packet: MeshPacket) {
|
||||||
|
val payload = packet.decoded?.payload ?: return
|
||||||
|
val sfpp =
|
||||||
|
try {
|
||||||
|
StoreForwardPlusPlus.ADAPTER.decode(payload)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
|
||||||
|
|
||||||
|
when (sfpp.sfpp_message_type) {
|
||||||
|
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
|
||||||
|
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
|
||||||
|
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
|
||||||
|
-> handleLinkProvide(sfpp)
|
||||||
|
|
||||||
|
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp)
|
||||||
|
|
||||||
|
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
|
||||||
|
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
|
||||||
|
}
|
||||||
|
|
||||||
|
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
|
||||||
|
Logger.i { "SF++: Node ${packet.from} is requesting links" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleLinkProvide(sfpp: StoreForwardPlusPlus) {
|
||||||
|
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
|
||||||
|
|
||||||
|
val status = if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
|
||||||
|
|
||||||
|
val hash =
|
||||||
|
when {
|
||||||
|
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
|
||||||
|
!isFragment && sfpp.message.size != 0 -> {
|
||||||
|
SfppHasher.computeMessageHash(
|
||||||
|
encryptedPayload = sfpp.message.toByteArray(),
|
||||||
|
to =
|
||||||
|
if (sfpp.encapsulated_to == 0) {
|
||||||
|
DataPacket.NODENUM_BROADCAST
|
||||||
|
} else {
|
||||||
|
sfpp.encapsulated_to
|
||||||
|
},
|
||||||
|
from = sfpp.encapsulated_from,
|
||||||
|
id = sfpp.encapsulated_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
} ?: return
|
||||||
|
|
||||||
|
Logger.d {
|
||||||
|
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
|
||||||
|
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
|
||||||
|
}
|
||||||
|
scope.handledLaunch {
|
||||||
|
packetRepository.value.updateSFPPStatus(
|
||||||
|
packetId = sfpp.encapsulated_id,
|
||||||
|
from = sfpp.encapsulated_from,
|
||||||
|
to = sfpp.encapsulated_to,
|
||||||
|
hash = hash,
|
||||||
|
status = status,
|
||||||
|
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
||||||
|
myNodeNum = nodeManager.myNodeNum ?: 0,
|
||||||
|
)
|
||||||
|
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus) {
|
||||||
|
scope.handledLaunch {
|
||||||
|
sfpp.message_hash.let {
|
||||||
|
packetRepository.value.updateSFPPStatusByHash(
|
||||||
|
hash = it.toByteArray(),
|
||||||
|
status = MessageStatus.SFPP_CONFIRMED,
|
||||||
|
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
|
||||||
|
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
|
||||||
|
val h = s.history
|
||||||
|
val lastRequest = h?.last_request ?: 0
|
||||||
|
Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
|
||||||
|
when {
|
||||||
|
s.stats != null -> {
|
||||||
|
val text = s.stats.toString()
|
||||||
|
val u =
|
||||||
|
dataPacket.copy(
|
||||||
|
bytes = text.encodeToByteArray().toByteString(),
|
||||||
|
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||||
|
)
|
||||||
|
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||||
|
}
|
||||||
|
h != null -> {
|
||||||
|
val text =
|
||||||
|
"Total messages: ${h.history_messages}\n" +
|
||||||
|
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
|
||||||
|
"Last request: ${h.last_request}"
|
||||||
|
val u =
|
||||||
|
dataPacket.copy(
|
||||||
|
bytes = text.encodeToByteArray().toByteString(),
|
||||||
|
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||||
|
)
|
||||||
|
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||||
|
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
|
||||||
|
}
|
||||||
|
s.heartbeat != null -> {
|
||||||
|
val hb = s.heartbeat!!
|
||||||
|
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
|
||||||
|
}
|
||||||
|
s.text != null -> {
|
||||||
|
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
|
||||||
|
dataPacket.to = DataPacket.ID_BROADCAST
|
||||||
|
}
|
||||||
|
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
|
||||||
|
dataHandler.value.rememberDataPacket(u, myNodeNum)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,18 +17,20 @@
|
||||||
package org.meshtastic.core.data.manager
|
package org.meshtastic.core.data.manager
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import kotlinx.atomicfu.atomic
|
||||||
|
import kotlinx.atomicfu.update
|
||||||
|
import kotlinx.collections.immutable.persistentMapOf
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.common.util.NumberFormatter
|
import org.meshtastic.core.common.util.NumberFormatter
|
||||||
import org.meshtastic.core.common.util.handledLaunch
|
import org.meshtastic.core.common.util.handledLaunch
|
||||||
import org.meshtastic.core.common.util.ioDispatcher
|
import org.meshtastic.core.common.util.ioDispatcher
|
||||||
import org.meshtastic.core.common.util.nowMillis
|
import org.meshtastic.core.common.util.nowMillis
|
||||||
import org.meshtastic.core.model.Node
|
|
||||||
import org.meshtastic.core.model.fullRouteDiscovery
|
import org.meshtastic.core.model.fullRouteDiscovery
|
||||||
import org.meshtastic.core.model.getFullTracerouteResponse
|
import org.meshtastic.core.model.getTracerouteResponse
|
||||||
import org.meshtastic.core.model.service.TracerouteResponse
|
import org.meshtastic.core.model.service.TracerouteResponse
|
||||||
import org.meshtastic.core.repository.CommandSender
|
|
||||||
import org.meshtastic.core.repository.NodeManager
|
import org.meshtastic.core.repository.NodeManager
|
||||||
import org.meshtastic.core.repository.NodeRepository
|
import org.meshtastic.core.repository.NodeRepository
|
||||||
import org.meshtastic.core.repository.ServiceRepository
|
import org.meshtastic.core.repository.ServiceRepository
|
||||||
|
|
@ -42,33 +44,43 @@ class TracerouteHandlerImpl(
|
||||||
private val serviceRepository: ServiceRepository,
|
private val serviceRepository: ServiceRepository,
|
||||||
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
|
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
|
||||||
private val nodeRepository: NodeRepository,
|
private val nodeRepository: NodeRepository,
|
||||||
private val commandSender: CommandSender,
|
|
||||||
) : TracerouteHandler {
|
) : TracerouteHandler {
|
||||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||||
|
|
||||||
|
private val startTimes = atomic(persistentMapOf<Int, Long>())
|
||||||
|
|
||||||
override fun start(scope: CoroutineScope) {
|
override fun start(scope: CoroutineScope) {
|
||||||
this.scope = scope
|
this.scope = scope
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
|
override fun recordStartTime(requestId: Int) {
|
||||||
|
startTimes.update { it.put(requestId, nowMillis) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) {
|
||||||
|
// Decode the route discovery once — avoids triple protobuf decode
|
||||||
|
val routeDiscovery = packet.fullRouteDiscovery ?: return
|
||||||
|
val forwardRoute = routeDiscovery.route
|
||||||
|
val returnRoute = routeDiscovery.route_back
|
||||||
|
|
||||||
|
// Require both directions for a "full" traceroute response
|
||||||
|
if (forwardRoute.isEmpty() || returnRoute.isEmpty()) return
|
||||||
|
|
||||||
val full =
|
val full =
|
||||||
packet.getFullTracerouteResponse(
|
routeDiscovery.getTracerouteResponse(
|
||||||
getUser = { num ->
|
getUser = { num ->
|
||||||
nodeManager.nodeDBbyNodeNum[num]?.let { node: Node ->
|
nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" }
|
||||||
"${node.user.long_name} (${node.user.short_name})"
|
?: "Unknown" // TODO: Use core:resources once available in core:data
|
||||||
} ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later
|
|
||||||
},
|
},
|
||||||
headerTowards = "Route towards destination:",
|
headerTowards = "Route towards destination:",
|
||||||
headerBack = "Route back to us:",
|
headerBack = "Route back to us:",
|
||||||
) ?: return
|
)
|
||||||
|
|
||||||
val requestId = packet.decoded?.request_id ?: 0
|
val requestId = packet.decoded?.request_id ?: 0
|
||||||
|
|
||||||
if (logUuid != null) {
|
if (logUuid != null) {
|
||||||
scope.handledLaunch {
|
scope.handledLaunch {
|
||||||
logInsertJob?.join()
|
logInsertJob?.join()
|
||||||
val routeDiscovery = packet.fullRouteDiscovery
|
|
||||||
val forwardRoute = routeDiscovery?.route.orEmpty()
|
|
||||||
val returnRoute = routeDiscovery?.route_back.orEmpty()
|
|
||||||
val routeNodeNums = (forwardRoute + returnRoute).distinct()
|
val routeNodeNums = (forwardRoute + returnRoute).distinct()
|
||||||
val nodeDbByNum = nodeRepository.nodeDBbyNum.value
|
val nodeDbByNum = nodeRepository.nodeDBbyNum.value
|
||||||
val snapshotPositions =
|
val snapshotPositions =
|
||||||
|
|
@ -77,28 +89,27 @@ class TracerouteHandlerImpl(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val start = commandSender.tracerouteStartTimes.remove(requestId)
|
val start = startTimes.value[requestId]
|
||||||
|
startTimes.update { it.remove(requestId) }
|
||||||
val responseText =
|
val responseText =
|
||||||
if (start != null) {
|
if (start != null) {
|
||||||
val elapsedMs = nowMillis - start
|
val elapsedMs = nowMillis - start
|
||||||
val seconds = elapsedMs / MILLIS_PER_SECOND
|
val seconds = elapsedMs / MILLIS_PER_SECOND
|
||||||
Logger.i { "Traceroute $requestId complete in $seconds s" }
|
Logger.i { "Traceroute $requestId complete in $seconds s" }
|
||||||
val durationText = "Duration: ${NumberFormatter.format(seconds, 1)} s"
|
"$full\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
|
||||||
"$full\n\n$durationText"
|
|
||||||
} else {
|
} else {
|
||||||
full
|
full
|
||||||
}
|
}
|
||||||
|
|
||||||
val routeDiscovery = packet.fullRouteDiscovery
|
val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0
|
||||||
val destination = routeDiscovery?.route?.firstOrNull() ?: routeDiscovery?.route_back?.lastOrNull() ?: 0
|
|
||||||
|
|
||||||
serviceRepository.setTracerouteResponse(
|
serviceRepository.setTracerouteResponse(
|
||||||
TracerouteResponse(
|
TracerouteResponse(
|
||||||
message = responseText,
|
message = responseText,
|
||||||
destinationNodeNum = destination,
|
destinationNodeNum = destination,
|
||||||
requestId = requestId,
|
requestId = requestId,
|
||||||
forwardRoute = routeDiscovery?.route.orEmpty(),
|
forwardRoute = forwardRoute,
|
||||||
returnRoute = routeDiscovery?.route_back.orEmpty(),
|
returnRoute = returnRoute,
|
||||||
logUuid = logUuid,
|
logUuid = logUuid,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,15 +16,47 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.data.manager
|
package org.meshtastic.core.data.manager
|
||||||
|
|
||||||
class FromRadioPacketHandlerImplTest {
|
import dev.mokkery.MockMode
|
||||||
/*
|
import dev.mokkery.answering.returns
|
||||||
|
import dev.mokkery.every
|
||||||
|
import dev.mokkery.mock
|
||||||
|
import dev.mokkery.verify
|
||||||
|
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||||
|
import org.meshtastic.core.repository.MeshConfigHandler
|
||||||
|
import org.meshtastic.core.repository.MeshRouter
|
||||||
|
import org.meshtastic.core.repository.MqttManager
|
||||||
|
import org.meshtastic.core.repository.NotificationManager
|
||||||
|
import org.meshtastic.core.repository.PacketHandler
|
||||||
|
import org.meshtastic.core.repository.ServiceRepository
|
||||||
|
import org.meshtastic.proto.Channel
|
||||||
|
import org.meshtastic.proto.ClientNotification
|
||||||
|
import org.meshtastic.proto.Config
|
||||||
|
import org.meshtastic.proto.DeviceMetadata
|
||||||
|
import org.meshtastic.proto.FromRadio
|
||||||
|
import org.meshtastic.proto.ModuleConfig
|
||||||
|
import org.meshtastic.proto.MqttClientProxyMessage
|
||||||
|
import org.meshtastic.proto.MyNodeInfo
|
||||||
|
import org.meshtastic.proto.QueueStatus
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
|
||||||
|
|
||||||
|
class FromRadioPacketHandlerImplTest {
|
||||||
|
|
||||||
|
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
|
||||||
|
private val mqttManager: MqttManager = mock(MockMode.autofill)
|
||||||
|
private val packetHandler: PacketHandler = mock(MockMode.autofill)
|
||||||
|
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||||
|
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
|
||||||
|
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
|
||||||
|
private val router: MeshRouter = mock(MockMode.autofill)
|
||||||
|
|
||||||
private lateinit var handler: FromRadioPacketHandlerImpl
|
private lateinit var handler: FromRadioPacketHandlerImpl
|
||||||
|
|
||||||
@Before
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
mockkStatic("org.meshtastic.core.resources.GetStringKt")
|
every { router.configFlowManager } returns configFlowManager
|
||||||
|
every { router.configHandler } returns configHandler
|
||||||
|
|
||||||
handler =
|
handler =
|
||||||
FromRadioPacketHandlerImpl(
|
FromRadioPacketHandlerImpl(
|
||||||
|
|
@ -43,7 +75,7 @@ class FromRadioPacketHandlerImplTest {
|
||||||
|
|
||||||
handler.handleFromRadio(proto)
|
handler.handleFromRadio(proto)
|
||||||
|
|
||||||
verify { router.configFlowManager.handleMyInfo(myInfo) }
|
verify { configFlowManager.handleMyInfo(myInfo) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -53,19 +85,19 @@ class FromRadioPacketHandlerImplTest {
|
||||||
|
|
||||||
handler.handleFromRadio(proto)
|
handler.handleFromRadio(proto)
|
||||||
|
|
||||||
verify { router.configFlowManager.handleLocalMetadata(metadata) }
|
verify { configFlowManager.handleLocalMetadata(metadata) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() {
|
fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() {
|
||||||
val nodeInfo = NodeInfo(num = 1234)
|
val nodeInfo = ProtoNodeInfo(num = 1234)
|
||||||
val proto = FromRadio(node_info = nodeInfo)
|
val proto = FromRadio(node_info = nodeInfo)
|
||||||
|
|
||||||
every { router.configFlowManager.newNodeCount } returns 1
|
every { configFlowManager.newNodeCount } returns 1
|
||||||
|
|
||||||
handler.handleFromRadio(proto)
|
handler.handleFromRadio(proto)
|
||||||
|
|
||||||
verify { router.configFlowManager.handleNodeInfo(nodeInfo) }
|
verify { configFlowManager.handleNodeInfo(nodeInfo) }
|
||||||
verify { serviceRepository.setConnectionProgress("Nodes (1)") }
|
verify { serviceRepository.setConnectionProgress("Nodes (1)") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +108,7 @@ class FromRadioPacketHandlerImplTest {
|
||||||
|
|
||||||
handler.handleFromRadio(proto)
|
handler.handleFromRadio(proto)
|
||||||
|
|
||||||
verify { router.configFlowManager.handleConfigComplete(nonce) }
|
verify { configFlowManager.handleConfigComplete(nonce) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -96,19 +128,52 @@ class FromRadioPacketHandlerImplTest {
|
||||||
|
|
||||||
handler.handleFromRadio(proto)
|
handler.handleFromRadio(proto)
|
||||||
|
|
||||||
verify { router.configHandler.handleDeviceConfig(config) }
|
verify { configHandler.handleDeviceConfig(config) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() {
|
fun `handleFromRadio routes MODULE_CONFIG to configHandler`() {
|
||||||
val notification = ClientNotification(message = "test")
|
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||||
val proto = FromRadio(clientNotification = notification)
|
val proto = FromRadio(moduleConfig = moduleConfig)
|
||||||
|
|
||||||
handler.handleFromRadio(proto)
|
handler.handleFromRadio(proto)
|
||||||
|
|
||||||
verify { serviceRepository.setClientNotification(notification) }
|
verify { configHandler.handleModuleConfig(moduleConfig) }
|
||||||
verify { packetHandler.removeResponse(0, complete = false) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
@Test
|
||||||
|
fun `handleFromRadio routes CHANNEL to configHandler`() {
|
||||||
|
val channel = Channel(index = 0)
|
||||||
|
val proto = FromRadio(channel = channel)
|
||||||
|
|
||||||
|
handler.handleFromRadio(proto)
|
||||||
|
|
||||||
|
verify { configHandler.handleChannel(channel) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handleFromRadio routes MQTT_CLIENT_PROXY_MESSAGE to mqttManager`() {
|
||||||
|
val proxyMsg = MqttClientProxyMessage(topic = "test/topic")
|
||||||
|
val proto = FromRadio(mqttClientProxyMessage = proxyMsg)
|
||||||
|
|
||||||
|
handler.handleFromRadio(proto)
|
||||||
|
|
||||||
|
verify { mqttManager.handleMqttProxyMessage(proxyMsg) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository`() {
|
||||||
|
val notification = ClientNotification(message = "test")
|
||||||
|
val proto = FromRadio(clientNotification = notification)
|
||||||
|
|
||||||
|
// Note: getString() from Compose Resources requires Skiko native lib which
|
||||||
|
// is not available in headless JVM tests. We test the parts that don't trigger it.
|
||||||
|
try {
|
||||||
|
handler.handleFromRadio(proto)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// Expected: Skiko can't load in headless JVM/native
|
||||||
|
}
|
||||||
|
|
||||||
|
verify { serviceRepository.setClientNotification(notification) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.data.manager
|
package org.meshtastic.core.data.manager
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
import org.meshtastic.proto.StoreAndForward
|
import org.meshtastic.proto.StoreAndForward
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
class HistoryManagerImplTest {
|
class HistoryManagerImplTest {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,25 @@
|
||||||
package org.meshtastic.core.data.manager
|
package org.meshtastic.core.data.manager
|
||||||
|
|
||||||
import dev.mokkery.MockMode
|
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 dev.mokkery.mock
|
||||||
|
import dev.mokkery.verify
|
||||||
|
import dev.mokkery.verifySuspend
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okio.ByteString.Companion.toByteString
|
||||||
|
import org.meshtastic.core.model.ContactSettings
|
||||||
|
import org.meshtastic.core.model.DataPacket
|
||||||
|
import org.meshtastic.core.model.Node
|
||||||
import org.meshtastic.core.model.util.MeshDataMapper
|
import org.meshtastic.core.model.util.MeshDataMapper
|
||||||
import org.meshtastic.core.repository.CommandSender
|
import org.meshtastic.core.repository.CommandSender
|
||||||
import org.meshtastic.core.repository.HistoryManager
|
|
||||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||||
import org.meshtastic.core.repository.MeshConfigHandler
|
import org.meshtastic.core.repository.MeshConfigHandler
|
||||||
import org.meshtastic.core.repository.MeshConnectionManager
|
import org.meshtastic.core.repository.MeshConnectionManager
|
||||||
|
|
@ -35,12 +50,23 @@ import org.meshtastic.core.repository.PlatformAnalytics
|
||||||
import org.meshtastic.core.repository.RadioConfigRepository
|
import org.meshtastic.core.repository.RadioConfigRepository
|
||||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||||
import org.meshtastic.core.repository.ServiceRepository
|
import org.meshtastic.core.repository.ServiceRepository
|
||||||
|
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||||
import org.meshtastic.core.repository.TracerouteHandler
|
import org.meshtastic.core.repository.TracerouteHandler
|
||||||
|
import org.meshtastic.proto.ChannelSet
|
||||||
|
import org.meshtastic.proto.Data
|
||||||
import org.meshtastic.proto.MeshPacket
|
import org.meshtastic.proto.MeshPacket
|
||||||
|
import org.meshtastic.proto.NeighborInfo
|
||||||
|
import org.meshtastic.proto.Paxcount
|
||||||
|
import org.meshtastic.proto.PortNum
|
||||||
|
import org.meshtastic.proto.Position
|
||||||
|
import org.meshtastic.proto.Routing
|
||||||
|
import org.meshtastic.proto.Telemetry
|
||||||
|
import org.meshtastic.proto.User
|
||||||
import kotlin.test.BeforeTest
|
import kotlin.test.BeforeTest
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class MeshDataHandlerTest {
|
class MeshDataHandlerTest {
|
||||||
|
|
||||||
private lateinit var handler: MeshDataHandlerImpl
|
private lateinit var handler: MeshDataHandlerImpl
|
||||||
|
|
@ -56,12 +82,15 @@ class MeshDataHandlerTest {
|
||||||
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
|
private val configHandler: MeshConfigHandler = mock(MockMode.autofill)
|
||||||
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
|
private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill)
|
||||||
private val commandSender: CommandSender = mock(MockMode.autofill)
|
private val commandSender: CommandSender = mock(MockMode.autofill)
|
||||||
private val historyManager: HistoryManager = mock(MockMode.autofill)
|
|
||||||
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
|
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
|
||||||
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
|
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
|
||||||
private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill)
|
private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill)
|
||||||
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||||
private val messageFilter: MessageFilter = mock(MockMode.autofill)
|
private val messageFilter: MessageFilter = mock(MockMode.autofill)
|
||||||
|
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
|
||||||
|
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val testScope = TestScope(testDispatcher)
|
||||||
|
|
||||||
@BeforeTest
|
@BeforeTest
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
|
|
@ -79,13 +108,21 @@ class MeshDataHandlerTest {
|
||||||
configHandler = lazy { configHandler },
|
configHandler = lazy { configHandler },
|
||||||
configFlowManager = lazy { configFlowManager },
|
configFlowManager = lazy { configFlowManager },
|
||||||
commandSender = commandSender,
|
commandSender = commandSender,
|
||||||
historyManager = historyManager,
|
|
||||||
connectionManager = lazy { connectionManager },
|
connectionManager = lazy { connectionManager },
|
||||||
tracerouteHandler = tracerouteHandler,
|
tracerouteHandler = tracerouteHandler,
|
||||||
neighborInfoHandler = neighborInfoHandler,
|
neighborInfoHandler = neighborInfoHandler,
|
||||||
radioConfigRepository = radioConfigRepository,
|
radioConfigRepository = radioConfigRepository,
|
||||||
messageFilter = messageFilter,
|
messageFilter = messageFilter,
|
||||||
|
storeForwardHandler = storeForwardHandler,
|
||||||
)
|
)
|
||||||
|
handler.start(testScope)
|
||||||
|
|
||||||
|
// Default: mapper returns null for empty packets, which is the safe default
|
||||||
|
every { dataMapper.toDataPacket(any()) } returns null
|
||||||
|
// Stub commonly accessed properties to avoid NPE from autofill
|
||||||
|
every { nodeManager.nodeDBbyID } returns emptyMap()
|
||||||
|
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||||
|
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -94,8 +131,582 @@ class MeshDataHandlerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `handleReceivedData processes packet`() {
|
fun `handleReceivedData returns early when dataMapper returns null`() {
|
||||||
val packet = MeshPacket()
|
val packet = MeshPacket()
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns null
|
||||||
|
|
||||||
handler.handleReceivedData(packet, 123)
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
// Should not broadcast if dataMapper returns null
|
||||||
|
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handleReceivedData does not broadcast for position from local node`() {
|
||||||
|
val myNodeNum = 123
|
||||||
|
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = myNodeNum,
|
||||||
|
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = DataPacket.nodeNumToDefaultId(myNodeNum),
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = position.encode().toByteString(),
|
||||||
|
dataType = PortNum.POSITION_APP.value,
|
||||||
|
time = 1000L,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, myNodeNum)
|
||||||
|
|
||||||
|
// Position from local node: shouldBroadcast stays as !fromUs = false
|
||||||
|
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handleReceivedData broadcasts for remote packets`() {
|
||||||
|
val myNodeNum = 123
|
||||||
|
val remoteNum = 456
|
||||||
|
val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP))
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = DataPacket.nodeNumToDefaultId(remoteNum),
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = null,
|
||||||
|
dataType = PortNum.PRIVATE_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, myNodeNum)
|
||||||
|
|
||||||
|
verify { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `handleReceivedData tracks analytics`() {
|
||||||
|
val packet = MeshPacket(from = 456, decoded = Data(portnum = PortNum.PRIVATE_APP))
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!other",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = null,
|
||||||
|
dataType = PortNum.PRIVATE_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
verify { analytics.track("num_data_receive", any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Position handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `position packet delegates to nodeManager`() {
|
||||||
|
val myNodeNum = 123
|
||||||
|
val remoteNum = 456
|
||||||
|
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = remoteNum,
|
||||||
|
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = position.encode().toByteString(),
|
||||||
|
dataType = PortNum.POSITION_APP.value,
|
||||||
|
time = 1000L,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, myNodeNum)
|
||||||
|
|
||||||
|
verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NodeInfo handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nodeinfo packet from remote delegates to handleReceivedUser`() {
|
||||||
|
val myNodeNum = 123
|
||||||
|
val remoteNum = 456
|
||||||
|
val user = User(id = "!remote", long_name = "Remote", short_name = "R")
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = remoteNum,
|
||||||
|
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = user.encode().toByteString(),
|
||||||
|
dataType = PortNum.NODEINFO_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, myNodeNum)
|
||||||
|
|
||||||
|
verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nodeinfo packet from local node is ignored`() {
|
||||||
|
val myNodeNum = 123
|
||||||
|
val user = User(id = "!local", long_name = "Local", short_name = "L")
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = myNodeNum,
|
||||||
|
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!local",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = user.encode().toByteString(),
|
||||||
|
dataType = PortNum.NODEINFO_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, myNodeNum)
|
||||||
|
|
||||||
|
verify(mode = dev.mokkery.verify.VerifyMode.not) { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Paxcounter handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `paxcounter packet delegates to nodeManager`() {
|
||||||
|
val remoteNum = 456
|
||||||
|
val pax = Paxcount(wifi = 10, ble = 5, uptime = 1000)
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = remoteNum,
|
||||||
|
decoded = Data(portnum = PortNum.PAXCOUNTER_APP, payload = pax.encode().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = pax.encode().toByteString(),
|
||||||
|
dataType = PortNum.PAXCOUNTER_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
verify { nodeManager.handleReceivedPaxcounter(remoteNum, any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Traceroute handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `traceroute packet delegates to tracerouteHandler and suppresses broadcast`() {
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = 456,
|
||||||
|
decoded = Data(portnum = PortNum.TRACEROUTE_APP, payload = byteArrayOf().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!remote",
|
||||||
|
to = "!local",
|
||||||
|
bytes = byteArrayOf().toByteString(),
|
||||||
|
dataType = PortNum.TRACEROUTE_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
verify { tracerouteHandler.handleTraceroute(packet, any(), any()) }
|
||||||
|
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- NeighborInfo handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `neighborinfo packet delegates to neighborInfoHandler and broadcasts`() {
|
||||||
|
val ni = NeighborInfo(node_id = 456)
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = 456,
|
||||||
|
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, payload = ni.encode().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = ni.encode().toByteString(),
|
||||||
|
dataType = PortNum.NEIGHBORINFO_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
verify { neighborInfoHandler.handleNeighborInfo(packet) }
|
||||||
|
verify { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Store-and-Forward handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `store forward packet delegates to storeForwardHandler`() {
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = 456,
|
||||||
|
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = byteArrayOf().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = byteArrayOf().toByteString(),
|
||||||
|
dataType = PortNum.STORE_FORWARD_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Routing/ACK-NAK handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `routing packet with successful ack broadcasts and removes response`() {
|
||||||
|
val routing = Routing(error_reason = Routing.Error.NONE)
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = 456,
|
||||||
|
decoded =
|
||||||
|
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = routing.encode().toByteString(),
|
||||||
|
dataType = PortNum.ROUTING_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
every { nodeManager.toNodeID(456) } returns "!remote"
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
verify { packetHandler.removeResponse(99, complete = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `routing packet always broadcasts`() {
|
||||||
|
val routing = Routing(error_reason = Routing.Error.NONE)
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = 456,
|
||||||
|
decoded =
|
||||||
|
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = routing.encode().toByteString(),
|
||||||
|
dataType = PortNum.ROUTING_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
every { nodeManager.toNodeID(456) } returns "!remote"
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
verify { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Telemetry handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `telemetry packet updates node via nodeManager`() {
|
||||||
|
val telemetry =
|
||||||
|
Telemetry(
|
||||||
|
time = 2000,
|
||||||
|
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
|
||||||
|
)
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = 456,
|
||||||
|
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = telemetry.encode().toByteString(),
|
||||||
|
dataType = PortNum.TELEMETRY_APP.value,
|
||||||
|
time = 2000000L,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
verify { nodeManager.updateNode(456, any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `telemetry from local node also updates connectionManager`() {
|
||||||
|
val myNodeNum = 123
|
||||||
|
val telemetry =
|
||||||
|
Telemetry(
|
||||||
|
time = 2000,
|
||||||
|
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
|
||||||
|
)
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
from = myNodeNum,
|
||||||
|
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!local",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = telemetry.encode().toByteString(),
|
||||||
|
dataType = PortNum.TELEMETRY_APP.value,
|
||||||
|
time = 2000000L,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, myNodeNum)
|
||||||
|
|
||||||
|
verify { connectionManager.updateTelemetry(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Text message handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text message is persisted via rememberDataPacket`() = testScope.runTest {
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
id = 42,
|
||||||
|
from = 456,
|
||||||
|
decoded =
|
||||||
|
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
id = 42,
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = "hello".encodeToByteArray().toByteString(),
|
||||||
|
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
everySuspend { packetRepository.findPacketsWithId(42) } returns emptyList()
|
||||||
|
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||||
|
every { messageFilter.shouldFilter(any(), any()) } returns false
|
||||||
|
// Provide sender node so getSenderName() doesn't fall back to getString (requires Skiko)
|
||||||
|
every { nodeManager.nodeDBbyID } returns
|
||||||
|
mapOf(
|
||||||
|
"!remote" to
|
||||||
|
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `duplicate text message is not inserted again`() = testScope.runTest {
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
id = 42,
|
||||||
|
from = 456,
|
||||||
|
decoded =
|
||||||
|
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
id = 42,
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = "hello".encodeToByteArray().toByteString(),
|
||||||
|
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
// Return existing packet on duplicate check
|
||||||
|
everySuspend { packetRepository.findPacketsWithId(42) } returns listOf(dataPacket)
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) {
|
||||||
|
packetRepository.insert(any(), any(), any(), any(), any(), any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Reaction handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text with reply_id and emoji is treated as reaction`() = testScope.runTest {
|
||||||
|
val emojiBytes = "👍".encodeToByteArray()
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
id = 99,
|
||||||
|
from = 456,
|
||||||
|
to = 123,
|
||||||
|
decoded =
|
||||||
|
Data(
|
||||||
|
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||||
|
payload = emojiBytes.toByteString(),
|
||||||
|
reply_id = 42,
|
||||||
|
emoji = 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
id = 99,
|
||||||
|
from = "!remote",
|
||||||
|
to = "!local",
|
||||||
|
bytes = emojiBytes.toByteString(),
|
||||||
|
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
every { nodeManager.nodeDBbyNodeNum } returns
|
||||||
|
mapOf(
|
||||||
|
456 to Node(num = 456, user = User(id = "!remote")),
|
||||||
|
123 to Node(num = 123, user = User(id = "!local")),
|
||||||
|
)
|
||||||
|
everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList()
|
||||||
|
every { nodeManager.myNodeNum } returns 123
|
||||||
|
everySuspend { packetRepository.getPacketByPacketId(42) } returns null
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verifySuspend { packetRepository.insertReaction(any(), 123) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Range test / detection sensor handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `range test packet is remembered as text message type`() = testScope.runTest {
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
id = 55,
|
||||||
|
from = 456,
|
||||||
|
decoded =
|
||||||
|
Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
id = 55,
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = "test".encodeToByteArray().toByteString(),
|
||||||
|
dataType = PortNum.RANGE_TEST_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
everySuspend { packetRepository.findPacketsWithId(55) } returns emptyList()
|
||||||
|
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||||
|
every { messageFilter.shouldFilter(any(), any()) } returns false
|
||||||
|
every { nodeManager.nodeDBbyID } returns
|
||||||
|
mapOf(
|
||||||
|
"!remote" to
|
||||||
|
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Range test should be remembered with TEXT_MESSAGE_APP dataType
|
||||||
|
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin message handling ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `admin message sets session passkey`() {
|
||||||
|
val admin = org.meshtastic.proto.AdminMessage(session_passkey = okio.ByteString.of(1, 2, 3))
|
||||||
|
val packet =
|
||||||
|
MeshPacket(from = 123, decoded = Data(portnum = PortNum.ADMIN_APP, payload = admin.encode().toByteString()))
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
from = "!local",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = admin.encode().toByteString(),
|
||||||
|
dataType = PortNum.ADMIN_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
|
||||||
|
verify { commandSender.setSessionPasskey(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Message filtering ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filtered message is inserted with filtered flag`() = testScope.runTest {
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
id = 77,
|
||||||
|
from = 456,
|
||||||
|
decoded =
|
||||||
|
Data(
|
||||||
|
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||||
|
payload = "spam content".encodeToByteArray().toByteString(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
id = 77,
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = "spam content".encodeToByteArray().toByteString(),
|
||||||
|
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
everySuspend { packetRepository.findPacketsWithId(77) } returns emptyList()
|
||||||
|
every { nodeManager.nodeDBbyID } returns emptyMap()
|
||||||
|
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||||
|
every { messageFilter.shouldFilter("spam content", false) } returns true
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Verify insert was called with filtered = true (6th param)
|
||||||
|
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `message from ignored node is filtered`() = testScope.runTest {
|
||||||
|
val packet =
|
||||||
|
MeshPacket(
|
||||||
|
id = 88,
|
||||||
|
from = 456,
|
||||||
|
decoded =
|
||||||
|
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||||
|
)
|
||||||
|
val dataPacket =
|
||||||
|
DataPacket(
|
||||||
|
id = 88,
|
||||||
|
from = "!remote",
|
||||||
|
to = DataPacket.ID_BROADCAST,
|
||||||
|
bytes = "hello".encodeToByteArray().toByteString(),
|
||||||
|
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||||
|
)
|
||||||
|
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||||
|
everySuspend { packetRepository.findPacketsWithId(88) } returns emptyList()
|
||||||
|
every { nodeManager.nodeDBbyID } returns
|
||||||
|
mapOf("!remote" to Node(num = 456, user = User(id = "!remote"), isIgnored = true))
|
||||||
|
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||||
|
|
||||||
|
handler.handleReceivedData(packet, 123)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,22 +16,29 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.data.manager
|
package org.meshtastic.core.data.manager
|
||||||
|
|
||||||
|
import dev.mokkery.MockMode
|
||||||
|
import dev.mokkery.answering.returns
|
||||||
|
import dev.mokkery.every
|
||||||
|
import dev.mokkery.mock
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.meshtastic.core.repository.FilterPrefs
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class MessageFilterImplTest {
|
class MessageFilterImplTest {
|
||||||
/*
|
|
||||||
|
|
||||||
private lateinit var filterPrefs: FilterPrefs
|
private lateinit var filterPrefs: FilterPrefs
|
||||||
private lateinit var filterEnabledFlow: MutableStateFlow<Boolean>
|
private val filterEnabledFlow = MutableStateFlow(true)
|
||||||
private lateinit var filterWordsFlow: MutableStateFlow<Set<String>>
|
private val filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
|
||||||
private lateinit var filterService: MessageFilterImpl
|
private lateinit var filterService: MessageFilterImpl
|
||||||
|
|
||||||
@Before
|
@BeforeTest
|
||||||
fun setup() {
|
fun setup() {
|
||||||
filterEnabledFlow = MutableStateFlow(true)
|
filterPrefs = mock(MockMode.autofill)
|
||||||
filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
|
every { filterPrefs.filterEnabled } returns filterEnabledFlow
|
||||||
filterPrefs = mockk {
|
every { filterPrefs.filterWords } returns filterWordsFlow
|
||||||
every { filterEnabled } returns filterEnabledFlow
|
|
||||||
every { filterWords } returns filterWordsFlow
|
|
||||||
}
|
|
||||||
filterService = MessageFilterImpl(filterPrefs)
|
filterService = MessageFilterImpl(filterPrefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +99,4 @@ class MessageFilterImplTest {
|
||||||
filterService.rebuildPatterns()
|
filterService.rebuildPatterns()
|
||||||
assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false))
|
assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,36 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.data.manager
|
package org.meshtastic.core.data.manager
|
||||||
|
|
||||||
|
import dev.mokkery.MockMode
|
||||||
|
import dev.mokkery.mock
|
||||||
|
import org.meshtastic.core.model.DataPacket
|
||||||
|
import org.meshtastic.core.model.Node
|
||||||
|
import org.meshtastic.core.repository.NodeRepository
|
||||||
|
import org.meshtastic.core.repository.NotificationManager
|
||||||
|
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||||
|
import org.meshtastic.proto.DeviceMetrics
|
||||||
|
import org.meshtastic.proto.EnvironmentMetrics
|
||||||
|
import org.meshtastic.proto.HardwareModel
|
||||||
|
import org.meshtastic.proto.Telemetry
|
||||||
|
import org.meshtastic.proto.User
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import org.meshtastic.proto.Position as ProtoPosition
|
||||||
|
|
||||||
class NodeManagerImplTest {
|
class NodeManagerImplTest {
|
||||||
/*
|
|
||||||
|
|
||||||
|
|
||||||
|
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||||
|
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
|
||||||
|
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||||
|
|
||||||
private lateinit var nodeManager: NodeManagerImpl
|
private lateinit var nodeManager: NodeManagerImpl
|
||||||
|
|
||||||
@Before
|
@BeforeTest
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
mockkStatic("org.meshtastic.core.resources.GetStringKt")
|
|
||||||
|
|
||||||
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
|
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,8 +82,9 @@ class NodeManagerImplTest {
|
||||||
@Test
|
@Test
|
||||||
fun `handleReceivedUser updates user if incoming is higher detail`() {
|
fun `handleReceivedUser updates user if incoming is higher detail`() {
|
||||||
val nodeNum = 1234
|
val nodeNum = 1234
|
||||||
|
// Use a non-UNSET hw_model so isUnknownUser=false (avoids new-node notification + getString)
|
||||||
val existingUser =
|
val existingUser =
|
||||||
User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET)
|
User(id = "!12345678", long_name = "Old Name", short_name = "ON", hw_model = HardwareModel.TLORA_V2)
|
||||||
|
|
||||||
nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) }
|
nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) }
|
||||||
|
|
||||||
|
|
@ -81,29 +101,30 @@ class NodeManagerImplTest {
|
||||||
@Test
|
@Test
|
||||||
fun `handleReceivedPosition updates node position`() {
|
fun `handleReceivedPosition updates node position`() {
|
||||||
val nodeNum = 1234
|
val nodeNum = 1234
|
||||||
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
|
val position = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000)
|
||||||
|
|
||||||
nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0)
|
nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0)
|
||||||
|
|
||||||
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
|
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
|
||||||
assertNotNull(result!!.position)
|
assertNotNull(result)
|
||||||
assertEquals(45.0, result.latitude, 0.0001)
|
assertNotNull(result.position)
|
||||||
assertEquals(90.0, result.longitude, 0.0001)
|
assertEquals(450000000, result.position.latitude_i)
|
||||||
|
assertEquals(900000000, result.position.longitude_i)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() {
|
fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() {
|
||||||
val nodeNum = 1234
|
val nodeNum = 1234
|
||||||
val initialPosition = Position(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10)
|
val initialPosition = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10)
|
||||||
nodeManager.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L)
|
nodeManager.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L)
|
||||||
|
|
||||||
// Receive "zero" position with new satellite count
|
// Receive "zero" position with new satellite count
|
||||||
val zeroPosition = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001)
|
val zeroPosition = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001)
|
||||||
nodeManager.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L)
|
nodeManager.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L)
|
||||||
|
|
||||||
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
|
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
|
||||||
assertEquals(45.0, result!!.latitude, 0.0001)
|
assertEquals(450000000, result!!.position.latitude_i)
|
||||||
assertEquals(90.0, result.longitude, 0.0001)
|
assertEquals(900000000, result.position.longitude_i)
|
||||||
assertEquals(5, result.position.sats_in_view)
|
assertEquals(5, result.position.sats_in_view)
|
||||||
assertEquals(1001, result.lastHeard)
|
assertEquals(1001, result.lastHeard)
|
||||||
}
|
}
|
||||||
|
|
@ -111,13 +132,13 @@ class NodeManagerImplTest {
|
||||||
@Test
|
@Test
|
||||||
fun `handleReceivedPosition for local node ignores purely empty packets`() {
|
fun `handleReceivedPosition for local node ignores purely empty packets`() {
|
||||||
val myNum = 1111
|
val myNum = 1111
|
||||||
val emptyPos = Position(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0)
|
val emptyPos = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0)
|
||||||
|
|
||||||
nodeManager.handleReceivedPosition(myNum, myNum, emptyPos, 0)
|
nodeManager.handleReceivedPosition(myNum, myNum, emptyPos, 0)
|
||||||
|
|
||||||
val result = nodeManager.nodeDBbyNodeNum[myNum]
|
val result = nodeManager.nodeDBbyNodeNum[myNum]
|
||||||
// Should still be a default/unset node if it didn't exist, or shouldn't have position
|
// Should still be null since the empty position for local node is ignored
|
||||||
assertTrue(result == null || result.position.latitude_i == null)
|
assertNull(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -125,11 +146,7 @@ class NodeManagerImplTest {
|
||||||
val nodeNum = 1234
|
val nodeNum = 1234
|
||||||
nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) }
|
nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) }
|
||||||
|
|
||||||
val telemetry =
|
val telemetry = Telemetry(time = 2000, device_metrics = DeviceMetrics(battery_level = 50))
|
||||||
org.meshtastic.proto.Telemetry(
|
|
||||||
time = 2000,
|
|
||||||
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 50),
|
|
||||||
)
|
|
||||||
|
|
||||||
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
|
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
|
||||||
|
|
||||||
|
|
@ -140,10 +157,7 @@ class NodeManagerImplTest {
|
||||||
@Test
|
@Test
|
||||||
fun `handleReceivedTelemetry updates device metrics`() {
|
fun `handleReceivedTelemetry updates device metrics`() {
|
||||||
val nodeNum = 1234
|
val nodeNum = 1234
|
||||||
val telemetry =
|
val telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 75, voltage = 3.8f))
|
||||||
org.meshtastic.proto.Telemetry(
|
|
||||||
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 75, voltage = 3.8f),
|
|
||||||
)
|
|
||||||
|
|
||||||
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
|
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
|
||||||
|
|
||||||
|
|
@ -157,10 +171,7 @@ class NodeManagerImplTest {
|
||||||
fun `handleReceivedTelemetry updates environment metrics`() {
|
fun `handleReceivedTelemetry updates environment metrics`() {
|
||||||
val nodeNum = 1234
|
val nodeNum = 1234
|
||||||
val telemetry =
|
val telemetry =
|
||||||
org.meshtastic.proto.Telemetry(
|
Telemetry(environment_metrics = EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f))
|
||||||
environment_metrics =
|
|
||||||
org.meshtastic.proto.EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f),
|
|
||||||
)
|
|
||||||
|
|
||||||
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
|
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
|
||||||
|
|
||||||
|
|
@ -180,5 +191,39 @@ class NodeManagerImplTest {
|
||||||
assertNull(nodeManager.myNodeNum)
|
assertNull(nodeManager.myNodeNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
@Test
|
||||||
|
fun `toNodeID returns broadcast ID for broadcast nodeNum`() {
|
||||||
|
val result = nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST)
|
||||||
|
assertEquals(DataPacket.ID_BROADCAST, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toNodeID returns default hex ID for unknown node`() {
|
||||||
|
val result = nodeManager.toNodeID(0x1234)
|
||||||
|
assertEquals(DataPacket.nodeNumToDefaultId(0x1234), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toNodeID returns user ID for known node`() {
|
||||||
|
val nodeNum = 5678
|
||||||
|
val userId = "!customid"
|
||||||
|
nodeManager.updateNode(nodeNum) { it.copy(user = it.user.copy(id = userId)) }
|
||||||
|
val result = nodeManager.toNodeID(nodeNum)
|
||||||
|
assertEquals(userId, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removeByNodenum removes node from both maps`() {
|
||||||
|
val nodeNum = 1234
|
||||||
|
nodeManager.updateNode(nodeNum) {
|
||||||
|
Node(num = nodeNum, user = User(id = "!testnode", long_name = "Test", short_name = "T"))
|
||||||
|
}
|
||||||
|
assertTrue(nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
|
||||||
|
assertTrue(nodeManager.nodeDBbyID.containsKey("!testnode"))
|
||||||
|
|
||||||
|
nodeManager.removeByNodenum(nodeNum)
|
||||||
|
|
||||||
|
assertTrue(!nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
|
||||||
|
assertTrue(!nodeManager.nodeDBbyID.containsKey("!testnode"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,11 @@ constructor(
|
||||||
private val radioController: RadioController,
|
private val radioController: RadioController,
|
||||||
) {
|
) {
|
||||||
/** Identifies nodes that match the cleanup criteria. */
|
/** Identifies nodes that match the cleanup criteria. */
|
||||||
suspend fun getNodesToClean(olderThanDays: Float, onlyUnknownNodes: Boolean, currentTimeSeconds: Long): List<Node> {
|
open suspend fun getNodesToClean(
|
||||||
|
olderThanDays: Float,
|
||||||
|
onlyUnknownNodes: Boolean,
|
||||||
|
currentTimeSeconds: Long,
|
||||||
|
): List<Node> {
|
||||||
val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
|
val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
|
||||||
val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds
|
val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds
|
||||||
|
|
||||||
|
|
@ -49,7 +53,7 @@ constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Performs the cleanup of specified nodes. */
|
/** Performs the cleanup of specified nodes. */
|
||||||
suspend fun cleanNodes(nodeNums: List<Int>) {
|
open suspend fun cleanNodes(nodeNums: List<Int>) {
|
||||||
if (nodeNums.isEmpty()) return
|
if (nodeNums.isEmpty()) return
|
||||||
|
|
||||||
nodeRepository.deleteNodes(nodeNums)
|
nodeRepository.deleteNodes(nodeNums)
|
||||||
|
|
|
||||||
|
|
@ -1,60 +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.model
|
|
||||||
|
|
||||||
class NodeInfoTest {
|
|
||||||
/*
|
|
||||||
|
|
||||||
private val model = HardwareModel.ANDROID_SIM
|
|
||||||
private val node =
|
|
||||||
listOf(
|
|
||||||
NodeInfo(4, MeshUser("+zero", "User Zero", "U0", model)),
|
|
||||||
NodeInfo(5, MeshUser("+one", "User One", "U1", model), Position(37.1, 121.1, 35)),
|
|
||||||
NodeInfo(6, MeshUser("+two", "User Two", "U2", model), Position(37.11, 121.1, 40)),
|
|
||||||
NodeInfo(7, MeshUser("+three", "User Three", "U3", model), Position(37.101, 121.1, 40)),
|
|
||||||
NodeInfo(8, MeshUser("+four", "User Four", "U4", model), Position(37.116, 121.1, 40)),
|
|
||||||
)
|
|
||||||
|
|
||||||
private val currentDefaultLocale = LocaleListCompat.getDefault().get(0) ?: Locale.US
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
Locale.setDefault(Locale.US)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
Locale.setDefault(currentDefaultLocale)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun distanceGood() {
|
|
||||||
assertEquals(1111, node[1].distance(node[2]))
|
|
||||||
assertEquals(111, node[1].distance(node[3]))
|
|
||||||
assertEquals(1779, node[1].distance(node[4]))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun distanceStrGood() {
|
|
||||||
assertEquals("1.1 km", node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value))
|
|
||||||
assertEquals("111 m", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value))
|
|
||||||
assertEquals("1.1 mi", node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
|
|
||||||
assertEquals("364 ft", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
|
|
||||||
}
|
|
||||||
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
@ -76,7 +76,7 @@ private fun formatTraceroutePath(nodesList: List<String>, snrList: List<Int>): S
|
||||||
.joinToString("\n")
|
.joinToString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun RouteDiscovery.getTracerouteResponse(
|
fun RouteDiscovery.getTracerouteResponse(
|
||||||
getUser: (nodeNum: Int) -> String,
|
getUser: (nodeNum: Int) -> String,
|
||||||
headerTowards: String = "Route traced toward destination:\n\n",
|
headerTowards: String = "Route traced toward destination:\n\n",
|
||||||
headerBack: String = "Route traced back to us:\n\n",
|
headerBack: String = "Route traced back to us:\n\n",
|
||||||
|
|
@ -98,15 +98,6 @@ fun MeshPacket.getTracerouteResponse(
|
||||||
headerBack: String = "Route traced back to us:\n\n",
|
headerBack: String = "Route traced back to us:\n\n",
|
||||||
): String? = fullRouteDiscovery?.getTracerouteResponse(getUser, headerTowards, headerBack)
|
): String? = fullRouteDiscovery?.getTracerouteResponse(getUser, headerTowards, headerBack)
|
||||||
|
|
||||||
/** Returns a traceroute response string only when the result is complete (both directions). */
|
|
||||||
fun MeshPacket.getFullTracerouteResponse(
|
|
||||||
getUser: (nodeNum: Int) -> String,
|
|
||||||
headerTowards: String = "Route traced toward destination:\n\n",
|
|
||||||
headerBack: String = "Route traced back to us:\n\n",
|
|
||||||
): String? = fullRouteDiscovery
|
|
||||||
?.takeIf { it.route.isNotEmpty() && it.route_back.isNotEmpty() }
|
|
||||||
?.getTracerouteResponse(getUser, headerTowards, headerBack)
|
|
||||||
|
|
||||||
enum class TracerouteMapAvailability {
|
enum class TracerouteMapAvailability {
|
||||||
Ok,
|
Ok,
|
||||||
MissingEndpoints,
|
MissingEndpoints,
|
||||||
|
|
|
||||||
|
|
@ -16,81 +16,83 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.model
|
package org.meshtastic.core.model
|
||||||
|
|
||||||
class CapabilitiesTest {
|
import kotlin.test.Test
|
||||||
/*
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class CapabilitiesTest {
|
||||||
|
|
||||||
private fun caps(version: String?) = Capabilities(version, forceEnableAll = false)
|
private fun caps(version: String?) = Capabilities(version, forceEnableAll = false)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun canMuteNodeRequiresV2718() {
|
fun canMuteNode_requires_V2_7_18() {
|
||||||
assertFalse(caps("2.7.15").canMuteNode)
|
assertFalse(caps("2.7.15").canMuteNode)
|
||||||
assertTrue(caps("2.7.18").canMuteNode)
|
assertTrue(caps("2.7.18").canMuteNode)
|
||||||
assertTrue(caps("2.8.0").canMuteNode)
|
assertTrue(caps("2.8.0").canMuteNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun canRequestNeighborInfoIsCurrentlyDisabled() {
|
fun canRequestNeighborInfo_is_currently_disabled() {
|
||||||
assertFalse(caps("2.7.14").canRequestNeighborInfo)
|
assertFalse(caps("2.7.14").canRequestNeighborInfo)
|
||||||
assertFalse(caps("3.0.0").canRequestNeighborInfo)
|
assertFalse(caps("3.0.0").canRequestNeighborInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun canSendVerifiedContactsRequiresV2712() {
|
fun canSendVerifiedContacts_requires_V2_7_12() {
|
||||||
assertFalse(caps("2.7.11").canSendVerifiedContacts)
|
assertFalse(caps("2.7.11").canSendVerifiedContacts)
|
||||||
assertTrue(caps("2.7.12").canSendVerifiedContacts)
|
assertTrue(caps("2.7.12").canSendVerifiedContacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun canToggleTelemetryEnabledRequiresV2712() {
|
fun canToggleTelemetryEnabled_requires_V2_7_12() {
|
||||||
assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
|
assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
|
||||||
assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
|
assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun canToggleUnmessageableRequiresV269() {
|
fun canToggleUnmessageable_requires_V2_6_9() {
|
||||||
assertFalse(caps("2.6.8").canToggleUnmessageable)
|
assertFalse(caps("2.6.8").canToggleUnmessageable)
|
||||||
assertTrue(caps("2.6.9").canToggleUnmessageable)
|
assertTrue(caps("2.6.9").canToggleUnmessageable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun supportsQrCodeSharingRequiresV268() {
|
fun supportsQrCodeSharing_requires_V2_6_8() {
|
||||||
assertFalse(caps("2.6.7").supportsQrCodeSharing)
|
assertFalse(caps("2.6.7").supportsQrCodeSharing)
|
||||||
assertTrue(caps("2.6.8").supportsQrCodeSharing)
|
assertTrue(caps("2.6.8").supportsQrCodeSharing)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun supportsSecondaryChannelLocationRequiresV2610() {
|
fun supportsSecondaryChannelLocation_requires_V2_6_10() {
|
||||||
assertFalse(caps("2.6.9").supportsSecondaryChannelLocation)
|
assertFalse(caps("2.6.9").supportsSecondaryChannelLocation)
|
||||||
assertTrue(caps("2.6.10").supportsSecondaryChannelLocation)
|
assertTrue(caps("2.6.10").supportsSecondaryChannelLocation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun supportsStatusMessageRequiresV2717() {
|
fun supportsStatusMessage_requires_V2_7_17() {
|
||||||
assertFalse(caps("2.7.16").supportsStatusMessage)
|
assertFalse(caps("2.7.16").supportsStatusMessage)
|
||||||
assertTrue(caps("2.7.17").supportsStatusMessage)
|
assertTrue(caps("2.7.17").supportsStatusMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun supportsTrafficManagementConfigRequiresV300() {
|
fun supportsTrafficManagementConfig_requires_V3_0_0() {
|
||||||
assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
|
assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
|
||||||
assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
|
assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun supportsTakConfigRequiresV2719() {
|
fun supportsTakConfig_requires_V2_7_19() {
|
||||||
assertFalse(caps("2.7.18").supportsTakConfig)
|
assertFalse(caps("2.7.18").supportsTakConfig)
|
||||||
assertTrue(caps("2.7.19").supportsTakConfig)
|
assertTrue(caps("2.7.19").supportsTakConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun supportsEsp32OtaRequiresV2718() {
|
fun supportsEsp32Ota_requires_V2_7_18() {
|
||||||
assertFalse(caps("2.7.17").supportsEsp32Ota)
|
assertFalse(caps("2.7.17").supportsEsp32Ota)
|
||||||
assertTrue(caps("2.7.18").supportsEsp32Ota)
|
assertTrue(caps("2.7.18").supportsEsp32Ota)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun nullFirmwareReturnsAllFalse() {
|
fun nullFirmware_returns_all_false() {
|
||||||
val c = caps(null)
|
val c = caps(null)
|
||||||
assertFalse(c.canMuteNode)
|
assertFalse(c.canMuteNode)
|
||||||
assertFalse(c.canRequestNeighborInfo)
|
assertFalse(c.canRequestNeighborInfo)
|
||||||
|
|
@ -106,7 +108,7 @@ class CapabilitiesTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun forceEnableAllReturnsTrueForEverythingRegardlessOfVersion() {
|
fun forceEnableAll_returns_true_regardless_of_version() {
|
||||||
val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
|
val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
|
||||||
assertTrue(c.canMuteNode)
|
assertTrue(c.canMuteNode)
|
||||||
assertTrue(c.canSendVerifiedContacts)
|
assertTrue(c.canSendVerifiedContacts)
|
||||||
|
|
@ -114,23 +116,4 @@ class CapabilitiesTest {
|
||||||
assertTrue(c.supportsTrafficManagementConfig)
|
assertTrue(c.supportsTrafficManagementConfig)
|
||||||
assertTrue(c.supportsTakConfig)
|
assertTrue(c.supportsTakConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deviceVersionParsingIsRobust() {
|
|
||||||
assertEquals(20712, DeviceVersion("2.7.12").asInt)
|
|
||||||
assertEquals(20712, DeviceVersion("2.7.12-beta").asInt)
|
|
||||||
assertEquals(30000, DeviceVersion("3.0.0").asInt)
|
|
||||||
assertEquals(20700, DeviceVersion("2.7").asInt) // Handles 2-part versions
|
|
||||||
assertEquals(0, DeviceVersion("invalid").asInt)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deviceVersionComparisonIsCorrect() {
|
|
||||||
assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
|
|
||||||
assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
|
|
||||||
assertTrue(DeviceVersion("2.7.12") == DeviceVersion("2.7.12"))
|
|
||||||
assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
|
|
||||||
}
|
|
||||||
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
@ -16,62 +16,55 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.model
|
package org.meshtastic.core.model
|
||||||
|
|
||||||
class ChannelOptionTest {
|
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
|
||||||
/*
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
|
||||||
|
class ChannelOptionTest {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This test ensures that every `ModemPreset` defined in the protobufs has a corresponding entry in our
|
* Ensures that every [ModemPreset] defined in the protobufs has a corresponding entry in [ChannelOption].
|
||||||
* `ChannelOption` enum.
|
|
||||||
*
|
*
|
||||||
* If this test fails, it means a `ModemPreset` was added or changed in the firmware/protobufs, and you must update
|
* If this test fails, a ModemPreset was added or changed in the firmware/protobufs and you must update the
|
||||||
* the `ChannelOption` enum to match.
|
* [ChannelOption] enum to match.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `ensure every ModemPreset is mapped in ChannelOption`() {
|
fun ensure_every_ModemPreset_is_mapped_in_ChannelOption() {
|
||||||
// Get all possible ModemPreset values.
|
val unmappedPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }
|
||||||
val unmappedPresets =
|
|
||||||
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }
|
|
||||||
|
|
||||||
unmappedPresets.forEach { preset ->
|
unmappedPresets.forEach { preset ->
|
||||||
// Attempt to find the corresponding ChannelOption
|
|
||||||
val channelOption = ChannelOption.from(preset)
|
val channelOption = ChannelOption.from(preset)
|
||||||
|
|
||||||
// Assert that a mapping exists, with a detailed failure message.
|
|
||||||
assertNotNull(
|
assertNotNull(
|
||||||
|
channelOption,
|
||||||
"Missing ChannelOption mapping for ModemPreset: '${preset.name}'. " +
|
"Missing ChannelOption mapping for ModemPreset: '${preset.name}'. " +
|
||||||
"Please add a corresponding entry to the ChannelOption enum class.",
|
"Please add a corresponding entry to the ChannelOption enum class.",
|
||||||
channelOption,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This test ensures that there are no extra entries in `ChannelOption` that don't correspond to a valid
|
* Ensures that there are no extra entries in [ChannelOption] that don't correspond to a valid [ModemPreset].
|
||||||
* `ModemPreset`.
|
|
||||||
*
|
*
|
||||||
* If this test fails, it means a `ModemPreset` was removed from the protobufs, and you must remove the
|
* If this test fails, a ModemPreset was removed from the protobufs and you must remove the corresponding entry from
|
||||||
* corresponding entry from the `ChannelOption` enum.
|
* the [ChannelOption] enum.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun `ensure no extra mappings exist in ChannelOption`() {
|
fun ensure_no_extra_mappings_exist_in_ChannelOption() {
|
||||||
val protoPresets =
|
val protoPresets = ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
|
||||||
Config.LoRaConfig.ModemPreset.entries.filter { it.name != "UNSET" && it.name != "UNRECOGNIZED" }.toSet()
|
|
||||||
val mappedPresets = ChannelOption.entries.map { it.modemPreset }.toSet()
|
val mappedPresets = ChannelOption.entries.map { it.modemPreset }.toSet()
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"The set of ModemPresets in protobufs does not match the set of ModemPresets mapped in ChannelOption. " +
|
|
||||||
"Check for removed presets in protobufs or duplicate mappings in ChannelOption.",
|
|
||||||
protoPresets,
|
protoPresets,
|
||||||
mappedPresets,
|
mappedPresets,
|
||||||
|
"The set of ModemPresets in protobufs does not match the set of ModemPresets mapped in ChannelOption. " +
|
||||||
|
"Check for removed presets in protobufs or duplicate mappings in ChannelOption.",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Each ChannelOption must map to a unique ModemPreset.",
|
|
||||||
protoPresets.size,
|
protoPresets.size,
|
||||||
ChannelOption.entries.size,
|
ChannelOption.entries.size,
|
||||||
|
"Each ChannelOption must map to a unique ModemPreset.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
@ -16,10 +16,11 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.model
|
package org.meshtastic.core.model
|
||||||
|
|
||||||
class DeviceVersionTest {
|
import kotlin.test.Test
|
||||||
/*
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class DeviceVersionTest {
|
||||||
|
|
||||||
/** make sure we match the python and device code behavior */
|
|
||||||
@Test
|
@Test
|
||||||
fun canParse() {
|
fun canParse() {
|
||||||
assertEquals(10000, DeviceVersion("1.0.0").asInt)
|
assertEquals(10000, DeviceVersion("1.0.0").asInt)
|
||||||
|
|
@ -28,5 +29,21 @@ class DeviceVersionTest {
|
||||||
assertEquals(12357, DeviceVersion("1.23.57.abde123").asInt)
|
assertEquals(12357, DeviceVersion("1.23.57.abde123").asInt)
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
@Test
|
||||||
|
fun twoPartVersionAppends_zero() {
|
||||||
|
assertEquals(20700, DeviceVersion("2.7").asInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invalidVersionReturns_zero() {
|
||||||
|
assertEquals(0, DeviceVersion("invalid").asInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun comparisonIsCorrect() {
|
||||||
|
kotlin.test.assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
|
||||||
|
kotlin.test.assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
|
||||||
|
assertEquals(DeviceVersion("2.7.12"), DeviceVersion("2.7.12"))
|
||||||
|
kotlin.test.assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +69,7 @@ kotlin {
|
||||||
|
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlinx.coroutines.test)
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
|
implementation(libs.turbine)
|
||||||
implementation(libs.kotest.assertions)
|
implementation(libs.kotest.assertions)
|
||||||
implementation(libs.kotest.property)
|
implementation(libs.kotest.property)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import kotlinx.serialization.json.Json
|
||||||
import org.meshtastic.core.model.MqttJsonPayload
|
import org.meshtastic.core.model.MqttJsonPayload
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class MQTTRepositoryImplTest {
|
class MQTTRepositoryImplTest {
|
||||||
|
|
||||||
|
|
@ -67,8 +68,8 @@ class MQTTRepositoryImplTest {
|
||||||
val json = Json { ignoreUnknownKeys = true }
|
val json = Json { ignoreUnknownKeys = true }
|
||||||
val jsonStr = json.encodeToString(MqttJsonPayload.serializer(), payload)
|
val jsonStr = json.encodeToString(MqttJsonPayload.serializer(), payload)
|
||||||
|
|
||||||
assert(jsonStr.contains("\"type\":\"text\""))
|
assertTrue(jsonStr.contains("\"type\":\"text\""))
|
||||||
assert(jsonStr.contains("\"from\":12345678"))
|
assertTrue(jsonStr.contains("\"from\":12345678"))
|
||||||
assert(jsonStr.contains("\"payload\":\"Hello World\""))
|
assertTrue(jsonStr.contains("\"payload\":\"Hello World\""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.flowOn
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
|
import java.net.NetworkInterface
|
||||||
import javax.jmdns.JmDNS
|
import javax.jmdns.JmDNS
|
||||||
import javax.jmdns.ServiceEvent
|
import javax.jmdns.ServiceEvent
|
||||||
import javax.jmdns.ServiceListener
|
import javax.jmdns.ServiceListener
|
||||||
|
|
@ -34,9 +35,14 @@ class JvmServiceDiscovery : ServiceDiscovery {
|
||||||
@Suppress("TooGenericExceptionCaught")
|
@Suppress("TooGenericExceptionCaught")
|
||||||
override val resolvedServices: Flow<List<DiscoveredService>> =
|
override val resolvedServices: Flow<List<DiscoveredService>> =
|
||||||
callbackFlow {
|
callbackFlow {
|
||||||
|
trySend(emptyList()) // Emit initial empty list so downstream combine() is not blocked
|
||||||
|
|
||||||
|
val bindAddress = findLanAddress() ?: InetAddress.getLocalHost()
|
||||||
|
Logger.i { "JmDNS binding to ${bindAddress.hostAddress}" }
|
||||||
|
|
||||||
val jmdns =
|
val jmdns =
|
||||||
try {
|
try {
|
||||||
JmDNS.create(InetAddress.getLocalHost())
|
JmDNS.create(bindAddress)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Logger.e(e) { "Failed to create JmDNS" }
|
Logger.e(e) { "Failed to create JmDNS" }
|
||||||
null
|
null
|
||||||
|
|
@ -93,4 +99,24 @@ class JvmServiceDiscovery : ServiceDiscovery {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Finds a non-loopback, up, IPv4 LAN address for JmDNS to bind to. On many systems (especially Windows),
|
||||||
|
* [InetAddress.getLocalHost] resolves to `127.0.0.1` or `::1`, which prevents JmDNS from seeing multicast
|
||||||
|
* traffic on the actual LAN interface.
|
||||||
|
*/
|
||||||
|
@Suppress("TooGenericExceptionCaught", "LoopWithTooManyJumpStatements")
|
||||||
|
internal fun findLanAddress(): InetAddress? = try {
|
||||||
|
NetworkInterface.getNetworkInterfaces()
|
||||||
|
?.toList()
|
||||||
|
.orEmpty()
|
||||||
|
.filter { it.isUp && !it.isLoopback }
|
||||||
|
.flatMap { it.inetAddresses.toList() }
|
||||||
|
.firstOrNull { !it.isLoopbackAddress && it is java.net.Inet4Address }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(e) { "Failed to enumerate network interfaces, falling back to getLocalHost()" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class JvmServiceDiscoveryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolvedServices emits initial empty list immediately`() = runTest {
|
||||||
|
val discovery = JvmServiceDiscovery()
|
||||||
|
discovery.resolvedServices.test {
|
||||||
|
val first = awaitItem()
|
||||||
|
assertNotNull(first, "First emission should not be null")
|
||||||
|
assertTrue(first.isEmpty(), "First emission should be an empty list")
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findLanAddress returns non-loopback address or null`() {
|
||||||
|
val address = JvmServiceDiscovery.findLanAddress()
|
||||||
|
// On CI machines there may be no LAN interface, so null is acceptable
|
||||||
|
if (address != null) {
|
||||||
|
assertTrue(!address.isLoopbackAddress, "Address should not be loopback")
|
||||||
|
assertTrue(address is java.net.Inet4Address, "Address should be IPv4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findLanAddress does not throw`() {
|
||||||
|
// Ensure the method handles exceptions gracefully
|
||||||
|
val result = runCatching { JvmServiceDiscovery.findLanAddress() }
|
||||||
|
assertTrue(result.isSuccess, "findLanAddress should not throw: ${result.exceptionOrNull()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,6 @@ import org.meshtastic.core.model.Position
|
||||||
import org.meshtastic.proto.AdminMessage
|
import org.meshtastic.proto.AdminMessage
|
||||||
import org.meshtastic.proto.ChannelSet
|
import org.meshtastic.proto.ChannelSet
|
||||||
import org.meshtastic.proto.LocalConfig
|
import org.meshtastic.proto.LocalConfig
|
||||||
import org.meshtastic.proto.NeighborInfo
|
|
||||||
|
|
||||||
/** Interface for sending commands and packets to the mesh network. */
|
/** Interface for sending commands and packets to the mesh network. */
|
||||||
@Suppress("TooManyFunctions")
|
@Suppress("TooManyFunctions")
|
||||||
|
|
@ -43,15 +42,6 @@ interface CommandSender {
|
||||||
/** Generates a new unique packet ID. */
|
/** Generates a new unique packet ID. */
|
||||||
fun generatePacketId(): Int
|
fun generatePacketId(): Int
|
||||||
|
|
||||||
/** The latest neighbor info received from the connected radio. */
|
|
||||||
var lastNeighborInfo: NeighborInfo?
|
|
||||||
|
|
||||||
/** Start times of traceroute requests for duration calculation. */
|
|
||||||
val tracerouteStartTimes: MutableMap<Int, Long>
|
|
||||||
|
|
||||||
/** Start times of neighbor info requests for duration calculation. */
|
|
||||||
val neighborInfoStartTimes: MutableMap<Int, Long>
|
|
||||||
|
|
||||||
/** Sets the session passkey for admin messages. */
|
/** Sets the session passkey for admin messages. */
|
||||||
fun setSessionPasskey(key: ByteString)
|
fun setSessionPasskey(key: ByteString)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,25 +14,20 @@
|
||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.model
|
package org.meshtastic.core.repository
|
||||||
|
|
||||||
class PositionTest {
|
/**
|
||||||
/*
|
* Shared constants for the two-stage mesh handshake protocol.
|
||||||
|
*
|
||||||
|
* Stage 1 (`CONFIG_NONCE`): requests device config, module config, and channels. Stage 2 (`NODE_INFO_NONCE`): requests
|
||||||
|
* the full node database.
|
||||||
|
*
|
||||||
|
* Both [MeshConfigFlowManager] (consumer) and [MeshConnectionManager] (sender) reference these.
|
||||||
|
*/
|
||||||
|
object HandshakeConstants {
|
||||||
|
/** Nonce sent in `want_config_id` to request config-only (Stage 1). */
|
||||||
|
const val CONFIG_NONCE = 69420
|
||||||
|
|
||||||
@Test
|
/** Nonce sent in `want_config_id` to request node info only (Stage 2). */
|
||||||
fun degGood() {
|
const val NODE_INFO_NONCE = 69421
|
||||||
assertEquals(Position.degI(89.0), 890000000)
|
|
||||||
assertEquals(Position.degI(-89.0), -890000000)
|
|
||||||
|
|
||||||
assertEquals(89.0, Position.degD(Position.degI(89.0)), 0.01)
|
|
||||||
assertEquals(-89.0, Position.degD(Position.degI(-89.0)), 0.01)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun givenPositionCreatedWithoutTime_thenTimeIsSet() {
|
|
||||||
val position = Position(37.1, 121.1, 35)
|
|
||||||
assertTrue(position.time != 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
@ -18,12 +18,19 @@ package org.meshtastic.core.repository
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import org.meshtastic.proto.MeshPacket
|
import org.meshtastic.proto.MeshPacket
|
||||||
|
import org.meshtastic.proto.NeighborInfo
|
||||||
|
|
||||||
/** Interface for handling neighbor info responses from the mesh. */
|
/** Interface for handling neighbor info responses from the mesh. */
|
||||||
interface NeighborInfoHandler {
|
interface NeighborInfoHandler {
|
||||||
/** Starts the neighbor info handler with the given coroutine scope. */
|
/** Starts the neighbor info handler with the given coroutine scope. */
|
||||||
fun start(scope: CoroutineScope)
|
fun start(scope: CoroutineScope)
|
||||||
|
|
||||||
|
/** Records the start time for a neighbor info request. */
|
||||||
|
fun recordStartTime(requestId: Int)
|
||||||
|
|
||||||
|
/** The latest neighbor info received from the connected radio. */
|
||||||
|
var lastNeighborInfo: NeighborInfo?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a neighbor info packet.
|
* Processes a neighbor info packet.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import org.meshtastic.core.model.DataPacket
|
||||||
|
import org.meshtastic.proto.MeshPacket
|
||||||
|
|
||||||
|
/** Interface for handling Store & Forward (legacy) and SF++ packets. */
|
||||||
|
interface StoreForwardPacketHandler {
|
||||||
|
/** Starts the handler with the given coroutine scope. */
|
||||||
|
fun start(scope: CoroutineScope)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a legacy Store & Forward packet.
|
||||||
|
*
|
||||||
|
* @param packet The received mesh packet.
|
||||||
|
* @param dataPacket The decoded data packet.
|
||||||
|
* @param myNodeNum The local node number.
|
||||||
|
*/
|
||||||
|
fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a Store Forward++ packet.
|
||||||
|
*
|
||||||
|
* @param packet The received mesh packet.
|
||||||
|
*/
|
||||||
|
fun handleStoreForwardPlusPlus(packet: MeshPacket)
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,9 @@ interface TracerouteHandler {
|
||||||
/** Starts the traceroute handler with the given coroutine scope. */
|
/** Starts the traceroute handler with the given coroutine scope. */
|
||||||
fun start(scope: CoroutineScope)
|
fun start(scope: CoroutineScope)
|
||||||
|
|
||||||
|
/** Records the start time for a traceroute request. */
|
||||||
|
fun recordStartTime(requestId: Int)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a traceroute packet.
|
* Processes a traceroute packet.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,120 +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.service
|
|
||||||
|
|
||||||
import org.meshtastic.core.model.DataPacket
|
|
||||||
import org.meshtastic.core.model.MeshUser
|
|
||||||
import org.meshtastic.core.model.MyNodeInfo
|
|
||||||
import org.meshtastic.core.model.NodeInfo
|
|
||||||
import org.meshtastic.core.model.Position
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fake implementation of [IMeshService] for testing purposes. This also serves as a contract verification: if the
|
|
||||||
* AIDL changes, this class will fail to compile.
|
|
||||||
*/
|
|
||||||
@Suppress("TooManyFunctions", "EmptyFunctionBlock")
|
|
||||||
open class FakeIMeshService : IMeshService.Stub() {
|
|
||||||
override fun subscribeReceiver(packageName: String?, receiverName: String?) {}
|
|
||||||
|
|
||||||
override fun setOwner(user: MeshUser?) {}
|
|
||||||
|
|
||||||
override fun setRemoteOwner(requestId: Int, destNum: Int, payload: ByteArray?) {}
|
|
||||||
|
|
||||||
override fun getRemoteOwner(requestId: Int, destNum: Int) {}
|
|
||||||
|
|
||||||
override fun getMyId(): String = "fake_id"
|
|
||||||
|
|
||||||
override fun getPacketId(): Int = 1234
|
|
||||||
|
|
||||||
override fun send(packet: DataPacket?) {}
|
|
||||||
|
|
||||||
override fun getNodes(): List<NodeInfo> = emptyList()
|
|
||||||
|
|
||||||
override fun getConfig(): ByteArray = byteArrayOf()
|
|
||||||
|
|
||||||
override fun setConfig(payload: ByteArray?) {}
|
|
||||||
|
|
||||||
override fun setRemoteConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
|
|
||||||
|
|
||||||
override fun getRemoteConfig(requestId: Int, destNum: Int, configTypeValue: Int) {}
|
|
||||||
|
|
||||||
override fun setModuleConfig(requestId: Int, destNum: Int, payload: ByteArray?) {}
|
|
||||||
|
|
||||||
override fun getModuleConfig(requestId: Int, destNum: Int, moduleConfigTypeValue: Int) {}
|
|
||||||
|
|
||||||
override fun setRingtone(destNum: Int, ringtone: String?) {}
|
|
||||||
|
|
||||||
override fun getRingtone(requestId: Int, destNum: Int) {}
|
|
||||||
|
|
||||||
override fun setCannedMessages(destNum: Int, messages: String?) {}
|
|
||||||
|
|
||||||
override fun getCannedMessages(requestId: Int, destNum: Int) {}
|
|
||||||
|
|
||||||
override fun setChannel(payload: ByteArray?) {}
|
|
||||||
|
|
||||||
override fun setRemoteChannel(requestId: Int, destNum: Int, payload: ByteArray?) {}
|
|
||||||
|
|
||||||
override fun getRemoteChannel(requestId: Int, destNum: Int, channelIndex: Int) {}
|
|
||||||
|
|
||||||
override fun beginEditSettings(destNum: Int) {}
|
|
||||||
|
|
||||||
override fun commitEditSettings(destNum: Int) {}
|
|
||||||
|
|
||||||
override fun removeByNodenum(requestID: Int, nodeNum: Int) {}
|
|
||||||
|
|
||||||
override fun requestPosition(destNum: Int, position: Position?) {}
|
|
||||||
|
|
||||||
override fun setFixedPosition(destNum: Int, position: Position?) {}
|
|
||||||
|
|
||||||
override fun requestTraceroute(requestId: Int, destNum: Int) {}
|
|
||||||
|
|
||||||
override fun requestNeighborInfo(requestId: Int, destNum: Int) {}
|
|
||||||
|
|
||||||
override fun requestShutdown(requestId: Int, destNum: Int) {}
|
|
||||||
|
|
||||||
override fun requestReboot(requestId: Int, destNum: Int) {}
|
|
||||||
|
|
||||||
override fun requestFactoryReset(requestId: Int, destNum: Int) {}
|
|
||||||
|
|
||||||
override fun rebootToDfu(destNum: Int) {}
|
|
||||||
|
|
||||||
override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {}
|
|
||||||
|
|
||||||
override fun getChannelSet(): ByteArray = byteArrayOf()
|
|
||||||
|
|
||||||
override fun connectionState(): String = "CONNECTED"
|
|
||||||
|
|
||||||
override fun setDeviceAddress(deviceAddr: String?): Boolean = true
|
|
||||||
|
|
||||||
override fun getMyNodeInfo(): MyNodeInfo? = null
|
|
||||||
|
|
||||||
override fun startFirmwareUpdate() {}
|
|
||||||
|
|
||||||
override fun getUpdateStatus(): Int = 0
|
|
||||||
|
|
||||||
override fun startProvideLocation() {}
|
|
||||||
|
|
||||||
override fun stopProvideLocation() {}
|
|
||||||
|
|
||||||
override fun requestUserInfo(destNum: Int) {}
|
|
||||||
|
|
||||||
override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) {}
|
|
||||||
|
|
||||||
override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {}
|
|
||||||
|
|
||||||
override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* 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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.meshtastic.core.ui.util.AlertManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared composable that observes [AlertManager.currentAlert] and renders a [MeshtasticDialog] when an alert is
|
||||||
|
* present. This eliminates duplicated alert-rendering boilerplate across Android and Desktop host shells.
|
||||||
|
*
|
||||||
|
* Usage: Place `AlertHost(alertManager)` once in the top-level composable of each platform host.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AlertHost(alertManager: AlertManager) {
|
||||||
|
val alertDialogState by alertManager.currentAlert.collectAsStateWithLifecycle()
|
||||||
|
alertDialogState?.let { state ->
|
||||||
|
MeshtasticDialog(
|
||||||
|
title = state.title,
|
||||||
|
titleRes = state.titleRes,
|
||||||
|
message = state.message,
|
||||||
|
messageRes = state.messageRes,
|
||||||
|
html = state.html,
|
||||||
|
icon = state.icon,
|
||||||
|
text = state.composableMessage?.let { msg -> { msg.Content() } },
|
||||||
|
confirmText = state.confirmText,
|
||||||
|
confirmTextRes = state.confirmTextRes,
|
||||||
|
onConfirm = state.onConfirm,
|
||||||
|
dismissText = state.dismissText,
|
||||||
|
dismissTextRes = state.dismissTextRes,
|
||||||
|
onDismiss = state.onDismiss,
|
||||||
|
choices = state.choices,
|
||||||
|
dismissable = state.dismissable,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/*
|
||||||
|
* 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.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared placeholder screen for desktop/JVM feature stubs that are not yet implemented. Displays a centered label in
|
||||||
|
* [MaterialTheme.typography.headlineMedium].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PlaceholderScreen(name: String) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
/*
|
||||||
|
* 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.runtime.Composable
|
||||||
|
import org.meshtastic.core.model.ConnectionState
|
||||||
|
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
||||||
|
import org.meshtastic.core.ui.share.SharedContactDialog
|
||||||
|
import org.meshtastic.proto.ChannelSet
|
||||||
|
import org.meshtastic.proto.SharedContact
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared composable that conditionally renders [SharedContactDialog] and [ScannedQrCodeDialog] when the device is
|
||||||
|
* connected and requests are pending.
|
||||||
|
*
|
||||||
|
* This eliminates identical boilerplate from Android `MainScreen` and Desktop `DesktopMainScreen`.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SharedDialogs(
|
||||||
|
connectionState: ConnectionState,
|
||||||
|
sharedContactRequested: SharedContact?,
|
||||||
|
requestChannelSet: ChannelSet?,
|
||||||
|
onDismissSharedContact: () -> Unit,
|
||||||
|
onDismissChannelSet: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (connectionState == ConnectionState.Connected) {
|
||||||
|
sharedContactRequested?.let { SharedContactDialog(sharedContact = it, onDismiss = onDismissSharedContact) }
|
||||||
|
|
||||||
|
requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(newChannelSet, onDismiss = onDismissChannelSet) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,8 @@ import androidx.lifecycle.ViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import org.koin.core.annotation.KoinViewModel
|
import org.koin.core.annotation.KoinViewModel
|
||||||
import org.meshtastic.core.model.MyNodeInfo
|
import org.meshtastic.core.model.MyNodeInfo
|
||||||
import org.meshtastic.core.model.Node
|
import org.meshtastic.core.model.Node
|
||||||
|
|
@ -27,6 +29,7 @@ import org.meshtastic.core.repository.NodeRepository
|
||||||
import org.meshtastic.core.repository.RadioConfigRepository
|
import org.meshtastic.core.repository.RadioConfigRepository
|
||||||
import org.meshtastic.core.repository.ServiceRepository
|
import org.meshtastic.core.repository.ServiceRepository
|
||||||
import org.meshtastic.core.repository.UiPrefs
|
import org.meshtastic.core.repository.UiPrefs
|
||||||
|
import org.meshtastic.proto.Config
|
||||||
import org.meshtastic.proto.LocalConfig
|
import org.meshtastic.proto.LocalConfig
|
||||||
|
|
||||||
@KoinViewModel
|
@KoinViewModel
|
||||||
|
|
@ -46,6 +49,28 @@ class ConnectionsViewModel(
|
||||||
|
|
||||||
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
|
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtered [ourNodeInfo] that only emits when display-relevant fields change, preventing continuous recomposition
|
||||||
|
* from lastHeard/snr updates.
|
||||||
|
*/
|
||||||
|
val ourNodeForDisplay: StateFlow<Node?> =
|
||||||
|
nodeRepository.ourNodeInfo
|
||||||
|
.distinctUntilChanged { old, new ->
|
||||||
|
old?.num == new?.num &&
|
||||||
|
old?.user == new?.user &&
|
||||||
|
old?.batteryLevel == new?.batteryLevel &&
|
||||||
|
old?.voltage == new?.voltage &&
|
||||||
|
old?.metadata?.firmware_version == new?.metadata?.firmware_version
|
||||||
|
}
|
||||||
|
.stateInWhileSubscribed(initialValue = nodeRepository.ourNodeInfo.value)
|
||||||
|
|
||||||
|
/** Whether the LoRa region is UNSET and needs to be configured. */
|
||||||
|
val regionUnset: StateFlow<Boolean> =
|
||||||
|
radioConfigRepository.localConfigFlow
|
||||||
|
.map { it.lora?.region == Config.LoRaConfig.RegionCode.UNSET }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateInWhileSubscribed(initialValue = false)
|
||||||
|
|
||||||
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value)
|
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value)
|
||||||
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
|
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ class UIViewModel(
|
||||||
private val uiPreferencesDataSource: UiPreferencesDataSource,
|
private val uiPreferencesDataSource: UiPreferencesDataSource,
|
||||||
private val notificationManager: NotificationManager,
|
private val notificationManager: NotificationManager,
|
||||||
packetRepository: PacketRepository,
|
packetRepository: PacketRepository,
|
||||||
private val alertManager: AlertManager,
|
val alertManager: AlertManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
|
private val _navigationDeepLink = MutableSharedFlow<MeshtasticUri>(replay = 1)
|
||||||
|
|
@ -121,8 +121,6 @@ class UIViewModel(
|
||||||
_scrollToTopEventFlow.tryEmit(event)
|
_scrollToTopEventFlow.tryEmit(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentAlert = alertManager.currentAlert
|
|
||||||
|
|
||||||
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
|
fun tracerouteMapAvailability(forwardRoute: List<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
|
||||||
evaluateTracerouteMapAvailability(
|
evaluateTracerouteMapAvailability(
|
||||||
forwardRoute = forwardRoute,
|
forwardRoute = forwardRoute,
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,17 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.core.ui.util
|
package org.meshtastic.core.ui.util
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import kotlin.test.Test
|
||||||
import org.junit.Assert.assertNotNull
|
import kotlin.test.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import kotlin.test.assertNotNull
|
||||||
import org.junit.Test
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
class AlertManagerTest {
|
class AlertManagerTest {
|
||||||
|
|
||||||
private val alertManager = AlertManager()
|
private val alertManager = AlertManager()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `showAlert updates currentAlert flow`() {
|
fun showAlert_updates_currentAlert_flow() {
|
||||||
val title = "Test Title"
|
val title = "Test Title"
|
||||||
val message = "Test Message"
|
val message = "Test Message"
|
||||||
|
|
||||||
|
|
@ -34,12 +34,12 @@ class AlertManagerTest {
|
||||||
|
|
||||||
val alertData = alertManager.currentAlert.value
|
val alertData = alertManager.currentAlert.value
|
||||||
assertNotNull(alertData)
|
assertNotNull(alertData)
|
||||||
assertEquals(title, alertData?.title)
|
assertEquals(title, alertData.title)
|
||||||
assertEquals(message, alertData?.message)
|
assertEquals(message, alertData.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dismissAlert clears currentAlert flow`() {
|
fun dismissAlert_clears_currentAlert_flow() {
|
||||||
alertManager.showAlert(title = "Title")
|
alertManager.showAlert(title = "Title")
|
||||||
assertNotNull(alertManager.currentAlert.value)
|
assertNotNull(alertManager.currentAlert.value)
|
||||||
|
|
||||||
|
|
@ -48,7 +48,7 @@ class AlertManagerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `onConfirm triggers and dismisses alert`() {
|
fun onConfirm_triggers_and_dismisses_alert() {
|
||||||
var confirmClicked = false
|
var confirmClicked = false
|
||||||
alertManager.showAlert(title = "Confirm Test", onConfirm = { confirmClicked = true })
|
alertManager.showAlert(title = "Confirm Test", onConfirm = { confirmClicked = true })
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ class AlertManagerTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `onDismiss triggers and dismisses alert`() {
|
fun onDismiss_triggers_and_dismisses_alert() {
|
||||||
var dismissClicked = false
|
var dismissClicked = false
|
||||||
alertManager.showAlert(title = "Dismiss Test", onDismiss = { dismissClicked = true })
|
alertManager.showAlert(title = "Dismiss Test", onDismiss = { dismissClicked = true })
|
||||||
|
|
||||||
|
|
@ -72,6 +72,7 @@ compose.desktop {
|
||||||
// App Icon & OS Specific Configurations
|
// App Icon & OS Specific Configurations
|
||||||
macOS {
|
macOS {
|
||||||
iconFile.set(project.file("src/main/resources/icon.icns"))
|
iconFile.set(project.file("src/main/resources/icon.icns"))
|
||||||
|
minimumSystemVersion = "12.0"
|
||||||
// TODO: To prepare for real distribution on macOS, you'll need to sign and notarize.
|
// TODO: To prepare for real distribution on macOS, you'll need to sign and notarize.
|
||||||
// You can inject these from CI environment variables.
|
// You can inject these from CI environment variables.
|
||||||
// bundleID = "org.meshtastic.desktop"
|
// bundleID = "org.meshtastic.desktop"
|
||||||
|
|
|
||||||
3
desktop/proguard-rules.pro
vendored
3
desktop/proguard-rules.pro
vendored
|
|
@ -3,6 +3,9 @@
|
||||||
-dontwarn com.squareup.wire.AndroidMessage**
|
-dontwarn com.squareup.wire.AndroidMessage**
|
||||||
-dontwarn io.ktor.**
|
-dontwarn io.ktor.**
|
||||||
|
|
||||||
|
# Room KMP: preserve generated database constructor (required for R8/ProGuard)
|
||||||
|
-keep class * extends androidx.room.RoomDatabase { <init>(); }
|
||||||
|
|
||||||
# Suppress ProGuard notes about duplicate resource files (common in Compose Desktop)
|
# Suppress ProGuard notes about duplicate resource files (common in Compose Desktop)
|
||||||
-dontnote **
|
-dontnote **
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,6 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.desktop.navigation
|
package org.meshtastic.desktop.navigation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.navigation3.runtime.EntryProviderScope
|
import androidx.navigation3.runtime.EntryProviderScope
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
|
@ -68,14 +61,3 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>)
|
||||||
// Connections — shared screen
|
// Connections — shared screen
|
||||||
connectionsGraph(backStack)
|
connectionsGraph(backStack)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun PlaceholderScreen(name: String) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text(
|
|
||||||
text = name,
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,12 @@ import androidx.navigation3.ui.NavDisplay
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.meshtastic.core.model.ConnectionState
|
|
||||||
import org.meshtastic.core.model.DeviceType
|
import org.meshtastic.core.model.DeviceType
|
||||||
import org.meshtastic.core.navigation.TopLevelDestination
|
import org.meshtastic.core.navigation.TopLevelDestination
|
||||||
import org.meshtastic.core.repository.RadioInterfaceService
|
import org.meshtastic.core.repository.RadioInterfaceService
|
||||||
|
import org.meshtastic.core.ui.component.AlertHost
|
||||||
|
import org.meshtastic.core.ui.component.SharedDialogs
|
||||||
import org.meshtastic.core.ui.navigation.icon
|
import org.meshtastic.core.ui.navigation.icon
|
||||||
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
|
||||||
import org.meshtastic.core.ui.share.SharedContactDialog
|
|
||||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||||
import org.meshtastic.desktop.navigation.desktopNavGraph
|
import org.meshtastic.desktop.navigation.desktopNavGraph
|
||||||
|
|
||||||
|
|
@ -67,15 +66,15 @@ fun DesktopMainScreen(
|
||||||
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
if (connectionState == ConnectionState.Connected) {
|
SharedDialogs(
|
||||||
sharedContactRequested?.let {
|
connectionState = connectionState,
|
||||||
SharedContactDialog(sharedContact = it, onDismiss = { uiViewModel.clearSharedContactRequested() })
|
sharedContactRequested = sharedContactRequested,
|
||||||
}
|
requestChannelSet = requestChannelSet,
|
||||||
|
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
|
||||||
|
onDismissChannelSet = { uiViewModel.clearRequestChannelUrl() },
|
||||||
|
)
|
||||||
|
|
||||||
requestChannelSet?.let { newChannelSet ->
|
AlertHost(uiViewModel.alertManager)
|
||||||
ScannedQrCodeDialog(newChannelSet, onDismiss = { uiViewModel.clearRequestChannelUrl() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver
|
||||||
- Do ensure modules are reachable from app bootstrap in `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`.
|
- Do ensure modules are reachable from app bootstrap in `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`.
|
||||||
- Don't assume feature/core `@Module` classes are active automatically.
|
- Don't assume feature/core `@Module` classes are active automatically.
|
||||||
- Do ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`.
|
- Do ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`.
|
||||||
- **Don't use Koin 0.4.0's A1 Module Compile Safety checks for inverted dependencies.**
|
- **Don't use Koin K2 Compiler Plugin's A1 Module Compile Safety checks for inverted dependencies.**
|
||||||
- **Do** leave A1 `compileSafety` disabled in `build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt`. We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) to handle our decoupled Clean Architecture design where interfaces are declared in one module and implemented in another.
|
- **Do** leave A1 `compileSafety` disabled in `build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt` (uses typed `KoinGradleExtension`). We rely on Koin's A3 full-graph validation (`startKoin` / `VerifyModule`) to handle our decoupled Clean Architecture design where interfaces are declared in one module and implemented in another.
|
||||||
- **Don't** expect Koin to inject default parameters automatically. Koin 0.4.0's `skipDefaultValues = true` (default behavior) will cause Koin to skip parameters that have default Kotlin values.
|
- **Don't** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` (default behavior) will cause Koin to skip parameters that have default Kotlin values.
|
||||||
|
|
||||||
### Current code anchors (DI)
|
### Current code anchors (DI)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,22 @@ Formerly found in 3 prefs files:
|
||||||
|
|
||||||
Vico chart screens (DeviceMetrics, EnvironmentMetrics, SignalMetrics, PowerMetrics, PaxMetrics) have been migrated to `feature:node/commonMain` using Vico's KMP artifacts (`vico-compose`, `vico-compose-m3`). Desktop wires them via shared composables. No Android-only chart code remains.
|
Vico chart screens (DeviceMetrics, EnvironmentMetrics, SignalMetrics, PowerMetrics, PaxMetrics) have been migrated to `feature:node/commonMain` using Vico's KMP artifacts (`vico-compose`, `vico-compose-m3`). Desktop wires them via shared composables. No Android-only chart code remains.
|
||||||
|
|
||||||
|
### B5. Cross-platform code deduplication *(resolved 2026-03-21)*
|
||||||
|
|
||||||
|
Comprehensive audit of `androidMain` vs `jvmMain` duplication across all feature modules. Extracted shared components:
|
||||||
|
|
||||||
|
| Component | Module | Eliminated from |
|
||||||
|
|---|---|---|
|
||||||
|
| `AlertHost` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` |
|
||||||
|
| `SharedDialogs` composable | `core:ui/commonMain` | Android `Main.kt`, Desktop `DesktopMainScreen.kt` |
|
||||||
|
| `PlaceholderScreen` composable | `core:ui/commonMain` | 4 copies: `desktop/navigation`, `feature:map/jvmMain`, `feature:node/jvmMain` (×2) |
|
||||||
|
| `ThemePickerDialog` + `ThemeOption` | `feature:settings/commonMain` | Android `SettingsScreen.kt`, Desktop `DesktopSettingsScreen.kt` |
|
||||||
|
| `formatLogsTo()` + `redactedKeys` | `feature:settings/commonMain` (`LogFormatter.kt`) | Android + Desktop `LogExporter.kt` actuals |
|
||||||
|
| `handleNodeAction()` | `feature:node/commonMain` | Android `NodeDetailScreen.kt`, Desktop `NodeDetailScreens.kt` |
|
||||||
|
| `findNodeByNameSuffix()` | `feature:connections/commonMain` | Android USB matcher, TCP recent device matcher |
|
||||||
|
|
||||||
|
Also fixed `Dispatchers.IO` usage in `StoreForwardPacketHandlerImpl` (would break iOS), removed dead `UIViewModel.currentAlert` property, and added `firebase-debug.log` to `.gitignore`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## C. DI Improvements
|
## C. DI Improvements
|
||||||
|
|
@ -203,12 +219,12 @@ Ordered by impact × effort:
|
||||||
| Area | Previous | Current | Notes |
|
| Area | Previous | Current | Notes |
|
||||||
|---|---:|---:|---|
|
|---|---:|---:|---|
|
||||||
| Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared |
|
| Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared |
|
||||||
| Shared feature/UI logic | 9.5/10 | **8.5/10** | All 7 KMP features; connections unified; Vico charts in commonMain |
|
| Shared feature/UI logic | 9.5/10 | **9/10** | All 7 KMP features; connections unified; cross-platform deduplication complete |
|
||||||
| Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files |
|
| Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files |
|
||||||
| Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
|
| Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
|
||||||
| CI confidence | 8.5/10 | **9/10** | 25 modules validated; feature:connections + desktop in CI; native release installers |
|
| CI confidence | 8.5/10 | **9/10** | 25 modules validated; feature:connections + desktop in CI; native release installers |
|
||||||
| DI portability | 7/10 | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform |
|
| DI portability | 7/10 | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform |
|
||||||
| Test maturity | — | **8/10** | 131 commonTest + 89 platform-specific = 219 tests across all 7 features; core:testing established |
|
| Test maturity | — | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 8 features |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# KMP Migration Status
|
# KMP Migration Status
|
||||||
|
|
||||||
> Last updated: 2026-03-16
|
> Last updated: 2026-03-21
|
||||||
|
|
||||||
Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/).
|
Single source of truth for Kotlin Multiplatform migration progress. For the forward-looking roadmap, see [`roadmap.md`](./roadmap.md). For completed decision records, see [`decisions/`](./decisions/).
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ Working Compose Desktop application with:
|
||||||
| Area | Score | Notes |
|
| Area | Score | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Shared business/data logic | **9/10** | All core layers shared; RadioTransport interface unified |
|
| Shared business/data logic | **9/10** | All core layers shared; RadioTransport interface unified |
|
||||||
| Shared feature/UI logic | **8.5/10** | All 7 KMP; feature:connections unified with dynamic transport detection |
|
| Shared feature/UI logic | **9/10** | All 7 KMP; feature:connections unified; cross-platform deduplication complete |
|
||||||
| Android decoupling | **9/10** | No known `java.*` calls in `commonMain`; app module extraction in progress (navigation, connections, background services, and widgets extracted) |
|
| Android decoupling | **9/10** | No known `java.*` calls in `commonMain`; app module extraction in progress (navigation, connections, background services, and widgets extracted) |
|
||||||
| Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
|
| Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
|
||||||
| CI confidence | **9/10** | 25 modules validated (including feature:connections); native release installers automated |
|
| CI confidence | **9/10** | 25 modules validated (including feature:connections); native release installers automated |
|
||||||
|
|
@ -87,7 +87,7 @@ Working Compose Desktop application with:
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| Android-first structural KMP | ~100% |
|
| Android-first structural KMP | ~100% |
|
||||||
| Shared business logic | ~98% |
|
| Shared business logic | ~98% |
|
||||||
| Shared feature/UI | ~95% |
|
| Shared feature/UI | ~97% |
|
||||||
| True multi-target readiness | ~85% |
|
| True multi-target readiness | ~85% |
|
||||||
| "Add iOS without surprises" | ~100% |
|
| "Add iOS without surprises" | ~100% |
|
||||||
|
|
||||||
|
|
@ -114,6 +114,7 @@ Based on the latest codebase investigation, the following steps are proposed to
|
||||||
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
|
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
|
||||||
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. |
|
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. |
|
||||||
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
|
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
|
||||||
|
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()` to `commonMain`; eliminated ~200 lines of duplicated code across Android/desktop |
|
||||||
|
|
||||||
## Navigation Parity Note
|
## Navigation Parity Note
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import org.meshtastic.core.datastore.model.RecentAddress
|
||||||
import org.meshtastic.core.model.Node
|
import org.meshtastic.core.model.Node
|
||||||
import org.meshtastic.core.network.repository.DiscoveredService
|
import org.meshtastic.core.network.repository.DiscoveredService
|
||||||
import org.meshtastic.core.network.repository.NetworkRepository
|
import org.meshtastic.core.network.repository.NetworkRepository
|
||||||
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
|
|
||||||
import org.meshtastic.core.network.repository.UsbRepository
|
import org.meshtastic.core.network.repository.UsbRepository
|
||||||
import org.meshtastic.core.repository.NodeRepository
|
import org.meshtastic.core.repository.NodeRepository
|
||||||
import org.meshtastic.core.repository.RadioInterfaceService
|
import org.meshtastic.core.repository.RadioInterfaceService
|
||||||
|
|
@ -55,7 +54,6 @@ class AndroidGetDiscoveredDevicesUseCase(
|
||||||
private val radioInterfaceService: RadioInterfaceService,
|
private val radioInterfaceService: RadioInterfaceService,
|
||||||
private val usbManagerLazy: Lazy<UsbManager>,
|
private val usbManagerLazy: Lazy<UsbManager>,
|
||||||
) : GetDiscoveredDevicesUseCase {
|
) : GetDiscoveredDevicesUseCase {
|
||||||
private val suffixLength = 4
|
|
||||||
private val macSuffixLength = 8
|
private val macSuffixLength = 8
|
||||||
|
|
||||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||||
|
|
@ -69,24 +67,8 @@ class AndroidGetDiscoveredDevicesUseCase(
|
||||||
tcpServices,
|
tcpServices,
|
||||||
recentList,
|
recentList,
|
||||||
->
|
->
|
||||||
val recentMap = recentList.associateBy({ it.address }) { it.name }
|
val defaultName = getString(Res.string.meshtastic)
|
||||||
tcpServices
|
processTcpServices(tcpServices, recentList, defaultName)
|
||||||
.map { service ->
|
|
||||||
val address = "t${service.toAddressString()}"
|
|
||||||
val txtRecords = service.txt
|
|
||||||
val shortNameBytes = txtRecords["shortname"]
|
|
||||||
val idBytes = txtRecords["id"]
|
|
||||||
|
|
||||||
val shortName =
|
|
||||||
shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
|
|
||||||
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
|
|
||||||
var displayName = recentMap[address] ?: shortName
|
|
||||||
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
|
|
||||||
displayName += "_$deviceId"
|
|
||||||
}
|
|
||||||
DeviceListEntry.Tcp(displayName, address)
|
|
||||||
}
|
|
||||||
.sortedBy { it.name }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val usbDevicesFlow =
|
val usbDevicesFlow =
|
||||||
|
|
@ -131,6 +113,7 @@ class AndroidGetDiscoveredDevicesUseCase(
|
||||||
@Suppress("UNCHECKED_CAST", "MagicNumber")
|
@Suppress("UNCHECKED_CAST", "MagicNumber")
|
||||||
val recentList = args[5] as List<RecentAddress>
|
val recentList = args[5] as List<RecentAddress>
|
||||||
|
|
||||||
|
// Android-specific: BLE node matching by MAC suffix and Meshtastic short name
|
||||||
val bleForUi =
|
val bleForUi =
|
||||||
bondedBle
|
bondedBle
|
||||||
.map { entry ->
|
.map { entry ->
|
||||||
|
|
@ -153,61 +136,20 @@ class AndroidGetDiscoveredDevicesUseCase(
|
||||||
}
|
}
|
||||||
.sortedBy { it.name }
|
.sortedBy { it.name }
|
||||||
|
|
||||||
|
// Android-specific: USB node matching via shared helper
|
||||||
val usbForUi =
|
val usbForUi =
|
||||||
(
|
(
|
||||||
usbDevices +
|
usbDevices +
|
||||||
if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList()
|
if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList()
|
||||||
)
|
)
|
||||||
.map { entry ->
|
.map { entry ->
|
||||||
val matchingNode =
|
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
|
||||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
|
||||||
db.values.find { node ->
|
|
||||||
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
|
|
||||||
suffix != null &&
|
|
||||||
suffix.length >= suffixLength &&
|
|
||||||
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
entry.copy(node = matchingNode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val discoveredTcpForUi =
|
// Shared TCP logic via helpers
|
||||||
processedTcp.map { entry ->
|
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
|
||||||
val matchingNode =
|
|
||||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
|
||||||
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
|
|
||||||
val deviceId = resolvedService?.txt?.get("id")?.let { String(it, Charsets.UTF_8) }
|
|
||||||
db.values.find { node ->
|
|
||||||
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
entry.copy(node = matchingNode)
|
|
||||||
}
|
|
||||||
|
|
||||||
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
|
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
|
||||||
val recentTcpForUi =
|
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
|
||||||
recentList
|
|
||||||
.filterNot { discoveredTcpAddresses.contains(it.address) }
|
|
||||||
.map { DeviceListEntry.Tcp(it.name, it.address) }
|
|
||||||
.map { entry ->
|
|
||||||
val matchingNode =
|
|
||||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
|
||||||
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
|
|
||||||
db.values.find { node ->
|
|
||||||
suffix != null &&
|
|
||||||
suffix.length >= suffixLength &&
|
|
||||||
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
entry.copy(node = matchingNode)
|
|
||||||
}
|
|
||||||
.sortedBy { it.name }
|
|
||||||
|
|
||||||
DiscoveredDevices(
|
DiscoveredDevices(
|
||||||
bleDevices = bleForUi,
|
bleDevices = bleForUi,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import org.koin.core.annotation.Single
|
||||||
import org.meshtastic.core.common.database.DatabaseManager
|
import org.meshtastic.core.common.database.DatabaseManager
|
||||||
import org.meshtastic.core.datastore.RecentAddressesDataSource
|
import org.meshtastic.core.datastore.RecentAddressesDataSource
|
||||||
import org.meshtastic.core.network.repository.NetworkRepository
|
import org.meshtastic.core.network.repository.NetworkRepository
|
||||||
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
|
|
||||||
import org.meshtastic.core.repository.NodeRepository
|
import org.meshtastic.core.repository.NodeRepository
|
||||||
import org.meshtastic.core.resources.Res
|
import org.meshtastic.core.resources.Res
|
||||||
import org.meshtastic.core.resources.demo_mode
|
import org.meshtastic.core.resources.demo_mode
|
||||||
|
|
@ -40,9 +39,7 @@ class CommonGetDiscoveredDevicesUseCase(
|
||||||
private val networkRepository: NetworkRepository,
|
private val networkRepository: NetworkRepository,
|
||||||
private val usbScanner: UsbScanner? = null,
|
private val usbScanner: UsbScanner? = null,
|
||||||
) : GetDiscoveredDevicesUseCase {
|
) : GetDiscoveredDevicesUseCase {
|
||||||
private val suffixLength = 4
|
|
||||||
|
|
||||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
|
||||||
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
|
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
|
||||||
val nodeDb = nodeRepository.nodeDBbyNum
|
val nodeDb = nodeRepository.nodeDBbyNum
|
||||||
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
|
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
|
||||||
|
|
@ -52,25 +49,8 @@ class CommonGetDiscoveredDevicesUseCase(
|
||||||
tcpServices,
|
tcpServices,
|
||||||
recentList,
|
recentList,
|
||||||
->
|
->
|
||||||
val recentMap = recentList.associateBy({ it.address }) { it.name }
|
val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
|
||||||
tcpServices
|
processTcpServices(tcpServices, recentList, defaultName)
|
||||||
.map { service ->
|
|
||||||
val address = "t${service.toAddressString()}"
|
|
||||||
val txtRecords = service.txt
|
|
||||||
val shortNameBytes = txtRecords["shortname"]
|
|
||||||
val idBytes = txtRecords["id"]
|
|
||||||
|
|
||||||
val shortName =
|
|
||||||
shortNameBytes?.let { it.decodeToString() }
|
|
||||||
?: runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
|
|
||||||
val deviceId = idBytes?.let { it.decodeToString() }?.replace("!", "")
|
|
||||||
var displayName = recentMap[address] ?: shortName
|
|
||||||
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
|
|
||||||
displayName += "_$deviceId"
|
|
||||||
}
|
|
||||||
DeviceListEntry.Tcp(displayName, address)
|
|
||||||
}
|
|
||||||
.sortedBy { it.name }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return combine(
|
return combine(
|
||||||
|
|
@ -80,42 +60,9 @@ class CommonGetDiscoveredDevicesUseCase(
|
||||||
recentAddressesDataSource.recentAddresses,
|
recentAddressesDataSource.recentAddresses,
|
||||||
usbFlow,
|
usbFlow,
|
||||||
) { db, processedTcp, resolved, recentList, usbList ->
|
) { db, processedTcp, resolved, recentList, usbList ->
|
||||||
val discoveredTcpForUi =
|
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
|
||||||
processedTcp.map { entry ->
|
|
||||||
val matchingNode =
|
|
||||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
|
||||||
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
|
|
||||||
val deviceId = resolvedService?.txt?.get("id")?.let { it.decodeToString() }
|
|
||||||
db.values.find { node ->
|
|
||||||
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
entry.copy(node = matchingNode)
|
|
||||||
}
|
|
||||||
|
|
||||||
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
|
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
|
||||||
|
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
|
||||||
val recentTcpForUi =
|
|
||||||
recentList
|
|
||||||
.filterNot { discoveredTcpAddresses.contains(it.address) }
|
|
||||||
.map { DeviceListEntry.Tcp(it.name, it.address) }
|
|
||||||
.map { entry ->
|
|
||||||
val matchingNode =
|
|
||||||
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
|
||||||
val suffix = entry.name.split("_").lastOrNull()?.lowercase()
|
|
||||||
db.values.find { node ->
|
|
||||||
suffix != null &&
|
|
||||||
suffix.length >= suffixLength &&
|
|
||||||
node.user.id.lowercase().endsWith(suffix)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
entry.copy(node = matchingNode)
|
|
||||||
}
|
|
||||||
.sortedBy { it.name }
|
|
||||||
|
|
||||||
DiscoveredDevices(
|
DiscoveredDevices(
|
||||||
discoveredTcpDevices = discoveredTcpForUi,
|
discoveredTcpDevices = discoveredTcpForUi,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
/*
|
||||||
|
* 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.feature.connections.domain.usecase
|
||||||
|
|
||||||
|
import org.meshtastic.core.common.database.DatabaseManager
|
||||||
|
import org.meshtastic.core.datastore.model.RecentAddress
|
||||||
|
import org.meshtastic.core.model.Node
|
||||||
|
import org.meshtastic.core.network.repository.DiscoveredService
|
||||||
|
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
|
||||||
|
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||||
|
|
||||||
|
private const val SUFFIX_LENGTH = 4
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared helpers for TCP device discovery logic used by both [CommonGetDiscoveredDevicesUseCase] and the
|
||||||
|
* Android-specific variant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Converts a list of [DiscoveredService] into [DeviceListEntry.Tcp] with display names derived from TXT records. */
|
||||||
|
internal fun processTcpServices(
|
||||||
|
tcpServices: List<DiscoveredService>,
|
||||||
|
recentAddresses: List<RecentAddress>,
|
||||||
|
defaultShortName: String = "Meshtastic",
|
||||||
|
): List<DeviceListEntry.Tcp> {
|
||||||
|
val recentMap = recentAddresses.associateBy({ it.address }) { it.name }
|
||||||
|
return tcpServices
|
||||||
|
.map { service ->
|
||||||
|
val address = "t${service.toAddressString()}"
|
||||||
|
val txtRecords = service.txt
|
||||||
|
val shortNameBytes = txtRecords["shortname"]
|
||||||
|
val idBytes = txtRecords["id"]
|
||||||
|
|
||||||
|
val shortName = shortNameBytes?.decodeToString() ?: defaultShortName
|
||||||
|
val deviceId = idBytes?.decodeToString()?.replace("!", "")
|
||||||
|
var displayName = recentMap[address] ?: shortName
|
||||||
|
if (deviceId != null && displayName.split("_").none { it == deviceId }) {
|
||||||
|
displayName += "_$deviceId"
|
||||||
|
}
|
||||||
|
DeviceListEntry.Tcp(displayName, address)
|
||||||
|
}
|
||||||
|
.sortedBy { it.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches each discovered TCP entry to a [Node] from the database using its mDNS device ID. */
|
||||||
|
internal fun matchDiscoveredTcpNodes(
|
||||||
|
entries: List<DeviceListEntry.Tcp>,
|
||||||
|
nodeDb: Map<Int, Node>,
|
||||||
|
resolvedServices: List<DiscoveredService>,
|
||||||
|
databaseManager: DatabaseManager,
|
||||||
|
): List<DeviceListEntry.Tcp> = entries.map { entry ->
|
||||||
|
val matchingNode =
|
||||||
|
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
|
||||||
|
val resolvedService = resolvedServices.find { "t${it.toAddressString()}" == entry.fullAddress }
|
||||||
|
val deviceId = resolvedService?.txt?.get("id")?.decodeToString()
|
||||||
|
nodeDb.values.find { node ->
|
||||||
|
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
entry.copy(node = matchingNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the "recent TCP devices" list by filtering out currently discovered addresses and matching each entry to a
|
||||||
|
* [Node] by name suffix.
|
||||||
|
*/
|
||||||
|
internal fun buildRecentTcpEntries(
|
||||||
|
recentAddresses: List<RecentAddress>,
|
||||||
|
discoveredAddresses: Set<String>,
|
||||||
|
nodeDb: Map<Int, Node>,
|
||||||
|
databaseManager: DatabaseManager,
|
||||||
|
): List<DeviceListEntry.Tcp> = recentAddresses
|
||||||
|
.filterNot { discoveredAddresses.contains(it.address) }
|
||||||
|
.map { DeviceListEntry.Tcp(it.name, it.address) }
|
||||||
|
.map { entry ->
|
||||||
|
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, nodeDb, databaseManager))
|
||||||
|
}
|
||||||
|
.sortedBy { it.name }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a [Node] matching the last `_`-delimited segment of [displayName], if a local database exists for the given
|
||||||
|
* [fullAddress]. Used by both TCP recent-device matching and Android USB device matching to avoid duplicated
|
||||||
|
* suffix-lookup logic.
|
||||||
|
*/
|
||||||
|
internal fun findNodeByNameSuffix(
|
||||||
|
displayName: String,
|
||||||
|
fullAddress: String,
|
||||||
|
nodeDb: Map<Int, Node>,
|
||||||
|
databaseManager: DatabaseManager,
|
||||||
|
): Node? {
|
||||||
|
val suffix = displayName.split("_").lastOrNull()?.lowercase()
|
||||||
|
return if (!databaseManager.hasDatabaseFor(fullAddress) || suffix == null || suffix.length < SUFFIX_LENGTH) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
nodeDb.values.find { it.user.id.lowercase().endsWith(suffix) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,7 +47,6 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
@ -86,7 +85,6 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||||
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
|
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
|
||||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||||
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
|
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
|
||||||
import org.meshtastic.proto.Config
|
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
|
||||||
/** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */
|
/** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */
|
||||||
|
|
@ -102,25 +100,12 @@ fun ConnectionsScreen(
|
||||||
onConfigNavigate: (Route) -> Unit,
|
onConfigNavigate: (Route) -> Unit,
|
||||||
) {
|
) {
|
||||||
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
|
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||||
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
|
|
||||||
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
|
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
|
||||||
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
|
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
|
||||||
|
val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle()
|
||||||
// Prevent continuous recomposition from lastHeard and snr updates on the node
|
val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle()
|
||||||
val ourNode by
|
|
||||||
remember(connectionsViewModel.ourNodeInfo) {
|
|
||||||
connectionsViewModel.ourNodeInfo.distinctUntilChanged { old, new ->
|
|
||||||
old?.num == new?.num &&
|
|
||||||
old?.user == new?.user &&
|
|
||||||
old?.batteryLevel == new?.batteryLevel &&
|
|
||||||
old?.voltage == new?.voltage &&
|
|
||||||
old?.metadata?.firmware_version == new?.metadata?.firmware_version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.collectAsStateWithLifecycle(initialValue = connectionsViewModel.ourNodeInfo.value)
|
|
||||||
|
|
||||||
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
|
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
|
||||||
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
|
|
||||||
|
|
||||||
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
|
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
|
||||||
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
|
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
|
||||||
|
|
@ -192,63 +177,31 @@ fun ConnectionsScreen(
|
||||||
|
|
||||||
Crossfade(targetState = uiState, label = "connection_state") { state ->
|
Crossfade(targetState = uiState, label = "connection_state") { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
2 -> {
|
2 ->
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
ConnectedDeviceContent(
|
||||||
ourNode?.let { node ->
|
ourNode = ourNode,
|
||||||
TitledCard(title = stringResource(Res.string.connected_device)) {
|
regionUnset = regionUnset,
|
||||||
CurrentlyConnectedInfo(
|
selectedDevice = selectedDevice,
|
||||||
node = node,
|
bleDevices = bleDevices,
|
||||||
bleDevice =
|
onNavigateToNodeDetails = onNavigateToNodeDetails,
|
||||||
bleDevices.find { it.fullAddress == selectedDevice }
|
onClickDisconnect = { scanModel.disconnect() },
|
||||||
as DeviceListEntry.Ble?,
|
onSetRegion = {
|
||||||
onNavigateToNodeDetails = onNavigateToNodeDetails,
|
isWaiting = true
|
||||||
onClickDisconnect = { scanModel.disconnect() },
|
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
|
||||||
)
|
},
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (regionUnset && selectedDevice != "m") {
|
1 ->
|
||||||
TitledCard(title = null) {
|
ConnectingDeviceContent(
|
||||||
ListItem(
|
selectedDevice = selectedDevice,
|
||||||
leadingIcon = Icons.Rounded.Language,
|
bleDevices = bleDevices,
|
||||||
text = stringResource(Res.string.set_your_region),
|
discoveredTcpDevices = discoveredTcpDevices,
|
||||||
) {
|
recentTcpDevices = recentTcpDevices,
|
||||||
isWaiting = true
|
usbDevices = usbDevices,
|
||||||
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
|
onClickDisconnect = { scanModel.disconnect() },
|
||||||
}
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
1 -> {
|
else -> NoDeviceContent()
|
||||||
val selectedEntry =
|
|
||||||
bleDevices.find { it.fullAddress == selectedDevice }
|
|
||||||
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
|
|
||||||
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
|
|
||||||
?: usbDevices.find { it.fullAddress == selectedDevice }
|
|
||||||
|
|
||||||
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
|
|
||||||
val address = selectedEntry?.address ?: selectedDevice
|
|
||||||
|
|
||||||
TitledCard(title = stringResource(Res.string.connected_device)) {
|
|
||||||
ConnectingDeviceInfo(
|
|
||||||
deviceName = name,
|
|
||||||
deviceAddress = address,
|
|
||||||
onClickDisconnect = { scanModel.disconnect() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
EmptyStateContent(
|
|
||||||
imageVector = MeshtasticIcons.NoDevice,
|
|
||||||
text = stringResource(Res.string.no_device_selected),
|
|
||||||
modifier = Modifier.height(160.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -334,3 +287,74 @@ fun ConnectionsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Content shown when connected to a device with node info available. */
|
||||||
|
@Composable
|
||||||
|
private fun ConnectedDeviceContent(
|
||||||
|
ourNode: org.meshtastic.core.model.Node?,
|
||||||
|
regionUnset: Boolean,
|
||||||
|
selectedDevice: String,
|
||||||
|
bleDevices: List<DeviceListEntry>,
|
||||||
|
onNavigateToNodeDetails: (Int) -> Unit,
|
||||||
|
onClickDisconnect: () -> Unit,
|
||||||
|
onSetRegion: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
ourNode?.let { node ->
|
||||||
|
TitledCard(title = stringResource(Res.string.connected_device)) {
|
||||||
|
CurrentlyConnectedInfo(
|
||||||
|
node = node,
|
||||||
|
bleDevice = bleDevices.find { it.fullAddress == selectedDevice } as DeviceListEntry.Ble?,
|
||||||
|
onNavigateToNodeDetails = onNavigateToNodeDetails,
|
||||||
|
onClickDisconnect = onClickDisconnect,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionUnset && selectedDevice != "m") {
|
||||||
|
TitledCard(title = null) {
|
||||||
|
ListItem(
|
||||||
|
leadingIcon = Icons.Rounded.Language,
|
||||||
|
text = stringResource(Res.string.set_your_region),
|
||||||
|
onClick = onSetRegion,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content shown when connecting or a device is selected but node info is not yet available. */
|
||||||
|
@Composable
|
||||||
|
private fun ConnectingDeviceContent(
|
||||||
|
selectedDevice: String,
|
||||||
|
bleDevices: List<DeviceListEntry>,
|
||||||
|
discoveredTcpDevices: List<DeviceListEntry>,
|
||||||
|
recentTcpDevices: List<DeviceListEntry>,
|
||||||
|
usbDevices: List<DeviceListEntry>,
|
||||||
|
onClickDisconnect: () -> Unit,
|
||||||
|
) {
|
||||||
|
val selectedEntry =
|
||||||
|
bleDevices.find { it.fullAddress == selectedDevice }
|
||||||
|
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
|
||||||
|
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
|
||||||
|
?: usbDevices.find { it.fullAddress == selectedDevice }
|
||||||
|
|
||||||
|
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
|
||||||
|
val address = selectedEntry?.address ?: selectedDevice
|
||||||
|
|
||||||
|
TitledCard(title = stringResource(Res.string.connected_device)) {
|
||||||
|
ConnectingDeviceInfo(deviceName = name, deviceAddress = address, onClickDisconnect = onClickDisconnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content shown when no device is selected. */
|
||||||
|
@Composable
|
||||||
|
private fun NoDeviceContent() {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
EmptyStateContent(
|
||||||
|
imageVector = MeshtasticIcons.NoDevice,
|
||||||
|
text = stringResource(Res.string.no_device_selected),
|
||||||
|
modifier = Modifier.height(160.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
/*
|
||||||
|
* 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.feature.connections.domain.usecase
|
||||||
|
|
||||||
|
import dev.mokkery.answering.returns
|
||||||
|
import dev.mokkery.every
|
||||||
|
import dev.mokkery.matcher.any
|
||||||
|
import dev.mokkery.mock
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import org.meshtastic.core.common.database.DatabaseManager
|
||||||
|
import org.meshtastic.core.datastore.model.RecentAddress
|
||||||
|
import org.meshtastic.core.network.repository.DiscoveredService
|
||||||
|
import org.meshtastic.core.testing.TestDataFactory
|
||||||
|
import org.meshtastic.feature.connections.model.DeviceListEntry
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/** Unit tests for the shared TCP discovery helper functions. */
|
||||||
|
class TcpDiscoveryHelpersTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `processTcpServices maps services to DeviceListEntry with shortname and id`() {
|
||||||
|
val services =
|
||||||
|
listOf(
|
||||||
|
DiscoveredService(
|
||||||
|
name = "Meshtastic_abcd",
|
||||||
|
hostAddress = "192.168.1.10",
|
||||||
|
port = 4403,
|
||||||
|
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!abcd".encodeToByteArray()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = processTcpServices(services, emptyList())
|
||||||
|
|
||||||
|
result.size shouldBe 1
|
||||||
|
result[0].name shouldBe "Mesh_abcd"
|
||||||
|
result[0].fullAddress shouldBe "t192.168.1.10"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `processTcpServices uses default shortname when missing`() {
|
||||||
|
val services =
|
||||||
|
listOf(DiscoveredService(name = "TestDevice", hostAddress = "10.0.0.1", port = 4403, txt = emptyMap()))
|
||||||
|
|
||||||
|
val result = processTcpServices(services, emptyList(), defaultShortName = "Meshtastic")
|
||||||
|
|
||||||
|
result.size shouldBe 1
|
||||||
|
result[0].name shouldBe "Meshtastic"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `processTcpServices uses recent name over shortname`() {
|
||||||
|
val services =
|
||||||
|
listOf(
|
||||||
|
DiscoveredService(
|
||||||
|
name = "Meshtastic_1234",
|
||||||
|
hostAddress = "192.168.1.50",
|
||||||
|
port = 4403,
|
||||||
|
txt = mapOf("shortname" to "Mesh".encodeToByteArray()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "MyNode"))
|
||||||
|
|
||||||
|
val result = processTcpServices(services, recentAddresses)
|
||||||
|
|
||||||
|
result.size shouldBe 1
|
||||||
|
result[0].name shouldBe "MyNode"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `processTcpServices does not duplicate id in display name`() {
|
||||||
|
val services =
|
||||||
|
listOf(
|
||||||
|
DiscoveredService(
|
||||||
|
name = "Meshtastic_1234",
|
||||||
|
hostAddress = "192.168.1.50",
|
||||||
|
port = 4403,
|
||||||
|
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!1234".encodeToByteArray()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "Mesh_1234"))
|
||||||
|
|
||||||
|
val result = processTcpServices(services, recentAddresses)
|
||||||
|
|
||||||
|
result.size shouldBe 1
|
||||||
|
// Should NOT become "Mesh_1234_1234"
|
||||||
|
result[0].name shouldBe "Mesh_1234"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `processTcpServices results are sorted by name`() {
|
||||||
|
val services =
|
||||||
|
listOf(
|
||||||
|
DiscoveredService("Z", "10.0.0.2", 4403, mapOf("shortname" to "Zulu".encodeToByteArray())),
|
||||||
|
DiscoveredService("A", "10.0.0.1", 4403, mapOf("shortname" to "Alpha".encodeToByteArray())),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = processTcpServices(services, emptyList())
|
||||||
|
|
||||||
|
result[0].name shouldBe "Alpha"
|
||||||
|
result[1].name shouldBe "Zulu"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `matchDiscoveredTcpNodes matches node by device id`() {
|
||||||
|
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
|
||||||
|
val nodeDb = mapOf(1 to node)
|
||||||
|
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
|
||||||
|
val resolved =
|
||||||
|
listOf(
|
||||||
|
DiscoveredService(
|
||||||
|
name = "Meshtastic",
|
||||||
|
hostAddress = "192.168.1.50",
|
||||||
|
port = 4403,
|
||||||
|
txt = mapOf("id" to "!1234".encodeToByteArray()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns true }
|
||||||
|
|
||||||
|
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
|
||||||
|
|
||||||
|
result.size shouldBe 1
|
||||||
|
assertNotNull(result[0].node)
|
||||||
|
result[0].node?.user?.id shouldBe "!1234"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `matchDiscoveredTcpNodes returns null node when no database`() {
|
||||||
|
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
|
||||||
|
val nodeDb = mapOf(1 to node)
|
||||||
|
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
|
||||||
|
val resolved =
|
||||||
|
listOf(
|
||||||
|
DiscoveredService(
|
||||||
|
name = "Meshtastic",
|
||||||
|
hostAddress = "192.168.1.50",
|
||||||
|
port = 4403,
|
||||||
|
txt = mapOf("id" to "!1234".encodeToByteArray()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns false }
|
||||||
|
|
||||||
|
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
|
||||||
|
|
||||||
|
result.size shouldBe 1
|
||||||
|
assertNull(result[0].node)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildRecentTcpEntries filters out discovered addresses`() {
|
||||||
|
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "NodeA"), RecentAddress("t192.168.1.51", "NodeB"))
|
||||||
|
val discoveredAddresses = setOf("t192.168.1.50")
|
||||||
|
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
|
||||||
|
|
||||||
|
val result = buildRecentTcpEntries(recentAddresses, discoveredAddresses, emptyMap(), databaseManager)
|
||||||
|
|
||||||
|
result.size shouldBe 1
|
||||||
|
result[0].name shouldBe "NodeB"
|
||||||
|
result[0].fullAddress shouldBe "t192.168.1.51"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildRecentTcpEntries matches node by suffix`() {
|
||||||
|
val node = TestDataFactory.createTestNode(num = 1, userId = "!test1234")
|
||||||
|
val recentAddresses = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
|
||||||
|
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("tMeshtastic_1234") } returns true }
|
||||||
|
|
||||||
|
val result = buildRecentTcpEntries(recentAddresses, emptySet(), mapOf(1 to node), databaseManager)
|
||||||
|
|
||||||
|
result.size shouldBe 1
|
||||||
|
assertNotNull(result[0].node)
|
||||||
|
result[0].node?.user?.id shouldBe "!test1234"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildRecentTcpEntries results are sorted by name`() {
|
||||||
|
val recentAddresses = listOf(RecentAddress("t10.0.0.2", "Zebra"), RecentAddress("t10.0.0.1", "Alpha"))
|
||||||
|
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
|
||||||
|
|
||||||
|
val result = buildRecentTcpEntries(recentAddresses, emptySet(), emptyMap(), databaseManager)
|
||||||
|
|
||||||
|
result[0].name shouldBe "Alpha"
|
||||||
|
result[1].name shouldBe "Zebra"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findNodeByNameSuffix returns null when no database`() {
|
||||||
|
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
|
||||||
|
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
|
||||||
|
|
||||||
|
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
|
||||||
|
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findNodeByNameSuffix matches by last underscore segment`() {
|
||||||
|
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
|
||||||
|
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
|
||||||
|
|
||||||
|
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
|
||||||
|
|
||||||
|
assertNotNull(result)
|
||||||
|
result.user.id shouldBe "!abcd1234"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findNodeByNameSuffix returns null when suffix is too short`() {
|
||||||
|
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
|
||||||
|
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
|
||||||
|
|
||||||
|
val result = findNodeByNameSuffix("Device_ab", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
|
||||||
|
|
||||||
|
// "ab" is only 2 chars, below the minimum SUFFIX_LENGTH of 4
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,16 +45,7 @@ kotlin {
|
||||||
implementation(projects.core.di)
|
implementation(projects.core.di)
|
||||||
}
|
}
|
||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies { implementation(libs.material) }
|
||||||
implementation(libs.androidx.datastore)
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
|
||||||
implementation(libs.androidx.navigation.common)
|
|
||||||
implementation(libs.androidx.savedstate.compose)
|
|
||||||
implementation(libs.androidx.savedstate.ktx)
|
|
||||||
implementation(libs.material)
|
|
||||||
}
|
|
||||||
|
|
||||||
androidUnitTest.dependencies {
|
androidUnitTest.dependencies {
|
||||||
implementation(libs.junit)
|
implementation(libs.junit)
|
||||||
|
|
|
||||||
|
|
@ -16,25 +16,11 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.map.navigation
|
package org.meshtastic.feature.map.navigation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import org.meshtastic.core.ui.component.PlaceholderScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
|
actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
|
||||||
// Desktop placeholder for now
|
// Desktop placeholder for now
|
||||||
org.meshtastic.feature.map.navigation.PlaceholderScreen(name = "Map")
|
PlaceholderScreen(name = "Map")
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun PlaceholderScreen(name: String) {
|
|
||||||
androidx.compose.foundation.layout.Box(
|
|
||||||
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = androidx.compose.ui.Alignment.Center,
|
|
||||||
) {
|
|
||||||
androidx.compose.material3.Text(
|
|
||||||
text = name,
|
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
|
|
||||||
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -229,34 +229,6 @@ private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable ()
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleNodeAction(
|
|
||||||
action: NodeDetailAction,
|
|
||||||
uiState: NodeDetailUiState,
|
|
||||||
navigateToMessages: (String) -> Unit,
|
|
||||||
onNavigateUp: () -> Unit,
|
|
||||||
onNavigate: (Route) -> Unit,
|
|
||||||
viewModel: NodeDetailViewModel,
|
|
||||||
) {
|
|
||||||
when (action) {
|
|
||||||
is NodeDetailAction.Navigate -> onNavigate(action.route)
|
|
||||||
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
|
|
||||||
is NodeDetailAction.HandleNodeMenuAction -> {
|
|
||||||
when (val menuAction = action.action) {
|
|
||||||
is NodeMenuAction.DirectMessage -> {
|
|
||||||
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
|
|
||||||
navigateToMessages(route)
|
|
||||||
}
|
|
||||||
is NodeMenuAction.Remove -> {
|
|
||||||
viewModel.handleNodeMenuAction(menuAction)
|
|
||||||
onNavigateUp()
|
|
||||||
}
|
|
||||||
else -> viewModel.handleNodeMenuAction(menuAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun NodeDetailListPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
|
private fun NodeDetailListPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
|
||||||
|
|
|
||||||
|
|
@ -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.feature.node.detail
|
||||||
|
|
||||||
|
import org.meshtastic.core.navigation.Route
|
||||||
|
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||||
|
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared handler for [NodeDetailAction]s that are common across all platforms.
|
||||||
|
*
|
||||||
|
* Platform-specific actions (e.g. [NodeDetailAction.ShareContact], [NodeDetailAction.OpenCompass]) are ignored by this
|
||||||
|
* handler and should be handled by the platform-specific caller.
|
||||||
|
*/
|
||||||
|
internal fun handleNodeAction(
|
||||||
|
action: NodeDetailAction,
|
||||||
|
uiState: NodeDetailUiState,
|
||||||
|
navigateToMessages: (String) -> Unit,
|
||||||
|
onNavigateUp: () -> Unit,
|
||||||
|
onNavigate: (Route) -> Unit,
|
||||||
|
viewModel: NodeDetailViewModel,
|
||||||
|
) {
|
||||||
|
when (action) {
|
||||||
|
is NodeDetailAction.Navigate -> onNavigate(action.route)
|
||||||
|
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
|
||||||
|
is NodeDetailAction.HandleNodeMenuAction -> {
|
||||||
|
when (val menuAction = action.action) {
|
||||||
|
is NodeMenuAction.DirectMessage -> {
|
||||||
|
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
|
||||||
|
navigateToMessages(route)
|
||||||
|
}
|
||||||
|
is NodeMenuAction.Remove -> {
|
||||||
|
viewModel.handleNodeMenuAction(menuAction)
|
||||||
|
onNavigateUp()
|
||||||
|
}
|
||||||
|
else -> viewModel.handleNodeMenuAction(menuAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,14 +23,13 @@ import dev.mokkery.verify
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Test
|
|
||||||
import org.meshtastic.core.model.Node
|
import org.meshtastic.core.model.Node
|
||||||
import org.meshtastic.core.model.RadioController
|
import org.meshtastic.core.model.RadioController
|
||||||
import org.meshtastic.core.repository.NodeRepository
|
import org.meshtastic.core.repository.NodeRepository
|
||||||
import org.meshtastic.core.repository.ServiceRepository
|
import org.meshtastic.core.repository.ServiceRepository
|
||||||
import org.meshtastic.core.ui.util.AlertManager
|
import org.meshtastic.core.ui.util.AlertManager
|
||||||
import org.meshtastic.proto.User
|
import org.meshtastic.proto.User
|
||||||
|
import kotlin.test.Test
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class NodeManagementActionsTest {
|
class NodeManagementActionsTest {
|
||||||
|
|
@ -51,7 +50,7 @@ class NodeManagementActionsTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `requestRemoveNode shows confirmation alert`() {
|
fun requestRemoveNode_shows_confirmation_alert() {
|
||||||
val node = Node(num = 123, user = User(long_name = "Test Node"))
|
val node = Node(num = 123, user = User(long_name = "Test Node"))
|
||||||
|
|
||||||
actions.requestRemoveNode(testScope, node)
|
actions.requestRemoveNode(testScope, node)
|
||||||
|
|
@ -70,11 +69,4 @@ class NodeManagementActionsTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `requestFavoriteNode shows confirmation alert`() = runTest(testDispatcher) {
|
|
||||||
// This test might fail due to getString() not being mocked easily
|
|
||||||
// but let's see if we can at least get requestRemoveNode passing.
|
|
||||||
// Actually, if getString() fails, the coroutine will fail.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2026 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
@ -16,17 +16,17 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.node.metrics
|
package org.meshtastic.feature.node.metrics
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.meshtastic.core.common.util.nowSeconds
|
import org.meshtastic.core.common.util.nowSeconds
|
||||||
import org.meshtastic.proto.EnvironmentMetrics
|
import org.meshtastic.proto.EnvironmentMetrics
|
||||||
import org.meshtastic.proto.Telemetry
|
import org.meshtastic.proto.Telemetry
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class EnvironmentMetricsStateTest {
|
class EnvironmentMetricsStateTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `environmentMetricsForGraphing correctly calculates times`() {
|
fun environmentMetricsForGraphing_correctly_calculates_times() {
|
||||||
val now = nowSeconds.toInt()
|
val now = nowSeconds.toInt()
|
||||||
val metrics =
|
val metrics =
|
||||||
listOf(
|
listOf(
|
||||||
|
|
@ -42,7 +42,7 @@ class EnvironmentMetricsStateTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `environmentMetricsForGraphing handles valid zero temperatures`() {
|
fun environmentMetricsForGraphing_handles_valid_zero_temperatures() {
|
||||||
val now = nowSeconds.toInt()
|
val now = nowSeconds.toInt()
|
||||||
val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f)))
|
val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f)))
|
||||||
val state = EnvironmentMetricsState(metrics)
|
val state = EnvironmentMetricsState(metrics)
|
||||||
|
|
@ -23,8 +23,6 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.meshtastic.core.navigation.Route
|
import org.meshtastic.core.navigation.Route
|
||||||
import org.meshtastic.feature.node.compass.CompassViewModel
|
import org.meshtastic.feature.node.compass.CompassViewModel
|
||||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
|
||||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun NodeDetailScreen(
|
actual fun NodeDetailScreen(
|
||||||
|
|
@ -45,24 +43,14 @@ actual fun NodeDetailScreen(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onAction = { action ->
|
onAction = { action ->
|
||||||
when (action) {
|
handleNodeAction(
|
||||||
is NodeDetailAction.Navigate -> onNavigate(action.route)
|
action = action,
|
||||||
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
|
uiState = uiState,
|
||||||
is NodeDetailAction.HandleNodeMenuAction -> {
|
navigateToMessages = navigateToMessages,
|
||||||
when (val menuAction = action.action) {
|
onNavigateUp = onNavigateUp,
|
||||||
is NodeMenuAction.DirectMessage -> {
|
onNavigate = onNavigate,
|
||||||
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
|
viewModel = viewModel,
|
||||||
navigateToMessages(route)
|
)
|
||||||
}
|
|
||||||
is NodeMenuAction.Remove -> {
|
|
||||||
viewModel.handleNodeMenuAction(menuAction)
|
|
||||||
onNavigateUp()
|
|
||||||
}
|
|
||||||
else -> viewModel.handleNodeMenuAction(menuAction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onFirmwareSelect = { /* No-op on desktop for now */ },
|
onFirmwareSelect = { /* No-op on desktop for now */ },
|
||||||
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
|
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
|
||||||
|
|
|
||||||
|
|
@ -16,21 +16,10 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.node.metrics
|
package org.meshtastic.feature.node.metrics
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import org.meshtastic.core.ui.component.PlaceholderScreen
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
PlaceholderScreen(name = "Position Log")
|
||||||
Text(
|
|
||||||
text = "Position Log",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,27 +16,11 @@
|
||||||
*/
|
*/
|
||||||
package org.meshtastic.feature.node.navigation
|
package org.meshtastic.feature.node.navigation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import org.meshtastic.core.ui.component.PlaceholderScreen
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
|
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
|
||||||
// Desktop placeholder for now
|
// Desktop placeholder for now
|
||||||
PlaceholderScreen(name = "Traceroute Map")
|
PlaceholderScreen(name = "Traceroute Map")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun PlaceholderScreen(name: String) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text(
|
|
||||||
text = name,
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package org.meshtastic.feature.settings.radio
|
package org.meshtastic.feature.settings.radio
|
||||||
|
|
||||||
import dev.mokkery.MockMode
|
import dev.mokkery.MockMode
|
||||||
|
import dev.mokkery.answering.returns
|
||||||
import dev.mokkery.everySuspend
|
import dev.mokkery.everySuspend
|
||||||
import dev.mokkery.matcher.any
|
import dev.mokkery.matcher.any
|
||||||
import dev.mokkery.mock
|
import dev.mokkery.mock
|
||||||
|
|
@ -58,7 +59,7 @@ class CleanNodeDatabaseViewModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `getNodesToDelete updates state`() = runTest {
|
fun getNodesToDelete_updates_state() = runTest {
|
||||||
val nodes = listOf(Node(num = 1), Node(num = 2))
|
val nodes = listOf(Node(num = 1), Node(num = 2))
|
||||||
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
|
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
|
||||||
|
|
||||||
|
|
@ -69,7 +70,7 @@ class CleanNodeDatabaseViewModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `cleanNodes calls useCase and clears state`() = runTest {
|
fun cleanNodes_calls_useCase_and_clears_state() = runTest {
|
||||||
val nodes = listOf(Node(num = 1))
|
val nodes = listOf(Node(num = 1))
|
||||||
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
|
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
|
||||||
viewModel.getNodesToDelete()
|
viewModel.getNodesToDelete()
|
||||||
|
|
@ -20,7 +20,6 @@ import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
|
@ -36,7 +35,6 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
import org.jetbrains.compose.resources.StringResource
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.meshtastic.core.common.util.nowMillis
|
import org.meshtastic.core.common.util.nowMillis
|
||||||
import org.meshtastic.core.common.util.toDate
|
import org.meshtastic.core.common.util.toDate
|
||||||
|
|
@ -46,23 +44,18 @@ import org.meshtastic.core.navigation.Route
|
||||||
import org.meshtastic.core.navigation.SettingsRoutes
|
import org.meshtastic.core.navigation.SettingsRoutes
|
||||||
import org.meshtastic.core.resources.Res
|
import org.meshtastic.core.resources.Res
|
||||||
import org.meshtastic.core.resources.bottom_nav_settings
|
import org.meshtastic.core.resources.bottom_nav_settings
|
||||||
import org.meshtastic.core.resources.choose_theme
|
|
||||||
import org.meshtastic.core.resources.dynamic
|
|
||||||
import org.meshtastic.core.resources.export_configuration
|
import org.meshtastic.core.resources.export_configuration
|
||||||
import org.meshtastic.core.resources.import_configuration
|
import org.meshtastic.core.resources.import_configuration
|
||||||
import org.meshtastic.core.resources.preferences_language
|
import org.meshtastic.core.resources.preferences_language
|
||||||
import org.meshtastic.core.resources.remotely_administrating
|
import org.meshtastic.core.resources.remotely_administrating
|
||||||
import org.meshtastic.core.resources.theme_dark
|
|
||||||
import org.meshtastic.core.resources.theme_light
|
|
||||||
import org.meshtastic.core.resources.theme_system
|
|
||||||
import org.meshtastic.core.ui.component.ListItem
|
import org.meshtastic.core.ui.component.ListItem
|
||||||
import org.meshtastic.core.ui.component.MainAppBar
|
import org.meshtastic.core.ui.component.MainAppBar
|
||||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||||
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
|
|
||||||
import org.meshtastic.feature.settings.component.AppInfoSection
|
import org.meshtastic.feature.settings.component.AppInfoSection
|
||||||
import org.meshtastic.feature.settings.component.AppearanceSection
|
import org.meshtastic.feature.settings.component.AppearanceSection
|
||||||
import org.meshtastic.feature.settings.component.PersistenceSection
|
import org.meshtastic.feature.settings.component.PersistenceSection
|
||||||
import org.meshtastic.feature.settings.component.PrivacySection
|
import org.meshtastic.feature.settings.component.PrivacySection
|
||||||
|
import org.meshtastic.feature.settings.component.ThemePickerDialog
|
||||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||||
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
||||||
import org.meshtastic.feature.settings.radio.RadioConfigItemList
|
import org.meshtastic.feature.settings.radio.RadioConfigItemList
|
||||||
|
|
@ -269,28 +262,3 @@ private fun LanguagePickerDialog(onDismiss: () -> Unit) {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class ThemeOption(val label: StringResource, val mode: Int) {
|
|
||||||
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
|
|
||||||
LIGHT(label = Res.string.theme_light, mode = AppCompatDelegate.MODE_NIGHT_NO),
|
|
||||||
DARK(label = Res.string.theme_dark, mode = AppCompatDelegate.MODE_NIGHT_YES),
|
|
||||||
SYSTEM(label = Res.string.theme_system, mode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM),
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
|
|
||||||
MeshtasticDialog(
|
|
||||||
title = stringResource(Res.string.choose_theme),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
ThemeOption.entries.forEach { option ->
|
|
||||||
ListItem(text = stringResource(option.label), trailingIcon = null) {
|
|
||||||
onClickTheme(option.mode)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -57,32 +57,7 @@ private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: L
|
||||||
}
|
}
|
||||||
|
|
||||||
context.contentResolver.openOutputStream(targetUri)?.use { os ->
|
context.contentResolver.openOutputStream(targetUri)?.use { os ->
|
||||||
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer ->
|
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) }
|
||||||
logs.forEach { log ->
|
|
||||||
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
|
|
||||||
writer.write(log.logMessage)
|
|
||||||
log.decodedPayload?.let { decodedPayload ->
|
|
||||||
if (decodedPayload.isNotBlank()) {
|
|
||||||
writer.write("\n\nDecoded Payload:\n{\n")
|
|
||||||
// Redact Decoded keys.
|
|
||||||
decodedPayload.lineSequence().forEach { line ->
|
|
||||||
var outputLine = line
|
|
||||||
val redacted = redactedKeys.firstOrNull { line.contains(it) }
|
|
||||||
if (redacted != null) {
|
|
||||||
val idx = line.indexOf(':')
|
|
||||||
if (idx != -1) {
|
|
||||||
outputLine = line.take(idx + 1)
|
|
||||||
outputLine += "<redacted>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writer.write(outputLine)
|
|
||||||
writer.write("\n")
|
|
||||||
}
|
|
||||||
writer.write("}\n\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Logger.i { "MeshLog exported successfully to $targetUri" }
|
Logger.i { "MeshLog exported successfully to $targetUri" }
|
||||||
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_success, logs.size) }
|
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_success, logs.size) }
|
||||||
|
|
@ -91,5 +66,3 @@ private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: L
|
||||||
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, e.message ?: "") }
|
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, e.message ?: "") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val redactedKeys = listOf("session_passkey", "private_key", "admin_key")
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
/*
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
@file:Suppress("MatchingDeclarationName")
|
||||||
|
|
||||||
|
package org.meshtastic.feature.settings.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import org.jetbrains.compose.resources.StringResource
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.meshtastic.core.resources.Res
|
||||||
|
import org.meshtastic.core.resources.choose_theme
|
||||||
|
import org.meshtastic.core.resources.dynamic
|
||||||
|
import org.meshtastic.core.resources.theme_dark
|
||||||
|
import org.meshtastic.core.resources.theme_light
|
||||||
|
import org.meshtastic.core.resources.theme_system
|
||||||
|
import org.meshtastic.core.ui.component.ListItem
|
||||||
|
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||||
|
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
|
||||||
|
|
||||||
|
/** Theme modes that match AppCompatDelegate constants for cross-platform use. */
|
||||||
|
enum class ThemeOption(val label: StringResource, val mode: Int) {
|
||||||
|
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
|
||||||
|
LIGHT(label = Res.string.theme_light, mode = 1), // AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
DARK(label = Res.string.theme_dark, mode = 2), // AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
SYSTEM(label = Res.string.theme_system, mode = -1), // AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared dialog for picking a theme option. Used by both Android and Desktop settings screens. */
|
||||||
|
@Composable
|
||||||
|
fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
MeshtasticDialog(
|
||||||
|
title = stringResource(Res.string.choose_theme),
|
||||||
|
onDismiss = onDismiss,
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
ThemeOption.entries.forEach { option ->
|
||||||
|
ListItem(text = stringResource(option.label), trailingIcon = null) {
|
||||||
|
onClickTheme(option.mode)
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2026 Meshtastic LLC
|
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* 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
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
|
|
||||||
|
|
@ -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.feature.settings.debugging
|
||||||
|
|
||||||
|
internal val redactedKeys = listOf("session_passkey", "private_key", "admin_key")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a list of [DebugViewModel.UiMeshLog] entries into the given [Appendable], redacting sensitive keys in decoded
|
||||||
|
* payloads.
|
||||||
|
*/
|
||||||
|
internal fun formatLogsTo(out: Appendable, logs: List<DebugViewModel.UiMeshLog>) {
|
||||||
|
logs.forEach { log ->
|
||||||
|
out.append("${log.formattedReceivedDate} [${log.messageType}]\n")
|
||||||
|
out.append(log.logMessage)
|
||||||
|
val decodedPayload = log.decodedPayload
|
||||||
|
if (!decodedPayload.isNullOrBlank()) {
|
||||||
|
appendRedactedPayload(out, decodedPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendRedactedPayload(out: Appendable, payload: String) {
|
||||||
|
out.append("\n\nDecoded Payload:\n{\n")
|
||||||
|
payload.lineSequence().forEach { line ->
|
||||||
|
out.append(redactLine(line))
|
||||||
|
out.append("\n")
|
||||||
|
}
|
||||||
|
out.append("}\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun redactLine(line: String): String {
|
||||||
|
if (redactedKeys.none { line.contains(it) }) return line
|
||||||
|
val idx = line.indexOf(':')
|
||||||
|
return if (idx != -1) line.take(idx + 1) + "<redacted>" else line
|
||||||
|
}
|
||||||
|
|
@ -17,15 +17,17 @@
|
||||||
package org.meshtastic.feature.settings.filter
|
package org.meshtastic.feature.settings.filter
|
||||||
|
|
||||||
import dev.mokkery.MockMode
|
import dev.mokkery.MockMode
|
||||||
|
import dev.mokkery.answering.returns
|
||||||
import dev.mokkery.every
|
import dev.mokkery.every
|
||||||
import dev.mokkery.matcher.any
|
import dev.mokkery.matcher.any
|
||||||
import dev.mokkery.mock
|
import dev.mokkery.mock
|
||||||
import dev.mokkery.verify
|
import dev.mokkery.verify
|
||||||
import org.junit.Assert.assertEquals
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.meshtastic.core.repository.FilterPrefs
|
import org.meshtastic.core.repository.FilterPrefs
|
||||||
import org.meshtastic.core.repository.MessageFilter
|
import org.meshtastic.core.repository.MessageFilter
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
class FilterSettingsViewModelTest {
|
class FilterSettingsViewModelTest {
|
||||||
|
|
||||||
|
|
@ -34,23 +36,23 @@ class FilterSettingsViewModelTest {
|
||||||
|
|
||||||
private lateinit var viewModel: FilterSettingsViewModel
|
private lateinit var viewModel: FilterSettingsViewModel
|
||||||
|
|
||||||
@Before
|
@BeforeTest
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
every { filterPrefs.filterEnabled.value } returns true
|
every { filterPrefs.filterEnabled } returns MutableStateFlow(true)
|
||||||
every { filterPrefs.filterWords.value } returns setOf("apple", "banana")
|
every { filterPrefs.filterWords } returns MutableStateFlow(setOf("apple", "banana"))
|
||||||
|
|
||||||
viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilter = messageFilter)
|
viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilter = messageFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `setFilterEnabled updates prefs and state`() {
|
fun setFilterEnabled_updates_prefs_and_state() {
|
||||||
viewModel.setFilterEnabled(false)
|
viewModel.setFilterEnabled(false)
|
||||||
verify { filterPrefs.setFilterEnabled(false) }
|
verify { filterPrefs.setFilterEnabled(false) }
|
||||||
assertEquals(false, viewModel.filterEnabled.value)
|
assertEquals(false, viewModel.filterEnabled.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `addFilterWord updates prefs and rebuilds patterns`() {
|
fun addFilterWord_updates_prefs_and_rebuilds_patterns() {
|
||||||
viewModel.addFilterWord("cherry")
|
viewModel.addFilterWord("cherry")
|
||||||
|
|
||||||
verify { filterPrefs.setFilterWords(any()) }
|
verify { filterPrefs.setFilterWords(any()) }
|
||||||
|
|
@ -59,7 +61,7 @@ class FilterSettingsViewModelTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `removeFilterWord updates prefs and rebuilds patterns`() {
|
fun removeFilterWord_updates_prefs_and_rebuilds_patterns() {
|
||||||
viewModel.removeFilterWord("apple")
|
viewModel.removeFilterWord("apple")
|
||||||
|
|
||||||
verify { filterPrefs.setFilterWords(any()) }
|
verify { filterPrefs.setFilterWords(any()) }
|
||||||
|
|
@ -42,7 +42,6 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.StringResource
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.meshtastic.core.database.DatabaseConstants
|
import org.meshtastic.core.database.DatabaseConstants
|
||||||
import org.meshtastic.core.navigation.Route
|
import org.meshtastic.core.navigation.Route
|
||||||
|
|
@ -52,28 +51,23 @@ import org.meshtastic.core.resources.acknowledgements
|
||||||
import org.meshtastic.core.resources.app_settings
|
import org.meshtastic.core.resources.app_settings
|
||||||
import org.meshtastic.core.resources.app_version
|
import org.meshtastic.core.resources.app_version
|
||||||
import org.meshtastic.core.resources.bottom_nav_settings
|
import org.meshtastic.core.resources.bottom_nav_settings
|
||||||
import org.meshtastic.core.resources.choose_theme
|
|
||||||
import org.meshtastic.core.resources.device_db_cache_limit
|
import org.meshtastic.core.resources.device_db_cache_limit
|
||||||
import org.meshtastic.core.resources.device_db_cache_limit_summary
|
import org.meshtastic.core.resources.device_db_cache_limit_summary
|
||||||
import org.meshtastic.core.resources.dynamic
|
|
||||||
import org.meshtastic.core.resources.info
|
import org.meshtastic.core.resources.info
|
||||||
import org.meshtastic.core.resources.modules_already_unlocked
|
import org.meshtastic.core.resources.modules_already_unlocked
|
||||||
import org.meshtastic.core.resources.modules_unlocked
|
import org.meshtastic.core.resources.modules_unlocked
|
||||||
import org.meshtastic.core.resources.preferences_language
|
import org.meshtastic.core.resources.preferences_language
|
||||||
import org.meshtastic.core.resources.remotely_administrating
|
import org.meshtastic.core.resources.remotely_administrating
|
||||||
import org.meshtastic.core.resources.theme
|
import org.meshtastic.core.resources.theme
|
||||||
import org.meshtastic.core.resources.theme_dark
|
|
||||||
import org.meshtastic.core.resources.theme_light
|
|
||||||
import org.meshtastic.core.resources.theme_system
|
|
||||||
import org.meshtastic.core.ui.component.DropDownPreference
|
import org.meshtastic.core.ui.component.DropDownPreference
|
||||||
import org.meshtastic.core.ui.component.ListItem
|
import org.meshtastic.core.ui.component.ListItem
|
||||||
import org.meshtastic.core.ui.component.MainAppBar
|
import org.meshtastic.core.ui.component.MainAppBar
|
||||||
import org.meshtastic.core.ui.component.MeshtasticDialog
|
import org.meshtastic.core.ui.component.MeshtasticDialog
|
||||||
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
|
|
||||||
import org.meshtastic.core.ui.util.rememberShowToastResource
|
import org.meshtastic.core.ui.util.rememberShowToastResource
|
||||||
import org.meshtastic.feature.settings.component.ExpressiveSection
|
import org.meshtastic.feature.settings.component.ExpressiveSection
|
||||||
import org.meshtastic.feature.settings.component.HomoglyphSetting
|
import org.meshtastic.feature.settings.component.HomoglyphSetting
|
||||||
import org.meshtastic.feature.settings.component.NotificationSection
|
import org.meshtastic.feature.settings.component.NotificationSection
|
||||||
|
import org.meshtastic.feature.settings.component.ThemePickerDialog
|
||||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||||
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
||||||
import org.meshtastic.feature.settings.radio.RadioConfigItemList
|
import org.meshtastic.feature.settings.radio.RadioConfigItemList
|
||||||
|
|
@ -291,31 +285,6 @@ private fun DesktopAppVersionButton(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class ThemeOption(val label: StringResource, val mode: Int) {
|
|
||||||
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
|
|
||||||
LIGHT(label = Res.string.theme_light, mode = 1), // MODE_NIGHT_NO
|
|
||||||
DARK(label = Res.string.theme_dark, mode = 2), // MODE_NIGHT_YES
|
|
||||||
SYSTEM(label = Res.string.theme_system, mode = -1), // MODE_NIGHT_FOLLOW_SYSTEM
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
|
|
||||||
MeshtasticDialog(
|
|
||||||
title = stringResource(Res.string.choose_theme),
|
|
||||||
onDismiss = onDismiss,
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
ThemeOption.entries.forEach { option ->
|
|
||||||
ListItem(text = stringResource(option.label), trailingIcon = null) {
|
|
||||||
onClickTheme(option.mode)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supported languages — tag must match the CMP `values-<qualifier>` directory names. Empty tag means system default.
|
* Supported languages — tag must match the CMP `values-<qualifier>` directory names. Empty tag means system default.
|
||||||
* Display names are written in the native language for clarity.
|
* Display names are written in the native language for clarity.
|
||||||
|
|
|
||||||
|
|
@ -54,32 +54,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
|
||||||
val exportFile = File(directory, selectedFile)
|
val exportFile = File(directory, selectedFile)
|
||||||
try {
|
try {
|
||||||
FileOutputStream(exportFile).use { fos ->
|
FileOutputStream(exportFile).use { fos ->
|
||||||
OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer ->
|
OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) }
|
||||||
logs.forEach { log ->
|
|
||||||
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
|
|
||||||
writer.write(log.logMessage)
|
|
||||||
log.decodedPayload?.let { decodedPayload ->
|
|
||||||
if (decodedPayload.isNotBlank()) {
|
|
||||||
writer.write("\n\nDecoded Payload:\n{\n")
|
|
||||||
// Redact Decoded keys.
|
|
||||||
decodedPayload.lineSequence().forEach { line ->
|
|
||||||
var outputLine = line
|
|
||||||
val redacted = redactedKeys.firstOrNull { line.contains(it) }
|
|
||||||
if (redacted != null) {
|
|
||||||
val idx = line.indexOf(':')
|
|
||||||
if (idx != -1) {
|
|
||||||
outputLine = line.take(idx + 1)
|
|
||||||
outputLine += "<redacted>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
writer.write(outputLine)
|
|
||||||
writer.write("\n")
|
|
||||||
}
|
|
||||||
writer.write("}\n\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Logger.i { "MeshLog exported successfully to ${exportFile.absolutePath}" }
|
Logger.i { "MeshLog exported successfully to ${exportFile.absolutePath}" }
|
||||||
} catch (e: java.io.IOException) {
|
} catch (e: java.io.IOException) {
|
||||||
|
|
@ -92,5 +67,3 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val redactedKeys = listOf("session_passkey", "private_key", "admin_key")
|
|
||||||
|
|
|
||||||
|
|
@ -1,140 +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.feature.settings
|
|
||||||
|
|
||||||
import dev.mokkery.MockMode
|
|
||||||
import dev.mokkery.every
|
|
||||||
import dev.mokkery.mock
|
|
||||||
import dev.mokkery.verify
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.meshtastic.core.common.BuildConfigProvider
|
|
||||||
import org.meshtastic.core.common.database.DatabaseManager
|
|
||||||
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
|
|
||||||
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
|
|
||||||
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
|
|
||||||
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
|
|
||||||
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
|
|
||||||
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
|
|
||||||
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
|
|
||||||
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
|
|
||||||
import org.meshtastic.core.model.RadioController
|
|
||||||
import org.meshtastic.core.repository.MeshLogPrefs
|
|
||||||
import org.meshtastic.core.repository.NodeRepository
|
|
||||||
import org.meshtastic.core.repository.RadioConfigRepository
|
|
||||||
import org.meshtastic.core.repository.UiPrefs
|
|
||||||
import org.robolectric.annotation.Config
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@Config(sdk = [34])
|
|
||||||
class LegacySettingsViewModelTest {
|
|
||||||
|
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
|
||||||
|
|
||||||
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
|
||||||
private val radioController: RadioController = mock(MockMode.autofill)
|
|
||||||
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
|
||||||
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
|
|
||||||
private val buildConfigProvider: BuildConfigProvider = mock(MockMode.autofill)
|
|
||||||
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
|
|
||||||
private val meshLogPrefs: MeshLogPrefs = mock(MockMode.autofill)
|
|
||||||
|
|
||||||
private lateinit var setThemeUseCase: SetThemeUseCase
|
|
||||||
private lateinit var setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase
|
|
||||||
private lateinit var setProvideLocationUseCase: SetProvideLocationUseCase
|
|
||||||
private lateinit var setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase
|
|
||||||
private lateinit var setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase
|
|
||||||
private lateinit var meshLocationUseCase: MeshLocationUseCase
|
|
||||||
private lateinit var exportDataUseCase: ExportDataUseCase
|
|
||||||
private lateinit var isOtaCapableUseCase: IsOtaCapableUseCase
|
|
||||||
|
|
||||||
private lateinit var viewModel: SettingsViewModel
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
Dispatchers.setMain(testDispatcher)
|
|
||||||
|
|
||||||
setThemeUseCase = mock(MockMode.autofill)
|
|
||||||
setAppIntroCompletedUseCase = mock(MockMode.autofill)
|
|
||||||
setProvideLocationUseCase = mock(MockMode.autofill)
|
|
||||||
setDatabaseCacheLimitUseCase = mock(MockMode.autofill)
|
|
||||||
setMeshLogSettingsUseCase = mock(MockMode.autofill)
|
|
||||||
meshLocationUseCase = mock(MockMode.autofill)
|
|
||||||
exportDataUseCase = mock(MockMode.autofill)
|
|
||||||
isOtaCapableUseCase = mock(MockMode.autofill)
|
|
||||||
|
|
||||||
// Return real StateFlows to avoid ClassCastException
|
|
||||||
every { databaseManager.cacheLimit } returns MutableStateFlow(100)
|
|
||||||
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
|
|
||||||
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
|
|
||||||
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig())
|
|
||||||
every { radioController.connectionState } returns
|
|
||||||
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
|
|
||||||
every { isOtaCapableUseCase() } returns flowOf(false)
|
|
||||||
|
|
||||||
viewModel =
|
|
||||||
SettingsViewModel(
|
|
||||||
app = mock(),
|
|
||||||
radioConfigRepository = radioConfigRepository,
|
|
||||||
radioController = radioController,
|
|
||||||
nodeRepository = nodeRepository,
|
|
||||||
uiPrefs = uiPrefs,
|
|
||||||
buildConfigProvider = buildConfigProvider,
|
|
||||||
databaseManager = databaseManager,
|
|
||||||
meshLogPrefs = meshLogPrefs,
|
|
||||||
setThemeUseCase = setThemeUseCase,
|
|
||||||
setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
|
|
||||||
setProvideLocationUseCase = setProvideLocationUseCase,
|
|
||||||
setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase,
|
|
||||||
setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
|
|
||||||
meshLocationUseCase = meshLocationUseCase,
|
|
||||||
exportDataUseCase = exportDataUseCase,
|
|
||||||
isOtaCapableUseCase = isOtaCapableUseCase,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `setTheme calls useCase`() {
|
|
||||||
viewModel.setTheme(1)
|
|
||||||
verify { setThemeUseCase(1) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `setDbCacheLimit calls useCase`() {
|
|
||||||
viewModel.setDbCacheLimit(50)
|
|
||||||
verify { setDatabaseCacheLimitUseCase(50) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `startProvidingLocation calls useCase`() {
|
|
||||||
viewModel.startProvidingLocation()
|
|
||||||
verify { meshLocationUseCase.startProvidingLocation() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -9,15 +9,12 @@ androidxTracing = "1.10.5"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
glance = "1.2.0-rc01"
|
glance = "1.2.0-rc01"
|
||||||
lifecycle = "2.10.0"
|
lifecycle = "2.10.0"
|
||||||
jetbrains-lifecycle = "2.10.0"
|
jetbrains-lifecycle = "2.11.0-alpha01"
|
||||||
navigation = "2.9.7"
|
|
||||||
navigation3 = "1.1.0-alpha04"
|
navigation3 = "1.1.0-alpha04"
|
||||||
navigationevent = "1.0.1"
|
navigationevent = "1.1.0-alpha01"
|
||||||
paging = "3.4.2"
|
paging = "3.4.2"
|
||||||
room = "3.0.0-alpha01"
|
room = "3.0.0-alpha01"
|
||||||
savedstate = "1.4.0"
|
|
||||||
koin = "4.2.0"
|
koin = "4.2.0"
|
||||||
koin-annotations = "2.1.0"
|
|
||||||
koin-plugin = "0.4.1"
|
koin-plugin = "0.4.1"
|
||||||
|
|
||||||
# Kotlin
|
# Kotlin
|
||||||
|
|
@ -96,14 +93,11 @@ androidx-glance-material3 = { module = "androidx.glance:glance-material3", versi
|
||||||
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
|
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle" }
|
||||||
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
androidx-lifecycle-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "lifecycle" }
|
androidx-lifecycle-testing = { module = "androidx.lifecycle:lifecycle-runtime-testing", version.ref = "lifecycle" }
|
||||||
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
|
||||||
# JetBrains KMP lifecycle (use in commonMain and androidMain)
|
# JetBrains KMP lifecycle (use in commonMain and androidMain)
|
||||||
jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" }
|
jetbrains-lifecycle-runtime = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime", version.ref = "jetbrains-lifecycle" }
|
||||||
jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrains-lifecycle" }
|
jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "jetbrains-lifecycle" }
|
||||||
jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" }
|
jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "jetbrains-lifecycle" }
|
||||||
jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" }
|
jetbrains-lifecycle-viewmodel-navigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "jetbrains-lifecycle" }
|
||||||
# AndroidX Navigation (legacy nav-compose; Android-only nav utilities)
|
|
||||||
androidx-navigation-common = { module = "androidx.navigation:navigation-common", version.ref = "navigation" }
|
|
||||||
# JetBrains Navigation 3 currently publishes `navigation3-ui` (no separate `navigation3-runtime` artifact).
|
# JetBrains Navigation 3 currently publishes `navigation3-ui` (no separate `navigation3-runtime` artifact).
|
||||||
# Both `jetbrains-navigation3-runtime` and `jetbrains-navigation3-ui` resolve to the same coordinate.
|
# Both `jetbrains-navigation3-runtime` and `jetbrains-navigation3-ui` resolve to the same coordinate.
|
||||||
jetbrains-navigation3-runtime = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
|
jetbrains-navigation3-runtime = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
|
||||||
|
|
@ -116,8 +110,6 @@ androidx-room-paging = { module = "androidx.room3:room3-paging", version.ref = "
|
||||||
androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" }
|
androidx-room-runtime = { module = "androidx.room3:room3-runtime", version.ref = "room" }
|
||||||
androidx-room-testing = { module = "androidx.room3:room3-testing", version.ref = "room" }
|
androidx-room-testing = { module = "androidx.room3:room3-testing", version.ref = "room" }
|
||||||
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = "2.6.2" }
|
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = "2.6.2" }
|
||||||
androidx-savedstate-compose = { module = "androidx.savedstate:savedstate-compose", version.ref = "savedstate" }
|
|
||||||
androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" }
|
|
||||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
|
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
|
||||||
androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.1" }
|
androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.1" }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue