refactor: Remove AIDL API and modernize service architecture (#5586)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-03 21:28:17 -05:00
committed by James Rich
parent ed4cbe3b54
commit fe5012b742
265 changed files with 4203 additions and 6750 deletions

View File

@@ -1,51 +0,0 @@
name: Publish Core Libraries
on:
release:
types: [created]
workflow_dispatch:
inputs:
version_suffix:
description: 'Version suffix (e.g. -alpha01, -SNAPSHOT)'
required: false
default: '-SNAPSHOT'
jobs:
publish:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Configure Version
id: version
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
VERSION_SUFFIX: ${{ inputs.version_suffix }}
run: |
if [[ "$EVENT_NAME" == "release" ]]; then
echo "VERSION_NAME=$RELEASE_TAG" >> $GITHUB_ENV
else
# Use a timestamp-based version for manual/branch builds to avoid collisions
# or use the base version + suffix
BASE_VERSION=$(grep "VERSION_NAME_BASE" config.properties | cut -d'=' -f2)
echo "VERSION_NAME=${BASE_VERSION}${VERSION_SUFFIX}" >> $GITHUB_ENV
fi
- name: Publish to GitHub Packages
run: ./gradlew :core:api:publish :core:model:publish :core:proto:publish
env:
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -113,7 +113,7 @@ jobs:
- name: Lint, Analysis & KMP Smoke Compile
if: inputs.run_lint == true
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug kmpSmokeCompile -Pci=true --continue
- name: KMP Smoke Compile (lint skipped)
if: inputs.run_lint == false

View File

@@ -5,7 +5,7 @@ Module directory, namespacing conventions, environment setup, and troubleshootin
- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26.
- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics)
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`.
- **Android-only Modules:** `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`.
## Codebase Map
@@ -28,7 +28,6 @@ Module directory, namespacing conventions, environment setup, and troubleshootin
| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies. `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence. |
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
| `core:barcode` | Barcode scanning (Android-only). |
| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. |

View File

@@ -17,7 +17,7 @@ Run in a single invocation for routine changes to ensure code formatting, analys
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP plugin.
> Conversely, `allTests` does **not** cover pure-Android modules (`:androidApp`, `:core:api`, etc.), which is why both `test` and `allTests` are needed.
> Conversely, `allTests` does **not** cover pure-Android modules (`:androidApp`, `:core:barcode`, etc.), which is why both `test` and `allTests` are needed.
*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*

View File

@@ -87,7 +87,6 @@ Each module has its own README with details on its responsibilities, API surface
| Module | Description |
|---|---|
| [core/api](core/api/README.md) | AIDL service API for third-party integrations |
| [core/domain](core/domain/README.md) | Business-logic use cases (radio config, sessions, exports) |
| [core/repository](core/repository/README.md) | Data & infrastructure contracts (RadioTransport, NodeRepository, ServiceRepository) |
| [core/takserver](core/takserver/README.md) | Meshtastic ↔ TAK (ATAK/iTAK) bridge — CoT server & conversion |
@@ -123,13 +122,9 @@ Each module has its own README with details on its responsibilities, API surface
You can help translate the app into your native language using [Crowdin](https://crowdin.meshtastic.org/android).
## API & Integration
## Integration
Developers can integrate with the Meshtastic Android app using our published API library via **JitPack**. This allows third-party applications (like the ATAK plugin) to communicate with the mesh service via AIDL.
For detailed integration instructions, see [core/api/README.md](core/api/README.md).
Additionally, the app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh.
The app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh.
## Building the Android App
> [!WARNING]

View File

@@ -112,7 +112,7 @@ configure<ApplicationExtension> {
),
)
}
ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") }
ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a") }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -126,7 +126,7 @@ configure<ApplicationExtension> {
abi {
isEnable = !disableSplits
reset()
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true
}
}

View File

@@ -127,8 +127,8 @@ public abstract class MarkerClusterer extends Overlay {
int zoomLevel = mapView.getZoomLevel();
if (zoomLevel != mLastZoomLevel && !mapView.isAnimating()){
hideInfoWindows();
mClusters = clusterer(mapView);
renderer(mClusters, canvas, mapView);
mClusters = clusterer(mapView);
renderer(mClusters, canvas, mapView);
mLastZoomLevel = zoomLevel;
}

View File

@@ -27,6 +27,8 @@ import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.MotionEvent;
import androidx.core.content.res.ResourcesCompat;
import org.meshtastic.app.map.model.MarkerWithLabel;
import org.osmdroid.bonuspack.R;
@@ -72,7 +74,7 @@ public class RadiusMarkerClusterer extends MarkerClusterer {
mTextPaint.setFakeBoldText(true);
mTextPaint.setTextAlign(Paint.Align.CENTER);
mTextPaint.setAntiAlias(true);
Drawable clusterIconD = ctx.getResources().getDrawable(R.drawable.marker_cluster);
Drawable clusterIconD = ResourcesCompat.getDrawable(ctx.getResources(), R.drawable.marker_cluster, ctx.getTheme());
Bitmap clusterIcon = ((BitmapDrawable) clusterIconD).getBitmap();
setIcon(clusterIcon);
mAnimated = true;

View File

@@ -52,6 +52,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -87,6 +88,7 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
@@ -113,9 +115,11 @@ import org.meshtastic.core.resources.map_purge_success
import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.now
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.resources.waypoint_delete
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BasicListItem
@@ -239,6 +243,9 @@ fun MapView(
val haptic = LocalHapticFeedback.current
fun performHapticFeedback() = haptic.performHapticFeedback(HapticFeedbackType.LongPress)
val unknownText = stringResource(Res.string.unknown)
val nowText = stringResource(Res.string.now)
// Accompanist permissions state for location
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
@@ -355,36 +362,37 @@ fun MapView(
val (p, u) = node.position to node.user
val nodePosition = GeoPoint(node.latitude, node.longitude)
MarkerWithLabel(mapView = this, label = "${u.short_name} ${formatAgo(p.time)}").apply {
id = u.id
title = u.long_name
snippet =
getString(
Res.string.map_node_popup_details,
node.gpsString(),
formatAgo(node.lastHeard),
formatAgo(p.time),
if (node.batteryStr != "") node.batteryStr else "?",
)
ourNode?.distanceStr(node, displayUnits)?.let { dist ->
ourNode.bearing(node)?.let { bearing ->
subDescription = getString(Res.string.map_subDescription, bearing, dist)
MarkerWithLabel(mapView = this, label = "${u.short_name} ${formatAgo(p.time, unknownText, nowText)}")
.apply {
id = u.id
title = u.long_name
snippet =
getString(
Res.string.map_node_popup_details,
node.gpsString(),
formatAgo(node.lastHeard, unknownText, nowText),
formatAgo(p.time, unknownText, nowText),
if (node.batteryStr != "") node.batteryStr else "?",
)
ourNode?.distanceStr(node, displayUnits)?.let { dist ->
ourNode.bearing(node)?.let { bearing ->
subDescription = getString(Res.string.map_subDescription, bearing, dist)
}
}
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
position = nodePosition
icon = markerIcon
setNodeColors(node.colors)
if (!mapFilterStateValue.showPrecisionCircle) {
setPrecisionBits(0)
} else {
setPrecisionBits(p.precision_bits)
}
setOnLongClickListener {
navigateToNodeDetails(node.num)
true
}
}
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
position = nodePosition
icon = markerIcon
setNodeColors(node.colors)
if (!mapFilterStateValue.showPrecisionCircle) {
setPrecisionBits(0)
} else {
setPrecisionBits(p.precision_bits)
}
setOnLongClickListener {
navigateToNodeDetails(node.num)
true
}
}
}
}
@@ -433,7 +441,7 @@ fun MapView(
}
}
fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) {
fun getUsername(id: String?) = if (id == NodeAddress.ID_LOCAL || (myId != null && id == myId)) {
getString(Res.string.you)
} else {
mapViewModel.getUser(id).long_name
@@ -446,7 +454,7 @@ fun MapView(
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else ""
val time = DateFormatter.formatDateTime(waypoint.time)
val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt())
val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt(), unknownText, nowText)
val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
val now = nowMillis
val expireTimeMillis = pt.expire * 1000L
@@ -818,15 +826,15 @@ private fun FdroidMainMapFilterDropdown(
@Composable
private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) {
val selected = remember { mutableStateOf(selectedMapStyle) }
val selected = remember { mutableIntStateOf(selectedMapStyle) }
MapsDialog(onDismiss = onDismiss) {
CustomTileSource.mTileSources.values.forEachIndexed { index, style ->
ListItem(
text = style,
trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null,
trailingIcon = if (index == selected.intValue) MeshtasticIcons.Check else null,
onClick = {
selected.value = index
selected.intValue = index
onSelectMapStyle(index)
onDismiss()
},
@@ -879,7 +887,7 @@ private fun PurgeTileSourceDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val cache = SqlTileWriterExt()
val sourceList by derivedStateOf { cache.sources.map { it.source as String } }
val sourceList by remember { derivedStateOf { cache.sources.map { it.source as String } } }
val selected = remember { mutableStateListOf<Int>() }

View File

@@ -22,11 +22,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.LocalConfig

View File

@@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.R
import org.meshtastic.app.map.MapViewModel
@@ -44,6 +45,9 @@ import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.app.map.zoomIn
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.now
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.feature.map.tracerouteNodeSelection
@@ -95,6 +99,9 @@ fun TracerouteOsmMap(
val displayNodes = tracerouteSelection.nodesForMarkers
val nodeLookup = tracerouteSelection.nodeLookup
val unknownText = stringResource(Res.string.unknown)
val nowText = stringResource(Res.string.now)
// Report mappable count
LaunchedEffect(tracerouteOverlay, displayNodes) {
if (tracerouteOverlay != null) {
@@ -191,7 +198,10 @@ fun TracerouteOsmMap(
displayNodes.forEach { node ->
val position = GeoPoint(node.latitude, node.longitude)
val marker =
MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}")
MarkerWithLabel(
mapView = map,
label = "${node.user.short_name} ${formatAgo(node.position.time, unknownText, nowText)}",
)
.apply {
id = node.user.id
title = node.user.long_name

View File

@@ -50,11 +50,12 @@ import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
@@ -670,8 +671,7 @@ class MapViewModel(
(currentTileProvider as? MBTilesProvider)?.close()
}
override fun getUser(userId: String?) =
nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
override fun getUser(userId: String?) = nodeRepository.getUser(userId ?: NodeAddress.ID_BROADCAST)
}
enum class LayerType {

View File

@@ -67,7 +67,6 @@
-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- We run our mesh code as a foreground service - FIXME, find a way to stop doing this -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
@@ -157,16 +156,12 @@
android:name="google_analytics_default_allow_analytics_storage"
android:value="false" />
<!-- This is the public API for doing mesh radio operations from android apps -->
<!-- In-app mesh foreground service -->
<service
android:name="org.meshtastic.core.service.MeshService"
android:enabled="true"
android:foregroundServiceType="connectedDevice|location"
android:exported="true" tools:ignore="ExportedActivity">
<intent-filter>
<action android:name="com.geeksville.mesh.Service" />
</intent-filter>
</service>
android:exported="false" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
@@ -268,7 +263,7 @@
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
<!-- for testing -->
<action android:name="com.geeksville.mesh.SIM_BOOT" />
<action android:name="org.meshtastic.app.SIM_BOOT" />
</intent-filter>
<!-- Also restart our service if the app gets upgraded -->

View File

@@ -63,7 +63,8 @@ import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.service.MeshServiceClient
import org.meshtastic.core.service.MeshService
import org.meshtastic.core.service.startService
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
@@ -95,18 +96,9 @@ class MainActivity : AppCompatActivity() {
private val usbRepository: UsbRepository by inject()
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
*/
internal val meshServiceClient: MeshServiceClient by inject { parametersOf(this) }
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
// Eagerly evaluate lazy Koin dependency so it registers its LifecycleObserver
meshServiceClient.hashCode()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -168,6 +160,11 @@ class MainActivity : AppCompatActivity() {
handleIntent(intent)
}
override fun onStart() {
super.onStart()
MeshService.startService(this)
}
override fun onResume() {
super.onResume()
// Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is

View File

@@ -111,7 +111,7 @@ class NetworkModule {
if (buildConfigProvider.isDebug) {
install(plugin = Logging) {
logger = KermitHttpLogger
level = LogLevel.BODY
level = LogLevel.INFO
}
}
}

View File

@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
@@ -57,12 +58,13 @@ fun MainScreen() {
val viewModel: UIViewModel = koinViewModel()
// Land on Connections for first-run / no-device-selected; otherwise on Nodes. Read synchronously
// from the StateFlow (seeded from persisted prefs) so the initial tab is set in one shot.
val initialTab =
val initialTab = remember {
if (viewModel.currentDeviceAddressFlow.value.isNullOrSelectedNone()) {
TopLevelDestination.Connect.route
} else {
NodesRoute.Nodes
}
}
val multiBackstack = rememberMultiBackstack(initialTab)
val backStack = multiBackstack.activeBackStack

View File

@@ -19,7 +19,7 @@ package org.meshtastic.app.service
import dev.mokkery.MockMode
import dev.mokkery.mock
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Telemetry
@@ -28,7 +28,7 @@ class Fakes {
val service: RadioInterfaceService = mock(MockMode.autofill)
}
class FakeMeshServiceNotifications : MeshServiceNotifications {
class FakeMeshNotificationManager : MeshNotificationManager {
override fun clearNotifications() {}
override fun initChannels() {}

View File

@@ -199,11 +199,6 @@ gradlePlugin {
implementationClass = "org.meshtastic.buildlogic.DocsTasks"
}
register("publishing") {
id = "meshtastic.publishing"
implementationClass = "PublishingConventionPlugin"
}
register("aboutLibraries") {
id = "meshtastic.aboutlibraries"
implementationClass = "AboutLibrariesConventionPlugin"

View File

@@ -53,7 +53,7 @@ class AndroidRoomConventionPlugin : Plugin<Project> {
dependencies { add("kspJvm", roomCompiler) }
}
pluginManager.withPlugin("org.jetbrains.kotlin.android") {
pluginManager.withPlugin("com.android.library") {
val hasAndroidTest = projectDir.resolve("src/androidTest").exists()
dependencies {
"implementation"(roomRuntime)

View File

@@ -48,7 +48,7 @@ class KoinConventionPlugin : Plugin<Project> {
}
}
pluginManager.withPlugin("org.jetbrains.kotlin.android") {
pluginManager.withPlugin("com.android.application") {
// If this is *only* an Android module (no KMP plugin)
if (!pluginManager.hasPlugin("org.jetbrains.kotlin.multiplatform")) {
dependencies {
@@ -58,6 +58,16 @@ class KoinConventionPlugin : Plugin<Project> {
}
}
pluginManager.withPlugin("com.android.library") {
// If this is *only* an Android library module (no KMP plugin)
if (!pluginManager.hasPlugin("org.jetbrains.kotlin.multiplatform")) {
dependencies {
add("implementation", koinCore)
add("implementation", koinAnnotations)
}
}
}
pluginManager.withPlugin("org.jetbrains.kotlin.jvm") {
// If this is *only* a JVM module (no KMP plugin)
if (!pluginManager.hasPlugin("org.jetbrains.kotlin.multiplatform")) {

View File

@@ -36,10 +36,16 @@ class KotlinXSerializationConventionPlugin : Plugin<Project> {
}
}
pluginManager.withPlugin("org.jetbrains.kotlin.android") {
pluginManager.withPlugin("com.android.application") {
dependencies { "implementation"(serializationLib) }
}
pluginManager.withPlugin("com.android.library") {
if (!pluginManager.hasPlugin("org.jetbrains.kotlin.multiplatform")) {
dependencies { "implementation"(serializationLib) }
}
}
pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { dependencies { "implementation"(serializationLib) } }
}
}

View File

@@ -1,57 +0,0 @@
/*
* 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/>.
*/
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.publish.PublishingExtension
import org.gradle.kotlin.dsl.configure
import org.meshtastic.buildlogic.configProperties
class PublishingConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("maven-publish")
group = "org.meshtastic"
if (version == "unspecified") {
version =
providers.environmentVariable("VERSION").orNull
?: providers.environmentVariable("VERSION_NAME").orNull
?: configProperties.getProperty("VERSION_NAME_BASE")
?: "0.0.0-SNAPSHOT"
}
val githubActor = providers.environmentVariable("GITHUB_ACTOR")
val githubToken = providers.environmentVariable("GITHUB_TOKEN")
if (githubActor.isPresent && githubToken.isPresent) {
extensions.configure<PublishingExtension> {
repositories {
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/meshtastic/Meshtastic-Android")
credentials {
username = githubActor.get()
password = githubToken.get()
}
}
}
}
}
}
}
}

View File

@@ -81,7 +81,6 @@ private val DEVICE_TEST_MODULES = listOf(":core:database", ":core:model")
private val ALL_MODULES_FULL =
listOf(
":androidApp",
":core:api",
":core:barcode",
":core:ble",
":core:common",
@@ -114,7 +113,7 @@ private val ALL_MODULES_FULL =
)
/** Android-only modules that don't apply the KMP plugin. */
private val ANDROID_ONLY_MODULES = setOf(":androidApp", ":core:api", ":core:barcode", ":feature:widget")
private val ANDROID_ONLY_MODULES = setOf(":androidApp", ":core:barcode", ":feature:widget")
/**
* Modules excluded from Dokka aggregation. Empty now that :core:proto has been replaced by
@@ -126,6 +125,6 @@ private fun allModules(): List<String> = ALL_MODULES_FULL
/**
* Modules that apply the KMP plugin and should be compiled for JVM + iOS targets. Excludes pure-Android modules
* (:androidApp, :core:api, :core:barcode, :feature:widget) and the desktop JVM-only module.
* (:androidApp, :core:barcode, :feature:widget) and the desktop JVM-only module.
*/
private fun kmpModules(): List<String> = allModules().filter { it !in ANDROID_ONLY_MODULES + ":desktopApp" }

View File

@@ -20,7 +20,6 @@ import org.gradle.api.Project
import org.gradle.api.provider.Provider
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension
import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag
internal fun Project.configureComposeCompiler() {
extensions.configure<ComposeCompilerGradlePluginExtension> {
@@ -40,6 +39,5 @@ internal fun Project.configureComposeCompiler() {
.relativeToRootProject("compose-reports")
.let(reportsDestination::set)
stabilityConfigurationFiles.add(isolated.rootProject.projectDirectory.file("compose_compiler_config.conf"))
featureFlags.add(ComposeFeatureFlag.OptimizeNonSkippingGroups)
}
}

View File

@@ -22,9 +22,6 @@ import com.android.build.api.dsl.KotlinMultiplatformAndroidLibraryTarget
import dev.mokkery.gradle.MokkeryGradleExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Project
import org.gradle.api.tasks.testing.Test
import org.gradle.jvm.toolchain.JavaLanguageVersion
import org.gradle.jvm.toolchain.JavaToolchainService
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.findByType
import org.gradle.kotlin.dsl.withType
@@ -53,9 +50,8 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
defaultConfig.targetSdk = targetSdkVersion
}
val javaVersion = if (project.name in PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21
compileOptions.sourceCompatibility = javaVersion
compileOptions.targetCompatibility = javaVersion
compileOptions.sourceCompatibility = JavaVersion.VERSION_21
compileOptions.targetCompatibility = JavaVersion.VERSION_21
testOptions.animationsDisabled = true
testOptions.unitTests.isReturnDefaultValues = true
@@ -222,9 +218,6 @@ internal fun Project.configureKotlinJvm() {
configureKotlin<KotlinJvmProjectExtension>()
}
/** Modules published for external consumers — use Java 17 for broader compatibility. */
private val PUBLISHED_MODULES = setOf("api", "model", "proto")
/** Compiler args shared across all Kotlin targets (JVM, Android, iOS, etc.). */
private val SHARED_COMPILER_ARGS =
listOf(
@@ -237,18 +230,12 @@ private val SHARED_COMPILER_ARGS =
"-Xbackend-threads=0",
)
private const val PUBLISHED_MODULE_JDK = 17
private const val APP_JDK = 21
private const val JDK_VERSION = 21
/** Configure base Kotlin options */
private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
val isPublishedModule = project.name in PUBLISHED_MODULES
extensions.configure<T> {
// Using Java 17 for published modules for better compatibility with consumers (e.g. plugins, older
// environments), and Java 21 for the rest of the app.
val javaVersion = if (isPublishedModule) PUBLISHED_MODULE_JDK else APP_JDK
jvmToolchain(javaVersion)
jvmToolchain(JDK_VERSION)
if (this is KotlinMultiplatformExtension) {
targets.configureEach {
@@ -256,9 +243,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
compilations.configureEach {
compileTaskProvider.configure {
compilerOptions {
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
if (isJvmTarget) {
freeCompilerArgs.add("-jvm-default=no-compatibility")
@@ -274,28 +259,16 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
tasks.withType<KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21)
jvmTarget.set(JvmTarget.JVM_21)
allWarningsAsErrors.set(warningsAsErrors)
// For non-KMP modules, configure compiler args here since they don't use targets.compilations.
// KMP modules already set these via the targets block above — only jvmTarget/warnings needed here.
if (T::class != KotlinMultiplatformExtension::class) {
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
freeCompilerArgs.add("-jvm-default=no-compatibility")
}
}
}
// Published modules compile to JVM 17 for binary compatibility, but their test runtime
// classpath includes non-published dependencies compiled to JVM 21. Override the test
// launcher to JDK 21 so the JVM can load all class file versions at runtime.
if (isPublishedModule) {
val toolchains = extensions.getByType(JavaToolchainService::class.java)
tasks.withType<Test>().configureEach {
javaLauncher.set(toolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(APP_JDK)) })
}
}
}

View File

@@ -26,7 +26,6 @@ plugins {
alias(libs.plugins.firebase.crashlytics) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.room) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.parcelize) apply false

View File

@@ -61,8 +61,6 @@ component_management:
ignore:
- "**/build/**"
- "**/*.pb.kt" # Generated Protobuf code
- "**/*.aidl" # AIDL interface files
- "**/aidl/**" # Generated AIDL code
- "core/resources/**" # Centralized resources
- "**/test/**" # Unit tests
- "**/androidTest/**" # Instrumented tests

View File

@@ -1,79 +0,0 @@
# `:core:api` (Meshtastic Android API)
> **Deprecation notice**
>
> The AIDL-based service integration (`IMeshService`) is deprecated and will be removed in a future
> release. The recommended integration path for ATAK and other external apps is the built-in
> **Local TAK Server** introduced in `core:takserver`. Connect ATAK to `127.0.0.1:8087` (TCP) and
> import the DataPackage exported from the TAK Config screen to complete setup. No AIDL binding or
> JitPack dependency is required.
## Overview
The `:core:api` module contains the AIDL interface and dependencies for third-party applications
that currently integrate with the Meshtastic Android app via service binding. New integrations
should use the Local TAK Server instead (see deprecation notice above).
## Integration
To communicate with the Meshtastic Android service from your own application, we recommend using **JitPack**.
### Dependencies
Add the following to your `build.gradle.kts`:
```kotlin
dependencies {
// The core AIDL interface and Intent constants
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-api:v2.x.x")
// Data models (DataPacket, MeshUser, NodeInfo, etc.) - Kotlin Multiplatform
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-model:v2.x.x")
// Protobuf definitions (PortNum, Telemetry, etc.) - Kotlin Multiplatform
implementation("com.github.meshtastic.Meshtastic-Android:meshtastic-android-proto:v2.x.x")
}
```
*(Replace `v2.x.x` with the latest stable version).*
## Usage
### 1. Bind to the Service
Use the `IMeshService` interface to bind to the Meshtastic service.
```kotlin
val intent = Intent("com.geeksville.mesh.Service")
// ... query package manager and bind
```
### 2. Interact with the API
Once bound, cast the `IBinder` to `IMeshService`.
### 3. Register a BroadcastReceiver
Use `MeshtasticIntent` constants for actions. Remember to use `RECEIVER_EXPORTED` on Android 13+.
## Key Components
- **`IMeshService.aidl`**: The primary AIDL interface.
- **`MeshtasticIntent.kt`**: Defines Intent actions for received messages and status changes.
## Module dependency graph
<!--region graph-->
```mermaid
graph TB
:core:api[api]:::android-library
:core:api --> :core:model
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
<!--endregion-->

View File

@@ -1,52 +0,0 @@
/*
* 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/>.
*/
plugins {
alias(libs.plugins.meshtastic.android.library)
id("meshtastic.publishing")
}
configure<com.android.build.api.dsl.LibraryExtension> {
namespace = "org.meshtastic.core.api"
buildFeatures { aidl = true }
defaultConfig {
// Lowering minSdk to 21 for better compatibility with ATAK and other plugins
minSdk = 21
}
publishing { singleVariant("release") { withSourcesJar() } }
}
// Suppress dep-ann warnings from AIDL-generated code where Javadoc @deprecated
// doesn't produce @Deprecated annotations on Stub/Proxy override methods.
tasks.withType<JavaCompile>().configureEach { options.compilerArgs.add("-Xlint:-dep-ann") }
// Map the Android component to a Maven publication.
// afterEvaluate is required because AGP registers the "release" component lazily
// after the android.publishing.singleVariant("release") configuration runs.
afterEvaluate {
publishing {
publications {
register<MavenPublication>("release") {
from(components["release"])
artifactId = "meshtastic-android-api"
}
}
}
}
dependencies { api(projects.core.model) }

View File

@@ -1 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable DataPacket;

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable MeshUser;

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable MyNodeInfo;

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable NodeInfo;

View File

@@ -1,3 +0,0 @@
package org.meshtastic.core.model;
parcelable Position;

View File

@@ -1,207 +0,0 @@
package org.meshtastic.core.service;
// Declare any non-default types here with import statements
import org.meshtastic.core.model.DataPacket;
import org.meshtastic.core.model.NodeInfo;
import org.meshtastic.core.model.MeshUser;
import org.meshtastic.core.model.Position;
import org.meshtastic.core.model.MyNodeInfo;
/**
This is the public android API for talking to meshtastic radios.
@deprecated The AIDL service integration is deprecated and will be removed in a future release.
New integrations should connect via the built-in Local TAK Server on 127.0.0.1:8087 (TCP).
Import the DataPackage from the TAK Config screen in the Meshtastic app to configure ATAK.
To connect to meshtastic you should bind to it per https://developer.android.com/guide/components/bound-services
The intent you use to reach the service should ideally use the action string:
val intent = Intent("com.geeksville.mesh.Service")
Or if using an explicit intent:
val intent = Intent().apply {
setClassName(
"com.geeksville.mesh",
"org.meshtastic.core.service.MeshService"
)
}
In Android 11+ you *may* need to add the following to the client app's manifest to allow binding of the mesh service:
<queries>
<package android:name="com.geeksville.mesh" />
</queries>
For additional information, see https://developer.android.com/guide/topics/manifest/queries-element
Once you have bound to the service you should register your broadcast receivers per https://developer.android.com/guide/components/broadcasts#context-registered-receivers
// com.geeksville.mesh.x broadcast intents, where x is:
// RECEIVED.<portnumm> - will **only** deliver packets for the specified port number. If a wellknown portnums.proto name for portnum is known it will be used
// (i.e. com.geeksville.mesh.RECEIVED.TEXT_MESSAGE_APP) else the numeric portnum will be included as a base 10 integer (com.geeksville.mesh.RECEIVED.4403 etc...)
// NODE_CHANGE for new IDs appearing or disappearing
// CONNECTION_CHANGED for losing/gaining connection to the packet radio
// MESSAGE_STATUS_CHANGED for any message status changes (for sent messages only, payload will contain a message ID and a MessageStatus)
Note - these calls might throw RemoteException to indicate mesh error states
*/
interface IMeshService {
/// Tell the service where to send its broadcasts of received packets
/// This call is only required for manifest declared receivers. If your receiver is context-registered
/// you don't need this.
void subscribeReceiver(String packageName, String receiverName);
/**
* Set the user info for this node
*/
void setOwner(in MeshUser user);
void setRemoteOwner(in int requestId, in int destNum, in byte []payload);
void getRemoteOwner(in int requestId, in int destNum);
/// Return my unique user ID string
String getMyId();
/// Return a unique packet ID
int getPacketId();
/*
Send a packet to a specified node name
typ is defined in mesh.proto Data.Type. For now juse use 0 to mean opaque bytes.
destId can be null to indicate "broadcast message"
messageStatus and id of the provided message will be updated by this routine to indicate
message send status and the ID that can be used to locate the message in the future
*/
void send(inout DataPacket packet);
/**
Get the IDs of everyone on the mesh. You should also subscribe for NODE_CHANGE broadcasts.
*/
List<NodeInfo> getNodes();
/// This method is only intended for use in our GUI, so the user can set radio options
/// It returns a DeviceConfig protobuf.
byte []getConfig();
/// It sets a Config protobuf via admin packet
void setConfig(in byte []payload);
/// Set and get a Config protobuf via admin packet
void setRemoteConfig(in int requestId, in int destNum, in byte []payload);
void getRemoteConfig(in int requestId, in int destNum, in int configTypeValue);
/// Set and get a ModuleConfig protobuf via admin packet
void setModuleConfig(in int requestId, in int destNum, in byte []payload);
void getModuleConfig(in int requestId, in int destNum, in int moduleConfigTypeValue);
/// Set and get the Ext Notification Ringtone string via admin packet
void setRingtone(in int destNum, in String ringtone);
void getRingtone(in int requestId, in int destNum);
/// Set and get the Canned Message Messages string via admin packet
void setCannedMessages(in int destNum, in String messages);
void getCannedMessages(in int requestId, in int destNum);
/// This method is only intended for use in our GUI, so the user can set radio options
/// It sets a Channel protobuf via admin packet
void setChannel(in byte []payload);
/// Set and get a Channel protobuf via admin packet
void setRemoteChannel(in int requestId, in int destNum, in byte []payload);
void getRemoteChannel(in int requestId, in int destNum, in int channelIndex);
/// Send beginEditSettings admin packet to nodeNum
void beginEditSettings(in int destNum);
/// Send commitEditSettings admin packet to nodeNum
void commitEditSettings(in int destNum);
/// delete a specific nodeNum from nodeDB
void removeByNodenum(in int requestID, in int nodeNum);
/// Send position packet with wantResponse to nodeNum
void requestPosition(in int destNum, in Position position);
/// Send setFixedPosition admin packet (or removeFixedPosition if Position is empty)
void setFixedPosition(in int destNum, in Position position);
/// Send traceroute packet with wantResponse to nodeNum
void requestTraceroute(in int requestId, in int destNum);
/// Send neighbor info packet with wantResponse to nodeNum
void requestNeighborInfo(in int requestId, in int destNum);
/// Send Shutdown admin packet to nodeNum
void requestShutdown(in int requestId, in int destNum);
/// Send Reboot admin packet to nodeNum
void requestReboot(in int requestId, in int destNum);
/// Send FactoryReset admin packet to nodeNum
void requestFactoryReset(in int requestId, in int destNum);
/// Send reboot to DFU admin packet
void rebootToDfu(in int destNum);
/// Send NodedbReset admin packet to nodeNum
void requestNodedbReset(in int requestId, in int destNum, in boolean preserveFavorites);
/// Returns a ChannelSet protobuf
byte []getChannelSet();
/**
Is the packet radio currently connected to the phone? Returns a ConnectionState string.
*/
String connectionState();
/**
* @deprecated For internal use only. External callers must not invoke this method;
* it will be removed from the public API in a future release.
*/
boolean setDeviceAddress(String deviceAddr);
/// Get basic device hardware info about our connected radio. Will never return NULL. Will return NULL
/// if no my node info is available (i.e. it will not throw an exception)
MyNodeInfo getMyNodeInfo();
/**
* @deprecated No-op stub — firmware update is now handled entirely by the in-app OTA system.
* This method will be removed from the public API in a future release.
*/
void startFirmwareUpdate();
/**
* @deprecated Always returns {@code -4}, which is outside the documented range.
* Firmware update progress is now tracked internally by the in-app OTA system.
* This method will be removed from the public API in a future release.
*/
int getUpdateStatus();
/// Start providing location (from phone GPS) to mesh
void startProvideLocation();
/// Stop providing location (from phone GPS) to mesh
void stopProvideLocation();
/// Send request for node UserInfo
void requestUserInfo(in int destNum);
/// Request device connection status from the radio
void getDeviceConnectionStatus(in int requestId, in int destNum);
/// Send request for telemetry to nodeNum
void requestTelemetry(in int requestId, in int destNum, in int type);
/**
* Tell the node to reboot into OTA mode for firmware update via BLE or WiFi (ESP32 only)
* mode is 1 for BLE, 2 for WiFi
* hash is the 32-byte firmware SHA256 hash (optional, can be null)
*/
void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash);
}

View File

@@ -1,85 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.api
import org.meshtastic.core.api.MeshtasticIntent.EXTRA_CONNECTED
import org.meshtastic.core.api.MeshtasticIntent.EXTRA_NODEINFO
import org.meshtastic.core.api.MeshtasticIntent.EXTRA_PACKET_ID
import org.meshtastic.core.api.MeshtasticIntent.EXTRA_STATUS
/**
* Constants for Meshtastic Android Intents. These are used by external applications to communicate with the Meshtastic
* service.
*/
object MeshtasticIntent {
private const val PREFIX = "com.geeksville.mesh"
/** Broadcast when a node's information changes. Extra: [EXTRA_NODEINFO] */
const val ACTION_NODE_CHANGE = "$PREFIX.NODE_CHANGE"
/** Broadcast when the mesh radio connects. Extra: [EXTRA_CONNECTED] */
const val ACTION_MESH_CONNECTED = "$PREFIX.MESH_CONNECTED"
/** Broadcast when the mesh radio disconnects. */
const val ACTION_MESH_DISCONNECTED = "$PREFIX.MESH_DISCONNECTED"
/**
* Legacy broadcast for connection changes. Extra: [EXTRA_CONNECTED]
*
* Prefer [ACTION_MESH_CONNECTED] / [ACTION_MESH_DISCONNECTED] instead. This constant will be removed from the
* public API in a future release.
*/
@Deprecated(
message = "Use ACTION_MESH_CONNECTED / ACTION_MESH_DISCONNECTED instead.",
replaceWith = ReplaceWith("ACTION_MESH_CONNECTED"),
)
const val ACTION_CONNECTION_CHANGED = "$PREFIX.CONNECTION_CHANGED"
/** Broadcast for message status updates. Extras: [EXTRA_PACKET_ID], [EXTRA_STATUS] */
const val ACTION_MESSAGE_STATUS = "$PREFIX.MESSAGE_STATUS"
/** Received a text message. */
const val ACTION_RECEIVED_TEXT_MESSAGE_APP = "$PREFIX.RECEIVED.TEXT_MESSAGE_APP"
/** Received a position update. */
const val ACTION_RECEIVED_POSITION_APP = "$PREFIX.RECEIVED.POSITION_APP"
/** Received node info. */
const val ACTION_RECEIVED_NODEINFO_APP = "$PREFIX.RECEIVED.NODEINFO_APP"
/** Received telemetry data. */
const val ACTION_RECEIVED_TELEMETRY_APP = "$PREFIX.RECEIVED.TELEMETRY_APP"
/** Received ATAK Plugin data. */
const val ACTION_RECEIVED_ATAK_PLUGIN = "$PREFIX.RECEIVED.ATAK_PLUGIN"
/** Received ATAK Forwarder data. */
const val ACTION_RECEIVED_ATAK_FORWARDER = "$PREFIX.RECEIVED.ATAK_FORWARDER"
/** Received detection sensor data. */
const val ACTION_RECEIVED_DETECTION_SENSOR_APP = "$PREFIX.RECEIVED.DETECTION_SENSOR_APP"
/** Received private app data. */
const val ACTION_RECEIVED_PRIVATE_APP = "$PREFIX.RECEIVED.PRIVATE_APP"
// standard EXTRA bundle definitions
const val EXTRA_CONNECTED = "$PREFIX.Connected"
const val EXTRA_PAYLOAD = "$PREFIX.Payload"
const val EXTRA_NODEINFO = "$PREFIX.NodeInfo"
const val EXTRA_PACKET_ID = "$PREFIX.PacketId"
const val EXTRA_STATUS = "$PREFIX.Status"
}

View File

@@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -56,13 +55,14 @@ import co.touchlab.kermit.Logger
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.BarcodeScanner
import java.util.concurrent.Executors
@Composable
fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner {
@@ -179,11 +179,9 @@ private fun ScannerReticule() {
private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
val cameraExecutor = remember { Dispatchers.Default.asExecutor() }
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
DisposableEffect(Unit) { onDispose { cameraExecutor.shutdown() } }
LaunchedEffect(Unit) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener(

View File

@@ -17,7 +17,6 @@
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.kotlin.parcelize)
id("meshtastic.kmp.jvm.android")
id("meshtastic.koin")
}

View File

@@ -20,32 +20,14 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import android.os.Parcel
import android.os.Parcelable
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import androidx.core.os.ParcelCompat
/** Reads a [Parcelable] from a [Parcel] in a backward-compatible way. */
inline fun <reified T : Parcelable> Parcel.readParcelableCompat(loader: ClassLoader?): T? =
ParcelCompat.readParcelable(this, loader, T::class.java)
/** Retrieves a [Parcelable] extra from an [Intent] in a backward-compatible way. */
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String?): T? =
IntentCompat.getParcelableExtra(this, key, T::class.java)
/** Retrieves [PackageInfo] for a given package name in a backward-compatible way. */
fun PackageManager.getPackageInfoCompat(packageName: String, flags: Int = 0): PackageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(flags.toLong()))
} else {
@Suppress("DEPRECATION")
getPackageInfo(packageName, flags)
}
/** Registers a [BroadcastReceiver] using [ContextCompat] to ensure consistent behavior across Android versions. */
fun Context.registerReceiverCompat(
receiver: BroadcastReceiver,

View File

@@ -1,34 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.os.RemoteException
import co.touchlab.kermit.Logger
/**
* Wraps an operation and converts any thrown exceptions into [RemoteException] for safe return through an AIDL
* interface.
*/
fun <T> toRemoteExceptions(inner: () -> T): T = try {
inner()
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Uncaught exception in service call, returning RemoteException to client" }
when (ex) {
is RemoteException -> throw ex
else -> throw RemoteException(ex.message).apply { initCause(ex) }
}
}

View File

@@ -1,31 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
import android.os.Parcelable
actual typealias CommonParcelable = Parcelable
actual typealias CommonParcelize = kotlinx.parcelize.Parcelize
actual typealias CommonIgnoredOnParcel = kotlinx.parcelize.IgnoredOnParcel
actual typealias CommonParceler<T> = kotlinx.parcelize.Parceler<T>
actual typealias CommonTypeParceler<T, P> = kotlinx.parcelize.TypeParceler<T, P>
actual typealias CommonParcel = android.os.Parcel

View File

@@ -1,58 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/** Platform-agnostic Parcelable interface. */
expect interface CommonParcelable
/** Platform-agnostic Parcelize annotation. */
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
expect annotation class CommonParcelize()
/** Platform-agnostic IgnoredOnParcel annotation. */
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
expect annotation class CommonIgnoredOnParcel()
/** Platform-agnostic Parceler interface. */
expect interface CommonParceler<T> {
fun create(parcel: CommonParcel): T
fun T.write(parcel: CommonParcel, flags: Int)
}
/** Platform-agnostic TypeParceler annotation. */
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
expect annotation class CommonTypeParceler<T, P : CommonParceler<in T>>()
/** Platform-agnostic Parcel representation for manual parceling (e.g. AIDL support). */
expect class CommonParcel {
fun readString(): String?
fun readInt(): Int
fun readLong(): Long
fun readFloat(): Float
fun createByteArray(): ByteArray?
fun writeByteArray(b: ByteArray?)
}

View File

@@ -45,38 +45,3 @@ actual fun currentLocaleCode(): String = "en"
actual fun currentLocaleQualifier(): String = "en"
actual fun String?.isValidAddress(): Boolean = false
actual interface CommonParcelable
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
actual annotation class CommonParcelize actual constructor()
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
actual annotation class CommonIgnoredOnParcel actual constructor()
actual interface CommonParceler<T> {
actual fun create(parcel: CommonParcel): T
actual fun T.write(parcel: CommonParcel, flags: Int)
}
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
actual annotation class CommonTypeParceler<T, P : CommonParceler<in T>> actual constructor()
actual class CommonParcel {
actual fun readString(): String? = null
actual fun readInt(): Int = 0
actual fun readLong(): Long = 0L
actual fun readFloat(): Float = 0.0f
actual fun createByteArray(): ByteArray? = null
actual fun writeByteArray(b: ByteArray?) {}
}

View File

@@ -1,55 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
actual interface CommonParcelable
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
actual annotation class CommonParcelize
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
actual annotation class CommonIgnoredOnParcel
actual interface CommonParceler<T> {
actual fun create(parcel: CommonParcel): T
actual fun T.write(parcel: CommonParcel, flags: Int)
}
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
actual annotation class CommonTypeParceler<T, P : CommonParceler<in T>>
actual class CommonParcel {
actual fun readString(): String? = unsupportedParcelOperation()
actual fun readInt(): Int = unsupportedParcelOperation()
actual fun readLong(): Long = unsupportedParcelOperation()
actual fun readFloat(): Float = unsupportedParcelOperation()
actual fun createByteArray(): ByteArray? = unsupportedParcelOperation()
actual fun writeByteArray(b: ByteArray?) = unsupportedParcelOperation<Unit>()
}
private fun <T> unsupportedParcelOperation(): T =
error("CommonParcel is unavailable on JVM smoke targets. Manual parcel operations remain Android-only.")

View File

@@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeout
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -377,7 +377,7 @@ class AiFunctionProviderImpl(
val unreadCount = packetRepository.getUnreadCount(contactKey)
if (unreadCount <= 0) return@mapNotNull null
val isBroadcast = lastPacket.to == DataPacket.ID_BROADCAST
val isBroadcast = lastPacket.to == NodeAddress.ID_BROADCAST
val displayName =
if (isBroadcast) {
val channelIndex = contactKey.firstOrNull()?.digitToIntOrNull() ?: 0
@@ -420,7 +420,7 @@ class AiFunctionProviderImpl(
// Try node name first
when (val nodeResult = fuzzyNameResolver.resolveNodeName(name)) {
is NodeNameResult.Found -> {
val channelIndex = DataPacket.PKC_CHANNEL_INDEX
val channelIndex = NodeAddress.PKC_CHANNEL_INDEX
return "${channelIndex}${nodeResult.userId}"
}
@@ -433,7 +433,7 @@ class AiFunctionProviderImpl(
// Try channel name
return when (val channelResult = fuzzyNameResolver.resolveChannelName(name)) {
is ChannelNameResult.Found -> "${channelResult.channelIndex}${DataPacket.ID_BROADCAST}"
is ChannelNameResult.Found -> "${channelResult.channelIndex}${NodeAddress.ID_BROADCAST}"
is ChannelNameResult.Ambiguous -> null
is ChannelNameResult.NotFound -> null
}
@@ -457,7 +457,7 @@ class AiFunctionProviderImpl(
is NodeNameResult.Found -> {
// DM contact key format: channel_index + nodeId
// For PKC DMs, use channel index 8; for legacy use no channel prefix
val channelIndex = DataPacket.PKC_CHANNEL_INDEX
val channelIndex = NodeAddress.PKC_CHANNEL_INDEX
ResolvedContact.Resolved(
contactKey = "${channelIndex}${result.userId}",
channelName = "DM to $recipientName",
@@ -477,7 +477,7 @@ class AiFunctionProviderImpl(
return when (val result = fuzzyNameResolver.resolveChannelName(channelName)) {
is ChannelNameResult.Found ->
ResolvedContact.Resolved(
contactKey = "${result.channelIndex}${DataPacket.ID_BROADCAST}",
contactKey = "${result.channelIndex}${NodeAddress.ID_BROADCAST}",
channelName = result.name,
)
@@ -490,7 +490,7 @@ class AiFunctionProviderImpl(
// Default: broadcast on primary channel (index 0)
val channelSet = radioConfigRepository.channelSetFlow.first()
val primaryName = channelSet.settings.firstOrNull()?.name?.ifBlank { "Primary" } ?: "Primary"
return ResolvedContact.Resolved(contactKey = "0${DataPacket.ID_BROADCAST}", channelName = primaryName)
return ResolvedContact.Resolved(contactKey = "0${NodeAddress.ID_BROADCAST}", channelName = primaryName)
}
private sealed class ResolvedContact {

View File

@@ -29,6 +29,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
@@ -99,7 +100,7 @@ class CommandSenderImpl(
/**
* Resolves the correct channel index for sending a packet to [toNum].
*
* PKI encryption ([DataPacket.PKC_CHANNEL_INDEX]) is only used for **admin** packets, where end-to-end encryption
* PKI encryption ([NodeAddress.PKC_CHANNEL_INDEX]) is only used for **admin** packets, where end-to-end encryption
* is appropriate. Protocol-level requests (traceroute, telemetry, position, nodeinfo, neighborinfo) must NOT use
* PKI because relay nodes need to read and/or modify the inner payload (e.g. traceroute appends each hop's node
* number). These requests fall back to the node's heard-on channel.
@@ -112,7 +113,7 @@ class CommandSenderImpl(
return when {
myNum == toNum -> 0
myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX
myNode?.hasPKC == true && destNode?.hasPKC == true -> NodeAddress.PKC_CHANNEL_INDEX
else ->
channelSet.value.settings
@@ -127,7 +128,7 @@ class CommandSenderImpl(
*/
private fun getChannelIndex(toNum: Int): Int = nodeManager.nodeDBbyNodeNum[toNum]?.channel ?: 0
override fun sendData(p: DataPacket) {
override suspend fun sendData(p: DataPacket) {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes ?: ByteString.EMPTY
require(p.dataType != 0) { "Port numbers must be non-zero!" }
@@ -152,10 +153,10 @@ class CommandSenderImpl(
sendNow(p)
}
private fun sendNow(p: DataPacket) {
private suspend fun sendNow(p: DataPacket) {
val meshPacket =
buildMeshPacket(
to = resolveNodeNum(p.to ?: DataPacket.ID_BROADCAST),
to = resolveNodeNum(NodeAddress.fromString(p.to)),
id = p.id,
wantAck = p.wantAck,
hopLimit = if (p.hopLimit > 0) p.hopLimit else computeHopLimit(),
@@ -172,7 +173,7 @@ class CommandSenderImpl(
packetHandler.sendToRadio(meshPacket)
}
override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
override suspend fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
@@ -191,7 +192,7 @@ class CommandSenderImpl(
return packetHandler.sendToRadioAndAwait(packet)
}
override fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) {
override suspend fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) {
val myNum = nodeManager.myNodeNum.value ?: return
val idNum = destNum ?: myNum
Logger.d { "Sending our position/time to=$idNum $pos" }
@@ -215,7 +216,7 @@ class CommandSenderImpl(
)
}
override fun requestPosition(destNum: Int, currentPosition: Position) {
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
val meshPosition =
ProtoPosition(
latitude_i = Position.degI(currentPosition.latitude),
@@ -238,7 +239,7 @@ class CommandSenderImpl(
)
}
override fun setFixedPosition(destNum: Int, pos: Position) {
override suspend fun setFixedPosition(destNum: Int, pos: Position) {
val meshPos =
ProtoPosition(
latitude_i = Position.degI(pos.latitude),
@@ -255,7 +256,7 @@ class CommandSenderImpl(
nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum.value ?: 0, meshPos, nowMillis)
}
override fun requestUserInfo(destNum: Int) {
override suspend fun requestUserInfo(destNum: Int) {
val myNum = nodeManager.myNodeNum.value ?: return
val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
packetHandler.sendToRadio(
@@ -272,7 +273,7 @@ class CommandSenderImpl(
)
}
override fun requestTraceroute(requestId: Int, destNum: Int) {
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteHandler.recordStartTime(requestId)
packetHandler.sendToRadio(
buildMeshPacket(
@@ -285,7 +286,7 @@ class CommandSenderImpl(
)
}
override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
val portNum: PortNum
@@ -319,7 +320,7 @@ class CommandSenderImpl(
)
}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoHandler.recordStartTime(requestId)
val myNum = nodeManager.myNodeNum.value ?: 0
if (destNum == myNum) {
@@ -373,20 +374,16 @@ class CommandSenderImpl(
}
}
fun resolveNodeNum(toId: String): Int = when (toId) {
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
fun resolveNodeNum(address: NodeAddress): Int = when (address) {
NodeAddress.Broadcast -> NodeAddress.NODENUM_BROADCAST
else -> {
val numericNum =
if (toId.startsWith(NODE_ID_PREFIX)) {
toId.substring(NODE_ID_START_INDEX).toLongOrNull(HEX_RADIX)?.toInt()
} else {
null
}
numericNum
?: nodeManager.nodeDBbyID[toId]?.num
?: throw IllegalArgumentException("Unknown node ID $toId")
}
NodeAddress.Local -> nodeManager.myNodeNum.value ?: 0
is NodeAddress.ByNum -> address.num
is NodeAddress.ById ->
nodeManager.getNodeById(address.id)?.num
?: throw IllegalArgumentException("Unknown node ID ${address.id}")
}
private fun buildMeshPacket(
@@ -404,7 +401,7 @@ class CommandSenderImpl(
var publicKey: ByteString = ByteString.EMPTY
var actualChannel = channel
if (channel == DataPacket.PKC_CHANNEL_INDEX) {
if (channel == NodeAddress.PKC_CHANNEL_INDEX) {
pkiEncrypted = true
val destNode = nodeManager.nodeDBbyNodeNum[to]
// Resolve the public key using the same fallback as Node.hasPKC:
@@ -457,9 +454,6 @@ class CommandSenderImpl(
private const val PACKET_ID_SHIFT_BITS = 32
private const val ADMIN_CHANNEL_NAME = "admin"
private const val NODE_ID_PREFIX = "!"
private const val NODE_ID_START_INDEX = 1
private const val HEX_RADIX = 16
private const val DEFAULT_HOP_LIMIT = 3
}

View File

@@ -23,12 +23,14 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.core.repository.XModemManager
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.duplicated_public_key_title
@@ -43,8 +45,10 @@ import org.meshtastic.proto.FromRadio
/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@Single
class FromRadioPacketHandlerImpl(
private val serviceRepository: ServiceRepository,
private val router: Lazy<MeshRouter>,
private val serviceStateWriter: ServiceStateWriter,
private val configFlowManager: Lazy<MeshConfigFlowManager>,
private val configHandler: Lazy<MeshConfigHandler>,
private val xmodemManager: Lazy<XModemManager>,
private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
private val notificationManager: NotificationManager,
@@ -71,34 +75,34 @@ class FromRadioPacketHandlerImpl(
val xmodemPacket = proto.xmodemPacket
when {
myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo)
myInfo != null -> configFlowManager.value.handleMyInfo(myInfo)
// deviceuiConfig arrives immediately after my_info (STATE_SEND_UIDATA). It carries
// the device's display, theme, node-filter, and other UI preferences.
deviceUIConfig != null -> router.value.configHandler.handleDeviceUIConfig(deviceUIConfig)
deviceUIConfig != null -> configHandler.value.handleDeviceUIConfig(deviceUIConfig)
metadata != null -> router.value.configFlowManager.handleLocalMetadata(metadata)
metadata != null -> configFlowManager.value.handleLocalMetadata(metadata)
nodeInfo != null -> {
router.value.configFlowManager.handleNodeInfo(nodeInfo)
serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})")
configFlowManager.value.handleNodeInfo(nodeInfo)
serviceStateWriter.setConnectionProgress("Nodes (${configFlowManager.value.newNodeCount})")
}
configCompleteId != null -> router.value.configFlowManager.handleConfigComplete(configCompleteId)
configCompleteId != null -> configFlowManager.value.handleConfigComplete(configCompleteId)
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
config != null -> router.value.configHandler.handleDeviceConfig(config)
config != null -> configHandler.value.handleDeviceConfig(config)
moduleConfig != null -> router.value.configHandler.handleModuleConfig(moduleConfig)
moduleConfig != null -> configHandler.value.handleModuleConfig(moduleConfig)
channel != null -> router.value.configHandler.handleChannel(channel)
channel != null -> configHandler.value.handleChannel(channel)
fileInfo != null -> router.value.configFlowManager.handleFileInfo(fileInfo)
fileInfo != null -> configFlowManager.value.handleFileInfo(fileInfo)
xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket)
xmodemPacket != null -> xmodemManager.value.handleIncomingXModem(xmodemPacket)
clientNotification != null -> handleClientNotification(clientNotification)
@@ -106,13 +110,13 @@ class FromRadioPacketHandlerImpl(
// Re-handshake immediately rather than waiting for the 30s stall guard.
proto.rebooted != null -> {
Logger.w { "Firmware rebooted (rebooted=${proto.rebooted}), re-initiating handshake" }
router.value.configFlowManager.triggerWantConfig()
configFlowManager.value.triggerWantConfig()
}
}
}
private fun handleClientNotification(cn: ClientNotification) {
serviceRepository.setClientNotification(cn)
serviceStateWriter.setClientNotification(cn)
scope.handledLaunch {
val inform = cn.key_verification_number_inform

View File

@@ -68,7 +68,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
private fun activeDeviceAddress(): String? =
meshPrefs.deviceAddress.value?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
override fun requestHistoryReplay(
override suspend fun requestHistoryReplay(
trigger: String,
myNodeNum: Int?,
storeForwardConfig: ModuleConfig.StoreForwardConfig?,

View File

@@ -1,404 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.OTAMode
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Single
class MeshActionHandlerImpl(
private val nodeManager: NodeManager,
private val commandSender: CommandSender,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val dataHandler: Lazy<MeshDataHandler>,
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
private val uiPrefs: UiPrefs,
private val databaseManager: DatabaseManager,
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy<MeshMessageProcessor>,
private val radioConfigRepository: RadioConfigRepository,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshActionHandler {
companion object {
private const val DEFAULT_REBOOT_DELAY = 5
private const val EMOJI_INDICATOR = 1
}
override suspend fun onServiceAction(action: ServiceAction) {
Logger.d { "ServiceAction dispatched: ${action::class.simpleName}" }
ignoreExceptionSuspend {
val myNodeNum = nodeManager.myNodeNum.value
if (myNodeNum == null) {
Logger.w { "MeshActionHandlerImpl: myNodeNum is null, skipping ServiceAction!" }
if (action is ServiceAction.SendContact) {
action.result.complete(false)
}
return@ignoreExceptionSuspend
}
when (action) {
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)
is ServiceAction.Mute -> handleMute(action, myNodeNum)
is ServiceAction.Reaction -> handleReaction(action, myNodeNum)
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
val accepted =
safeCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)
action.result.complete(accepted)
}
is ServiceAction.GetDeviceMetadata -> {
commandSender.sendAdmin(action.destNum, wantResponse = true) {
AdminMessage(get_device_metadata_request = true)
}
}
}
}
}
private fun handleFavorite(action: ServiceAction.Favorite, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) {
if (node.isFavorite) {
AdminMessage(remove_favorite_node = node.num)
} else {
AdminMessage(set_favorite_node = node.num)
}
}
nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) }
}
private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) {
val node = action.node
val newIgnoredStatus = !node.isIgnored
commandSender.sendAdmin(myNodeNum) {
if (newIgnoredStatus) {
AdminMessage(set_ignored_node = node.num)
} else {
AdminMessage(remove_ignored_node = node.num)
}
}
nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnoredStatus) }
scope.handledLaunch { packetRepository.value.updateFilteredBySender(node.user.id, newIgnoredStatus) }
}
private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) }
nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) }
}
private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) {
val channel = action.contactKey[0].digitToInt()
val destId = action.contactKey.substring(1)
val dataPacket =
DataPacket(
to = destId,
dataType = PortNum.TEXT_MESSAGE_APP.value,
bytes = action.emoji.encodeToByteArray().toByteString(),
channel = channel,
replyId = action.replyId,
wantAck = true,
emoji = EMOJI_INDICATOR,
)
.apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL }
commandSender.sendData(dataPacket)
rememberReaction(action, dataPacket.id, myNodeNum)
}
private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) {
val verifiedContact = action.contact.copy(manually_verified = true)
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) }
nodeManager.handleReceivedUser(
verifiedContact.node_num,
verifiedContact.user ?: User(),
manuallyVerified = true,
)
}
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) {
scope.handledLaunch {
val user = nodeManager.nodeDBbyNodeNum[myNodeNum]?.user ?: User(id = nodeManager.getMyId())
val reaction =
Reaction(
replyId = action.replyId,
user = user,
emoji = action.emoji,
timestamp = nowMillis,
snr = 0f,
rssi = 0,
hopsAway = 0,
packetId = packetId,
status = MessageStatus.QUEUED,
to = action.contactKey.substring(1),
channel = action.contactKey[0].digitToInt(),
)
packetRepository.value.insertReaction(reaction, myNodeNum)
}
}
override fun handleSetOwner(u: MeshUser, myNodeNum: Int) {
Logger.d { "Setting owner: longName=${u.longName}, shortName=${u.shortName}" }
val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) }
nodeManager.handleReceivedUser(myNodeNum, newUser)
}
override fun handleSend(p: DataPacket, myNodeNum: Int) {
commandSender.sendData(p)
serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
dataHandler.value.rememberDataPacket(p, myNodeNum, false)
val bytes = p.bytes ?: ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
if (destNum != myNodeNum) {
val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value
val currentPosition =
when {
provideLocation && position.isValid() -> position
provideLocation ->
nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
?: Position(0.0, 0.0, 0)
else -> Position(0.0, 0.0, 0)
}
commandSender.requestPosition(destNum, currentPosition)
}
}
override fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
nodeManager.removeByNodenum(nodeNum)
commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) }
}
override fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
val u = User.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) }
nodeManager.handleReceivedUser(destNum, u)
}
override fun handleGetRemoteOwner(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) }
}
override fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) }
// Optimistically persist the config locally so CommandSender picks up
// the new values (e.g. hop_limit) immediately instead of waiting for
// the next want_config handshake.
scope.handledLaunch { radioConfigRepository.setLocalConfig(c) }
}
override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) }
// When targeting the local node, optimistically persist the config so the
// UI reflects changes immediately (matching handleSetConfig behaviour).
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.setLocalConfig(c) }
}
}
override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) {
AdminMessage(get_device_metadata_request = true)
} else {
AdminMessage(get_config_request = AdminMessage.ConfigType.fromValue(config))
}
}
}
override fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = ModuleConfig.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) }
c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) }
// Optimistically persist module config locally so the UI reflects the
// new values immediately instead of waiting for the next want_config handshake.
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(c) }
}
}
override fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config))
}
}
override fun handleSetRingtone(destNum: Int, ringtone: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) }
}
override fun handleGetRingtone(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) }
}
override fun handleSetCannedMessages(destNum: Int, messages: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) }
}
override fun handleGetCannedMessages(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_canned_message_module_messages_request = true)
}
}
override fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) }
// Optimistically persist the channel settings locally so the UI
// reflects changes immediately instead of waiting for the next
// want_config handshake.
scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) }
}
}
override fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) }
// When targeting the local node, optimistically persist the channel so
// the UI reflects changes immediately (matching handleSetChannel behaviour).
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) }
}
}
}
override fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) }
}
override fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
commandSender.requestNeighborInfo(requestId, destNum)
}
override fun handleBeginEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) }
}
override fun handleCommitEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) }
}
override fun handleRebootToDfu(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) }
}
override fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
commandSender.requestTelemetry(requestId, destNum, type)
}
override fun handleRequestShutdown(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) }
}
override fun handleRequestReboot(requestId: Int, destNum: Int) {
Logger.i { "Reboot requested for node $destNum" }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) }
}
override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
val otaEvent =
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY)
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
}
override fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
Logger.i { "Factory reset requested for node $destNum" }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) }
}
override fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) }
}
override fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId, wantResponse = true) {
AdminMessage(get_device_connection_status_request = true)
}
}
override fun handleUpdateLastAddress(deviceAddr: String?) {
val currentAddr = meshPrefs.deviceAddress.value
if (deviceAddr != currentAddr) {
Logger.i { "Device address changed, switching database and clearing node DB" }
meshPrefs.setDeviceAddress(deviceAddr)
scope.handledLaunch {
nodeManager.clear()
messageProcessor.value.clearEarlyPackets()
databaseManager.switchActiveDatabase(deviceAddr)
notificationManager.cancelAll()
nodeManager.loadCachedNodeDB()
}
}
}
}

View File

@@ -34,8 +34,7 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.FirmwareEdition
@@ -51,8 +50,7 @@ class MeshConfigFlowManagerImpl(
private val connectionManager: Lazy<MeshConnectionManager>,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val serviceStateWriter: ServiceStateWriter,
private val analytics: PlatformAnalytics,
private val commandSender: CommandSender,
private val heartbeatSender: DataLayerHeartbeatSender,
@@ -177,7 +175,7 @@ class MeshConfigFlowManagerImpl(
val entities =
state.nodes.mapNotNull { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
nodeManager.installNodeInfo(nodeInfo)
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
?: run {
Logger.w { "Node ${nodeInfo.num} missing from DB after installNodeInfo; skipping" }
@@ -190,8 +188,7 @@ class MeshConfigFlowManagerImpl(
analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown")
nodeManager.setNodeDbReady(true)
nodeManager.setAllowNodeDbWrites(true)
serviceRepository.setConnectionState(ConnectionState.Connected)
serviceBroadcasts.broadcastConnection()
serviceStateWriter.setConnectionState(ConnectionState.Connected)
connectionManager.value.onNodeDbReady()
}
}

View File

@@ -28,7 +28,7 @@ import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceUIConfig
@@ -39,7 +39,7 @@ import org.meshtastic.proto.ModuleConfig
@Single
class MeshConfigHandlerImpl(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val serviceStateWriter: ServiceStateWriter,
private val nodeManager: NodeManager,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConfigHandler {
@@ -58,13 +58,13 @@ class MeshConfigHandlerImpl(
override fun handleDeviceConfig(config: Config) {
Logger.d { "Device config received: ${config.summarize()}" }
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
serviceRepository.setConnectionProgress("Device config received")
serviceStateWriter.setConnectionProgress("Device config received")
}
override fun handleModuleConfig(config: ModuleConfig) {
Logger.d { "Module config received: ${config.summarize()}" }
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
serviceRepository.setConnectionProgress("Module config received")
serviceStateWriter.setConnectionProgress("Module config received")
config.statusmessage?.let { sm ->
nodeManager.myNodeNum.value?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) }
@@ -79,9 +79,9 @@ class MeshConfigHandlerImpl(
val mi = nodeManager.getMyNodeInfo()
val index = channel.index
if (mi != null) {
serviceRepository.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})")
serviceStateWriter.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})")
} else {
serviceRepository.setConnectionProgress("Channels (${index + 1})")
serviceStateWriter.setConnectionProgress("Channels (${index + 1})")
}
}

View File

@@ -42,7 +42,7 @@ import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeManager
@@ -52,7 +52,6 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.UiPrefs
@@ -70,8 +69,7 @@ import kotlin.time.DurationUnit
class MeshConnectionManagerImpl(
private val radioInterfaceService: RadioInterfaceService,
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val serviceNotifications: MeshNotificationManager,
private val uiPrefs: UiPrefs,
private val packetHandler: PacketHandler,
private val nodeRepository: NodeRepository,
@@ -200,7 +198,6 @@ class MeshConnectionManagerImpl(
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
connectTimeMsec = nowMillis
// Send a wake-up heartbeat before the config request. The firmware may be in a
@@ -276,8 +273,6 @@ class MeshConnectionManagerImpl(
Logger.d { "device sleep timeout cancelled" }
}
}
serviceBroadcasts.broadcastConnection()
}
private fun handleDisconnected() {
@@ -290,8 +285,6 @@ class MeshConnectionManagerImpl(
DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
)
analytics.track(EVENT_NUM_NODES, DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size))
serviceBroadcasts.broadcastConnection()
}
override fun startConfigOnly() {
@@ -319,7 +312,7 @@ class MeshConnectionManagerImpl(
}
}
override fun onNodeDbReady() {
override suspend fun onNodeDbReady() {
handshakeTimeout?.cancel()
handshakeTimeout = null

View File

@@ -31,14 +31,19 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.destination
import org.meshtastic.core.model.isBroadcast
import org.meshtastic.core.model.isFromLocal
import org.meshtastic.core.model.source
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
@@ -48,8 +53,7 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
@@ -82,11 +86,10 @@ import org.meshtastic.proto.Waypoint
class MeshDataHandlerImpl(
private val nodeManager: NodeManager,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
private val serviceStateWriter: ServiceStateWriter,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
private val serviceNotifications: MeshServiceNotifications,
private val serviceNotifications: MeshNotificationManager,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
private val tracerouteHandler: TracerouteHandler,
@@ -112,11 +115,8 @@ class MeshDataHandlerImpl(
val fromUs = myNodeNum == packet.from
dataPacket.status = MessageStatus.RECEIVED
val shouldBroadcast = handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
if (shouldBroadcast) {
serviceBroadcasts.broadcastReceivedData(dataPacket)
}
analytics.track("num_data_receive", DataPair("num_data_receive", 1))
}
@@ -127,50 +127,35 @@ class MeshDataHandlerImpl(
fromUs: Boolean,
logUuid: String?,
logInsertJob: Job?,
): Boolean {
var shouldBroadcast = !fromUs
val decoded = packet.decoded ?: return shouldBroadcast
) {
val decoded = packet.decoded ?: return
when (decoded.portnum) {
PortNum.TEXT_MESSAGE_APP -> handleTextMessage(packet, dataPacket, myNodeNum)
PortNum.NODE_STATUS_APP -> handleNodeStatus(packet, dataPacket, myNodeNum)
PortNum.ALERT_APP -> rememberDataPacket(dataPacket, myNodeNum)
PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum)
PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum)
PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet)
PortNum.TELEMETRY_APP -> telemetryHandler.handleTelemetry(packet, dataPacket, myNodeNum)
else ->
shouldBroadcast =
handleSpecializedDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
else -> handleSpecializedDataPacket(packet, dataPacket, myNodeNum, logUuid, logInsertJob)
}
return shouldBroadcast
}
private fun handleSpecializedDataPacket(
packet: MeshPacket,
dataPacket: DataPacket,
myNodeNum: Int,
fromUs: Boolean,
logUuid: String?,
logInsertJob: Job?,
): Boolean {
var shouldBroadcast = !fromUs
val decoded = packet.decoded ?: return shouldBroadcast
) {
val decoded = packet.decoded ?: return
when (decoded.portnum) {
PortNum.TRACEROUTE_APP -> {
tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob)
shouldBroadcast = false
}
PortNum.ROUTING_APP -> {
handleRouting(packet, dataPacket)
shouldBroadcast = true
}
PortNum.PAXCOUNTER_APP -> {
@@ -191,30 +176,21 @@ class MeshDataHandlerImpl(
PortNum.NEIGHBORINFO_APP -> {
neighborInfoHandler.handleNeighborInfo(packet)
shouldBroadcast = true
}
PortNum.ATAK_PLUGIN,
PortNum.ATAK_PLUGIN_V2,
PortNum.PRIVATE_APP,
-> {
shouldBroadcast = true
}
-> {}
PortNum.RANGE_TEST_APP,
PortNum.DETECTION_SENSOR_APP,
-> {
handleRangeTest(dataPacket, myNodeNum)
shouldBroadcast = true
}
else -> {
// By default, if we don't know what it is, we should probably broadcast it
// so that external apps can handle it.
shouldBroadcast = true
}
else -> {}
}
return shouldBroadcast
}
private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) {
@@ -279,7 +255,7 @@ class MeshDataHandlerImpl(
val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return
if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) {
scope.launch {
serviceRepository.setErrorMessage(getStringSuspend(Res.string.error_duty_cycle), Severity.Warn)
serviceStateWriter.setErrorMessage(getStringSuspend(Res.string.error_duty_cycle), Severity.Warn)
}
}
handleAckNak(
@@ -326,16 +302,13 @@ class MeshDataHandlerImpl(
packetRepository.value.updateReaction(updated)
}
}
serviceBroadcasts.broadcastMessageStatus(requestId, m)
}
}
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
if (dataPacket.dataType !in rememberDataType) return
val fromLocal =
dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum)
val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST
val fromLocal = dataPacket.isFromLocal(myNodeNum)
val toBroadcast = dataPacket.isBroadcast
val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
// contactKey: unique contact key filter (channel)+(nodeId)
@@ -374,7 +347,7 @@ class MeshDataHandlerImpl(
@Suppress("ReturnCount")
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true
val isIgnored = nodeManager.getNodeById(dataPacket.from.orEmpty())?.isIgnored == true
if (isIgnored) return true
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
@@ -388,7 +361,7 @@ class MeshDataHandlerImpl(
updateNotification: Boolean,
) {
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val nodeMuted = nodeManager.getNodeById(dataPacket.from.orEmpty())?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
scope.launch {
@@ -407,19 +380,21 @@ class MeshDataHandlerImpl(
}
private suspend fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
if (packet.source is NodeAddress.Local) {
val myId = nodeManager.getMyId()
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
return nodeManager.getNodeById(myId)?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
return nodeManager.getNodeById(packet.from.orEmpty())?.user?.long_name
?: getStringSuspend(Res.string.unknown_username)
}
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
when (dataPacket.dataType) {
PortNum.TEXT_MESSAGE_APP.value -> {
val message = dataPacket.text!!
val isBroadcast = dataPacket.destination is NodeAddress.Broadcast
val channelName =
if (dataPacket.to == DataPacket.ID_BROADCAST) {
if (isBroadcast) {
radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
} else {
null
@@ -428,7 +403,7 @@ class MeshDataHandlerImpl(
contactKey,
getSenderName(dataPacket),
message,
dataPacket.to == DataPacket.ID_BROADCAST,
isBroadcast,
channelName,
isSilent,
)
@@ -496,15 +471,16 @@ class MeshDataHandlerImpl(
packetRepository.value.getPacketByPacketId(decoded.reply_id)?.let { originalPacket ->
// Skip notification if the original message was filtered
val targetId =
if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
if (originalPacket.source is NodeAddress.Local) originalPacket.to else originalPacket.from
val contactKey = "${originalPacket.channel}$targetId"
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
val nodeMuted = nodeManager.getNodeById(fromId)?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (!isSilent) {
val isBroadcast = originalPacket.destination is NodeAddress.Broadcast
val channelName =
if (originalPacket.to == DataPacket.ID_BROADCAST) {
if (isBroadcast) {
radioConfigRepository.channelSetFlow
.first()
.settings
@@ -517,7 +493,7 @@ class MeshDataHandlerImpl(
contactKey,
getSenderName(dataMapper.toDataPacket(packet)!!),
emoji,
originalPacket.to == DataPacket.ID_BROADCAST,
isBroadcast,
channelName,
isSilent,
)

View File

@@ -36,11 +36,11 @@ import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
import org.meshtastic.proto.MeshPacket
@@ -53,9 +53,9 @@ import kotlin.uuid.Uuid
@Single
class MeshMessageProcessorImpl(
private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val serviceStateWriter: ServiceStateWriter,
private val meshLogRepository: Lazy<MeshLogRepository>,
private val router: Lazy<MeshRouter>,
private val dataHandler: Lazy<MeshDataHandler>,
private val fromRadioDispatcher: FromRadioPacketHandler,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshMessageProcessor {
@@ -212,15 +212,13 @@ class MeshMessageProcessorImpl(
}
}
scope.handledLaunch { serviceRepository.emitMeshPacket(packet) }
scope.handledLaunch { serviceStateWriter.emitMeshPacket(packet) }
myNodeNum?.let { myNum ->
val from = packet.from
val isOtherNode = myNum != from
nodeManager.updateNode(myNum, withBroadcast = isOtherNode) { node: Node ->
node.copy(lastHeard = nowSeconds.toInt())
}
nodeManager.updateNode(from, withBroadcast = false, channel = packet.channel) { node: Node ->
nodeManager.updateNode(myNum) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) }
nodeManager.updateNode(from, channel = packet.channel) { node: Node ->
val viaMqtt = packet.via_mqtt == true
val isDirect = packet.hop_start == packet.hop_limit
@@ -255,7 +253,7 @@ class MeshMessageProcessorImpl(
}
try {
router.value.dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob)
dataHandler.value.handleReceivedData(packet, myNum, log.uuid, logJob)
} finally {
scope.launch {
mapsMutex.withLock {
@@ -284,7 +282,7 @@ class MeshMessageProcessorImpl(
lastLocalNodeRefreshMs = now
val myNum = nodeManager.myNodeNum.value ?: return
nodeManager.updateNode(myNum, withBroadcast = false) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) }
nodeManager.updateNode(myNum) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) }
}
private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.value.insert(log) }

View File

@@ -1,66 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.repository.XModemManager
/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */
@Suppress("LongParameterList")
@Single
class MeshRouterImpl(
private val dataHandlerLazy: Lazy<MeshDataHandler>,
private val configHandlerLazy: Lazy<MeshConfigHandler>,
private val tracerouteHandlerLazy: Lazy<TracerouteHandler>,
private val neighborInfoHandlerLazy: Lazy<NeighborInfoHandler>,
private val configFlowManagerLazy: Lazy<MeshConfigFlowManager>,
private val mqttManagerLazy: Lazy<MqttManager>,
private val actionHandlerLazy: Lazy<MeshActionHandler>,
private val xmodemManagerLazy: Lazy<XModemManager>,
) : MeshRouter {
override val dataHandler: MeshDataHandler
get() = dataHandlerLazy.value
override val configHandler: MeshConfigHandler
get() = configHandlerLazy.value
override val tracerouteHandler: TracerouteHandler
get() = tracerouteHandlerLazy.value
override val neighborInfoHandler: NeighborInfoHandler
get() = neighborInfoHandlerLazy.value
override val configFlowManager: MeshConfigFlowManager
get() = configFlowManagerLazy.value
override val mqttManager: MqttManager
get() = mqttManagerLazy.value
override val actionHandler: MeshActionHandler
get() = actionHandlerLazy.value
override val xmodemManager: XModemManager
get() = xmodemManagerLazy.value
}

View File

@@ -37,7 +37,7 @@ import org.meshtastic.core.network.repository.resolveEndpoint
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.mqtt.ConnectionState
import org.meshtastic.mqtt.MqttClient
import org.meshtastic.mqtt.MqttException
@@ -51,7 +51,7 @@ import kotlin.uuid.Uuid
class MqttManagerImpl(
private val mqttRepository: MQTTRepository,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
private val serviceStateWriter: ServiceStateWriter,
private val nodeRepository: NodeRepository,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MqttManager {
@@ -79,7 +79,7 @@ class MqttManagerImpl(
is MqttException.ConnectionLost -> "MQTT: connection lost"
else -> "MQTT proxy failed: ${throwable.message}"
}
serviceRepository.setErrorMessage(text = message, severity = Severity.Warn)
serviceStateWriter.setErrorMessage(text = message, severity = Severity.Warn)
}
.launchIn(scope)
}

View File

@@ -17,35 +17,26 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
@Single
class NeighborInfoHandlerImpl(
private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val serviceStateWriter: ServiceStateWriter,
private val nodeRepository: NodeRepository,
) : NeighborInfoHandler {
private val startTimes = atomic(persistentMapOf<Int, Long>())
private val requestTimer = RequestTimer()
override var lastNeighborInfo: NeighborInfo? = null
override fun recordStartTime(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
override fun recordStartTime(requestId: Int) = requestTimer.start(requestId)
override fun handleNeighborInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
@@ -58,13 +49,8 @@ class NeighborInfoHandlerImpl(
Logger.d { "Stored last neighbor info from connected radio" }
}
// Update Node DB
nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) }
// Format for UI response
val requestId = packet.decoded?.request_id ?: 0
val start = startTimes.value[requestId]
startTimes.update { it.remove(requestId) }
val neighbors =
ni.neighbors.joinToString("\n") { n ->
@@ -76,20 +62,8 @@ class NeighborInfoHandlerImpl(
val fromUser = nodeRepository.getUser(from)
val formatted = "Neighbors of ${fromUser.long_name}:\n$neighbors"
val responseText =
if (start != null) {
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Neighbor info $requestId complete in $seconds s" }
"$formatted\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
} else {
formatted
}
val responseText = requestTimer.appendDuration(requestId, formatted, "Neighbor info")
serviceRepository.setNeighborInfoResponse(responseText)
}
companion object {
private const val MILLIS_PER_SECOND = 1000.0
serviceStateWriter.setNeighborInfoResponse(responseText)
}
}

View File

@@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -28,20 +29,14 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.clampTimestampToNow
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.util.NodeIdLookup
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.new_node_seen
@@ -60,19 +55,57 @@ import org.meshtastic.proto.Position as ProtoPosition
@Single(binds = [NodeManager::class, NodeIdLookup::class])
class NodeManagerImpl(
private val nodeRepository: NodeRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
@Named("ServiceScope") private val scope: CoroutineScope,
) : NodeManager {
private val _nodeDBbyNodeNum = atomic(persistentMapOf<Int, Node>())
private val _nodeDBbyID = atomic(persistentMapOf<String, Node>())
// Two indices over the same node set: byNum is the canonical store (mesh-level identifier), byId is a secondary
// O(1) lookup for the user-facing hex string. Both are held in a single atomic ref so updates are observed
// consistently — concurrent readers never see an entry present in one index but not the other.
private data class NodeIndex(
val byNum: PersistentMap<Int, Node> = persistentMapOf(),
val byId: PersistentMap<String, Node> = persistentMapOf(),
) {
fun put(num: Int, node: Node): NodeIndex {
val previous = byNum[num]
var nextById = byId
// If the user.id changed (e.g. firmware reassigned the hex id) drop the stale id entry.
if (previous != null && previous.user.id.isNotEmpty() && previous.user.id != node.user.id) {
nextById = nextById.remove(previous.user.id)
}
if (node.user.id.isNotEmpty()) {
nextById = nextById.put(node.user.id, node)
}
return NodeIndex(byNum = byNum.put(num, node), byId = nextById)
}
fun remove(num: Int): NodeIndex {
val previous = byNum[num] ?: return this
return NodeIndex(
byNum = byNum.remove(num),
byId = if (previous.user.id.isNotEmpty()) byId.remove(previous.user.id) else byId,
)
}
companion object {
fun fromByNum(nodes: Map<Int, Node>): NodeIndex {
var byNum = persistentMapOf<Int, Node>()
var byId = persistentMapOf<String, Node>()
for ((num, node) in nodes) {
byNum = byNum.put(num, node)
if (node.user.id.isNotEmpty()) byId = byId.put(node.user.id, node)
}
return NodeIndex(byNum, byId)
}
}
}
private val nodeIndex = atomic(NodeIndex())
override val nodeDBbyNodeNum: Map<Int, Node>
get() = _nodeDBbyNodeNum.value
get() = nodeIndex.value.byNum
override val nodeDBbyID: Map<String, Node>
get() = _nodeDBbyID.value
override fun getNodeById(id: String): Node? = nodeIndex.value.byId[id]
override val isNodeDbReady = MutableStateFlow(false)
override val allowNodeDbWrites = MutableStateFlow(false)
@@ -104,10 +137,7 @@ class NodeManagerImpl(
override fun loadCachedNodeDB() {
scope.handledLaunch {
val nodes = nodeRepository.nodeDBbyNum.first()
_nodeDBbyNodeNum.value = persistentMapOf<Int, Node>().putAll(nodes)
val byId = mutableMapOf<String, Node>()
nodes.values.forEach { byId[it.user.id] = it }
_nodeDBbyID.value = persistentMapOf<String, Node>().putAll(byId)
nodeIndex.value = NodeIndex.fromByNum(nodes)
if (myNodeNum.value == null) {
myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum
}
@@ -115,8 +145,7 @@ class NodeManagerImpl(
}
override fun clear() {
_nodeDBbyNodeNum.value = persistentMapOf()
_nodeDBbyID.value = persistentMapOf()
nodeIndex.value = NodeIndex()
isNodeDbReady.value = false
allowNodeDbWrites.value = false
myNodeNum.value = null
@@ -125,7 +154,7 @@ class NodeManagerImpl(
override fun getMyNodeInfo(): MyNodeInfo? {
val mi = nodeRepository.myNodeInfo.value ?: return null
val myNode = _nodeDBbyNodeNum.value[mi.myNodeNum]
val myNode = nodeIndex.value.byNum[mi.myNodeNum]
return MyNodeInfo(
myNodeNum = mi.myNodeNum,
hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
@@ -146,24 +175,16 @@ class NodeManagerImpl(
override fun getMyId(): String {
val num = myNodeNum.value ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
return _nodeDBbyNodeNum.value[num]?.user?.id ?: ""
return nodeIndex.value.byNum[num]?.user?.id ?: ""
}
override fun getNodes(): List<NodeInfo> = _nodeDBbyNodeNum.value.values.map { it.toNodeInfo() }
override fun removeByNodenum(nodeNum: Int) {
val removed = atomic<Node?>(null)
_nodeDBbyNodeNum.update { map ->
val node = map[nodeNum]
removed.value = node
map.remove(nodeNum)
}
removed.value?.let { node -> _nodeDBbyID.update { it.remove(node.user.id) } }
nodeIndex.update { it.remove(nodeNum) }
}
internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = _nodeDBbyNodeNum.value[n]
internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeIndex.value.byNum[n]
?: run {
val userId = DataPacket.nodeNumToDefaultId(n)
val userId = NodeAddress.numToDefaultId(n)
val defaultUser =
User(
id = userId,
@@ -175,29 +196,22 @@ class NodeManagerImpl(
Node(num = n, user = defaultUser, channel = channel)
}
override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) {
override fun updateNode(nodeNum: Int, channel: Int, transform: (Node) -> Node) {
// Perform read + transform inside update{} to ensure atomicity.
// Without this, concurrent calls for the same nodeNum could read the same snapshot
// and the last writer would silently overwrite the other's changes.
var next: Node? = null
_nodeDBbyNodeNum.update { map ->
val current = map[nodeNum] ?: getOrCreateNode(nodeNum, channel)
nodeIndex.update { index ->
val current = index.byNum[nodeNum] ?: getOrCreateNode(nodeNum, channel)
val transformed = transform(current)
next = transformed
map.put(nodeNum, transformed)
index.put(nodeNum, transformed)
}
val result = next ?: return
if (result.user.id.isNotEmpty()) {
_nodeDBbyID.update { it.put(result.user.id, result) }
}
if (result.user.id.isNotEmpty() && isNodeDbReady.value) {
scope.handledLaunch { nodeRepository.upsert(result) }
}
if (withBroadcast) {
serviceBroadcasts.broadcastNodeChange(result)
}
}
override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) {
@@ -287,8 +301,8 @@ class NodeManagerImpl(
updateNode(nodeNum) { it.copy(nodeStatus = status?.takeIf { s -> s.isNotEmpty() }) }
}
override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) {
updateNode(info.num, withBroadcast = withBroadcast) { node ->
override fun installNodeInfo(info: ProtoNodeInfo) {
updateNode(info.num) { node ->
var next = node
val user = info.user
if (user != null) {
@@ -334,48 +348,9 @@ class NodeManagerImpl(
return hasExistingUser && isDefaultName && isDefaultHwModel
}
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
override fun toNodeID(nodeNum: Int): String = if (nodeNum == NodeAddress.NODENUM_BROADCAST) {
NodeAddress.ID_BROADCAST
} else {
_nodeDBbyNodeNum.value[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
nodeIndex.value.byNum[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum)
}
private fun Node.toNodeInfo(): NodeInfo = NodeInfo(
num = num,
user =
MeshUser(
id = user.id,
longName = user.long_name,
shortName = user.short_name,
hwModel = user.hw_model,
role = user.role.value,
),
position =
Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits,
)
.takeIf { latitude != 0.0 || longitude != 0.0 },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics =
DeviceMetrics(
batteryLevel = deviceMetrics.battery_level ?: 0,
voltage = deviceMetrics.voltage ?: 0f,
channelUtilization = deviceMetrics.channel_utilization ?: 0f,
airUtilTx = deviceMetrics.air_util_tx ?: 0f,
uptimeSeconds = deviceMetrics.uptime_seconds ?: 0,
),
channel = channel,
environmentMetrics = EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
hopsAway = hopsAway,
nodeStatus = nodeStatus,
)
}

View File

@@ -24,9 +24,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.asDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -43,12 +41,11 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.ConnectionStateProvider
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
@@ -61,10 +58,9 @@ import kotlin.uuid.Uuid
@Single
class PacketHandlerImpl(
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: Lazy<MeshLogRepository>,
private val serviceRepository: ServiceRepository,
private val connectionStateProvider: ConnectionStateProvider,
@Named("ServiceScope") private val scope: CoroutineScope,
) : PacketHandler {
@@ -77,11 +73,6 @@ class PacketHandlerImpl(
private val queueMutex = Mutex()
private val queuedPackets = mutableListOf<MeshPacket>()
// Unbounded channel preserves FIFO ordering of fire-and-forget sendToRadio(MeshPacket)
// calls. The non-suspend entry point does trySend (always succeeds for UNLIMITED) and
// a single consumer coroutine enqueues packets under queueMutex in arrival order.
private val outboundChannel = Channel<MeshPacket>(Channel.UNLIMITED)
// Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
// and the queue processor's finally block to prevent restarting a stopped queue.
private var queueStopped = false
@@ -89,20 +80,6 @@ class PacketHandlerImpl(
private val responseMutex = Mutex()
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
init {
// Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket)
// entry point, preserving FIFO across rapid concurrent callers.
scope.launch {
outboundChannel.consumeAsFlow().collect { packet ->
queueMutex.withLock {
queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
queuedPackets.add(packet)
startPacketQueueLocked()
}
}
}
}
override fun sendToRadio(p: ToRadio) {
Logger.d { "Sending to radio ${p.toPIIString()}" }
val b = p.encode()
@@ -126,10 +103,18 @@ class PacketHandlerImpl(
}
}
override fun sendToRadio(packet: MeshPacket) {
// Non-suspend entry point — order-preserving via unbounded channel drained by
// a single consumer coroutine. trySend on UNLIMITED never fails for capacity.
outboundChannel.trySend(packet)
/**
* Enqueue [packet] for transmission. Order is preserved for sequential calls from the same coroutine (mutex
* acquisition is uncontested between sequential calls). Transactional sequences that require strict ordering across
* multiple calls — e.g. an `editSettings { … }` begin → writes → commit sequence — MUST be issued from a single
* coroutine; concurrent senders share FIFO only at the per-call grain.
*/
override suspend fun sendToRadio(packet: MeshPacket) {
queueMutex.withLock {
queueStopped = false
queuedPackets.add(packet)
startPacketQueueLocked()
}
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
@@ -206,7 +191,7 @@ class PacketHandlerImpl(
queueJob =
scope.handledLaunch {
try {
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
while (connectionStateProvider.connectionState.value == ConnectionState.Connected) {
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
@@ -247,7 +232,6 @@ class PacketHandlerImpl(
getDataPacketById(packetId)?.let { p ->
if (p.status == m) return@handledLaunch
packetRepository.value.updateMessageStatus(p, m)
serviceBroadcasts.broadcastMessageStatus(packetId, m)
}
}
}
@@ -266,7 +250,7 @@ class PacketHandlerImpl(
// Reuse a deferred pre-registered by sendToRadioAndAwait, or create a new one.
val deferred = responseMutex.withLock { queueResponse.getOrPut(packet.id) { CompletableDeferred() } }
try {
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
if (connectionStateProvider.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()
}
sendToRadio(ToRadio(packet = packet))

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
/**
* Tracks per-request start times and reports round-trip durations for request/response handlers.
*
* Request handlers (traceroute, neighbor-info, …) call [start] when issuing a request keyed by its id, then
* [appendDuration] when the matching response arrives to annotate the user-facing text with how long the round trip
* took. Start times are stored in an atomic immutable map so [start] (any coroutine) and [appendDuration] (the handler
* scope) never race.
*/
internal class RequestTimer {
private val startTimes = atomic(persistentMapOf<Int, Long>())
/** Records the start time for [requestId]. */
fun start(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
/**
* Consumes the start time recorded for [requestId] and appends a `Duration: N s` line to [text], logging completion
* under [logLabel]. Returns [text] unchanged when no start time was recorded for the id.
*/
fun appendDuration(requestId: Int, text: String, logLabel: String): String {
val start = startTimes.value[requestId]
startTimes.update { it.remove(requestId) }
if (start == null) return text
val seconds = (nowMillis - start) / MILLIS_PER_SECOND
Logger.i { "$logLabel $requestId complete in $seconds s" }
return "$text\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
}
private companion object {
private const val MILLIS_PER_SECOND = 1000.0
}
}

View File

@@ -25,12 +25,12 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
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
@@ -43,7 +43,6 @@ import kotlin.time.Duration.Companion.milliseconds
class StoreForwardPacketHandlerImpl(
private val nodeManager: NodeManager,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val historyManager: HistoryManager,
private val dataHandler: Lazy<MeshDataHandler>,
@Named("ServiceScope") private val scope: CoroutineScope,
@@ -105,7 +104,7 @@ class StoreForwardPacketHandlerImpl(
encryptedPayload = sfpp.message.toByteArray(),
to =
if (sfpp.encapsulated_to == 0) {
DataPacket.NODENUM_BROADCAST
NodeAddress.NODENUM_BROADCAST
} else {
sfpp.encapsulated_to
},
@@ -131,7 +130,6 @@ class StoreForwardPacketHandlerImpl(
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum.value ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
}
@@ -183,7 +181,7 @@ class StoreForwardPacketHandlerImpl(
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
dataPacket.to = NodeAddress.ID_BROADCAST
}
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
dataHandler.value.rememberDataPacket(u, myNodeNum)

View File

@@ -16,22 +16,16 @@
*/
package org.meshtastic.core.data.manager
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.Job
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.ServiceStateWriter
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.repository.TracerouteSnapshotRepository
import org.meshtastic.core.resources.Res
@@ -42,17 +36,15 @@ import org.meshtastic.proto.MeshPacket
@Single
class TracerouteHandlerImpl(
private val serviceRepository: ServiceRepository,
private val serviceStateWriter: ServiceStateWriter,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val nodeRepository: NodeRepository,
@Named("ServiceScope") private val scope: CoroutineScope,
) : TracerouteHandler {
private val startTimes = atomic(persistentMapOf<Int, Long>())
private val requestTimer = RequestTimer()
override fun recordStartTime(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
override fun recordStartTime(requestId: Int) = requestTimer.start(requestId)
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) {
// Decode the route discovery once — avoids triple protobuf decode
@@ -85,21 +77,11 @@ class TracerouteHandlerImpl(
tracerouteSnapshotRepository.upsertSnapshotPositions(logUuid, requestId, snapshotPositions)
}
val start = startTimes.value[requestId]
startTimes.update { it.remove(requestId) }
val responseText =
if (start != null) {
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Traceroute $requestId complete in $seconds s" }
"$full\n\nDuration: ${NumberFormatter.format(seconds, 1)} s"
} else {
full
}
val responseText = requestTimer.appendDuration(requestId, full, "Traceroute")
val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0
serviceRepository.setTracerouteResponse(
serviceStateWriter.setTracerouteResponse(
TracerouteResponse(
message = responseText,
destinationNodeNum = destination,
@@ -111,8 +93,4 @@ class TracerouteHandlerImpl(
)
}
}
companion object {
private const val MILLIS_PER_SECOND = 1000.0
}
}

View File

@@ -43,10 +43,10 @@ import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.repository.NodeRepository
@@ -137,10 +137,10 @@ class NodeRepositoryImpl(
/** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */
override fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
?: Node(num = NodeAddress.idToNum(userId) ?: 0, user = getUser(userId))
/** Returns the [User] info for a given [nodeNum]. */
override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
override fun getUser(nodeNum: Int): User = getUser(NodeAddress.numToDefaultId(nodeNum))
private val last4 = 4
@@ -152,14 +152,17 @@ class NodeRepositoryImpl(
}
val fallbackId = userId.takeLast(last4)
// Single equality check replaces two NodeAddress.fromString calls — getUser is called per paged contact
// and per text-message arrival, so the parser allocations add up.
val isLocal = userId == NodeAddress.ID_LOCAL
val defaultLong =
if (userId == DataPacket.ID_LOCAL) {
if (isLocal) {
ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local"
} else {
"Meshtastic $fallbackId"
}
val defaultShort =
if (userId == DataPacket.ID_LOCAL) {
if (isLocal) {
ourNodeInfo.value?.user?.short_name?.takeIf { it.isNotBlank() } ?: "Local"
} else {
fallbackId

View File

@@ -20,6 +20,7 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -38,6 +39,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Reaction
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
@@ -90,13 +92,15 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() }
override suspend fun clearUnreadCount(contact: String, timestamp: Long) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) }
withContext(dispatchers.io + NonCancellable) {
dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp)
}
override suspend fun clearAllUnreadCounts() =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearAllUnreadCounts() }
withContext(dispatchers.io + NonCancellable) { dbManager.currentDb.value.packetDao().clearAllUnreadCounts() }
override suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) =
withContext(dispatchers.io) {
withContext(dispatchers.io + NonCancellable) {
val dao = dbManager.currentDb.value.packetDao()
val current = dao.getContactSettings(contact)
val existingTimestamp = current?.lastReadMessageTimestamp ?: Long.MIN_VALUE
@@ -116,7 +120,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
}
suspend fun insertRoomPacket(packet: RoomPacket) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(packet) }
withContext(dispatchers.io + NonCancellable) { dbManager.currentDb.value.packetDao().insert(packet) }
override suspend fun savePacket(
myNodeNum: Int,
@@ -342,13 +346,13 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
val dao = dbManager.currentDb.value.packetDao()
val packets = findPacketsWithIdInternal(packetId)
val reactions = findReactionsWithIdInternal(packetId)
val fromId = DataPacket.nodeNumToDefaultId(from)
val fromId = NodeAddress.numToDefaultId(from)
val isFromLocalNode = myNodeNum != null && from == myNodeNum
val toId =
if (to == 0 || to == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
if (to == 0 || to == NodeAddress.NODENUM_BROADCAST) {
NodeAddress.ID_BROADCAST
} else {
DataPacket.nodeNumToDefaultId(to)
NodeAddress.numToDefaultId(to)
}
val hashByteString = hash.toByteString()
@@ -356,7 +360,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
packets.forEach { packet ->
// For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches =
packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.ID_LOCAL)
packet.data.from == fromId || (isFromLocalNode && packet.data.from == NodeAddress.ID_LOCAL)
co.touchlab.kermit.Logger.d {
"SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " +
"isFromLocal=$isFromLocalNode fromMatches=$fromMatches " +
@@ -376,7 +380,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
reactions.forEach { reaction ->
val reactionFrom = reaction.userId
// For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == DataPacket.ID_LOCAL)
val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == NodeAddress.ID_LOCAL)
val toMatches = reaction.to == toId
@@ -529,7 +533,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
packets.map { packet ->
val node = getNode(packet.data.from)
val isFromLocal =
node.user.id == DataPacket.ID_LOCAL ||
node.user.id == NodeAddress.ID_LOCAL ||
(packet.myNodeNum != 0 && node.num == packet.myNodeNum)
Message(
uuid = packet.uuid,

View File

@@ -0,0 +1,293 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import okio.ByteString
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
@Suppress("LargeClass")
class CommandSenderImplTest {
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val tracerouteHandler = mock<TracerouteHandler>(MockMode.autofill)
private val neighborInfoHandler = mock<NeighborInfoHandler>(MockMode.autofill)
private val sessionManager = mock<SessionManager>(MockMode.autofill)
private lateinit var commandSender: CommandSenderImpl
@BeforeTest
fun setup() {
every { radioConfigRepository.localConfigFlow } returns flowOf(LocalConfig())
every { radioConfigRepository.channelSetFlow } returns flowOf(ChannelSet())
every { nodeManager.myNodeNum } returns MutableStateFlow(MY_NODE_NUM)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { sessionManager.getPasskey(any()) } returns ByteString.EMPTY
commandSender =
CommandSenderImpl(
packetHandler = packetHandler,
nodeManager = nodeManager,
radioConfigRepository = radioConfigRepository,
tracerouteHandler = tracerouteHandler,
neighborInfoHandler = neighborInfoHandler,
sessionManager = sessionManager,
scope = TestScope(),
)
}
// --- generatePacketId ---
@Test
fun generatePacketId_returnsNonZero() {
val id = commandSender.generatePacketId()
assertNotEquals(0, id)
}
@Test
fun generatePacketId_isIncrementing() {
val first = commandSender.generatePacketId()
val second = commandSender.generatePacketId()
assertNotEquals(first, second)
}
@Test
fun generatePacketId_staysNonZeroOverManyIterations() {
repeat(100) { assertNotEquals(0, commandSender.generatePacketId()) }
}
// --- resolveNodeNum ---
@Test
fun resolveNodeNum_broadcast_returnsNodeNumBroadcast() {
val result = commandSender.resolveNodeNum(NodeAddress.Broadcast)
assertEquals(NodeAddress.NODENUM_BROADCAST, result)
}
@Test
fun resolveNodeNum_local_returnsMyNodeNum() {
val result = commandSender.resolveNodeNum(NodeAddress.Local)
assertEquals(MY_NODE_NUM, result)
}
@Test
fun resolveNodeNum_local_returnsZeroWhenMyNodeNumNull() {
every { nodeManager.myNodeNum } returns MutableStateFlow(null)
commandSender =
CommandSenderImpl(
packetHandler = packetHandler,
nodeManager = nodeManager,
radioConfigRepository = radioConfigRepository,
tracerouteHandler = tracerouteHandler,
neighborInfoHandler = neighborInfoHandler,
sessionManager = sessionManager,
scope = TestScope(),
)
assertEquals(0, commandSender.resolveNodeNum(NodeAddress.Local))
}
@Test
fun resolveNodeNum_byNum_returnsPassthrough() {
assertEquals(42, commandSender.resolveNodeNum(NodeAddress.ByNum(42)))
}
@Test
fun resolveNodeNum_byId_looksUpAndReturns() {
val node = Node(num = 99, user = User(id = "!deadbeef"))
every { nodeManager.getNodeById("!deadbeef") } returns node
assertEquals(99, commandSender.resolveNodeNum(NodeAddress.ById("!deadbeef")))
}
@Test
fun resolveNodeNum_byId_throwsForUnknown() {
every { nodeManager.getNodeById("!unknown") } returns null
assertFailsWith<IllegalArgumentException> { commandSender.resolveNodeNum(NodeAddress.ById("!unknown")) }
}
// --- sendData ---
@Test
fun sendData_setsIdWhenZero() = runTest {
val packet = DataPacket(to = "^all", bytes = "hi".encodeUtf8(), dataType = PortNum.TEXT_MESSAGE_APP.value)
packet.id = 0
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
commandSender.sendData(packet)
assertNotEquals(0, packet.id)
}
@Test
fun sendData_setsStatusQueued() = runTest {
val packet = DataPacket(to = "^all", bytes = "hello".encodeUtf8(), dataType = PortNum.TEXT_MESSAGE_APP.value)
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
commandSender.sendData(packet)
assertEquals(MessageStatus.QUEUED, packet.status)
}
@Test
fun sendData_rejectsOversizedPayload() = runTest {
val oversizedBytes = ByteString.of(*ByteArray(300) { 0x42 })
val packet = DataPacket(to = "^all", bytes = oversizedBytes, dataType = PortNum.TEXT_MESSAGE_APP.value)
val ex = assertFailsWith<IllegalStateException> { commandSender.sendData(packet) }
assertTrue(ex.message!!.contains("Message too long"))
assertEquals(MessageStatus.ERROR, packet.status)
}
@Test
fun sendData_requiresNonZeroDataType() = runTest {
val packet = DataPacket(to = "^all", bytes = "test".encodeUtf8(), dataType = 0)
assertFailsWith<IllegalArgumentException> { commandSender.sendData(packet) }
}
// --- sendAdmin ---
@Test
fun sendAdmin_injectsSessionPasskey() = runTest {
val passkey = "secret".encodeUtf8()
every { sessionManager.getPasskey(DEST_NODE) } returns passkey
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
commandSender.sendAdmin(DEST_NODE) { org.meshtastic.proto.AdminMessage(get_owner_request = true) }
verifySuspend { packetHandler.sendToRadio(any<MeshPacket>()) }
}
// --- requestTraceroute ---
@Test
fun requestTraceroute_recordsStartTime() = runTest {
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
commandSender.requestTraceroute(requestId = 42, destNum = DEST_NODE)
verify { tracerouteHandler.recordStartTime(42) }
}
// --- requestNeighborInfo ---
@Test
fun requestNeighborInfo_localNode_usesCachedNeighborInfo() = runTest {
val cached = NeighborInfo(node_id = MY_NODE_NUM, last_sent_by_id = MY_NODE_NUM)
every { neighborInfoHandler.lastNeighborInfo } returns cached
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
commandSender.requestNeighborInfo(requestId = 1, destNum = MY_NODE_NUM)
verifySuspend { packetHandler.sendToRadio(any<MeshPacket>()) }
}
@Test
fun requestNeighborInfo_localNode_generatesDummyWhenNoCached() = runTest {
every { neighborInfoHandler.lastNeighborInfo } returns null
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
commandSender.requestNeighborInfo(requestId = 1, destNum = MY_NODE_NUM)
verifySuspend { packetHandler.sendToRadio(any<MeshPacket>()) }
}
@Test
fun requestNeighborInfo_remoteNode_sendsRequest() = runTest {
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
commandSender.requestNeighborInfo(requestId = 1, destNum = DEST_NODE)
verifySuspend { packetHandler.sendToRadio(any<MeshPacket>()) }
}
// --- sendPosition ---
@Test
fun sendPosition_updatesLocalPositionWhenNotFixed() = runTest {
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
val pos = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 20000000)
commandSender.sendPosition(pos)
verify { nodeManager.handleReceivedPosition(MY_NODE_NUM, MY_NODE_NUM, any(), any()) }
}
@Test
fun sendPosition_skipsLocalUpdateWhenFixedPosition() = runTest {
// Use MutableStateFlow so the init launchIn picks it up immediately in TestScope
val configFlow =
MutableStateFlow(LocalConfig(position = org.meshtastic.proto.Config.PositionConfig(fixed_position = true)))
every { radioConfigRepository.localConfigFlow } returns configFlow
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
val testScope = TestScope()
val fixedSender =
CommandSenderImpl(
packetHandler = packetHandler,
nodeManager = nodeManager,
radioConfigRepository = radioConfigRepository,
tracerouteHandler = tracerouteHandler,
neighborInfoHandler = neighborInfoHandler,
sessionManager = sessionManager,
scope = testScope,
)
testScope.testScheduler.advanceUntilIdle()
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
val pos = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 20000000)
fixedSender.sendPosition(pos)
verify(mode = dev.mokkery.verify.VerifyMode.not) {
nodeManager.handleReceivedPosition(any(), any(), any(), any())
}
}
companion object {
private const val MY_NODE_NUM = 100
private const val DEST_NODE = 200
}
}

View File

@@ -23,11 +23,11 @@ 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.core.repository.XModemManager
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
@@ -49,19 +49,18 @@ class FromRadioPacketHandlerImplTest {
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 val xmodemManager: XModemManager = mock(MockMode.autofill)
private lateinit var handler: FromRadioPacketHandlerImpl
@BeforeTest
fun setup() {
every { router.configFlowManager } returns configFlowManager
every { router.configHandler } returns configHandler
handler =
FromRadioPacketHandlerImpl(
serviceRepository,
lazy { router },
lazy { configFlowManager },
lazy { configHandler },
lazy { xmodemManager },
mqttManager,
packetHandler,
notificationManager,

View File

@@ -1,587 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.not
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MeshActionHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val meshPrefs = mock<MeshPrefs>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val databaseManager = mock<DatabaseManager>(MockMode.autofill)
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
private val messageProcessor = mock<MeshMessageProcessor>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val myNodeNumFlow = MutableStateFlow<Int?>(MY_NODE_NUM)
private lateinit var handler: MeshActionHandlerImpl
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
companion object {
private const val MY_NODE_NUM = 12345
private const val REMOTE_NODE_NUM = 67890
}
@BeforeTest
fun setUp() {
every { nodeManager.myNodeNum } returns myNodeNumFlow
every { nodeManager.getMyId() } returns "!12345678"
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
}
private fun createHandler(scope: CoroutineScope): MeshActionHandlerImpl = MeshActionHandlerImpl(
nodeManager = nodeManager,
commandSender = commandSender,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
dataHandler = lazy { dataHandler },
analytics = analytics,
meshPrefs = meshPrefs,
uiPrefs = uiPrefs,
databaseManager = databaseManager,
notificationManager = notificationManager,
messageProcessor = lazy { messageProcessor },
radioConfigRepository = radioConfigRepository,
scope = scope,
)
// ---- handleUpdateLastAddress (device-switch path — P0 critical) ----
@Test
fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress("new_addr")
advanceUntilIdle()
verify { meshPrefs.setDeviceAddress("new_addr") }
verify { nodeManager.clear() }
verifySuspend { messageProcessor.clearEarlyPackets() }
verifySuspend { databaseManager.switchActiveDatabase("new_addr") }
verify { notificationManager.cancelAll() }
verify { nodeManager.loadCachedNodeDB() }
}
@Test
fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr")
handler.handleUpdateLastAddress("same_addr")
advanceUntilIdle()
verify(not) { meshPrefs.setDeviceAddress(any()) }
verify(not) { nodeManager.clear() }
}
@Test
fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress(null)
advanceUntilIdle()
verify { meshPrefs.setDeviceAddress(null) }
verify { nodeManager.clear() }
verifySuspend { databaseManager.switchActiveDatabase(null) }
}
@Test
fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow(null)
handler.handleUpdateLastAddress(null)
advanceUntilIdle()
verify(not) { meshPrefs.setDeviceAddress(any()) }
}
@Test
fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress("new")
advanceUntilIdle()
// Verify critical sequence: clear -> switchDB -> cancelNotifications -> loadCachedNodeDB
verify { nodeManager.clear() }
verifySuspend { databaseManager.switchActiveDatabase("new") }
verify { notificationManager.cancelAll() }
verify { nodeManager.loadCachedNodeDB() }
}
// ---- onServiceAction: null myNodeNum early-return ----
@Test
fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
myNodeNumFlow.value = null
val node = createTestNode(REMOTE_NODE_NUM)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- onServiceAction: Favorite ----
@Test
fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
@Test
fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
// ---- onServiceAction: Ignore ----
@Test
fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false)
handler.onServiceAction(ServiceAction.Ignore(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
verifySuspend { packetRepository.updateFilteredBySender(any(), any()) }
}
// ---- onServiceAction: Mute ----
@Test
fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isMuted = false)
handler.onServiceAction(ServiceAction.Mute(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
// ---- onServiceAction: GetDeviceMetadata ----
@Test
fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- onServiceAction: SendContact ----
@Test
fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
val action = ServiceAction.SendContact(SharedContact())
handler.onServiceAction(action)
advanceUntilIdle()
assertTrue(action.result.isCompleted)
assertTrue(action.result.await())
}
@Test
fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false
val action = ServiceAction.SendContact(SharedContact())
handler.onServiceAction(action)
advanceUntilIdle()
assertTrue(action.result.isCompleted)
assertFalse(action.result.await())
}
// ---- onServiceAction: ImportContact ----
@Test
fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val contact =
SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser"))
handler.onServiceAction(ServiceAction.ImportContact(contact))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleSetOwner ----
@Test
fun handleSetOwner_sendsAdminAndUpdatesLocalNode() {
handler = createHandler(testScope)
val meshUser =
MeshUser(
id = "!12345678",
longName = "Test Long",
shortName = "TL",
hwModel = HardwareModel.UNSET,
isLicensed = false,
)
handler.handleSetOwner(meshUser, MY_NODE_NUM)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleSend ----
@Test
fun handleSend_sendsDataAndBroadcastsStatus() {
handler = createHandler(testScope)
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
handler.handleSend(packet, MY_NODE_NUM)
verify { commandSender.sendData(any()) }
verify { serviceBroadcasts.broadcastMessageStatus(any(), any()) }
verify { dataHandler.rememberDataPacket(any(), any(), any()) }
}
// ---- handleRequestPosition: 3 branches ----
@Test
fun handleRequestPosition_sameNode_doesNothing() {
handler = createHandler(testScope)
handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM)
verify(not) { commandSender.requestPosition(any(), any()) }
}
@Test
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
handler = createHandler(testScope)
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
verify { commandSender.requestPosition(REMOTE_NODE_NUM, validPosition) }
}
@Test
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
handler = createHandler(testScope)
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
val invalidPosition = Position(0.0, 0.0, 0)
handler.handleRequestPosition(REMOTE_NODE_NUM, invalidPosition, MY_NODE_NUM)
// Falls back to Position(0.0, 0.0, 0) when node has no position in DB
verify { commandSender.requestPosition(any(), any()) }
}
@Test
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
handler = createHandler(testScope)
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
// Should send zero position regardless of valid input
verify { commandSender.requestPosition(any(), any()) }
}
// ---- handleSetConfig: optimistic persist ----
@Test
fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit
val config = Config(lora = Config.LoRaConfig(hop_limit = 5))
val payload = config.encode()
handler.handleSetConfig(payload, MY_NODE_NUM)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.setLocalConfig(any()) }
}
// ---- handleSetModuleConfig: conditional persist ----
@Test
fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val payload = moduleConfig.encode()
handler.handleSetModuleConfig(0, MY_NODE_NUM, payload)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.setLocalModuleConfig(any()) }
}
@Test
fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val payload = moduleConfig.encode()
handler.handleSetModuleConfig(0, REMOTE_NODE_NUM, payload)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend(not) { radioConfigRepository.setLocalModuleConfig(any()) }
}
// ---- handleSetChannel: null payload guard ----
@Test
fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit
val channel = Channel(index = 1)
val payload = channel.encode()
handler.handleSetChannel(payload, MY_NODE_NUM)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.updateChannelSettings(any()) }
}
@Test
fun handleSetChannel_nullPayload_doesNothing() {
handler = createHandler(testScope)
handler.handleSetChannel(null, MY_NODE_NUM)
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRemoveByNodenum ----
@Test
fun handleRemoveByNodenum_removesAndSendsAdmin() {
handler = createHandler(testScope)
handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM)
verify { nodeManager.removeByNodenum(REMOTE_NODE_NUM) }
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleSetRemoteOwner ----
@Test
fun handleSetRemoteOwner_decodesAndSendsAdmin() {
handler = createHandler(testScope)
val user = User(id = "!remote01", long_name = "Remote", short_name = "RM")
val payload = user.encode()
handler.handleSetRemoteOwner(1, REMOTE_NODE_NUM, payload)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleGetRemoteConfig: sessionkey vs regular ----
@Test
fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() {
handler = createHandler(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() {
handler = createHandler(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleSetRemoteChannel: null payload guard ----
@Test
fun handleSetRemoteChannel_nullPayload_doesNothing() {
handler = createHandler(testScope)
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null)
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() {
handler = createHandler(testScope)
val channel = Channel(index = 2)
val payload = channel.encode()
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, payload)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRequestRebootOta: null hash ----
@Test
fun handleRequestRebootOta_withNullHash_sendsAdmin() {
handler = createHandler(testScope)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleRequestRebootOta_withHash_sendsAdmin() {
handler = createHandler(testScope)
val hash = byteArrayOf(0x01, 0x02, 0x03)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRequestNodedbReset ----
@Test
fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() {
handler = createHandler(testScope)
handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- Helper ----
private fun createTestNode(
num: Int,
isFavorite: Boolean = false,
isIgnored: Boolean = false,
isMuted: Boolean = false,
): Node = Node(
num = num,
user = User(id = "!${num.toString(16).padStart(8, '0')}", long_name = "Node $num", short_name = "N$num"),
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
)
}

View File

@@ -41,7 +41,6 @@ import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
@@ -61,7 +60,6 @@ class MeshConfigFlowManagerImplTest {
private val nodeRepository = mock<NodeRepository>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
@@ -100,8 +98,7 @@ class MeshConfigFlowManagerImplTest {
connectionManager = lazy { connectionManager },
nodeRepository = nodeRepository,
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
serviceBroadcasts = serviceBroadcasts,
serviceStateWriter = serviceRepository,
analytics = analytics,
commandSender = commandSender,
heartbeatSender = DataLayerHeartbeatSender(packetHandler),
@@ -306,11 +303,10 @@ class MeshConfigFlowManagerImplTest {
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
verify { nodeManager.installNodeInfo(any(), withBroadcast = false) }
verify { nodeManager.installNodeInfo(any()) }
verify { nodeManager.setNodeDbReady(true) }
verify { nodeManager.setAllowNodeDbWrites(true) }
verify { serviceBroadcasts.broadcastConnection() }
verify { connectionManager.onNodeDbReady() }
verifySuspend { connectionManager.onNodeDbReady() }
}
@Test
@@ -334,7 +330,7 @@ class MeshConfigFlowManagerImplTest {
advanceUntilIdle()
verify { nodeManager.setNodeDbReady(true) }
verify { connectionManager.onNodeDbReady() }
verifySuspend { connectionManager.onNodeDbReady() }
}
// ---------- Unknown config_complete_id ----------
@@ -402,7 +398,7 @@ class MeshConfigFlowManagerImplTest {
advanceUntilIdle()
verify { nodeManager.setNodeDbReady(true) }
verify { connectionManager.onNodeDbReady() }
verifySuspend { connectionManager.onNodeDbReady() }
// After complete, newNodeCount should be 0 (state is Complete)
assertEquals(0, manager.newNodeCount)

View File

@@ -65,7 +65,7 @@ class MeshConfigHandlerImplTest {
private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl(
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
serviceStateWriter = serviceRepository,
nodeManager = nodeManager,
scope = scope,
)

View File

@@ -24,6 +24,7 @@ import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
@@ -39,7 +40,7 @@ import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeManager
@@ -48,7 +49,6 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.UiPrefs
@@ -66,8 +66,8 @@ import kotlin.test.assertEquals
class MeshConnectionManagerImplTest {
private val radioInterfaceService = mock<RadioInterfaceService>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val serviceNotifications = mock<MeshServiceNotifications>(MockMode.autofill)
private val serviceNotifications = mock<MeshNotificationManager>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
private val nodeRepository = FakeNodeRepository()
@@ -105,7 +105,7 @@ class MeshConnectionManagerImplTest {
connectionStateFlow.value = call.arg<ConnectionState>(0)
}
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { commandSender.sendAdmin(any(), any(), any(), any()) } returns Unit
everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
@@ -116,7 +116,6 @@ class MeshConnectionManagerImplTest {
private fun createManager(scope: CoroutineScope): MeshConnectionManagerImpl = MeshConnectionManagerImpl(
radioInterfaceService,
serviceRepository,
serviceBroadcasts,
serviceNotifications,
uiPrefs,
packetHandler,
@@ -149,7 +148,6 @@ class MeshConnectionManagerImplTest {
serviceRepository.connectionState.value,
"State should be Connecting after radio Connected",
)
verify { serviceBroadcasts.broadcastConnection() }
}
@Test
@@ -290,10 +288,10 @@ class MeshConnectionManagerImplTest {
store_forward = ModuleConfig.StoreForwardConfig(enabled = true),
)
moduleConfigFlow.value = moduleConfig
every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
everySuspend { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
every { mqttManager.startProxy(any(), any()) } returns Unit
every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
everySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
every { nodeManager.getMyNodeInfo() } returns null
manager = createManager(backgroundScope)
@@ -301,7 +299,7 @@ class MeshConnectionManagerImplTest {
advanceUntilIdle()
verify { mqttManager.startProxy(true, true) }
verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
verifySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
}
@Test

View File

@@ -34,9 +34,10 @@ 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.NodeAddress
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
@@ -45,7 +46,6 @@ import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
@@ -72,9 +72,8 @@ class MeshDataHandlerTest {
private val packetHandler: PacketHandler = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill)
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private val dataMapper: MeshDataMapper = mock(MockMode.autofill)
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
@@ -94,9 +93,8 @@ class MeshDataHandlerTest {
MeshDataHandlerImpl(
nodeManager = nodeManager,
packetHandler = packetHandler,
serviceRepository = serviceRepository,
serviceStateWriter = serviceRepository,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
notificationManager = notificationManager,
serviceNotifications = serviceNotifications,
analytics = analytics,
@@ -114,7 +112,7 @@ class MeshDataHandlerTest {
// 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.getNodeById(any()) } returns null
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
}
@@ -132,7 +130,6 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, 123)
// Should not broadcast if dataMapper returns null
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
@@ -146,8 +143,8 @@ class MeshDataHandlerTest {
)
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(myNodeNum),
to = DataPacket.ID_BROADCAST,
from = NodeAddress.numToDefaultId(myNodeNum),
to = NodeAddress.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
@@ -156,8 +153,7 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, myNodeNum)
// Position from local node: shouldBroadcast stays as !fromUs = false
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
// Position from local node — no further action expected
}
@Test
@@ -167,16 +163,14 @@ class MeshDataHandlerTest {
val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP))
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(remoteNum),
to = DataPacket.ID_BROADCAST,
from = NodeAddress.numToDefaultId(remoteNum),
to = NodeAddress.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
@@ -185,7 +179,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!other",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
@@ -211,7 +205,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
@@ -238,7 +232,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
@@ -261,7 +255,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
@@ -286,7 +280,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = pax.encode().toByteString(),
dataType = PortNum.PAXCOUNTER_APP.value,
)
@@ -318,7 +312,6 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, 123)
verify { tracerouteHandler.handleTraceroute(packet, any(), any()) }
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- NeighborInfo handling ---
@@ -334,7 +327,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = ni.encode().toByteString(),
dataType = PortNum.NEIGHBORINFO_APP.value,
)
@@ -343,7 +336,6 @@ class MeshDataHandlerTest {
handler.handleReceivedData(packet, 123)
verify { neighborInfoHandler.handleNeighborInfo(packet) }
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Store-and-Forward handling ---
@@ -358,7 +350,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = byteArrayOf().toByteString(),
dataType = PortNum.STORE_FORWARD_APP.value,
)
@@ -383,7 +375,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
@@ -407,7 +399,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
@@ -415,8 +407,6 @@ class MeshDataHandlerTest {
every { nodeManager.toNodeID(456) } returns "!remote"
handler.handleReceivedData(packet, 123)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Telemetry handling ---
@@ -436,7 +426,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
@@ -464,7 +454,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
@@ -491,7 +481,7 @@ class MeshDataHandlerTest {
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
@@ -500,11 +490,8 @@ class MeshDataHandlerTest {
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")),
)
every { nodeManager.getNodeById("!remote") } returns
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU"))
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
@@ -525,7 +512,7 @@ class MeshDataHandlerTest {
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
@@ -598,7 +585,7 @@ class MeshDataHandlerTest {
DataPacket(
id = 55,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "test".encodeToByteArray().toByteString(),
dataType = PortNum.RANGE_TEST_APP.value,
)
@@ -606,11 +593,8 @@ class MeshDataHandlerTest {
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")),
)
every { nodeManager.getNodeById("!remote") } returns
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU"))
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
@@ -629,7 +613,7 @@ class MeshDataHandlerTest {
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = admin.encode().toByteString(),
dataType = PortNum.ADMIN_APP.value,
)
@@ -658,13 +642,13 @@ class MeshDataHandlerTest {
DataPacket(
id = 77,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.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()
every { nodeManager.getNodeById(any()) } returns null
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter("spam content", false) } returns true
@@ -688,14 +672,14 @@ class MeshDataHandlerTest {
DataPacket(
id = 88,
from = "!remote",
to = DataPacket.ID_BROADCAST,
to = NodeAddress.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))
every { nodeManager.getNodeById("!remote") } returns
Node(num = 456, user = User(id = "!remote"), isIgnored = true)
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
handler.handleReceivedData(packet, 123)

View File

@@ -33,7 +33,6 @@ import okio.ByteString
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Data
@@ -50,7 +49,6 @@ class MeshMessageProcessorImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val meshLogRepository = mock<MeshLogRepository>(MockMode.autofill)
private val router = mock<MeshRouter>(MockMode.autofill)
private val fromRadioDispatcher = mock<FromRadioPacketHandler>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
@@ -65,14 +63,13 @@ class MeshMessageProcessorImplTest {
fun setUp() {
every { nodeManager.isNodeDbReady } returns isNodeDbReady
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(myNodeNum)
every { router.dataHandler } returns dataHandler
}
private fun createProcessor(scope: CoroutineScope): MeshMessageProcessorImpl = MeshMessageProcessorImpl(
nodeManager = nodeManager,
serviceRepository = serviceRepository,
serviceStateWriter = serviceRepository,
meshLogRepository = lazy { meshLogRepository },
router = lazy { router },
dataHandler = lazy { dataHandler },
fromRadioDispatcher = fromRadioDispatcher,
scope = scope,
)
@@ -251,7 +248,7 @@ class MeshMessageProcessorImplTest {
advanceUntilIdle()
// Should have called updateNode for myNodeNum (lastHeard update)
verify { nodeManager.updateNode(myNodeNum, withBroadcast = true, any(), any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any()) }
}
@Test
@@ -273,7 +270,7 @@ class MeshMessageProcessorImplTest {
advanceUntilIdle()
// Should have called updateNode for the sender
verify { nodeManager.updateNode(senderNode, withBroadcast = false, any(), any()) }
verify { nodeManager.updateNode(senderNode, any(), any()) }
}
// ---------- handleReceivedMeshPacket: null decoded ----------

View File

@@ -1,189 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.repository.XModemManager
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MeshRouterImplTest {
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
private val tracerouteHandler = mock<TracerouteHandler>(MockMode.autofill)
private val neighborInfoHandler = mock<NeighborInfoHandler>(MockMode.autofill)
private val configFlowManager = mock<MeshConfigFlowManager>(MockMode.autofill)
private val mqttManager = mock<MqttManager>(MockMode.autofill)
private val actionHandler = mock<MeshActionHandler>(MockMode.autofill)
private val xmodemManager = mock<XModemManager>(MockMode.autofill)
private val configHandler =
object : MeshConfigHandler {
override val localConfig = MutableStateFlow(LocalConfig())
override val moduleConfig = MutableStateFlow(LocalModuleConfig())
override fun handleDeviceConfig(config: org.meshtastic.proto.Config) = Unit
override fun handleModuleConfig(config: org.meshtastic.proto.ModuleConfig) = Unit
override fun handleChannel(channel: org.meshtastic.proto.Channel) = Unit
override fun handleDeviceUIConfig(config: org.meshtastic.proto.DeviceUIConfig) = Unit
}
private lateinit var dataHandlerLazy: TrackingLazy<MeshDataHandler>
private lateinit var configHandlerLazy: TrackingLazy<MeshConfigHandler>
private lateinit var tracerouteHandlerLazy: TrackingLazy<TracerouteHandler>
private lateinit var neighborInfoHandlerLazy: TrackingLazy<NeighborInfoHandler>
private lateinit var configFlowManagerLazy: TrackingLazy<MeshConfigFlowManager>
private lateinit var mqttManagerLazy: TrackingLazy<MqttManager>
private lateinit var actionHandlerLazy: TrackingLazy<MeshActionHandler>
private lateinit var xmodemManagerLazy: TrackingLazy<XModemManager>
private lateinit var router: MeshRouterImpl
@BeforeTest
fun setUp() {
dataHandlerLazy = TrackingLazy { dataHandler }
configHandlerLazy = TrackingLazy { configHandler }
tracerouteHandlerLazy = TrackingLazy { tracerouteHandler }
neighborInfoHandlerLazy = TrackingLazy { neighborInfoHandler }
configFlowManagerLazy = TrackingLazy { configFlowManager }
mqttManagerLazy = TrackingLazy { mqttManager }
actionHandlerLazy = TrackingLazy { actionHandler }
xmodemManagerLazy = TrackingLazy { xmodemManager }
router =
MeshRouterImpl(
dataHandlerLazy = dataHandlerLazy,
configHandlerLazy = configHandlerLazy,
tracerouteHandlerLazy = tracerouteHandlerLazy,
neighborInfoHandlerLazy = neighborInfoHandlerLazy,
configFlowManagerLazy = configFlowManagerLazy,
mqttManagerLazy = mqttManagerLazy,
actionHandlerLazy = actionHandlerLazy,
xmodemManagerLazy = xmodemManagerLazy,
)
}
@Test
fun `send message routing uses the action handler lazily`() {
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
assertAllHandlersUninitialized()
router.actionHandler.handleSend(packet, 12345)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(dataHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
verify { actionHandler.handleSend(packet, 12345) }
}
@Test
fun `request position routing uses the action handler lazily`() {
val position = Position(latitude = 37.7749, longitude = -122.4194, altitude = 10)
router.actionHandler.handleRequestPosition(destNum = 67890, position = position, myNodeNum = 12345)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
verify { actionHandler.handleRequestPosition(67890, position, 12345) }
}
@Test
fun `traceroute routing uses the traceroute handler lazily`() {
assertAllHandlersUninitialized()
router.tracerouteHandler.recordStartTime(77)
assertTrue(tracerouteHandlerLazy.isInitialized())
assertFalse(actionHandlerLazy.isInitialized())
verify { tracerouteHandler.recordStartTime(77) }
}
@Test
fun `admin command routing uses the action handler lazily`() {
assertAllHandlersUninitialized()
router.actionHandler.handleGetRemoteConfig(id = 42, destNum = 67890, config = 7)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(configHandlerLazy.isInitialized())
verify { actionHandler.handleGetRemoteConfig(42, 67890, 7) }
}
@Test
fun `service actions are passed through unchanged to the action handler`() = runTest {
val action = ServiceAction.Favorite(Node(num = 67890))
router.actionHandler.onServiceAction(action)
assertTrue(actionHandlerLazy.isInitialized())
assertFalse(dataHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
verifySuspend { actionHandler.onServiceAction(action) }
}
private fun assertAllHandlersUninitialized() {
assertFalse(dataHandlerLazy.isInitialized())
assertFalse(configHandlerLazy.isInitialized())
assertFalse(tracerouteHandlerLazy.isInitialized())
assertFalse(neighborInfoHandlerLazy.isInitialized())
assertFalse(configFlowManagerLazy.isInitialized())
assertFalse(mqttManagerLazy.isInitialized())
assertFalse(actionHandlerLazy.isInitialized())
assertFalse(xmodemManagerLazy.isInitialized())
}
private class TrackingLazy<T>(private val initializer: () -> T) : Lazy<T> {
private var cached: Any? = Uninitialized
override val value: T
get() {
if (cached === Uninitialized) {
cached = initializer()
}
@Suppress("UNCHECKED_CAST")
return cached as T
}
override fun isInitialized(): Boolean = cached !== Uninitialized
private object Uninitialized
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.flow.MutableStateFlow
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class NeighborInfoHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val nodeRepository = mock<NodeRepository>(MockMode.autofill)
private lateinit var handler: NeighborInfoHandlerImpl
private val myNodeNum = 12345
@BeforeTest
fun setUp() {
every { nodeManager.myNodeNum } returns MutableStateFlow<Int?>(myNodeNum)
handler = NeighborInfoHandlerImpl(nodeManager, serviceRepository, nodeRepository)
}
@Test
fun `handleNeighborInfo stores lastNeighborInfo when from own node`() {
val ni = NeighborInfo(node_id = myNodeNum, neighbors = listOf(Neighbor(node_id = 100, snr = 5.0f)))
val packet = createPacketWithNeighborInfo(from = myNodeNum, ni = ni)
every { nodeRepository.getUser(100) } returns User(long_name = "Alice", short_name = "AL")
every { nodeRepository.getUser(myNodeNum) } returns User(long_name = "Me", short_name = "ME")
handler.handleNeighborInfo(packet)
assertEquals(ni, handler.lastNeighborInfo)
}
@Test
fun `handleNeighborInfo does not store lastNeighborInfo when from remote node`() {
val remoteNode = 99999
val ni = NeighborInfo(node_id = remoteNode, neighbors = listOf(Neighbor(node_id = 200, snr = 3.0f)))
val packet = createPacketWithNeighborInfo(from = remoteNode, ni = ni)
every { nodeRepository.getUser(200) } returns User(long_name = "Bob", short_name = "BO")
every { nodeRepository.getUser(remoteNode) } returns User(long_name = "Remote", short_name = "RM")
handler.handleNeighborInfo(packet)
assertNull(handler.lastNeighborInfo)
}
@Test
fun `handleNeighborInfo sets response on serviceRepository`() {
val ni =
NeighborInfo(
node_id = myNodeNum,
neighbors = listOf(Neighbor(node_id = 100, snr = 5.5f), Neighbor(node_id = 200, snr = -2.0f)),
)
val packet = createPacketWithNeighborInfo(from = myNodeNum, ni = ni)
every { nodeRepository.getUser(100) } returns User(long_name = "Alice", short_name = "AL")
every { nodeRepository.getUser(200) } returns User(long_name = "Bob", short_name = "BO")
every { nodeRepository.getUser(myNodeNum) } returns User(long_name = "Me", short_name = "ME")
handler.handleNeighborInfo(packet)
verify { serviceRepository.setNeighborInfoResponse(any()) }
}
@Test
fun `handleNeighborInfo ignores packet with null decoded`() {
val packet = MeshPacket(from = myNodeNum)
handler.handleNeighborInfo(packet)
assertNull(handler.lastNeighborInfo)
}
@Test
fun `recordStartTime and handleNeighborInfo includes duration`() {
val requestId = 42
val ni = NeighborInfo(node_id = myNodeNum, neighbors = listOf(Neighbor(node_id = 100, snr = 1.0f)))
val packet = createPacketWithNeighborInfo(from = myNodeNum, ni = ni, requestId = requestId)
every { nodeRepository.getUser(100) } returns User(long_name = "Alice", short_name = "AL")
every { nodeRepository.getUser(myNodeNum) } returns User(long_name = "Me", short_name = "ME")
handler.recordStartTime(requestId)
handler.handleNeighborInfo(packet)
verify { serviceRepository.setNeighborInfoResponse(any()) }
}
private fun createPacketWithNeighborInfo(from: Int, ni: NeighborInfo, requestId: Int = 0): MeshPacket {
val encoded = NeighborInfo.ADAPTER.encode(ni).toByteString()
return MeshPacket(from = from, decoded = Data(payload = encoded, request_id = requestId))
}
}

View File

@@ -17,15 +17,18 @@
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 kotlinx.coroutines.test.TestScope
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
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
@@ -43,7 +46,6 @@ import org.meshtastic.proto.Position as ProtoPosition
class NodeManagerImplTest {
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private val testScope = TestScope()
@@ -51,7 +53,7 @@ class NodeManagerImplTest {
@BeforeTest
fun setUp() {
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope)
nodeManager = NodeManagerImpl(nodeRepository, notificationManager, testScope)
}
@Test
@@ -62,7 +64,7 @@ class NodeManagerImplTest {
assertNotNull(result)
assertEquals(nodeNum, result.num)
assertTrue(result.user.long_name.startsWith("Meshtastic"))
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), result.user.id)
assertEquals(NodeAddress.numToDefaultId(nodeNum), result.user.id)
}
@Test
@@ -192,20 +194,20 @@ class NodeManagerImplTest {
nodeManager.clear()
assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty())
assertTrue(nodeManager.nodeDBbyID.isEmpty())
assertNull(nodeManager.getNodeById("!000004d2"))
assertNull(nodeManager.myNodeNum.value)
}
@Test
fun `toNodeID returns broadcast ID for broadcast nodeNum`() {
val result = nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST)
assertEquals(DataPacket.ID_BROADCAST, result)
val result = nodeManager.toNodeID(NodeAddress.NODENUM_BROADCAST)
assertEquals(NodeAddress.ID_BROADCAST, result)
}
@Test
fun `toNodeID returns default hex ID for unknown node`() {
val result = nodeManager.toNodeID(0x1234)
assertEquals(DataPacket.nodeNumToDefaultId(0x1234), result)
assertEquals(NodeAddress.numToDefaultId(0x1234), result)
}
@Test
@@ -218,18 +220,18 @@ class NodeManagerImplTest {
}
@Test
fun `removeByNodenum removes node from both maps`() {
fun `removeByNodenum removes node from map`() {
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"))
assertNotNull(nodeManager.getNodeById("!testnode"))
nodeManager.removeByNodenum(nodeNum)
assertTrue(!nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
assertTrue(!nodeManager.nodeDBbyID.containsKey("!testnode"))
assertNull(nodeManager.getNodeById("!testnode"))
}
@Test
@@ -330,4 +332,111 @@ class NodeManagerImplTest {
assertEquals(ByteString.EMPTY, result.publicKey)
assertEquals(ByteString.EMPTY, result.user.public_key)
}
@Test
fun `getMyNodeInfo returns null when repository has no info`() {
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
val result = nodeManager.getMyNodeInfo()
assertNull(result)
}
@Test
fun `getMyNodeInfo synthesizes from repository and nodeDB`() {
val myNum = 1234
val repoInfo =
MyNodeInfo(
myNodeNum = myNum,
hasGPS = false,
model = "tbeam",
firmwareVersion = "2.5.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 100L,
messageTimeoutMsec = 5000,
minAppVersion = 30000,
maxChannels = 8,
hasWifi = false,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = null,
)
every { nodeRepository.myNodeInfo } returns MutableStateFlow(repoInfo)
// Add node with position (non-zero lat → hasGPS = true)
nodeManager.handleReceivedPosition(myNum, myNum, ProtoPosition(latitude_i = 100), 0)
nodeManager.updateNode(myNum) { it.copy(user = it.user.copy(id = "!mydevice", hw_model = HardwareModel.TBEAM)) }
val result = nodeManager.getMyNodeInfo()
assertNotNull(result)
assertEquals(myNum, result.myNodeNum)
assertTrue(result.hasGPS)
assertEquals("tbeam", result.model)
assertEquals("!mydevice", result.deviceId)
}
@Test
fun `getMyNodeInfo falls back to nodeDB model when repository model is null`() {
val myNum = 1234
val repoInfo =
MyNodeInfo(
myNodeNum = myNum,
hasGPS = false,
model = null,
firmwareVersion = "2.5.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 100L,
messageTimeoutMsec = 5000,
minAppVersion = 30000,
maxChannels = 8,
hasWifi = false,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = null,
)
every { nodeRepository.myNodeInfo } returns MutableStateFlow(repoInfo)
nodeManager.updateNode(myNum) { it.copy(user = it.user.copy(hw_model = HardwareModel.HELTEC_V3)) }
val result = nodeManager.getMyNodeInfo()
assertNotNull(result)
assertEquals("HELTEC_V3", result.model)
}
@Test
fun `handleReceivedTelemetry with null metrics does not crash`() {
val nodeNum = 1234
nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) }
// Telemetry with no metrics at all
val telemetry = Telemetry(time = 3000)
nodeManager.handleReceivedTelemetry(nodeNum, telemetry)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertNotNull(result)
assertEquals(3000, result.lastHeard)
}
@Test
fun `getMyId returns empty when disconnected`() {
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
val result = nodeManager.getMyId()
assertEquals("", result)
}
@Test
fun `getMyId returns user ID when connected`() {
val myNum = 1234
nodeManager.setMyNodeNum(myNum)
nodeManager.updateNode(myNum) { it.copy(user = it.user.copy(id = "!mynode42")) }
val result = nodeManager.getMyId()
assertEquals("!mynode42", result)
}
}

View File

@@ -34,7 +34,6 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
@@ -48,7 +47,6 @@ import kotlin.test.assertNotNull
class PacketHandlerImplTest {
private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val meshLogRepository: MeshLogRepository = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
@@ -67,7 +65,6 @@ class PacketHandlerImplTest {
handler =
PacketHandlerImpl(
lazy { packetRepository },
serviceBroadcasts,
radioInterfaceService,
lazy { meshLogRepository },
serviceRepository,

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class RequestTimerTest {
@Test
fun appendDuration_withoutStart_returnsTextUnchanged() {
val timer = RequestTimer()
assertEquals("base", timer.appendDuration(requestId = 1, text = "base", logLabel = "Test"))
}
@Test
fun appendDuration_afterStart_appendsDurationLine() {
val timer = RequestTimer()
timer.start(requestId = 7)
val result = timer.appendDuration(requestId = 7, text = "base", logLabel = "Test")
assertTrue(result.startsWith("base\n\nDuration: "), "expected a duration suffix, got: $result")
assertTrue(result.endsWith(" s"))
}
@Test
fun appendDuration_consumesStartTime_soSecondCallIsUnchanged() {
val timer = RequestTimer()
timer.start(requestId = 7)
timer.appendDuration(requestId = 7, text = "first", logLabel = "Test")
// The start time is single-use; a second response for the same id gets no duration.
assertEquals("second", timer.appendDuration(requestId = 7, text = "second", logLabel = "Test"))
}
@Test
fun start_tracksRequestsIndependently() {
val timer = RequestTimer()
timer.start(requestId = 1)
timer.start(requestId = 2)
// Consuming one id must not affect the other.
timer.appendDuration(requestId = 1, text = "a", logLabel = "Test")
assertTrue(timer.appendDuration(requestId = 2, text = "b", logLabel = "Test").contains("Duration: "))
}
}

View File

@@ -27,6 +27,7 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
@@ -78,8 +79,8 @@ class TelemetryPacketHandlerImplTest {
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
id = 1,
time = 1700000000000L,
to = DataPacket.ID_BROADCAST,
from = DataPacket.nodeNumToDefaultId(from),
to = NodeAddress.ID_BROADCAST,
from = NodeAddress.numToDefaultId(from),
bytes = null,
dataType = PortNum.TELEMETRY_APP.value,
)
@@ -97,7 +98,7 @@ class TelemetryPacketHandlerImplTest {
advanceUntilIdle()
verify { connectionManager.updateTelemetry(any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any()) }
}
// ---------- Device metrics from remote node ----------
@@ -112,7 +113,7 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(remoteNodeNum, any(), any()) }
}
// ---------- Environment metrics ----------
@@ -130,7 +131,7 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(remoteNodeNum, any(), any()) }
}
// ---------- Power metrics ----------
@@ -144,7 +145,7 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(remoteNodeNum, any(), any()) }
}
// ---------- Telemetry time handling ----------
@@ -158,7 +159,7 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any()) }
}
// ---------- Null payload ----------

View File

@@ -32,11 +32,11 @@ import kotlinx.coroutines.test.runTest
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
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.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
@@ -50,7 +50,6 @@ class StoreForwardPacketHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val historyManager = mock<HistoryManager>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
@@ -69,7 +68,6 @@ class StoreForwardPacketHandlerImplTest {
StoreForwardPacketHandlerImpl(
nodeManager = nodeManager,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
historyManager = historyManager,
dataHandler = lazy { dataHandler },
scope = testScope,
@@ -89,8 +87,8 @@ class StoreForwardPacketHandlerImplTest {
private fun makeDataPacket(from: Int): DataPacket = DataPacket(
id = 1,
time = 1700000000000L,
to = DataPacket.ID_BROADCAST,
from = DataPacket.nodeNumToDefaultId(from),
to = NodeAddress.ID_BROADCAST,
from = NodeAddress.numToDefaultId(from),
bytes = null,
dataType = PortNum.STORE_FORWARD_APP.value,
)
@@ -222,7 +220,6 @@ class StoreForwardPacketHandlerImplTest {
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
verify { serviceBroadcasts.broadcastMessageStatus(42, any()) }
}
// ---------- SF++: CANON_ANNOUNCE ----------

View File

@@ -33,6 +33,7 @@ import org.meshtastic.core.database.MeshtasticDatabaseConstructor
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
import org.robolectric.annotation.Config
@@ -168,7 +169,7 @@ class MigrationTest {
contact_key = "$channel!broadcast",
received_time = nowMillis,
read = false,
data = DataPacket(to = DataPacket.ID_BROADCAST, channel = channel, text = text),
data = DataPacket(to = NodeAddress.ID_BROADCAST, channel = channel, text = text),
)
packetDao.insert(packet)
}

View File

@@ -26,12 +26,7 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
@@ -46,32 +41,32 @@ data class NodeWithRelations(
@Relation(entity = MetadataEntity::class, parentColumns = ["num"], entityColumns = ["num"])
val metadata: MetadataEntity?,
) {
fun toModel() = with(node) {
Node(
num = num,
metadata = metadata?.proto,
user = user,
position = position,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(),
powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(),
paxcounter = paxcounter,
publicKey = publicKey ?: user.public_key,
notes = notes,
manuallyVerified = manuallyVerified,
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
}
// Direct construction avoids the previous `node.toModel().copy(metadata = …, manuallyVerified = …)` pattern,
// which allocated the Node twice per DB row (once from toModel, once from copy). Hot path on every DB emission.
fun toModel() = Node(
num = node.num,
user = node.user,
position = node.position,
snr = node.snr,
rssi = node.rssi,
lastHeard = node.lastHeard,
deviceMetrics = node.deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(),
channel = node.channel,
viaMqtt = node.viaMqtt,
hopsAway = node.hopsAway,
isFavorite = node.isFavorite,
isIgnored = node.isIgnored,
isMuted = node.isMuted,
environmentMetrics = node.environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(),
powerMetrics = node.powerMetrics ?: org.meshtastic.proto.PowerMetrics(),
paxcounter = node.paxcounter,
publicKey = node.publicKey ?: node.user.public_key,
notes = node.notes,
nodeStatus = node.nodeStatus,
lastTransport = node.lastTransport,
metadata = metadata?.proto,
manuallyVerified = node.manuallyVerified,
)
fun toEntity() = with(node) {
NodeEntity(
@@ -211,49 +206,4 @@ data class NodeEntity(
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
fun toNodeInfo() = NodeInfo(
num = num,
user =
MeshUser(
id = user.id,
longName = user.long_name,
shortName = user.short_name,
hwModel = user.hw_model,
role = user.role.value,
)
.takeIf { user.id.isNotEmpty() },
position =
Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits,
)
.takeIf { it.isValid() },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics =
DeviceMetrics(
time = deviceTelemetry.time,
batteryLevel = deviceMetrics?.battery_level ?: 0,
voltage = deviceMetrics?.voltage ?: 0f,
channelUtilization = deviceMetrics?.channel_utilization ?: 0f,
airUtilTx = deviceMetrics?.air_util_tx ?: 0f,
uptimeSeconds = deviceMetrics?.uptime_seconds ?: 0,
),
channel = channel,
environmentMetrics =
EnvironmentMetrics.fromTelemetryProto(
environmentTelemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics(),
environmentTelemetry.time,
),
hopsAway = hopsAway,
nodeStatus = nodeStatus,
)
}

View File

@@ -28,6 +28,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.util.getShortDateTime
@@ -38,7 +39,7 @@ data class PacketEntity(
) {
suspend fun toMessage(getNode: suspend (userId: String?) -> Node) = with(packet) {
val node = getNode(data.from)
val isFromLocal = node.user.id == DataPacket.ID_LOCAL || (myNodeNum != 0 && node.num == myNodeNum)
val isFromLocal = node.user.id == NodeAddress.ID_LOCAL || (myNodeNum != 0 && node.num == myNodeNum)
Message(
uuid = uuid,
receivedTime = received_time,

View File

@@ -20,6 +20,7 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.FromRadio
@@ -41,7 +42,7 @@ class ConvertersTest {
fun `data packet string converter round trips`() {
val packet =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "hello mesh".encodeToByteArray().toByteString(),
dataType = 1,
from = "!12345678",

View File

@@ -27,6 +27,7 @@ import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.getInMemoryDatabaseBuilder
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.proto.PortNum
import kotlin.test.AfterTest
import kotlin.test.Test
@@ -57,7 +58,7 @@ abstract class CommonPacketDaoTest {
private val myNodeNum: Int
get() = myNodeInfo.myNodeNum
private val testContactKeys = listOf("0${DataPacket.ID_BROADCAST}", "1!test1234")
private val testContactKeys = listOf("0${NodeAddress.ID_BROADCAST}", "1!test1234")
private fun generateTestPackets(myNodeNum: Int) = testContactKeys.flatMap { contactKey ->
List(SAMPLE_SIZE) {
@@ -70,7 +71,7 @@ abstract class CommonPacketDaoTest {
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "Message $it!".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
@@ -157,7 +158,7 @@ abstract class CommonPacketDaoTest {
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "Queued".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
status = MessageStatus.QUEUED,
@@ -191,12 +192,12 @@ abstract class CommonPacketDaoTest {
uuid = 0L,
myNodeNum = myNodeNum,
port_num = PortNum.WAYPOINT_APP.value,
contact_key = "0${DataPacket.ID_BROADCAST}",
contact_key = "0${NodeAddress.ID_BROADCAST}",
received_time = nowMillis,
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "Waypoint".encodeToByteArray().toByteString(),
dataType = PortNum.WAYPOINT_APP.value,
),
@@ -231,7 +232,7 @@ abstract class CommonPacketDaoTest {
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
@@ -251,7 +252,7 @@ abstract class CommonPacketDaoTest {
read = true,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),
@@ -293,7 +294,7 @@ abstract class CommonPacketDaoTest {
read = false,
data =
DataPacket(
to = DataPacket.ID_BROADCAST,
to = NodeAddress.ID_BROADCAST,
bytes = "Chunk $id".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
),

View File

@@ -35,7 +35,6 @@ src/commonMain/kotlin/org/meshtastic/core/domain/
├── ImportProfileUseCase.kt
├── InstallProfileUseCase.kt
├── IsOtaCapableUseCase.kt
├── MeshLocationUseCase.kt
├── ProcessRadioResponseUseCase.kt
├── RadioConfigUseCase.kt
├── SetAppIntroCompletedUseCase.kt

View File

@@ -30,8 +30,7 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import kotlin.time.Duration.Companion.seconds
@@ -55,7 +54,7 @@ import kotlin.time.Duration.Companion.seconds
@Single
open class EnsureRemoteAdminSessionUseCase(
private val sessionManager: SessionManager,
private val meshActionHandler: MeshActionHandler,
private val radioController: RadioController,
private val serviceRepository: ServiceRepository,
@Named("ServiceScope") private val serviceScope: CoroutineScope,
) {
@@ -94,7 +93,7 @@ open class EnsureRemoteAdminSessionUseCase(
sessionManager.sessionRefreshFlow.filter { it == destNum }.first()
}
try {
meshActionHandler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum))
radioController.refreshMetadata(destNum)
refreshed.await()
EnsureSessionResult.Refreshed
} finally {

View File

@@ -17,8 +17,8 @@
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioController
/**
* Use case for performing administrative and destructive actions on mesh nodes.
@@ -39,7 +39,7 @@ constructor(
* @return The packet ID of the request.
*/
open suspend fun reboot(destNum: Int): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.reboot(destNum, packetId)
return packetId
}
@@ -51,7 +51,7 @@ constructor(
* @return The packet ID of the request.
*/
open suspend fun shutdown(destNum: Int): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.shutdown(destNum, packetId)
return packetId
}
@@ -64,7 +64,7 @@ constructor(
* @return The packet ID of the request.
*/
open suspend fun factoryReset(destNum: Int, isLocal: Boolean): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.factoryReset(destNum, packetId)
if (isLocal) {
@@ -84,7 +84,7 @@ constructor(
* @return The packet ID of the request.
*/
open suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean, isLocal: Boolean): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.nodedbReset(destNum, packetId, preserveFavorites)
if (isLocal) {

View File

@@ -18,8 +18,8 @@ package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioController
import kotlin.time.Duration.Companion.days
/** Use case for cleaning up nodes from the database. */
@@ -58,7 +58,7 @@ constructor(
nodeRepository.deleteNodes(nodeNums)
for (nodeNum in nodeNums) {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.removeByNodenum(packetId, nodeNum)
}
}

View File

@@ -18,7 +18,8 @@ package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.AdminEditScope
import org.meshtastic.core.repository.RadioController
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
@@ -37,118 +38,72 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC
* @param currentUser The current user configuration of the destination node (to preserve names if not in profile).
*/
open suspend operator fun invoke(destNum: Int, profile: DeviceProfile, currentUser: User?) {
radioController.beginEditSettings(destNum)
installOwner(destNum, profile, currentUser)
installConfig(destNum, profile.config)
installFixedPosition(destNum, profile.fixed_position)
installModuleConfig(destNum, profile.module_config)
radioController.commitEditSettings(destNum)
radioController.editSettings(destNum) {
installOwner(profile, currentUser)
installConfig(profile.config)
installFixedPosition(profile.fixed_position)
installModuleConfig(profile.module_config)
}
}
private suspend fun installOwner(destNum: Int, profile: DeviceProfile, currentUser: User?) {
private suspend fun AdminEditScope.installOwner(profile: DeviceProfile, currentUser: User?) {
if (profile.long_name != null || profile.short_name != null) {
currentUser?.let {
val user =
setOwner(
it.copy(
long_name = profile.long_name ?: it.long_name,
short_name = profile.short_name ?: it.short_name,
)
radioController.setOwner(destNum, user, radioController.getPacketId())
),
)
}
}
}
private suspend fun installConfig(destNum: Int, config: LocalConfig?) {
private suspend fun AdminEditScope.installConfig(config: LocalConfig?) {
config?.let { lc ->
lc.device?.let { radioController.setConfig(destNum, Config(device = it), radioController.getPacketId()) }
lc.position?.let {
radioController.setConfig(destNum, Config(position = it), radioController.getPacketId())
}
lc.power?.let { radioController.setConfig(destNum, Config(power = it), radioController.getPacketId()) }
lc.network?.let { radioController.setConfig(destNum, Config(network = it), radioController.getPacketId()) }
lc.display?.let { radioController.setConfig(destNum, Config(display = it), radioController.getPacketId()) }
lc.lora?.let { radioController.setConfig(destNum, Config(lora = it), radioController.getPacketId()) }
lc.bluetooth?.let {
radioController.setConfig(destNum, Config(bluetooth = it), radioController.getPacketId())
}
lc.security?.let {
radioController.setConfig(destNum, Config(security = it), radioController.getPacketId())
}
lc.device?.let { setConfig(Config(device = it)) }
lc.position?.let { setConfig(Config(position = it)) }
lc.power?.let { setConfig(Config(power = it)) }
lc.network?.let { setConfig(Config(network = it)) }
lc.display?.let { setConfig(Config(display = it)) }
lc.lora?.let { setConfig(Config(lora = it)) }
lc.bluetooth?.let { setConfig(Config(bluetooth = it)) }
lc.security?.let { setConfig(Config(security = it)) }
}
}
private suspend fun installFixedPosition(destNum: Int, fixedPosition: org.meshtastic.proto.Position?) {
private suspend fun AdminEditScope.installFixedPosition(fixedPosition: org.meshtastic.proto.Position?) {
if (fixedPosition != null) {
radioController.setFixedPosition(destNum, Position(fixedPosition))
setFixedPosition(Position(fixedPosition))
}
}
private suspend fun installModuleConfig(destNum: Int, moduleConfig: LocalModuleConfig?) {
private suspend fun AdminEditScope.installModuleConfig(moduleConfig: LocalModuleConfig?) {
moduleConfig?.let { lmc ->
installModuleConfigPart1(destNum, lmc)
installModuleConfigPart2(destNum, lmc)
installModuleConfigPart1(lmc)
installModuleConfigPart2(lmc)
}
}
private suspend fun installModuleConfigPart1(destNum: Int, lmc: LocalModuleConfig) {
lmc.mqtt?.let {
radioController.setModuleConfig(destNum, ModuleConfig(mqtt = it), radioController.getPacketId())
}
lmc.serial?.let {
radioController.setModuleConfig(destNum, ModuleConfig(serial = it), radioController.getPacketId())
}
lmc.external_notification?.let {
radioController.setModuleConfig(
destNum,
ModuleConfig(external_notification = it),
radioController.getPacketId(),
)
}
lmc.store_forward?.let {
radioController.setModuleConfig(destNum, ModuleConfig(store_forward = it), radioController.getPacketId())
}
lmc.range_test?.let {
radioController.setModuleConfig(destNum, ModuleConfig(range_test = it), radioController.getPacketId())
}
lmc.telemetry?.let {
radioController.setModuleConfig(destNum, ModuleConfig(telemetry = it), radioController.getPacketId())
}
lmc.canned_message?.let {
radioController.setModuleConfig(destNum, ModuleConfig(canned_message = it), radioController.getPacketId())
}
lmc.audio?.let {
radioController.setModuleConfig(destNum, ModuleConfig(audio = it), radioController.getPacketId())
}
private suspend fun AdminEditScope.installModuleConfigPart1(lmc: LocalModuleConfig) {
lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) }
lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) }
lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) }
lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) }
lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) }
lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) }
lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) }
lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) }
}
private suspend fun installModuleConfigPart2(destNum: Int, lmc: LocalModuleConfig) {
lmc.remote_hardware?.let {
radioController.setModuleConfig(destNum, ModuleConfig(remote_hardware = it), radioController.getPacketId())
}
lmc.neighbor_info?.let {
radioController.setModuleConfig(destNum, ModuleConfig(neighbor_info = it), radioController.getPacketId())
}
lmc.ambient_lighting?.let {
radioController.setModuleConfig(destNum, ModuleConfig(ambient_lighting = it), radioController.getPacketId())
}
lmc.detection_sensor?.let {
radioController.setModuleConfig(destNum, ModuleConfig(detection_sensor = it), radioController.getPacketId())
}
lmc.paxcounter?.let {
radioController.setModuleConfig(destNum, ModuleConfig(paxcounter = it), radioController.getPacketId())
}
lmc.statusmessage?.let {
radioController.setModuleConfig(destNum, ModuleConfig(statusmessage = it), radioController.getPacketId())
}
lmc.traffic_management?.let {
radioController.setModuleConfig(
destNum,
ModuleConfig(traffic_management = it),
radioController.getPacketId(),
)
}
lmc.tak?.let { radioController.setModuleConfig(destNum, ModuleConfig(tak = it), radioController.getPacketId()) }
private suspend fun AdminEditScope.installModuleConfigPart2(lmc: LocalModuleConfig) {
lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) }
lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) }
lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) }
lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
}
}

View File

@@ -24,9 +24,9 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.isBle
import org.meshtastic.core.repository.isSerial

View File

@@ -1,34 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.model.RadioController
/** Use case for controlling location sharing with the mesh. */
@Single
open class MeshLocationUseCase constructor(private val radioController: RadioController) {
/** Starts providing the phone's location to the mesh. */
fun startProvidingLocation() {
radioController.startProvideLocation()
}
/** Stops providing the phone's location to the mesh. */
fun stopProvidingLocation() {
radioController.stopProvideLocation()
}
}

View File

@@ -18,7 +18,7 @@ package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.RadioController
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
@@ -35,7 +35,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun setOwner(destNum: Int, user: User): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.setOwner(destNum, user, packetId)
return packetId
}
@@ -47,7 +47,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun getOwner(destNum: Int): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.getOwner(destNum, packetId)
return packetId
}
@@ -60,7 +60,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun setConfig(destNum: Int, config: Config): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.setConfig(destNum, config, packetId)
return packetId
}
@@ -73,7 +73,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun getConfig(destNum: Int, configType: Int): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.getConfig(destNum, configType, packetId)
return packetId
}
@@ -86,7 +86,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun setModuleConfig(destNum: Int, config: ModuleConfig): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.setModuleConfig(destNum, config, packetId)
return packetId
}
@@ -99,7 +99,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.getModuleConfig(destNum, moduleConfigType, packetId)
return packetId
}
@@ -112,7 +112,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun getChannel(destNum: Int, index: Int): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.getChannel(destNum, index, packetId)
return packetId
}
@@ -125,7 +125,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.setRemoteChannel(destNum, channel, packetId)
return packetId
}
@@ -152,7 +152,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun getRingtone(destNum: Int): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.getRingtone(destNum, packetId)
return packetId
}
@@ -169,7 +169,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun getCannedMessages(destNum: Int): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.getCannedMessages(destNum, packetId)
return packetId
}
@@ -181,7 +181,7 @@ open class RadioConfigUseCase constructor(private val radioController: RadioCont
* @return The packet ID of the request.
*/
open suspend fun getDeviceConnectionStatus(destNum: Int): Int {
val packetId = radioController.getPacketId()
val packetId = radioController.generatePacketId()
radioController.getDeviceConnectionStatus(destNum, packetId)
return packetId
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetAppIntroCompletedUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: Boolean) {
uiPrefs.setAppIntroCompleted(value)
}
}

View File

@@ -1,30 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.database.DatabaseConstants
/** Use case for setting the database cache limit. */
@Single
open class SetDatabaseCacheLimitUseCase constructor(private val databaseManager: DatabaseManager) {
operator fun invoke(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
databaseManager.setCacheLimit(clamped)
}
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetLocaleUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: String) {
uiPrefs.setLocale(value)
}
}

View File

@@ -1,30 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.NotificationPrefs
/** Use case for updating application-level notification preferences. */
@Single
class SetNotificationSettingsUseCase(private val notificationPrefs: NotificationPrefs) {
fun setMessagesEnabled(enabled: Boolean) = notificationPrefs.setMessagesEnabled(enabled)
fun setNodeEventsEnabled(enabled: Boolean) = notificationPrefs.setNodeEventsEnabled(enabled)
fun setLowBatteryEnabled(enabled: Boolean) = notificationPrefs.setLowBatteryEnabled(enabled)
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetProvideLocationUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
}
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetThemeUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: Int) {
uiPrefs.setTheme(value)
}
}

View File

@@ -1,28 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.AnalyticsPrefs
/** Use case for toggling the analytics preference. */
@Single
open class ToggleAnalyticsUseCase constructor(private val analyticsPrefs: AnalyticsPrefs) {
open operator fun invoke() {
analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value)
}
}

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