diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md index a3c5f4aaf0..711b8e8c71 100644 --- a/.agent_memory/session_context.md +++ b/.agent_memory/session_context.md @@ -3,6 +3,22 @@ # Do NOT edit or remove previous entries — stale state claims cause agent confusion. # Format: ## YYYY-MM-DD — +## 2026-05-30 — Merged main into remove-aidl-api branch; resolved conflicts; PR #5586 mergeable +- `git merge origin/main` (branch was 40 behind / 31 ahead). 12 files flagged; git auto-resolved 11. Only real conflict: this append-only `session_context.md` (both sides added top entries — kept both in date order). +- AIDL removal preserved through the merge (main still ships `IMeshService.aidl`/`FakeIMeshService` etc.; our deletions held). Manifest `MeshService` now `exported=false` with no AIDL bind filter; main's Android Auto removal (#5662) carried through. +- proto submodule advanced to v2.7.24 (dd6c3f8) per main #5654; synced working tree. main dep bumps merged: takpacket-sdk 0.5.1, firebase-bom 34.14.0, atomicfu 0.33.0. +- Validated: `assembleDebug`, `spotlessCheck`, `detekt`, `:core:data:jvmTest`, `:core:takserver:allTests` all green (the two conflicted feature modules). Pushed; PR #5586 → MERGEABLE, CI re-running. +- Aside (separate repo): meshtastic-sdk PR #3 was superseded by main's PR #1; extracted only the toolchain bump into SDK PR #4 (Kotlin 2.3.21 / SKIE 0.10.12 / Wire 6.4.0 / Ktor 3.5.0 / coroutines stable). + +## 2026-05-29 — Removed AIDL/broadcast service layer; modernized RadioController; deferred R4/R5 +- AIDL bound-service + broadcast (`core:api`, `IMeshService`, `ServiceBroadcasts`, `ServiceAction`, `MeshActionHandler`, `MeshRouter`) removed; replaced with direct suspend-based `RadioController`. +- `RadioController` split into `AdminController`/`MessagingController`/`NodeController`/`RequestController`, composed in `DirectRadioControllerImpl` via Kotlin `by` delegation to four focused impls (`AdminControllerImpl` etc., core/service commonMain). +- Typed addressing: `NodeAddress` (sealed) + `ContactKey` (value class) in core/model; 6 hand-rolled contact-key parsers consolidated onto `ContactKey.channelOrNull`/`addressString`. +- Idempotent node ops: `setFavorite`/`setIgnored(Boolean)` + `toggleMuted` (fixed a latent toggle bug in `SendMessageUseCase`); `editSettings { }` DSL (`AdminEditScope`) replaced begin/commitEditSettings. +- Shared `RequestTimer` extracted from Traceroute/NeighborInfo handlers. `formatAgo` runBlocking removed. Contact import re-marks `manually_verified=true` (review-fix regression). +- Admin sends are intentionally fire-and-forget (device = source of truth). +- R4 (`AdminResult`) and R5 (`NodeId` value class) investigated and DEFERRED to the SDK migration — do not build standalone. Rationale in `.agent_plans/post-aidl-modernization.md`. + ## 2026-05-28 — Stabilized DatabaseManager withDb retry host test - Hardened `DatabaseManagerWithDbRetryTest` to remove CI race conditions by running the manager on a `StandardTestDispatcher(testScheduler)` instead of real `Dispatchers.IO`. - Added a `withTimeout(10_000)` guard around the test body to fail fast on coordination stalls instead of hanging/flapping. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c207b35f84..70027dcf5a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -33,7 +33,7 @@ git submodule update --init KMP modules have different task names than pure-Android modules. Using the wrong name silently skips tests or fails resolution. -| Intent | KMP modules (`core:*`, `feature:*`) | Android-only (`app`, `core:api`, `core:barcode`) | +| Intent | KMP modules (`core:*`, `feature:*`) | Android-only (`app`, `core:barcode`) | |--------|--------------------------------------|--------------------------------------------------| | Run tests | `:module:allTests` | `:module:testFdroidDebugUnitTest` | | Detekt | `:module:detekt` (lifecycle task) | `:module:detekt` | diff --git a/.github/workflows/publish-core.yml b/.github/workflows/publish-core.yml deleted file mode 100644 index 6bbf344f0e..0000000000 --- a/.github/workflows/publish-core.yml +++ /dev/null @@ -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 }} diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml index 7a0a02b77f..5784ffae56 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -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 diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md index 6c8133ad8b..3869243072 100644 --- a/.skills/project-overview/SKILL.md +++ b/.skills/project-overview/SKILL.md @@ -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`. | diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md index 4144354c33..0bdb2fb8c5 100644 --- a/.skills/testing-ci/SKILL.md +++ b/.skills/testing-ci/SKILL.md @@ -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.* diff --git a/README.md b/README.md index 028173a50e..c08aa3ac07 100644 --- a/README.md +++ b/README.md @@ -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] diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 480989d8a1..81f9328d42 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -110,7 +110,7 @@ configure { ), ) } - ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") } + ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a") } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -124,7 +124,7 @@ configure { abi { isEnable = !disableSplits reset() - include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + include("armeabi-v7a", "arm64-v8a") isUniversalApk = true } } diff --git a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java index 38e51da529..d1b5fee601 100644 --- a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java +++ b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/MarkerClusterer.java @@ -127,8 +127,8 @@ protected void hideInfoWindows(){ 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; } diff --git a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java index e2710352ab..be133b47ec 100644 --- a/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java +++ b/androidApp/src/fdroid/java/org/meshtastic/app/map/cluster/RadiusMarkerClusterer.java @@ -27,6 +27,8 @@ 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 RadiusMarkerClusterer(Context ctx) { 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; diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index cebaf39316..339dd574bc 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -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() } diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt index eefd9df435..5d68e9d219 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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 diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt index 95e9a55682..2563a86267 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt @@ -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 diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 8a4a798a81..0e36f2699a 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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 { diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 1617931552..94e3f1d55a 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -67,7 +67,6 @@ --> - @@ -157,16 +156,12 @@ android:name="google_analytics_default_allow_analytics_storage" android:value="false" /> - + - - - - + android:exported="false" /> - + diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 78e8ce5592..962b4acd81 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -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 diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/androidApp/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt index ab895e435f..f4d90ec13b 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt @@ -111,7 +111,7 @@ class NetworkModule { if (buildConfigProvider.isDebug) { install(plugin = Logging) { logger = KermitHttpLogger - level = LogLevel.BODY + level = LogLevel.INFO } } } diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt index b27b300dfa..1c1fde70ae 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -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 diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt index 0da77c5243..7e4046123f 100644 --- a/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt +++ b/androidApp/src/test/kotlin/org/meshtastic/app/service/Fakes.kt @@ -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() {} diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 5ab717bc29..3ad4ad0a53 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -199,11 +199,6 @@ gradlePlugin { implementationClass = "org.meshtastic.buildlogic.DocsTasks" } - register("publishing") { - id = "meshtastic.publishing" - implementationClass = "PublishingConventionPlugin" - } - register("aboutLibraries") { id = "meshtastic.aboutlibraries" implementationClass = "AboutLibrariesConventionPlugin" diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt index ee9ce4a526..62d2fdf8dd 100644 --- a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt @@ -53,7 +53,7 @@ class AndroidRoomConventionPlugin : Plugin { 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) diff --git a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt index b4f2acfbe4..427cc3d1fa 100644 --- a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt @@ -48,7 +48,7 @@ class KoinConventionPlugin : Plugin { } } - 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 { } } + 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")) { diff --git a/build-logic/convention/src/main/kotlin/KotlinXSerializationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KotlinXSerializationConventionPlugin.kt index 14fceaec5e..9c064b28c2 100644 --- a/build-logic/convention/src/main/kotlin/KotlinXSerializationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KotlinXSerializationConventionPlugin.kt @@ -36,10 +36,16 @@ class KotlinXSerializationConventionPlugin : Plugin { } } - 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) } } } } diff --git a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt b/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt deleted file mode 100644 index f0581fe209..0000000000 --- a/build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt +++ /dev/null @@ -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 . - */ -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 { - 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 { - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/meshtastic/Meshtastic-Android") - credentials { - username = githubActor.get() - password = githubToken.get() - } - } - } - } - } - } - } -} diff --git a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt index 45cc867bab..5b2037ccaa 100644 --- a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt @@ -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", @@ -115,7 +114,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. :core:proto contains only auto-generated Wire classes (no KDoc value) and @@ -128,6 +127,6 @@ private fun allModules(): List = 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 = allModules().filter { it !in ANDROID_ONLY_MODULES + ":desktopApp" } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ComposeCompilerConfiguration.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ComposeCompilerConfiguration.kt index db0ee17002..5f396e2bac 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ComposeCompilerConfiguration.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ComposeCompilerConfiguration.kt @@ -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 { @@ -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) } } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt index 44a1a0ec19..cfb5620afe 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt @@ -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 @@ -236,9 +232,6 @@ internal fun Project.configureKotlinJvm() { configureKotlin() } -/** 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( @@ -251,18 +244,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 Project.configureKotlin() { - val isPublishedModule = project.name in PUBLISHED_MODULES - extensions.configure { - // 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 { @@ -270,9 +257,7 @@ private inline fun 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") @@ -288,28 +273,16 @@ private inline fun Project.configureKotlin() { tasks.withType().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().configureEach { - javaLauncher.set(toolchains.launcherFor { languageVersion.set(JavaLanguageVersion.of(APP_JDK)) }) - } - } } diff --git a/build.gradle.kts b/build.gradle.kts index 0cff19056c..54603d7377 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 diff --git a/codecov.yml b/codecov.yml index 0bccd30ce3..cd63704a7d 100644 --- a/codecov.yml +++ b/codecov.yml @@ -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 diff --git a/core/api/README.md b/core/api/README.md deleted file mode 100644 index 4d2be1b403..0000000000 --- a/core/api/README.md +++ /dev/null @@ -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 - - -```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; - -``` - diff --git a/core/api/build.gradle.kts b/core/api/build.gradle.kts deleted file mode 100644 index 7a798e7531..0000000000 --- a/core/api/build.gradle.kts +++ /dev/null @@ -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 . - */ -plugins { - alias(libs.plugins.meshtastic.android.library) - id("meshtastic.publishing") -} - -configure { - 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().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("release") { - from(components["release"]) - artifactId = "meshtastic-android-api" - } - } - } -} - -dependencies { api(projects.core.model) } diff --git a/core/api/src/main/AndroidManifest.xml b/core/api/src/main/AndroidManifest.xml deleted file mode 100644 index 94cbbcfc39..0000000000 --- a/core/api/src/main/AndroidManifest.xml +++ /dev/null @@ -1 +0,0 @@ - diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl deleted file mode 100644 index b8a1640568..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable DataPacket; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl deleted file mode 100644 index ba71539738..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable MeshUser; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl deleted file mode 100644 index 1286d7c7fe..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable MyNodeInfo; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl deleted file mode 100644 index ab7c1c9261..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable NodeInfo; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl deleted file mode 100644 index be49bd57a9..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable Position; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl deleted file mode 100644 index f2307dd904..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ /dev/null @@ -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: - - - -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. - 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 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); -} diff --git a/core/api/src/main/kotlin/org/meshtastic/core/api/MeshtasticIntent.kt b/core/api/src/main/kotlin/org/meshtastic/core/api/MeshtasticIntent.kt deleted file mode 100644 index ac0dbf6482..0000000000 --- a/core/api/src/main/kotlin/org/meshtastic/core/api/MeshtasticIntent.kt +++ /dev/null @@ -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 . - */ -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" -} diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt index 4cebae1984..09f980977c 100644 --- a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt +++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt @@ -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(null) } - DisposableEffect(Unit) { onDispose { cameraExecutor.shutdown() } } - LaunchedEffect(Unit) { val cameraProviderFuture = ProcessCameraProvider.getInstance(context) cameraProviderFuture.addListener( diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index d535d4efff..ea932b26a5 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -17,7 +17,6 @@ plugins { alias(libs.plugins.meshtastic.kmp.library) - alias(libs.plugins.kotlin.parcelize) id("meshtastic.kmp.jvm.android") id("meshtastic.koin") } diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CompatExtensions.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CompatExtensions.kt index 6ecdd18e10..1ed14a0145 100644 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CompatExtensions.kt +++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CompatExtensions.kt @@ -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 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 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, diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ExceptionsAndroid.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ExceptionsAndroid.kt deleted file mode 100644 index 2e71fda0c5..0000000000 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ExceptionsAndroid.kt +++ /dev/null @@ -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 . - */ -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 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) } - } -} diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/Parcelable.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/Parcelable.android.kt deleted file mode 100644 index 0ae5ef693d..0000000000 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/Parcelable.android.kt +++ /dev/null @@ -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 . - */ -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 = kotlinx.parcelize.Parceler - -actual typealias CommonTypeParceler = kotlinx.parcelize.TypeParceler - -actual typealias CommonParcel = android.os.Parcel diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Parcelable.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Parcelable.kt deleted file mode 100644 index 672594bb9d..0000000000 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Parcelable.kt +++ /dev/null @@ -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 . - */ -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 { - 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>() - -/** 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?) -} diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt index 621d52093b..4d3b1b3630 100644 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt @@ -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 { - 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> 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?) {} -} diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/Parcelable.jvm.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/Parcelable.jvm.kt deleted file mode 100644 index 23e195b392..0000000000 --- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/Parcelable.jvm.kt +++ /dev/null @@ -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 . - */ -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 { - 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> - -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() -} - -private fun unsupportedParcelOperation(): T = - error("CommonParcel is unavailable on JVM smoke targets. Manual parcel operations remain Android-only.") diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt index 24ababf144..d49d371d76 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt @@ -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 - - 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") - } + fun resolveNodeNum(address: NodeAddress): Int = when (address) { + NodeAddress.Broadcast -> NodeAddress.NODENUM_BROADCAST + + 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 } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt index 7ea4e92d57..d20cdcd461 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt @@ -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, + private val serviceStateWriter: ServiceStateWriter, + private val configFlowManager: Lazy, + private val configHandler: Lazy, + private val xmodemManager: Lazy, 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 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt index 7ea4d7cf09..ab82836604 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt @@ -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?, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt deleted file mode 100644 index e16852d251..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ /dev/null @@ -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 . - */ -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, - private val serviceBroadcasts: ServiceBroadcasts, - private val dataHandler: Lazy, - private val analytics: PlatformAnalytics, - private val meshPrefs: MeshPrefs, - private val uiPrefs: UiPrefs, - private val databaseManager: DatabaseManager, - private val notificationManager: NotificationManager, - private val messageProcessor: Lazy, - 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() - } - } - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index 9e32381861..602c7964d3 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -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, 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() } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt index d8f76f7f0c..9945f9bd6f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt @@ -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})") } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index a62cb5bedc..0db2490db7 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -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 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 96edbe41ff..24733e9d97 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -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, - 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, ) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index e93ea478ee..5d474b39fd 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -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, - private val router: Lazy, + private val dataHandler: Lazy, 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) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt deleted file mode 100644 index fe58735da6..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt +++ /dev/null @@ -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 . - */ -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, - private val configHandlerLazy: Lazy, - private val tracerouteHandlerLazy: Lazy, - private val neighborInfoHandlerLazy: Lazy, - private val configFlowManagerLazy: Lazy, - private val mqttManagerLazy: Lazy, - private val actionHandlerLazy: Lazy, - private val xmodemManagerLazy: Lazy, -) : 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 -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt index 5d24a9aaf4..7819c8ed9e 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt @@ -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 @@ -50,7 +50,7 @@ import org.meshtastic.proto.ToRadio 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 { @@ -78,7 +78,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) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 2975341cca..7d7e549a7b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -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()) + 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 - } - - serviceRepository.setNeighborInfoResponse(responseText) - } + val responseText = requestTimer.appendDuration(requestId, formatted, "Neighbor info") - companion object { - private const val MILLIS_PER_SECOND = 1000.0 + serviceStateWriter.setNeighborInfoResponse(responseText) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index e054f1a2c4..63569f9d25 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -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()) - private val _nodeDBbyID = atomic(persistentMapOf()) + // 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 = persistentMapOf(), + val byId: PersistentMap = 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): NodeIndex { + var byNum = persistentMapOf() + var byId = persistentMapOf() + 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 - get() = _nodeDBbyNodeNum.value + get() = nodeIndex.value.byNum - override val nodeDBbyID: Map - 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().putAll(nodes) - val byId = mutableMapOf() - nodes.values.forEach { byId[it.user.id] = it } - _nodeDBbyID.value = persistentMapOf().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 = _nodeDBbyNodeNum.value.values.map { it.toNodeInfo() } - override fun removeByNodenum(nodeNum: Int) { - val removed = atomic(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, - ) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt index aa62b76b97..cc77ba0def 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt @@ -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, - private val serviceBroadcasts: ServiceBroadcasts, private val radioInterfaceService: RadioInterfaceService, private val meshLogRepository: Lazy, - 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() - // 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(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>() - 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)) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RequestTimer.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RequestTimer.kt new file mode 100644 index 0000000000..1dde1d6a8a --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RequestTimer.kt @@ -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 . + */ +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()) + + /** 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 + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index 60f2310c39..98eed9752b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -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, - private val serviceBroadcasts: ServiceBroadcasts, private val historyManager: HistoryManager, private val dataHandler: Lazy, @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) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index e3668df393..bbf91dbdc2 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt @@ -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()) + 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 - } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt index 14cc42b302..19d82afcaf 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt @@ -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 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index c47fe5bf15..81801b7870 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -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.map @@ -37,6 +38,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 @@ -89,13 +91,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 @@ -115,7 +119,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, @@ -339,13 +343,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() @@ -353,7 +357,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 " + @@ -373,7 +377,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 diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt new file mode 100644 index 0000000000..f19259e0f4 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt @@ -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 . + */ +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(MockMode.autofill) + private val nodeManager = mock(MockMode.autofill) + private val radioConfigRepository = mock(MockMode.autofill) + private val tracerouteHandler = mock(MockMode.autofill) + private val neighborInfoHandler = mock(MockMode.autofill) + private val sessionManager = mock(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 { 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()) } 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()) } 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 { 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 { commandSender.sendData(packet) } + } + + // --- sendAdmin --- + + @Test + fun sendAdmin_injectsSessionPasskey() = runTest { + val passkey = "secret".encodeUtf8() + every { sessionManager.getPasskey(DEST_NODE) } returns passkey + everySuspend { packetHandler.sendToRadio(any()) } returns Unit + + commandSender.sendAdmin(DEST_NODE) { org.meshtastic.proto.AdminMessage(get_owner_request = true) } + + verifySuspend { packetHandler.sendToRadio(any()) } + } + + // --- requestTraceroute --- + + @Test + fun requestTraceroute_recordsStartTime() = runTest { + everySuspend { packetHandler.sendToRadio(any()) } 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()) } returns Unit + + commandSender.requestNeighborInfo(requestId = 1, destNum = MY_NODE_NUM) + + verifySuspend { packetHandler.sendToRadio(any()) } + } + + @Test + fun requestNeighborInfo_localNode_generatesDummyWhenNoCached() = runTest { + every { neighborInfoHandler.lastNeighborInfo } returns null + everySuspend { packetHandler.sendToRadio(any()) } returns Unit + + commandSender.requestNeighborInfo(requestId = 1, destNum = MY_NODE_NUM) + + verifySuspend { packetHandler.sendToRadio(any()) } + } + + @Test + fun requestNeighborInfo_remoteNode_sendsRequest() = runTest { + everySuspend { packetHandler.sendToRadio(any()) } returns Unit + + commandSender.requestNeighborInfo(requestId = 1, destNum = DEST_NODE) + + verifySuspend { packetHandler.sendToRadio(any()) } + } + + // --- sendPosition --- + + @Test + fun sendPosition_updatesLocalPositionWhenNotFixed() = runTest { + everySuspend { packetHandler.sendToRadio(any()) } 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()) } 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 + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt index 7b5c39b8ba..c8b450c820 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt @@ -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, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt deleted file mode 100644 index 816c0934aa..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ /dev/null @@ -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 . - */ -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(MockMode.autofill) - private val commandSender = mock(MockMode.autofill) - private val packetRepository = mock(MockMode.autofill) - private val serviceBroadcasts = mock(MockMode.autofill) - private val dataHandler = mock(MockMode.autofill) - private val analytics = mock(MockMode.autofill) - private val meshPrefs = mock(MockMode.autofill) - private val uiPrefs = mock(MockMode.autofill) - private val databaseManager = mock(MockMode.autofill) - private val notificationManager = mock(MockMode.autofill) - private val messageProcessor = mock(MockMode.autofill) - private val radioConfigRepository = mock(MockMode.autofill) - - private val myNodeNumFlow = MutableStateFlow(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, - ) -} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt index af0925d38c..5ce2bc20aa 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt @@ -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(MockMode.autofill) private val radioConfigRepository = mock(MockMode.autofill) private val serviceRepository = mock(MockMode.autofill) - private val serviceBroadcasts = mock(MockMode.autofill) private val analytics = mock(MockMode.autofill) private val commandSender = mock(MockMode.autofill) private val packetHandler = mock(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) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt index bf3247815b..83fd4b70a0 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt @@ -65,7 +65,7 @@ class MeshConfigHandlerImplTest { private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl( radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, + serviceStateWriter = serviceRepository, nodeManager = nodeManager, scope = scope, ) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt index fadd19542e..541c4ceadb 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt @@ -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(MockMode.autofill) private val serviceRepository = mock(MockMode.autofill) - private val serviceBroadcasts = mock(MockMode.autofill) - private val serviceNotifications = mock(MockMode.autofill) + + private val serviceNotifications = mock(MockMode.autofill) private val uiPrefs = mock(MockMode.autofill) private val packetHandler = mock(MockMode.autofill) private val nodeRepository = FakeNodeRepository() @@ -105,7 +105,7 @@ class MeshConnectionManagerImplTest { connectionStateFlow.value = call.arg(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 diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 5327449e9c..7c934af5e1 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt @@ -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) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt index 580e4c8b88..378f087ee2 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt @@ -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(MockMode.autofill) private val serviceRepository = mock(MockMode.autofill) private val meshLogRepository = mock(MockMode.autofill) - private val router = mock(MockMode.autofill) private val fromRadioDispatcher = mock(MockMode.autofill) private val dataHandler = mock(MockMode.autofill) @@ -65,14 +63,13 @@ class MeshMessageProcessorImplTest { fun setUp() { every { nodeManager.isNodeDbReady } returns isNodeDbReady every { nodeManager.myNodeNum } returns MutableStateFlow(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 ---------- diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshRouterImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshRouterImplTest.kt deleted file mode 100644 index bce47d266c..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshRouterImplTest.kt +++ /dev/null @@ -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 . - */ -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(MockMode.autofill) - private val tracerouteHandler = mock(MockMode.autofill) - private val neighborInfoHandler = mock(MockMode.autofill) - private val configFlowManager = mock(MockMode.autofill) - private val mqttManager = mock(MockMode.autofill) - private val actionHandler = mock(MockMode.autofill) - private val xmodemManager = mock(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 - private lateinit var configHandlerLazy: TrackingLazy - private lateinit var tracerouteHandlerLazy: TrackingLazy - private lateinit var neighborInfoHandlerLazy: TrackingLazy - private lateinit var configFlowManagerLazy: TrackingLazy - private lateinit var mqttManagerLazy: TrackingLazy - private lateinit var actionHandlerLazy: TrackingLazy - private lateinit var xmodemManagerLazy: TrackingLazy - - 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(private val initializer: () -> T) : Lazy { - 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 - } -} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImplTest.kt new file mode 100644 index 0000000000..75981e190f --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImplTest.kt @@ -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 . + */ +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(MockMode.autofill) + private val serviceRepository = mock(MockMode.autofill) + private val nodeRepository = mock(MockMode.autofill) + + private lateinit var handler: NeighborInfoHandlerImpl + + private val myNodeNum = 12345 + + @BeforeTest + fun setUp() { + every { nodeManager.myNodeNum } returns MutableStateFlow(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)) + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index 5090668672..67227fd149 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -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) + } } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt index e0bda60759..9d9f7310a5 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt @@ -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, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/RequestTimerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/RequestTimerTest.kt new file mode 100644 index 0000000000..245effd152 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/RequestTimerTest.kt @@ -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 . + */ +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: ")) + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt index d93bc12c08..05739aca07 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt @@ -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 ---------- diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index 702f53eebe..e366bd103f 100644 --- a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -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(MockMode.autofill) private val packetRepository = mock(MockMode.autofill) - private val serviceBroadcasts = mock(MockMode.autofill) private val historyManager = mock(MockMode.autofill) private val dataHandler = mock(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 ---------- diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 451a621740..3177f21d88 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -32,6 +32,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 @@ -166,7 +167,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) } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index b30a4306f0..c5ee575f34 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -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, - ) } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt index 5a16fd7b1a..ae2603625b 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -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, diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/ConvertersTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/ConvertersTest.kt index 28792ea0bf..2ddd87fc7f 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/ConvertersTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/ConvertersTest.kt @@ -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", diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt index 4116cb99f8..933dcf6e1b 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt @@ -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, ), diff --git a/core/domain/README.md b/core/domain/README.md index c855cff2c7..47da7b870f 100644 --- a/core/domain/README.md +++ b/core/domain/README.md @@ -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 diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt index 7f93b09d33..3c55b45e4d 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt @@ -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 { diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt index d6c48b14d5..3957a264e2 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt @@ -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) { diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt index 0ad5b47586..bc053a54ad 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt @@ -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) } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt index 2f64981339..c48ba054d7 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt @@ -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)) } } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt index b2aef85bc6..ee6711efd2 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt @@ -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 diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt deleted file mode 100644 index 0352372ec1..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt +++ /dev/null @@ -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 . - */ -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() - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt index 838617b2ed..af7fdfa295 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt @@ -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 } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt deleted file mode 100644 index cc3a1a37ea..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt +++ /dev/null @@ -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 . - */ -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) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt deleted file mode 100644 index 8d3018266b..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt +++ /dev/null @@ -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 . - */ -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) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt deleted file mode 100644 index 6e994f4efd..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt +++ /dev/null @@ -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 . - */ -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) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt deleted file mode 100644 index c72c447bce..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt +++ /dev/null @@ -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 . - */ -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) -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt deleted file mode 100644 index d768ba0091..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt +++ /dev/null @@ -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 . - */ -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) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt deleted file mode 100644 index 58d260e32d..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt +++ /dev/null @@ -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 . - */ -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) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt deleted file mode 100644 index 2ba3064115..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt +++ /dev/null @@ -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 . - */ -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) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt deleted file mode 100644 index feee583935..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt +++ /dev/null @@ -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 . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.HomoglyphPrefs - -/** Use case for toggling the homoglyph encoding preference. */ -@Single -open class ToggleHomoglyphEncodingUseCase constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) { - open operator fun invoke() { - homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value) - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt index aa4f0e2eb4..9bd017c636 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt @@ -35,8 +35,7 @@ import kotlinx.coroutines.test.runTest import okio.ByteString 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.test.Test @@ -68,9 +67,14 @@ class EnsureRemoteAdminSessionUseCaseTest { @Test fun `returns Disconnected without dispatching when not connected`() = runTest { val sessionManager = stubSessionManager() - val handler = mock(MockMode.autofill) + val controller = mock(MockMode.autofill) val useCase = - EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(ConnectionState.Disconnected), this) + EnsureRemoteAdminSessionUseCase( + sessionManager, + controller, + connectedRepo(ConnectionState.Disconnected), + this, + ) val result = useCase(destNum) @@ -81,8 +85,8 @@ class EnsureRemoteAdminSessionUseCaseTest { fun `returns AlreadyActive without dispatching when status already Active`() = runTest { val active = SessionStatus.Active(Clock.System.now()) val sessionManager = stubSessionManager(initialStatus = active) - val handler = mock(MockMode.autofill) - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) + val controller = mock(MockMode.autofill) + val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, controller, connectedRepo(), this) val result = useCase(destNum) @@ -93,30 +97,30 @@ class EnsureRemoteAdminSessionUseCaseTest { fun `dispatches metadata request and returns Refreshed when refresh flow emits`() = runTest { val refresh = MutableSharedFlow(extraBufferCapacity = 8) val sessionManager = stubSessionManager(refreshFlow = refresh) - val handler = mock(MockMode.autofill) + val controller = mock(MockMode.autofill) // Simulate the radio responding by emitting on the refresh flow when the metadata request fires. - everySuspend { handler.onServiceAction(any()) } calls + everySuspend { controller.refreshMetadata(any()) } calls { refresh.tryEmit(destNum) Unit } - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) + val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, controller, connectedRepo(), this) val result = useCase(destNum) assertEquals(EnsureSessionResult.Refreshed, result) - verifySuspend { handler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) } + verifySuspend { controller.refreshMetadata(destNum) } } @Test fun `returns Timeout when no refresh arrives within deadline`() = runTest { val refresh = MutableSharedFlow(extraBufferCapacity = 8) val sessionManager = stubSessionManager(refreshFlow = refresh) - val handler = mock(MockMode.autofill) - everySuspend { handler.onServiceAction(any()) } returns Unit + val controller = mock(MockMode.autofill) + everySuspend { controller.refreshMetadata(any()) } returns Unit - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) + val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, controller, connectedRepo(), this) var observed: EnsureSessionResult? = null val job = launch { observed = useCase(destNum) } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt index 2c449344a0..fc191f210a 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt @@ -63,8 +63,7 @@ class InstallProfileUseCaseTest { fun `invoke calls begin and commit edit settings`() = runTest { useCase(1234, DeviceProfile(), User()) - assertTrue(radioController.beginEditSettingsCalled) - assertTrue(radioController.commitEditSettingsCalled) + assertTrue(radioController.editSettingsCalled) } @Test @@ -108,7 +107,6 @@ class InstallProfileUseCaseTest { useCase(1234, profile, org.meshtastic.proto.User(long_name = "Old")) - assertTrue(radioController.beginEditSettingsCalled) - assertTrue(radioController.commitEditSettingsCalled) + assertTrue(radioController.editSettingsCalled) } } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt index 5851a2080f..213a0f98f5 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt @@ -26,9 +26,9 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.Node -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.proto.HardwareModel import org.meshtastic.proto.User diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt deleted file mode 100644 index 8c58505ded..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt +++ /dev/null @@ -1,46 +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 . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.meshtastic.core.testing.FakeRadioController -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertTrue - -class MeshLocationUseCaseTest { - - private lateinit var radioController: FakeRadioController - private lateinit var useCase: MeshLocationUseCase - - @BeforeTest - fun setUp() { - radioController = FakeRadioController() - useCase = MeshLocationUseCase(radioController) - } - - @Test - fun `startProvidingLocation calls radioController`() { - useCase.startProvidingLocation() - assertTrue(radioController.startProvideLocationCalled) - } - - @Test - fun `stopProvidingLocation calls radioController`() { - useCase.stopProvidingLocation() - assertTrue(radioController.stopProvideLocationCalled) - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt deleted file mode 100644 index ec52587856..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt +++ /dev/null @@ -1,49 +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 . - */ -package org.meshtastic.core.domain.usecase.settings - -import dev.mokkery.mock -import dev.mokkery.verify -import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.database.DatabaseConstants -import kotlin.test.BeforeTest -import kotlin.test.Test - -class SetDatabaseCacheLimitUseCaseTest { - - private lateinit var databaseManager: DatabaseManager - private lateinit var useCase: SetDatabaseCacheLimitUseCase - - @BeforeTest - fun setUp() { - databaseManager = mock(dev.mokkery.MockMode.autofill) - useCase = SetDatabaseCacheLimitUseCase(databaseManager) - } - - @Test - fun `invoke calls setCacheLimit with clamped value`() { - // Act & Assert - useCase(0) - verify { databaseManager.setCacheLimit(DatabaseConstants.MIN_CACHE_LIMIT) } - - useCase(100) - verify { databaseManager.setCacheLimit(DatabaseConstants.MAX_CACHE_LIMIT) } - - useCase(5) - verify { databaseManager.setCacheLimit(5) } - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCaseTest.kt deleted file mode 100644 index 23431f816c..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCaseTest.kt +++ /dev/null @@ -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 . - */ -package org.meshtastic.core.domain.usecase.settings - -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import org.meshtastic.core.repository.NotificationPrefs -import kotlin.test.BeforeTest -import kotlin.test.Test - -class SetNotificationSettingsUseCaseTest { - - private val notificationPrefs: NotificationPrefs = mock() - private lateinit var useCase: SetNotificationSettingsUseCase - - @BeforeTest - fun setUp() { - useCase = SetNotificationSettingsUseCase(notificationPrefs) - } - - @Test - fun `setMessagesEnabled calls notificationPrefs`() { - every { notificationPrefs.setMessagesEnabled(any()) } returns Unit - useCase.setMessagesEnabled(true) - verify { notificationPrefs.setMessagesEnabled(true) } - } - - @Test - fun `setNodeEventsEnabled calls notificationPrefs`() { - every { notificationPrefs.setNodeEventsEnabled(any()) } returns Unit - useCase.setNodeEventsEnabled(false) - verify { notificationPrefs.setNodeEventsEnabled(false) } - } - - @Test - fun `setLowBatteryEnabled calls notificationPrefs`() { - every { notificationPrefs.setLowBatteryEnabled(any()) } returns Unit - useCase.setLowBatteryEnabled(true) - verify { notificationPrefs.setLowBatteryEnabled(true) } - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt deleted file mode 100644 index f563def741..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt +++ /dev/null @@ -1,48 +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 . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.meshtastic.core.testing.FakeAnalyticsPrefs -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class ToggleAnalyticsUseCaseTest { - - private lateinit var analyticsPrefs: FakeAnalyticsPrefs - private lateinit var useCase: ToggleAnalyticsUseCase - - @BeforeTest - fun setUp() { - analyticsPrefs = FakeAnalyticsPrefs() - useCase = ToggleAnalyticsUseCase(analyticsPrefs) - } - - @Test - fun `invoke toggles from false to true`() { - analyticsPrefs.setAnalyticsAllowed(false) - useCase() - assertEquals(true, analyticsPrefs.analyticsAllowed.value) - } - - @Test - fun `invoke toggles from true to false`() { - analyticsPrefs.setAnalyticsAllowed(true) - useCase() - assertEquals(false, analyticsPrefs.analyticsAllowed.value) - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt deleted file mode 100644 index c37998ae90..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt +++ /dev/null @@ -1,48 +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 . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.meshtastic.core.testing.FakeHomoglyphPrefs -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class ToggleHomoglyphEncodingUseCaseTest { - - private lateinit var homoglyphPrefs: FakeHomoglyphPrefs - private lateinit var useCase: ToggleHomoglyphEncodingUseCase - - @BeforeTest - fun setUp() { - homoglyphPrefs = FakeHomoglyphPrefs() - useCase = ToggleHomoglyphEncodingUseCase(homoglyphPrefs) - } - - @Test - fun `invoke toggles from false to true`() { - homoglyphPrefs.setHomoglyphEncodingEnabled(false) - useCase() - assertEquals(true, homoglyphPrefs.homoglyphEncodingEnabled.value) - } - - @Test - fun `invoke toggles from true to false`() { - homoglyphPrefs.setHomoglyphEncodingEnabled(true) - useCase() - assertEquals(false, homoglyphPrefs.homoglyphEncodingEnabled.value) - } -} diff --git a/core/model/README.md b/core/model/README.md index 43dbdf3929..e2f96808a8 100644 --- a/core/model/README.md +++ b/core/model/README.md @@ -4,17 +4,20 @@ The `:core:model` module is a **Kotlin Multiplatform (KMP)** library containing the domain models and data classes used throughout the application and its API. These models are platform-agnostic and designed to be shared across Android, JVM, and future supported platforms. ## Multiplatform Support -Models in this module use the `CommonParcelable` and `CommonParcelize` abstractions from `:core:common`. This allows them to maintain Android `Parcelable` compatibility (via `@Parcelize`) while residing in `commonMain` and remaining accessible to non-Android targets. +Models are plain `commonMain` Kotlin types — `@Serializable` (kotlinx.serialization) data classes and +`@JvmInline value class`es — with no Android `Parcelable` dependency, so they are shared verbatim +across Android, JVM, and iOS. ## Key Models - **`DataPacket`**: Represents a mesh packet (text, telemetry, etc.). -- **`NodeInfo`**: Contains detailed information about a node (position, SNR, battery, etc.). +- **`Node`**: Contains detailed information about a node (position, SNR, battery, etc.). +- **`NodeAddress` / `ContactKey`**: Type-safe node addressing (`Broadcast`/`Local`/`ByNum`/`ById`) and contact-key parsing, replacing stringly-typed `"^all"`/`"!hex"` handling. - **`DeviceHardware`**: Represents supported Meshtastic hardware devices and their capabilities. - **`Channel`**: Represents a mesh channel configuration. ## Usage -This module is a core dependency of `core:api` and most feature modules. +This module is a core dependency of most feature modules. ```kotlin // In commonMain diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index cb3d908d2a..e26ff0a6c7 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -18,9 +18,7 @@ plugins { alias(libs.plugins.meshtastic.kmp.library) alias(libs.plugins.meshtastic.kotlinx.serialization) - alias(libs.plugins.kotlin.parcelize) id("meshtastic.kmp.jvm.android") - id("meshtastic.publishing") } kotlin { @@ -56,16 +54,3 @@ kotlin { commonTest.dependencies { implementation(projects.core.testing) } } } - -// Modern KMP publication uses the project name as the artifactId by default. -// We rename the publications to include the 'core-' prefix for consistency. -publishing { - publications.withType().configureEach { - val baseId = artifactId - if (baseId == "model") { - artifactId = "meshtastic-android-model" - } else if (baseId.startsWith("model-")) { - artifactId = baseId.replace("model-", "meshtastic-android-model-") - } - } -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt index ffe57a7088..8a7563a3bf 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Contact.kt @@ -16,10 +16,6 @@ */ package org.meshtastic.core.model -import org.meshtastic.core.common.util.CommonParcelable -import org.meshtastic.core.common.util.CommonParcelize - -@CommonParcelize data class Contact( val contactKey: String, val shortName: String, @@ -31,7 +27,7 @@ data class Contact( val isMuted: Boolean, val isUnmessageable: Boolean, val nodeColors: Pair? = null, -) : CommonParcelable +) data class ContactSettings( val contactKey: String, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt index 4214dd62ce..641856b119 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt @@ -16,25 +16,16 @@ */ package org.meshtastic.core.model -import co.touchlab.kermit.Logger import kotlinx.serialization.Serializable import okio.ByteString import okio.ByteString.Companion.toByteString -import org.meshtastic.core.common.util.CommonIgnoredOnParcel -import org.meshtastic.core.common.util.CommonParcel -import org.meshtastic.core.common.util.CommonParcelable -import org.meshtastic.core.common.util.CommonParcelize -import org.meshtastic.core.common.util.CommonTypeParceler -import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.model.util.ByteStringParceler import org.meshtastic.core.model.util.ByteStringSerializer import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.Waypoint -@CommonParcelize -enum class MessageStatus : CommonParcelable { +enum class MessageStatus { UNKNOWN, // Not set for this message RECEIVED, // Came in from the mesh QUEUED, // Waiting to send to the mesh as soon as we connect to the device @@ -45,17 +36,14 @@ enum class MessageStatus : CommonParcelable { ERROR, // We received back a nak, message not delivered } -/** A parcelable version of the protobuf MeshPacket + Data subpacket. */ +/** A data class version of the protobuf MeshPacket + Data subpacket. */ @Serializable -@CommonParcelize data class DataPacket( - var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast - @Serializable(with = ByteStringSerializer::class) - @CommonTypeParceler - var bytes: ByteString?, + var to: String? = NodeAddress.ID_BROADCAST, + @Serializable(with = ByteStringSerializer::class) var bytes: ByteString?, // A port number for this packet var dataType: Int, - var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost + var from: String? = NodeAddress.ID_LOCAL, var time: Long = nowMillis, // msecs since 1970 var id: Int = 0, // 0 means unassigned var status: MessageStatus? = MessageStatus.UNKNOWN, @@ -70,54 +58,13 @@ data class DataPacket( var relays: Int = 0, var viaMqtt: Boolean = false, // True if this packet passed via MQTT somewhere along its path var emoji: Int = 0, - @Serializable(with = ByteStringSerializer::class) - @CommonTypeParceler - var sfppHash: ByteString? = null, + @Serializable(with = ByteStringSerializer::class) var sfppHash: ByteString? = null, /** The transport mechanism this packet arrived over (see [MeshPacket.TransportMechanism]). */ var transportMechanism: Int = 0, -) : CommonParcelable { - - fun readFromParcel(parcel: CommonParcel) { - to = parcel.readString() - bytes = ByteStringParceler.create(parcel) - dataType = parcel.readInt() - from = parcel.readString() - time = parcel.readLong() - id = parcel.readInt() - - // MessageStatus is a known Parcelable type (enum), so Parcelize writes it optimized: - // 1. Presence flag (Int: 1 or 0) - // 2. Content (Enum Name as String) - status = - if (parcel.readInt() != 0) { - val name = parcel.readString() - try { - if (name != null) MessageStatus.valueOf(name) else MessageStatus.UNKNOWN - } catch (e: IllegalArgumentException) { - Logger.w(e) { "Unknown MessageStatus: $name" } - MessageStatus.UNKNOWN - } - } else { - null - } - - hopLimit = parcel.readInt() - channel = parcel.readInt() - wantAck = (parcel.readInt() != 0) - hopStart = parcel.readInt() - snr = parcel.readFloat() - rssi = parcel.readInt() - replyId = if (parcel.readInt() == 0) null else parcel.readInt() - relayNode = if (parcel.readInt() == 0) null else parcel.readInt() - relays = parcel.readInt() - viaMqtt = (parcel.readInt() != 0) - emoji = parcel.readInt() - sfppHash = ByteStringParceler.create(parcel) - transportMechanism = parcel.readInt() - } +) { /** If there was an error with this message, this string describes what was wrong. */ - @CommonIgnoredOnParcel var errorMessage: String? = null + var errorMessage: String? = null /** Syntactic sugar to make it easy to create text messages */ constructor( @@ -175,25 +122,4 @@ data class DataPacket( val hopsAway: Int get() = if (hopStart == 0 || (hopLimit > hopStart)) -1 else hopStart - hopLimit - - companion object { - // Special node IDs that can be used for sending messages - - /** the Node ID for broadcast destinations */ - const val ID_BROADCAST = "^all" - - /** The Node ID for the local node - used for from when sender doesn't know our local node ID */ - const val ID_LOCAL = "^local" - - // special broadcast address - const val NODENUM_BROADCAST = (0xffffffff).toInt() - - // Public-key cryptography (PKC) channel index - const val PKC_CHANNEL_INDEX = 8 - - fun nodeNumToDefaultId(n: Int): String = formatString("!%08x", n) - - @Suppress("MagicNumber") - fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull() - } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceMetrics.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceMetrics.kt new file mode 100644 index 0000000000..963712db7c --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceMetrics.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.core.common.util.nowSeconds + +data class DeviceMetrics( + val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) + val batteryLevel: Int = 0, + val voltage: Float, + val channelUtilization: Float, + val airUtilTx: Float, + val uptimeSeconds: Int, +) { + companion object { + @Suppress("MagicNumber") + fun currentTime() = nowSeconds.toInt() + } + + /** Create our model object from a protobuf. */ + constructor( + p: org.meshtastic.proto.DeviceMetrics, + telemetryTime: Int = currentTime(), + ) : this( + telemetryTime, + p.battery_level ?: 0, + p.voltage ?: 0f, + p.channel_utilization ?: 0f, + p.air_util_tx ?: 0f, + p.uptime_seconds ?: 0, + ) +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt index e77327d12e..c7da5f2728 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt @@ -16,45 +16,34 @@ */ package org.meshtastic.core.model -import co.touchlab.kermit.Logger +import kotlin.jvm.JvmInline -/** Provide structured access to parse and compare device version strings */ -data class DeviceVersion(val asString: String) : Comparable { +/** Zero-overhead wrapper providing structured access to parse and compare device version strings. */ +@JvmInline +value class DeviceVersion(val asString: String) : Comparable { - /** The integer representation of the version (e.g., 2.7.12 -> 20712). Calculated once. */ - @Suppress("TooGenericExceptionCaught", "SwallowedException") - val asInt: Int = - try { - verStringToInt(asString) - } catch (e: Exception) { - Logger.w { "Exception while parsing version '$asString', assuming version 0" } - 0 - } - - /** - * Convert a version string of the form 1.23.57 to a comparable integer of the form 12357. - * - * Or throw an exception if the string can not be parsed - */ - @Suppress("TooGenericExceptionThrown", "MagicNumber") - private fun verStringToInt(s: String): Int { - // Allow 1 to two digits per match - val versionString = - if (s.split(".").size == 2) { - "$s.0" - } else { - s - } - val match = - Regex("(\\d{1,2}).(\\d{1,2}).(\\d{1,2})").find(versionString) ?: throw Exception("Can't parse version $s") - val (major, minor, build) = match.destructured - return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt() - } + /** The integer representation of the version (e.g., 2.7.12 → 20712). */ + val asInt: Int + get() = parseVersion(asString) override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt) companion object { const val MIN_FW_VERSION = "2.5.14" const val ABS_MIN_FW_VERSION = "2.3.15" + + private val VERSION_REGEX = Regex("(\\d{1,2})\\.(\\d{1,2})\\.(\\d{1,2})") + + /** + * Convert a version string of the form 1.23.57 to a comparable integer (12357). Returns 0 for unparseable + * strings. + */ + @Suppress("MagicNumber") + private fun parseVersion(s: String): Int { + val versionString = if (s.count { it == '.' } == 1) "$s.0" else s + val match = VERSION_REGEX.find(versionString) ?: return 0 + val (major, minor, build) = match.destructured + return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt() + } } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EnvironmentMetrics.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EnvironmentMetrics.kt new file mode 100644 index 0000000000..2ed7788cc8 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EnvironmentMetrics.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.core.common.util.nowSeconds + +data class EnvironmentMetrics( + val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) + val temperature: Float?, + val relativeHumidity: Float?, + val soilTemperature: Float?, + val soilMoisture: Int?, + val barometricPressure: Float?, + val gasResistance: Float?, + val voltage: Float?, + val current: Float?, + val iaq: Int?, + val lux: Float? = null, + val uvLux: Float? = null, +) { + @Suppress("MagicNumber") + companion object { + fun currentTime() = nowSeconds.toInt() + + fun fromTelemetryProto(proto: org.meshtastic.proto.EnvironmentMetrics, time: Int): EnvironmentMetrics = + EnvironmentMetrics( + temperature = proto.temperature?.takeIf { !it.isNaN() }, + // 0%RH is treated as "no reading" — firmware emits 0 when the humidity sensor isn't fitted and a real + // outdoor reading of exactly 0%RH is physically implausible. Other fields don't get this guard because + // their natural zero values (0 V, 0 A, 0°C) are meaningful sensor data. + relativeHumidity = proto.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f }, + soilTemperature = proto.soil_temperature?.takeIf { !it.isNaN() }, + soilMoisture = proto.soil_moisture?.takeIf { it != Int.MIN_VALUE }, + barometricPressure = proto.barometric_pressure?.takeIf { !it.isNaN() }, + gasResistance = proto.gas_resistance?.takeIf { !it.isNaN() }, + voltage = proto.voltage?.takeIf { !it.isNaN() }, + current = proto.current?.takeIf { !it.isNaN() }, + iaq = proto.iaq?.takeIf { it != Int.MIN_VALUE }, + lux = proto.lux?.takeIf { !it.isNaN() }, + uvLux = proto.uv_lux?.takeIf { !it.isNaN() }, + time = time, + ) + } +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshUser.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshUser.kt new file mode 100644 index 0000000000..860f20f3e5 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshUser.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.core.model.util.anonymize +import org.meshtastic.proto.HardwareModel + +data class MeshUser( + val id: String, + val longName: String, + val shortName: String, + val hwModel: HardwareModel, + val isLicensed: Boolean = false, + val role: Int = 0, +) { + + override fun toString(): String = "MeshUser(id=${id.anonymize}, " + + "longName=${longName.anonymize}, " + + "shortName=${shortName.anonymize}, " + + "hwModel=$hwModelString, " + + "isLicensed=$isLicensed, " + + "role=$role)" + + /** Create our model object from a protobuf. */ + constructor( + p: org.meshtastic.proto.User, + ) : this(p.id, p.long_name, p.short_name, p.hw_model, p.is_licensed, p.role.value) + + /** + * A pretty lowercase rendering of the hardware model: underscores → dashes, version markers `p` → + * `.` (e.g. `RAK4631_V1P0` → `rak4631-v1.0`). Returns null when the model is unset. The + * version-marker substitution is constrained to digit-p-digit so model names containing a literal 'p' (e.g. + * `HELTEC_WIRELESS_PAPER`) are preserved correctly. + */ + val hwModelString: String? + get() = + if (hwModel == HardwareModel.UNSET) { + null + } else { + hwModel.name.replace('_', '-').replace(VERSION_P_REGEX, "$1.$2").lowercase() + } + + companion object { + private val VERSION_P_REGEX = Regex("(\\d)p(\\d)", RegexOption.IGNORE_CASE) + } +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MyNodeInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MyNodeInfo.kt index 1d3df2fad0..9c15cc6a47 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MyNodeInfo.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MyNodeInfo.kt @@ -16,11 +16,7 @@ */ package org.meshtastic.core.model -import org.meshtastic.core.common.util.CommonParcelable -import org.meshtastic.core.common.util.CommonParcelize - // MyNodeInfo sent via special protobuf from radio -@CommonParcelize data class MyNodeInfo( val myNodeNum: Int, val hasGPS: Boolean, @@ -37,7 +33,7 @@ data class MyNodeInfo( val airUtilTx: Float, val deviceId: String?, val pioEnv: String? = null, -) : CommonParcelable { +) { /** A human readable description of the software/hardware version */ val firmwareString: String get() = "$model $firmwareVersion" diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 159385415d..70efcae009 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -209,7 +209,7 @@ data class Node( /** Creates a fallback [Node] when the node is not found in the database. */ fun createFallback(nodeNum: Int, fallbackNamePrefix: String): Node { - val userId = DataPacket.nodeNumToDefaultId(nodeNum) + val userId = NodeAddress.numToDefaultId(nodeNum) val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH) val longName = "$fallbackNamePrefix $safeUserId" val defaultUser = diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeAddress.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeAddress.kt new file mode 100644 index 0000000000..f6faa5d113 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeAddress.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.core.common.util.formatString +import kotlin.jvm.JvmInline + +/** + * Type-safe representation of a mesh node address. + * + * Replaces stringly-typed node addressing (`"^all"`, `"^local"`, `"!hexid"`) with exhaustive sealed dispatch, enabling + * compile-time verification of address handling. + */ +sealed class NodeAddress { + /** Broadcast to all nodes in the mesh. */ + data object Broadcast : NodeAddress() + + /** The local node (used as `from` when the sender's ID is unknown). */ + data object Local : NodeAddress() + + /** Address by numeric node number (the canonical mesh-level identifier). */ + data class ByNum(val num: Int) : NodeAddress() + + /** Address by hex string ID (e.g. `"!a1b2c3d4"`). */ + data class ById(val id: String) : NodeAddress() + + /** Convert back to the legacy string representation used in [DataPacket]. */ + fun toIdString(): String = when (this) { + Broadcast -> ID_BROADCAST + Local -> ID_LOCAL + is ByNum -> numToDefaultId(num) + is ById -> id + } + + /** Build a [ContactKey] for this address on the given [channel]. */ + fun toContactKey(channel: Int): ContactKey = ContactKey("$channel${toIdString()}") + + companion object { + /** The broadcast address string `"^all"`. */ + const val ID_BROADCAST = "^all" + + /** The local node address string `"^local"`. */ + const val ID_LOCAL = "^local" + + /** The broadcast node number (`0xFFFFFFFF`). */ + @Suppress("MagicNumber") + const val NODENUM_BROADCAST = (0xffffffff).toInt() + + /** Public-key cryptography (PKC) channel index. */ + const val PKC_CHANNEL_INDEX = 8 + + private const val NODE_ID_PREFIX = "!" + private const val HEX_RADIX = 16 + + /** Parse a legacy string address into a typed [NodeAddress]. */ + fun fromString(id: String?): NodeAddress = when { + id == null || id == ID_BROADCAST -> Broadcast + id == ID_LOCAL -> Local + id.startsWith(NODE_ID_PREFIX) -> idToNum(id)?.let(::ByNum) ?: ById(id) + else -> ById(id) + } + + /** Convert a node number to its canonical hex string ID (e.g. `"!a1b2c3d4"`). */ + fun numToDefaultId(n: Int): String = formatString("!%08x", n) + + /** + * Parse a hex node ID string (with or without `!` prefix) to its integer value, or null if the input is not a + * valid 32-bit hex value. Values larger than `0xFFFFFFFF` return null rather than silently truncating, so a + * malformed `!100000000` won't be misclassified as node `0`. + */ + @Suppress("MagicNumber") + fun idToNum(id: String?): Int? = + id?.removePrefix(NODE_ID_PREFIX)?.toLongOrNull(HEX_RADIX)?.takeIf { it in 0L..0xFFFFFFFFL }?.toInt() + } +} + +/** + * Type-safe wrapper for contact key strings (channel index + node address). + * + * Contact keys are persisted as strings in the format `""` (e.g. `"0^all"`, `"1!a1b2c3d4"`). + */ +@JvmInline +value class ContactKey(val value: String) { + /** + * The channel index if the key carries a leading channel digit, or `null` for a legacy unprefixed direct-message + * key. Callers that must distinguish "channel 0" from "no channel prefix" (e.g. PKI vs legacy DM routing) need this + * rather than [channel]. + */ + val channelOrNull: Int? + get() = value.firstOrNull()?.takeIf { it.isDigit() }?.digitToInt() + + /** The channel index (first character). Returns 0 if the key is empty or has no channel digit. */ + val channel: Int + get() = channelOrNull ?: 0 + + /** + * The node address portion: everything after the channel digit, or the whole key when there is no channel prefix. + * Empty if the key is empty. + */ + val addressString: String + get() = if (channelOrNull != null) value.substring(1) else value + + /** Parsed [NodeAddress] for the contact. */ + val address: NodeAddress + get() = NodeAddress.fromString(addressString) + + companion object { + /** Create a broadcast contact key for the given channel. */ + fun broadcast(channel: Int = 0): ContactKey = NodeAddress.Broadcast.toContactKey(channel) + } +} + +/** Type-safe interpretation of [DataPacket.to]. */ +val DataPacket.destination: NodeAddress + get() = NodeAddress.fromString(to) + +/** Type-safe interpretation of [DataPacket.from]. */ +val DataPacket.source: NodeAddress + get() = NodeAddress.fromString(from) + +/** Checks whether this packet originated from the local device. */ +fun DataPacket.isFromLocal(myNodeNum: Int? = null): Boolean { + val src = source + return src is NodeAddress.Local || (myNodeNum != null && src is NodeAddress.ByNum && src.num == myNodeNum) +} + +/** Checks whether this packet is addressed to the broadcast channel. */ +val DataPacket.isBroadcast: Boolean + get() = destination is NodeAddress.Broadcast diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt deleted file mode 100644 index 3a3deddd5e..0000000000 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt +++ /dev/null @@ -1,275 +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 . - */ -package org.meshtastic.core.model - -import org.meshtastic.core.common.util.CommonParcelable -import org.meshtastic.core.common.util.CommonParcelize -import org.meshtastic.core.common.util.bearing -import org.meshtastic.core.common.util.latLongToMeter -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.util.anonymize -import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.proto.Config -import org.meshtastic.proto.HardwareModel - -// -// model objects that directly map to the corresponding protobufs -// - -@CommonParcelize -data class MeshUser( - val id: String, - val longName: String, - val shortName: String, - val hwModel: HardwareModel, - val isLicensed: Boolean = false, - val role: Int = 0, -) : CommonParcelable { - - override fun toString(): String = "MeshUser(id=${id.anonymize}, " + - "longName=${longName.anonymize}, " + - "shortName=${shortName.anonymize}, " + - "hwModel=$hwModelString, " + - "isLicensed=$isLicensed, " + - "role=$role)" - - /** Create our model object from a protobuf. */ - constructor( - p: org.meshtastic.proto.User, - ) : this(p.id, p.long_name, p.short_name, p.hw_model, p.is_licensed, p.role.value) - - /** - * a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot or null - * if unset - */ - val hwModelString: String? - get() = - if (hwModel == HardwareModel.UNSET) { - null - } else { - hwModel.name.replace('_', '-').replace('p', '.').lowercase() - } -} - -@CommonParcelize -data class Position( - val latitude: Double, - val longitude: Double, - val altitude: Int, - val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) - val satellitesInView: Int = 0, - val groundSpeed: Int = 0, - val groundTrack: Int = 0, // "heading" - val precisionBits: Int = 0, -) : CommonParcelable { - - @Suppress("MagicNumber") - companion object { - // / Convert to a double representation of degrees - fun degD(i: Int) = i * 1e-7 - - fun degI(d: Double) = (d * 1e7).toInt() - - fun currentTime() = nowSeconds.toInt() - } - - /** - * Create our model object from a protobuf. If time is unspecified in the protobuf, the provided default time will - * be used. - */ - constructor( - position: org.meshtastic.proto.Position, - defaultTime: Int = currentTime(), - ) : this( - // We prefer the int version of lat/lon but if not available use the depreciated legacy version - degD(position.latitude_i ?: 0), - degD(position.longitude_i ?: 0), - position.altitude ?: 0, - if (position.time != 0) position.time else defaultTime, - position.sats_in_view, - position.ground_speed ?: 0, - position.ground_track ?: 0, - position.precision_bits, - ) - - // / @return distance in meters to some other node (or null if unknown) - fun distance(o: Position) = latLongToMeter(latitude, longitude, o.latitude, o.longitude) - - // / @return bearing to the other position in degrees - fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude) - - // If GPS gives a crap position don't crash our app - @Suppress("MagicNumber") - fun isValid(): Boolean = latitude != 0.0 && - longitude != 0.0 && - (latitude >= -90 && latitude <= 90.0) && - (longitude >= -180 && longitude <= 180) - - override fun toString(): String = - "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)" -} - -@CommonParcelize -data class DeviceMetrics( - val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) - val batteryLevel: Int = 0, - val voltage: Float, - val channelUtilization: Float, - val airUtilTx: Float, - val uptimeSeconds: Int, -) : CommonParcelable { - companion object { - @Suppress("MagicNumber") - fun currentTime() = nowSeconds.toInt() - } - - /** Create our model object from a protobuf. */ - constructor( - p: org.meshtastic.proto.DeviceMetrics, - telemetryTime: Int = currentTime(), - ) : this( - telemetryTime, - p.battery_level ?: 0, - p.voltage ?: 0f, - p.channel_utilization ?: 0f, - p.air_util_tx ?: 0f, - p.uptime_seconds ?: 0, - ) -} - -@CommonParcelize -data class EnvironmentMetrics( - val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) - val temperature: Float?, - val relativeHumidity: Float?, - val soilTemperature: Float?, - val soilMoisture: Int?, - val barometricPressure: Float?, - val gasResistance: Float?, - val voltage: Float?, - val current: Float?, - val iaq: Int?, - val lux: Float? = null, - val uvLux: Float? = null, -) : CommonParcelable { - @Suppress("MagicNumber") - companion object { - fun currentTime() = nowSeconds.toInt() - - fun fromTelemetryProto(proto: org.meshtastic.proto.EnvironmentMetrics, time: Int): EnvironmentMetrics = - EnvironmentMetrics( - temperature = proto.temperature?.takeIf { !it.isNaN() }, - relativeHumidity = proto.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f }, - soilTemperature = proto.soil_temperature?.takeIf { !it.isNaN() }, - soilMoisture = proto.soil_moisture?.takeIf { it != Int.MIN_VALUE }, - barometricPressure = proto.barometric_pressure?.takeIf { !it.isNaN() }, - gasResistance = proto.gas_resistance?.takeIf { !it.isNaN() }, - voltage = proto.voltage?.takeIf { !it.isNaN() }, - current = proto.current?.takeIf { !it.isNaN() }, - iaq = proto.iaq?.takeIf { it != Int.MIN_VALUE }, - lux = proto.lux?.takeIf { !it.isNaN() }, - uvLux = proto.uv_lux?.takeIf { !it.isNaN() }, - time = time, - ) - } -} - -@CommonParcelize -data class NodeInfo( - val num: Int, // This is immutable, and used as a key - var user: MeshUser? = null, - var position: Position? = null, - var snr: Float = Float.MAX_VALUE, - var rssi: Int = Int.MAX_VALUE, - var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970 - var deviceMetrics: DeviceMetrics? = null, - var channel: Int = 0, - var environmentMetrics: EnvironmentMetrics? = null, - var hopsAway: Int = 0, - var nodeStatus: String? = null, -) : CommonParcelable { - - @Suppress("MagicNumber") - val colors: Pair - get() { // returns foreground and background @ColorInt for each 'num' - val r = (num and 0xFF0000) shr 16 - val g = (num and 0x00FF00) shr 8 - val b = num and 0x0000FF - val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255 - val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() - val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b - return foreground to background - } - - val batteryLevel - get() = deviceMetrics?.batteryLevel - - val voltage - get() = deviceMetrics?.voltage - - @Suppress("ImplicitDefaultLocale") - val batteryStr - get() = if (batteryLevel in 1..100) "$batteryLevel%" else "" - - /** true if the device was heard from recently */ - val isOnline: Boolean - get() { - return lastHeard > onlineTimeThreshold() - } - - // / return the position if it is valid, else null - val validPosition: Position? - get() { - return position?.takeIf { it.isValid() } - } - - // / @return distance in meters to some other node (or null if unknown) - fun distance(o: NodeInfo?): Int? { - val p = validPosition - val op = o?.validPosition - return if (p != null && op != null) p.distance(op).toInt() else null - } - - // / @return bearing to the other position in degrees - fun bearing(o: NodeInfo?): Int? { - val p = validPosition - val op = o?.validPosition - return if (p != null && op != null) p.bearing(op).toInt() else null - } - - // / @return a nice human readable string for the distance, or null for unknown - @Suppress("MagicNumber") - fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist -> - when { - dist == 0 -> null - - // same point - prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist < 1000 -> "$dist m" - - prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist >= 1000 -> - "${(dist / 100).toDouble() / 10.0} km" - - prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist < 1609 -> - "${(dist.toDouble() * 3.281).toInt()} ft" - - prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist >= 1609 -> - "${(dist / 160.9).toInt() / 10.0} mi" - - else -> null - } - } -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Position.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Position.kt new file mode 100644 index 0000000000..87a2b9ab3a --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Position.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.core.common.util.bearing +import org.meshtastic.core.common.util.latLongToMeter +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.util.anonymize + +data class Position( + val latitude: Double, + val longitude: Double, + val altitude: Int, + val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!) + val satellitesInView: Int = 0, + val groundSpeed: Int = 0, + val groundTrack: Int = 0, // "heading" + val precisionBits: Int = 0, +) { + + @Suppress("MagicNumber") + companion object { + fun degD(i: Int) = i * 1e-7 + + fun degI(d: Double) = (d * 1e7).toInt() + + fun currentTime() = nowSeconds.toInt() + } + + /** + * Create our model object from a protobuf. If time is unspecified in the protobuf, the provided default time will + * be used. + */ + constructor( + position: org.meshtastic.proto.Position, + defaultTime: Int = currentTime(), + ) : this( + degD(position.latitude_i ?: 0), + degD(position.longitude_i ?: 0), + position.altitude ?: 0, + if (position.time != 0) position.time else defaultTime, + position.sats_in_view, + position.ground_speed ?: 0, + position.ground_track ?: 0, + position.precision_bits, + ) + + /** @return distance in meters to some other position */ + fun distance(o: Position) = latLongToMeter(latitude, longitude, o.latitude, o.longitude) + + /** @return bearing to the other position in degrees */ + fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude) + + @Suppress("MagicNumber") + fun isValid(): Boolean = latitude != 0.0 && + longitude != 0.0 && + (latitude >= -90 && latitude <= 90.0) && + (longitude >= -180 && longitude <= 180) + + override fun toString(): String = + "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)" +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt deleted file mode 100644 index 84994e6288..0000000000 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ /dev/null @@ -1,342 +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 . - */ -package org.meshtastic.core.model - -import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.proto.ClientNotification - -/** - * Central interface for controlling the radio and mesh network. - * - * This component provides an abstraction over the underlying communication transport (e.g., BLE, Serial, TCP) and the - * low-level mesh protocols. It allows feature modules to interact with the mesh without needing to know about - * platform-specific service details or AIDL interfaces. - */ -@Suppress("TooManyFunctions") -interface RadioController { - /** - * Canonical app-level connection state, delegated from [ServiceRepository][connectionState]. - * - * This exposes the same single source of truth as `ServiceRepository.connectionState`, surfaced through the - * controller interface for convenience in feature modules and ViewModels that depend on [RadioController] rather - * than [ServiceRepository] directly. - * - * This is **not** the transport-level state — it reflects the fully reconciled app-level state including handshake - * progress and device sleep policy. - */ - val connectionState: StateFlow - - /** - * Flow of notifications from the radio client. - * - * These represent high-level events like "Handshake completed" or "Channel configuration updated." - */ - val clientNotification: StateFlow - - /** - * Sends a data packet to the mesh. - * - * @param packet The [DataPacket] containing the payload and routing information. - */ - suspend fun sendMessage(packet: DataPacket) - - /** Clears the current [clientNotification]. */ - fun clearClientNotification() - - /** - * Toggles the favorite status of a node on the radio. - * - * @param nodeNum The node number to favorite/unfavorite. - */ - suspend fun favoriteNode(nodeNum: Int) - - /** - * Sends our shared contact information (identity and public key) to the firmware's NodeDB. - * - * This ensures the firmware has the correct public key for the destination node before a PKI-encrypted direct - * message is sent. The method suspends until the radio acknowledges the admin packet. - * - * @param nodeNum The destination node number. - * @return `true` if the radio accepted the contact, `false` on timeout or failure. - */ - suspend fun sendSharedContact(nodeNum: Int): Boolean - - /** - * Updates the local radio configuration. - * - * @param config The new configuration [org.meshtastic.proto.Config]. - */ - suspend fun setLocalConfig(config: org.meshtastic.proto.Config) - - /** - * Updates a local radio channel. - * - * @param channel The channel configuration [org.meshtastic.proto.Channel]. - */ - suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) - - /** - * Updates the owner (user info) on a remote node. - * - * @param destNum The destination node number. - * @param user The new user info [org.meshtastic.proto.User]. - * @param packetId The request packet ID. - */ - suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) - - /** - * Updates the general configuration on a remote node. - * - * @param destNum The destination node number. - * @param config The new configuration [org.meshtastic.proto.Config]. - * @param packetId The request packet ID. - */ - suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) - - /** - * Updates a module configuration on a remote node. - * - * @param destNum The destination node number. - * @param config The new module configuration [org.meshtastic.proto.ModuleConfig]. - * @param packetId The request packet ID. - */ - suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) - - /** - * Updates a channel configuration on a remote node. - * - * @param destNum The destination node number. - * @param channel The new channel configuration [org.meshtastic.proto.Channel]. - * @param packetId The request packet ID. - */ - suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) - - /** - * Sets a fixed position on a remote node. - * - * @param destNum The destination node number. - * @param position The position to set. - */ - suspend fun setFixedPosition(destNum: Int, position: Position) - - /** - * Updates the notification ringtone on a remote node. - * - * @param destNum The destination node number. - * @param ringtone The name/ID of the ringtone. - */ - suspend fun setRingtone(destNum: Int, ringtone: String) - - /** - * Updates the canned messages configuration on a remote node. - * - * @param destNum The destination node number. - * @param messages The canned messages string. - */ - suspend fun setCannedMessages(destNum: Int, messages: String) - - /** - * Requests the current owner (user info) from a remote node. - * - * @param destNum The remote node number. - * @param packetId The request packet ID. - */ - suspend fun getOwner(destNum: Int, packetId: Int) - - /** - * Requests a specific configuration section from a remote node. - * - * @param destNum The remote node number. - * @param configType The numeric type of the configuration section. - * @param packetId The request packet ID. - */ - suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) - - /** - * Requests a module configuration section from a remote node. - * - * @param destNum The remote node number. - * @param moduleConfigType The numeric type of the module configuration section. - * @param packetId The request packet ID. - */ - suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) - - /** - * Requests a specific channel configuration from a remote node. - * - * @param destNum The remote node number. - * @param index The channel index. - * @param packetId The request packet ID. - */ - suspend fun getChannel(destNum: Int, index: Int, packetId: Int) - - /** - * Requests the current ringtone from a remote node. - * - * @param destNum The remote node number. - * @param packetId The request packet ID. - */ - suspend fun getRingtone(destNum: Int, packetId: Int) - - /** - * Requests the current canned messages from a remote node. - * - * @param destNum The remote node number. - * @param packetId The request packet ID. - */ - suspend fun getCannedMessages(destNum: Int, packetId: Int) - - /** - * Requests the hardware connection status from a remote node. - * - * @param destNum The remote node number. - * @param packetId The request packet ID. - */ - suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) - - /** - * Commands a node to reboot. - * - * @param destNum The target node number. - * @param packetId The request packet ID. - */ - suspend fun reboot(destNum: Int, packetId: Int) - - /** - * Commands a node to reboot into DFU (Device Firmware Update) mode. - * - * @param nodeNum The target node number. - */ - suspend fun rebootToDfu(nodeNum: Int) - - /** - * Initiates an Over-The-Air (OTA) reboot request. - * - * @param requestId The request ID. - * @param destNum The target node number. - * @param mode The OTA mode. - * @param hash Optional hash for verification. - */ - suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) - - /** - * Commands a node to shut down. - * - * @param destNum The target node number. - * @param packetId The request packet ID. - */ - suspend fun shutdown(destNum: Int, packetId: Int) - - /** - * Performs a factory reset on a node. - * - * @param destNum The target node number. - * @param packetId The request packet ID. - */ - suspend fun factoryReset(destNum: Int, packetId: Int) - - /** - * Resets the NodeDB on a node. - * - * @param destNum The target node number. - * @param packetId The request packet ID. - * @param preserveFavorites Whether to keep favorite nodes in the database. - */ - suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) - - /** - * Removes a node from the mesh by its node number. - * - * @param packetId The request packet ID. - * @param nodeNum The node number to remove. - */ - suspend fun removeByNodenum(packetId: Int, nodeNum: Int) - - /** - * Requests the current GPS position from a remote node. - * - * @param destNum The target node number. - * @param currentPosition Our current position to provide in the request. - */ - suspend fun requestPosition(destNum: Int, currentPosition: Position) - - /** - * Requests detailed user info from a remote node. - * - * @param destNum The target node number. - */ - suspend fun requestUserInfo(destNum: Int) - - /** - * Initiates a traceroute request to a remote node. - * - * @param requestId The request ID. - * @param destNum The destination node number. - */ - suspend fun requestTraceroute(requestId: Int, destNum: Int) - - /** - * Requests telemetry data from a remote node. - * - * @param requestId The request ID. - * @param destNum The destination node number. - * @param typeValue The numeric type of telemetry requested. - */ - suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) - - /** - * Requests neighbor information (detected nodes) from a remote node. - * - * @param requestId The request ID. - * @param destNum The destination node number. - */ - suspend fun requestNeighborInfo(requestId: Int, destNum: Int) - - /** - * Signals the start of a batch configuration session. - * - * @param destNum The target node number. - */ - suspend fun beginEditSettings(destNum: Int) - - /** - * Commits all pending configuration changes in a batch session. - * - * @param destNum The target node number. - */ - suspend fun commitEditSettings(destNum: Int) - - /** - * Generates a unique packet ID for a new request. - * - * @return A unique 32-bit integer. - */ - fun getPacketId(): Int - - /** Starts providing the phone's location to the mesh. */ - fun startProvideLocation() - - /** Stops providing the phone's location to the mesh. */ - fun stopProvideLocation() - - /** - * Changes the device address (e.g., BLE MAC, IP address) we are communicating with. - * - * @param address The new device identifier. - */ - fun setDeviceAddress(address: String) -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt deleted file mode 100644 index 9ffe944d4e..0000000000 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt +++ /dev/null @@ -1,49 +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 . - */ -package org.meshtastic.core.model.service - -import kotlinx.coroutines.CompletableDeferred -import org.meshtastic.core.model.Node -import org.meshtastic.proto.SharedContact - -sealed class ServiceAction { - data class GetDeviceMetadata(val destNum: Int) : ServiceAction() - - data class Favorite(val node: Node) : ServiceAction() - - data class Ignore(val node: Node) : ServiceAction() - - data class Mute(val node: Node) : ServiceAction() - - data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction() - - data class ImportContact(val contact: SharedContact) : ServiceAction() - - /** - * Sends a shared contact (identity + public key) to the firmware's NodeDB. - * - * The [result] deferred is completed with `true` when the radio acknowledges the admin packet, or `false` on - * timeout/failure. Callers that need to guarantee the contact is stored before sending a subsequent DM should - * `await()` this deferred. - * - * Not a data class: [result] is a [CompletableDeferred] with identity-based equality that would break data class - * equals/hashCode/copy semantics. - */ - class SendContact(val contact: SharedContact) : ServiceAction() { - val result: CompletableDeferred = CompletableDeferred() - } -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt index 25e19bbefd..9e220f57f5 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/ByteStringSerializer.kt @@ -23,8 +23,6 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import okio.ByteString import okio.ByteString.Companion.toByteString -import org.meshtastic.core.common.util.CommonParcel -import org.meshtastic.core.common.util.CommonParceler /** Serializer for Okio [ByteString] using kotlinx.serialization */ object ByteStringSerializer : KSerializer { @@ -38,12 +36,3 @@ object ByteStringSerializer : KSerializer { override fun deserialize(decoder: Decoder): ByteString = byteArraySerializer.deserialize(decoder).toByteString() } - -/** Parceler for Okio [ByteString] for Android Parcelable support */ -object ByteStringParceler : CommonParceler { - override fun create(parcel: CommonParcel): ByteString? = parcel.createByteArray()?.toByteString() - - override fun ByteString?.write(parcel: CommonParcel, flags: Int) { - parcel.writeByteArray(this?.toByteArray()) - } -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt index 4df932c50f..d1f6dc6dbc 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt @@ -20,6 +20,7 @@ package org.meshtastic.core.model.util import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.NodeAddress import org.meshtastic.proto.MeshPacket /** @@ -40,7 +41,7 @@ open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) { dataType = decoded.portnum.value, bytes = decoded.payload.toByteArray().toByteString(), hopLimit = packet.hop_limit, - channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel, + channel = if (packet.pki_encrypted == true) NodeAddress.PKC_CHANNEL_INDEX else packet.channel, wantAck = packet.want_ack == true, hopStart = packet.hop_start, snr = packet.rx_snr, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/WireExtensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/WireExtensions.kt index aed84d2088..d5d4e3eefe 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/WireExtensions.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/WireExtensions.kt @@ -59,7 +59,7 @@ fun > ProtoAdapter.decodeOrNull(bytes: ByteArray?, logger: * ``` * val data = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = bytes) * if (!Data.ADAPTER.isWithinSizeLimit(data, MAX_PAYLOAD)) { - * throw RemoteException("Payload too large") + * error("Payload too large") * } * ``` * diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt index d386482b39..f16930929f 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/DataPacketTest.kt @@ -38,8 +38,8 @@ class DataPacketTest { @Test fun nodeNumToDefaultId_formatsHexWithPrefix() { - assertEquals("!1234abcd", DataPacket.nodeNumToDefaultId(0x1234ABCD)) - assertEquals("!ffffffff", DataPacket.nodeNumToDefaultId(DataPacket.NODENUM_BROADCAST)) + assertEquals("!1234abcd", NodeAddress.numToDefaultId(0x1234ABCD)) + assertEquals("!ffffffff", NodeAddress.numToDefaultId(NodeAddress.NODENUM_BROADCAST)) } @Test diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeAddressTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeAddressTest.kt new file mode 100644 index 0000000000..0e0440640a --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeAddressTest.kt @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.proto.PortNum +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NodeAddressTest { + + // --- fromString parsing --- + + @Test + fun fromString_null_returnsBroadcast() { + assertEquals(NodeAddress.Broadcast, NodeAddress.fromString(null)) + } + + @Test + fun fromString_broadcastString_returnsBroadcast() { + assertEquals(NodeAddress.Broadcast, NodeAddress.fromString("^all")) + } + + @Test + fun fromString_localString_returnsLocal() { + assertEquals(NodeAddress.Local, NodeAddress.fromString("^local")) + } + + @Test + fun fromString_validHexId_returnsByNum() { + val result = NodeAddress.fromString("!a1b2c3d4") + assertIs(result) + assertEquals(0xa1b2c3d4.toInt(), result.num) + } + + @Test + fun fromString_shortHexId_returnsByNum() { + val result = NodeAddress.fromString("!1234") + assertIs(result) + assertEquals(0x1234, result.num) + } + + @Test + fun fromString_invalidHexAfterBang_returnsByIdFallback() { + val result = NodeAddress.fromString("!notahex") + assertIs(result) + assertEquals("!notahex", result.id) + } + + @Test + fun fromString_arbitraryString_returnsById() { + val result = NodeAddress.fromString("some-node-name") + assertIs(result) + assertEquals("some-node-name", result.id) + } + + @Test + fun fromString_emptyString_returnsById() { + val result = NodeAddress.fromString("") + assertIs(result) + assertEquals("", result.id) + } + + // --- numToDefaultId --- + + @Test + fun numToDefaultId_typicalValue_formatsCorrectly() { + assertEquals("!a1b2c3d4", NodeAddress.numToDefaultId(0xa1b2c3d4.toInt())) + } + + @Test + fun numToDefaultId_zero_padsToEightChars() { + assertEquals("!00000000", NodeAddress.numToDefaultId(0)) + } + + @Test + fun numToDefaultId_maxInt_formatsCorrectly() { + assertEquals("!7fffffff", NodeAddress.numToDefaultId(Int.MAX_VALUE)) + } + + @Test + fun numToDefaultId_negativeOne_formatsAsFffffffff() { + assertEquals("!ffffffff", NodeAddress.numToDefaultId(-1)) + } + + // --- idToNum --- + + @Test + fun idToNum_validHex_returnsInt() { + assertEquals(0xa1b2c3d4.toInt(), NodeAddress.idToNum("a1b2c3d4")) + } + + @Test + fun idToNum_withBangPrefix_stripsAndParses() { + assertEquals(0x1234, NodeAddress.idToNum("!1234")) + } + + @Test + fun idToNum_null_returnsNull() { + assertNull(NodeAddress.idToNum(null)) + } + + @Test + fun idToNum_emptyString_returnsNull() { + assertNull(NodeAddress.idToNum("")) + } + + @Test + fun idToNum_nonHex_returnsNull() { + assertNull(NodeAddress.idToNum("zzzzzzzz")) + } + + @Test + fun idToNum_overflow_returnsNull() { + assertNull(NodeAddress.idToNum("ffffffffffffffffff")) + } + + // --- roundtrip --- + + @Test + fun numToDefaultId_idToNum_roundtrip() { + val original = 0xdeadbeef.toInt() + val id = NodeAddress.numToDefaultId(original) + val parsed = NodeAddress.idToNum(id) + assertEquals(original, parsed) + } + + // --- toIdString --- + + @Test + fun toIdString_broadcast() { + assertEquals("^all", NodeAddress.Broadcast.toIdString()) + } + + @Test + fun toIdString_local() { + assertEquals("^local", NodeAddress.Local.toIdString()) + } + + @Test + fun toIdString_byNum() { + assertEquals("!0000abcd", NodeAddress.ByNum(0xabcd).toIdString()) + } + + @Test + fun toIdString_byId() { + assertEquals("custom-id", NodeAddress.ById("custom-id").toIdString()) + } + + // --- toContactKey --- + + @Test + fun toContactKey_formatsChannelPlusId() { + val key = NodeAddress.Broadcast.toContactKey(0) + assertEquals("0^all", key.value) + } + + @Test + fun toContactKey_nonZeroChannel() { + val key = NodeAddress.ByNum(0x1234).toContactKey(3) + assertEquals("3!00001234", key.value) + } + + // --- ContactKey --- + + @Test + fun contactKey_channel_extractsFirstDigit() { + val key = ContactKey("2!abcdef01") + assertEquals(2, key.channel) + } + + @Test + fun contactKey_addressString_extractsAfterFirstChar() { + val key = ContactKey("0^all") + assertEquals("^all", key.addressString) + } + + @Test + fun contactKey_channelOrNull_returnsDigitForPrefixedKey() { + assertEquals(2, ContactKey("2!abcdef01").channelOrNull) + assertEquals(0, ContactKey("0^all").channelOrNull) + } + + @Test + fun contactKey_unprefixedKey_signalsNoChannelAndKeepsWholeAddress() { + // A legacy direct-message key has no leading channel digit. channelOrNull must stay null so + // callers can distinguish it from channel 0, and addressString must keep the whole string. + val key = ContactKey("!abcdef01") + assertNull(key.channelOrNull) + assertEquals(0, key.channel) + assertEquals("!abcdef01", key.addressString) + } + + @Test + fun contactKey_emptyKey_isSafe() { + val key = ContactKey("") + assertNull(key.channelOrNull) + assertEquals("", key.addressString) + } + + @Test + fun contactKey_address_parsesCorrectly() { + val key = ContactKey("0^local") + assertEquals(NodeAddress.Local, key.address) + } + + @Test + fun contactKey_broadcast_factory() { + val key = ContactKey.broadcast(1) + assertEquals("1^all", key.value) + assertEquals(NodeAddress.Broadcast, key.address) + } + + // --- DataPacket extensions --- + + private fun testPacket(to: String? = "^all", from: String? = "^local") = + DataPacket(to = to, bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, from = from) + + @Test + fun dataPacket_destination_parsesBroadcast() { + assertEquals(NodeAddress.Broadcast, testPacket(to = "^all").destination) + } + + @Test + fun dataPacket_source_parsesLocal() { + assertEquals(NodeAddress.Local, testPacket(from = "^local").source) + } + + @Test + fun dataPacket_isFromLocal_trueForLocal() { + assertTrue(testPacket(from = "^local").isFromLocal()) + } + + @Test + fun dataPacket_isFromLocal_trueForMatchingNodeNum() { + assertTrue(testPacket(from = "!000000ff").isFromLocal(myNodeNum = 0xff)) + } + + @Test + fun dataPacket_isFromLocal_falseForDifferentNodeNum() { + assertFalse(testPacket(from = "!000000ff").isFromLocal(myNodeNum = 0xaa)) + } + + @Test + fun dataPacket_isFromLocal_falseWithoutNodeNum() { + assertFalse(testPacket(from = "!000000ff").isFromLocal(myNodeNum = null)) + } + + @Test + fun dataPacket_isBroadcast_trueForBroadcastDestination() { + assertTrue(testPacket(to = "^all").isBroadcast) + } + + @Test + fun dataPacket_isBroadcast_falseForUnicastDestination() { + assertFalse(testPacket(to = "!12345678").isBroadcast) + } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt index 6e88b38af5..9fe3ab2fac 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt @@ -17,8 +17,8 @@ package org.meshtastic.core.model.util import okio.ByteString.Companion.encodeUtf8 -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MeshUser +import org.meshtastic.core.model.NodeAddress import org.meshtastic.proto.Config import org.meshtastic.proto.Data import org.meshtastic.proto.DeviceMetrics @@ -104,7 +104,7 @@ class MeshDataMapperTest { val mapped = mapper.toDataPacket(packet) assertNotNull(mapped) - assertEquals(DataPacket.PKC_CHANNEL_INDEX, mapped.channel) + assertEquals(NodeAddress.PKC_CHANNEL_INDEX, mapped.channel) } @Test @@ -281,6 +281,6 @@ class MeshDataMapperTest { } private class TestNodeIdLookup : NodeIdLookup { - override fun toNodeID(nodeNum: Int): String = DataPacket.nodeNumToDefaultId(nodeNum) + override fun toNodeID(nodeNum: Int): String = NodeAddress.numToDefaultId(nodeNum) } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbManager.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbManager.kt index 543552cb69..09f7ac72f0 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbManager.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbManager.kt @@ -28,7 +28,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import org.meshtastic.core.common.util.registerReceiverCompat -private const val ACTION_USB_PERMISSION = "com.geeksville.mesh.USB_PERMISSION" +private const val ACTION_USB_PERMISSION = "org.meshtastic.app.USB_PERMISSION" internal fun UsbManager.requestPermission(context: Context, device: UsbDevice): Flow = callbackFlow { val receiver = diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt index cabeb977a4..71a8d8b630 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt @@ -20,8 +20,7 @@ import co.touchlab.kermit.Logger import io.ktor.client.plugins.logging.Logger as KtorLogger /** - * Bridges Ktor's HTTP client logging to [Kermit][Logger] so HTTP request/response events appear in the standard app - * logs rather than going to [System.out] via Ktor's default [io.ktor.client.plugins.logging.Logger.DEFAULT]. + * Bridges Ktor's HTTP client logging to [Kermit][Logger]. * * Usage: * ``` @@ -34,7 +33,5 @@ import io.ktor.client.plugins.logging.Logger as KtorLogger * ``` */ object KermitHttpLogger : KtorLogger { - override fun log(message: String) { - Logger.d { message } - } + override fun log(message: String) = Logger.withTag("HttpClient").d { message } } diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt index b94eeffbfd..b728816039 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt @@ -24,7 +24,7 @@ import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Channel -import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.util.getInitials import org.meshtastic.core.repository.RadioTransport import org.meshtastic.core.repository.RadioTransportCallback @@ -333,7 +333,7 @@ class MockRadioTransport( num = numIn, user = User( - id = DataPacket.nodeNumToDefaultId(numIn), + id = NodeAddress.numToDefaultId(numIn), long_name = "Sim ${numIn.toString(16)}", short_name = getInitials("Sim ${numIn.toString(16)}"), hw_model = HardwareModel.ANDROID_SIM, diff --git a/core/proto/build.gradle.kts b/core/proto/build.gradle.kts index 412e12e90b..a8d231f755 100644 --- a/core/proto/build.gradle.kts +++ b/core/proto/build.gradle.kts @@ -18,13 +18,9 @@ plugins { alias(libs.plugins.meshtastic.kmp.library) alias(libs.plugins.wire) - id("meshtastic.publishing") } kotlin { - // Override minSdk for ATAK compatibility (standard is 26) - androidLibrary { minSdk = 21 } - sourceSets { commonMain.dependencies { api(libs.wire.runtime) @@ -125,16 +121,3 @@ wire { // release minify. (Team/MemberRole are NOT shipped by the SDK, so they stay.) prune("meshtastic.Marti") } - -// Modern KMP publication uses the project name as the artifactId by default. -// We rename the publications to include the 'core-' prefix for consistency. -publishing { - publications.withType().configureEach { - val baseId = artifactId - if (baseId == "proto") { - artifactId = "meshtastic-android-proto" - } else if (baseId.startsWith("proto-")) { - artifactId = baseId.replace("proto-", "meshtastic-android-proto-") - } - } -} diff --git a/core/repository/README.md b/core/repository/README.md index edb71c7524..8f460b84e7 100644 --- a/core/repository/README.md +++ b/core/repository/README.md @@ -22,24 +22,30 @@ The `:core:repository` module defines the **data and infrastructure contracts** src/ ├── commonMain/kotlin/org/meshtastic/core/repository/ │ ├── RadioTransport.kt ← interface: raw hardware I/O -│ ├── ServiceRepository.kt ← interface: service ↔ UI bridge +│ ├── ServiceRepository.kt ← interface: service ↔ UI bridge (extends all providers) +│ ├── ConnectionStateProvider.kt ← interface: read-only connection state +│ ├── ResponseProviders.kt ← interfaces: TracerouteResponseProvider, NeighborInfoResponseProvider +│ ├── ServiceStateWriter.kt ← interface: write-side for handlers │ ├── NodeRepository.kt ← interface: mesh node database │ ├── SessionManager.kt ← interface: per-node passkey store │ ├── MeshConnectionManager.kt ← interface: connection lifecycle callbacks │ ├── AppWidgetUpdater.kt ← interface: trigger widget refresh │ ├── LocationRepository.kt │ ├── LocationService.kt +│ ├── RadioController.kt ← interface: composite radio command API +│ ├── AdminController.kt ← config, channels, owner, device lifecycle, editSettings +│ ├── MessagingController.kt ← send packets, reactions, contacts +│ ├── NodeController.kt ← favorite, ignore, mute, remove nodes +│ ├── QueryController.kt ← telemetry, traceroute, position queries │ ├── CommandSender.kt │ ├── AdminPacketHandler.kt │ ├── FromRadioPacketHandler.kt -│ ├── MeshActionHandler.kt │ ├── MeshConfigFlowManager.kt │ ├── MeshConfigHandler.kt │ ├── MeshDataHandler.kt │ ├── MeshLocationManager.kt │ ├── MeshLogRepository.kt │ ├── MeshMessageProcessor.kt -│ ├── MeshRouter.kt │ ├── MessageFilter.kt │ ├── MessageQueue.kt │ ├── MqttManager.kt @@ -51,7 +57,6 @@ src/ │ ├── RadioConfigRepository.kt │ ├── RadioInterfaceService.kt │ ├── RadioTransportCallback.kt / RadioTransportFactory.kt -│ ├── ServiceBroadcasts.kt │ ├── StoreForwardPacketHandler.kt │ ├── TelemetryPacketHandler.kt │ ├── TracerouteHandler.kt / TracerouteSnapshotRepository.kt @@ -83,24 +88,50 @@ interface RadioTransport { ### `ServiceRepository` The primary reactive bridge between the long-running mesh service and all feature/UI layers. +Decomposed into focused sub-interfaces via Interface Segregation Principle: ```kotlin -interface ServiceRepository { +interface ConnectionStateProvider { val connectionState: StateFlow +} + +interface TracerouteResponseProvider { + val tracerouteResponse: StateFlow + fun clearTracerouteResponse() +} + +interface NeighborInfoResponseProvider { + val neighborInfoResponse: StateFlow + fun clearNeighborInfoResponse() +} + +interface ServiceStateWriter { + fun setConnectionState(state: ConnectionState) + suspend fun emitMeshPacket(packet: MeshPacket) + fun setTracerouteResponse(value: TracerouteResponse?) + fun setNeighborInfoResponse(value: String?) + // …setters/clearers for error, progress, notification +} + +interface ServiceRepository : + ConnectionStateProvider, + TracerouteResponseProvider, + NeighborInfoResponseProvider, + ServiceStateWriter { val clientNotification: StateFlow val errorMessage: StateFlow val connectionProgress: StateFlow val meshPacketFlow: Flow - val tracerouteResponse: Flow<...> - val neighborInfoResponse: Flow<...> - val serviceAction: Flow - - fun setConnectionState(state: ConnectionState) - fun emitMeshPacket(packet: MeshPacket) - fun onServiceAction(action: ServiceAction) } ``` +VMs inject the narrowest interface they need (e.g., `ConnectionStateProvider` for read-only +connection state). Handlers inject `ServiceStateWriter` for mutations. The full +`ServiceRepository` union is still available for backward compatibility. + +Radio commands are issued through `RadioController` (a composite of `AdminController`, +`MessagingController`, `NodeController`, `QueryController`) rather than an action/intent bus. + ### `NodeRepository` Reactive mesh node database. Backed by Room KMP in `:core:service`. @@ -120,7 +151,7 @@ interface NodeRepository { suspend fun clearNodeDB(preserveFavorites: Boolean = false) suspend fun deleteNode(num: Int) suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) - suspend fun installConfig(mi: MyNodeInfo, nodes: List) + suspend fun installConfig(mi: MyNodeInfo, nodes: List) } ``` diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt new file mode 100644 index 0000000000..b5466797df --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import org.meshtastic.core.model.Position +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.User + +/** + * Device configuration and control operations. + * + * Mirrors the SDK's `AdminApi` interface — local and remote configuration, channel management, owner identity, device + * lifecycle commands, and batch edit sessions. When the SDK is adopted, this interface becomes the adapter boundary: + * implementations delegate to `RadioClient.admin`. + * + * @see RadioController which extends this interface for backward compatibility + */ +@Suppress("TooManyFunctions") +interface AdminController { + + // ── Local configuration ───────────────────────────────────────────────── + + /** + * Updates the local radio configuration. + * + * Fire-and-forget by design: the device is the source of truth. Local persistence is an optimistic cache that will + * self-heal on next config refresh. + */ + suspend fun setLocalConfig(config: Config) + + /** Updates a local radio channel. Same fire-and-forget contract as [setLocalConfig]. */ + suspend fun setLocalChannel(channel: Channel) + + // ── Remote configuration ──────────────────────────────────────────────── + + /** Updates the owner (user info) on a remote node. */ + suspend fun setOwner(destNum: Int, user: User, packetId: Int) + + /** Updates the general configuration on a remote node. */ + suspend fun setConfig(destNum: Int, config: Config, packetId: Int) + + /** Updates a module configuration on a remote node. */ + suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) + + /** Updates a channel configuration on a remote node. */ + suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) + + /** Sets a fixed position on a remote node. */ + suspend fun setFixedPosition(destNum: Int, position: Position) + + /** Updates the notification ringtone on a remote node. */ + suspend fun setRingtone(destNum: Int, ringtone: String) + + /** Updates the canned messages configuration on a remote node. */ + suspend fun setCannedMessages(destNum: Int, messages: String) + + // ── Remote queries ────────────────────────────────────────────────────── + + /** Requests the current owner (user info) from a remote node. */ + suspend fun getOwner(destNum: Int, packetId: Int) + + /** Requests a specific configuration section from a remote node. */ + suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) + + /** Requests a module configuration section from a remote node. */ + suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) + + /** Requests a specific channel configuration from a remote node. */ + suspend fun getChannel(destNum: Int, index: Int, packetId: Int) + + /** Requests the current ringtone from a remote node. */ + suspend fun getRingtone(destNum: Int, packetId: Int) + + /** Requests the current canned messages from a remote node. */ + suspend fun getCannedMessages(destNum: Int, packetId: Int) + + /** Requests the hardware connection status from a remote node. */ + suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) + + // ── Device lifecycle ──────────────────────────────────────────────────── + + /** Commands a node to reboot. */ + suspend fun reboot(destNum: Int, packetId: Int) + + /** Commands a node to reboot into DFU mode. */ + suspend fun rebootToDfu(nodeNum: Int) + + /** Initiates an OTA reboot request. */ + suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) + + /** Commands a node to shut down. */ + suspend fun shutdown(destNum: Int, packetId: Int) + + /** Performs a factory reset on a node. */ + suspend fun factoryReset(destNum: Int, packetId: Int) + + /** Resets the NodeDB on a node. */ + suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) + + // ── Batch edit ────────────────────────────────────────────────────────── + + /** + * Runs [block] inside a begin/commit edit-settings transaction on [destNum]. + * + * The session is opened before [block] runs and committed after it returns normally, so callers can neither forget + * to commit nor leak a half-open session. Operations inside the block target [destNum] implicitly. Mirrors the + * SDK's `AdminApi.editSettings { }`. + * + * All admin packets for the session — begin, the [block]'s writes, and commit — are issued from the calling + * coroutine, which is required for the firmware to associate them with one transaction. + */ + suspend fun editSettings(destNum: Int, block: suspend AdminEditScope.() -> Unit) +} + +/** + * Configuration operations valid inside an [AdminController.editSettings] transaction, scoped to a single destination + * node so callers don't repeat the node number or manage packet IDs. Mirrors the SDK's `AdminEdit`. + */ +interface AdminEditScope { + /** Updates the owner (user info) on the session's node. */ + suspend fun setOwner(user: User) + + /** Updates the general configuration on the session's node. */ + suspend fun setConfig(config: Config) + + /** Updates a module configuration on the session's node. */ + suspend fun setModuleConfig(config: ModuleConfig) + + /** Updates a channel configuration on the session's node. */ + suspend fun setChannel(channel: Channel) + + /** Sets a fixed position on the session's node. */ + suspend fun setFixedPosition(position: Position) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt index a6b58bb485..1e4c398bfd 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt @@ -38,10 +38,10 @@ interface CommandSender { fun generatePacketId(): Int /** Sends a data packet to the mesh. */ - fun sendData(p: DataPacket) + suspend fun sendData(p: DataPacket) /** Sends an admin message to a specific node. */ - fun sendAdmin( + suspend fun sendAdmin( destNum: Int, requestId: Int = generatePacketId(), wantResponse: Boolean = false, @@ -64,23 +64,23 @@ interface CommandSender { ): Boolean /** Sends our current position to the mesh. */ - fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false) + suspend fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false) /** Requests the position of a specific node. */ - fun requestPosition(destNum: Int, currentPosition: Position) + suspend fun requestPosition(destNum: Int, currentPosition: Position) /** Sets a fixed position for a node. */ - fun setFixedPosition(destNum: Int, pos: Position) + suspend fun setFixedPosition(destNum: Int, pos: Position) /** Requests user info from a specific node. */ - fun requestUserInfo(destNum: Int) + suspend fun requestUserInfo(destNum: Int) /** Requests a traceroute to a specific node. */ - fun requestTraceroute(requestId: Int, destNum: Int) + suspend fun requestTraceroute(requestId: Int, destNum: Int) /** Requests telemetry from a specific node. */ - fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) + suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) /** Requests neighbor info from a specific node. */ - fun requestNeighborInfo(requestId: Int, destNum: Int) + suspend fun requestNeighborInfo(requestId: Int, destNum: Int) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateProvider.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateProvider.kt new file mode 100644 index 0000000000..b7403fcc3c --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.ConnectionState + +/** + * Read-only provider of the canonical app-level connection state. + * + * Inject this interface in ViewModels and feature modules that only need to **observe** connection state — never write + * it. This enforces the single-writer contract (only [MeshConnectionManager] may mutate state via + * [ServiceRepository.setConnectionState]). + * + * @see ServiceRepository for the full read/write interface + */ +interface ConnectionStateProvider { + /** + * Canonical app-level connection state. + * + * This is the **single source of truth** for connection status across the entire application. + * + * @see ServiceRepository.connectionState + */ + val connectionState: StateFlow +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt index 0087dde970..1cf46034fb 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt @@ -28,7 +28,7 @@ interface HistoryManager { * @param storeForwardConfig The store-and-forward module configuration. * @param transport The transport method being used (for logging). */ - fun requestHistoryReplay( + suspend fun requestHistoryReplay( trigger: String, myNodeNum: Int?, storeForwardConfig: ModuleConfig.StoreForwardConfig?, diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt deleted file mode 100644 index 873e1c76bd..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt +++ /dev/null @@ -1,119 +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 . - */ -package org.meshtastic.core.repository - -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.service.ServiceAction - -/** Interface for handling UI-triggered actions and administrative commands for the mesh. */ -@Suppress("TooManyFunctions") -interface MeshActionHandler { - /** Processes a service action from the UI. */ - suspend fun onServiceAction(action: ServiceAction) - - /** Sets the owner of the local node. */ - fun handleSetOwner(u: MeshUser, myNodeNum: Int) - - /** Sends a data packet through the mesh. */ - fun handleSend(p: DataPacket, myNodeNum: Int) - - /** Requests the position of a remote node. */ - fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) - - /** Removes a node from the database by its node number. */ - fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) - - /** Sets the owner of a remote node. */ - fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) - - /** Gets the owner of a remote node. */ - fun handleGetRemoteOwner(id: Int, destNum: Int) - - /** Sets the configuration of the local node. */ - fun handleSetConfig(payload: ByteArray, myNodeNum: Int) - - /** Sets the configuration of a remote node. */ - fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) - - /** Gets the configuration of a remote node. */ - fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) - - /** Sets the module configuration of a remote node. */ - fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) - - /** Gets the module configuration of a remote node. */ - fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) - - /** Sets the ringtone of a remote node. */ - fun handleSetRingtone(destNum: Int, ringtone: String) - - /** Gets the ringtone of a remote node. */ - fun handleGetRingtone(id: Int, destNum: Int) - - /** Sets canned messages on a remote node. */ - fun handleSetCannedMessages(destNum: Int, messages: String) - - /** Gets canned messages from a remote node. */ - fun handleGetCannedMessages(id: Int, destNum: Int) - - /** Sets a channel configuration on the local node. */ - fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) - - /** Sets a channel configuration on a remote node. */ - fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) - - /** Gets a channel configuration from a remote node. */ - fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) - - /** Requests neighbor information from a remote node. */ - fun handleRequestNeighborInfo(requestId: Int, destNum: Int) - - /** Begins editing settings on a remote node. */ - fun handleBeginEditSettings(destNum: Int) - - /** Commits settings edits on a remote node. */ - fun handleCommitEditSettings(destNum: Int) - - /** Reboots a remote node into DFU mode. */ - fun handleRebootToDfu(destNum: Int) - - /** Requests telemetry from a remote node. */ - fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) - - /** Requests a remote node to shut down. */ - fun handleRequestShutdown(requestId: Int, destNum: Int) - - /** Requests a remote node to reboot. */ - fun handleRequestReboot(requestId: Int, destNum: Int) - - /** Requests a remote node to reboot in OTA mode. */ - fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) - - /** Requests a factory reset on a remote node. */ - fun handleRequestFactoryReset(requestId: Int, destNum: Int) - - /** Requests a node database reset on a remote node. */ - fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) - - /** Gets the connection status of a remote node. */ - fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) - - /** Updates the last used device address. */ - fun handleUpdateLastAddress(deviceAddr: String?) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt index 9d898a3333..a390539549 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt @@ -30,7 +30,7 @@ interface MeshConnectionManager { fun startNodeInfoOnly() /** Called when the node database is ready and fully populated. */ - fun onNodeDbReady() + suspend fun onNodeDbReady() /** Updates the telemetry information for the local node. */ fun updateTelemetry(t: Telemetry) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLocationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLocationManager.kt index accd503f91..a729f19adf 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLocationManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshLocationManager.kt @@ -22,7 +22,15 @@ import org.meshtastic.proto.Position /** Interface for managing the local node's location updates and reporting. */ interface MeshLocationManager { /** Starts location updates and reports them via the given function. */ - fun start(scope: CoroutineScope, sendPositionFn: (Position) -> Unit) + fun start(scope: CoroutineScope, sendPositionFn: suspend (Position) -> Unit) + + /** + * Retries starting location updates using the previously-provided scope and callback. + * + * Call this after a permission grant or GPS enablement to re-check conditions and start location updates that were + * skipped on the initial [start] call. + */ + fun restart() /** Stops location updates. */ fun stop() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt similarity index 82% rename from core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt index 9a15b86601..26eaf0289a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt @@ -23,8 +23,14 @@ import org.meshtastic.proto.Telemetry const val SERVICE_NOTIFY_ID = 101 +/** + * Mesh-domain notification builder. Provides high-level operations for the message arrival, waypoint, reaction, new + * node, low-battery, and client notification flows specific to this app. Implementations are expected to render the + * platform notification themselves; the generic dispatch primitive is [NotificationManager] (which posts/cancels opaque + * [Notification] records and is *not* domain-aware). + */ @Suppress("TooManyFunctions") -interface MeshServiceNotifications { +interface MeshNotificationManager { fun clearNotifications() fun initChannels() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt deleted file mode 100644 index 490f507255..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt +++ /dev/null @@ -1,44 +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 . - */ -package org.meshtastic.core.repository - -/** Interface for the central router that orchestrates specialized mesh packet handlers. */ -interface MeshRouter { - /** Access to the data handler. */ - val dataHandler: MeshDataHandler - - /** Access to the configuration handler. */ - val configHandler: MeshConfigHandler - - /** Access to the traceroute handler. */ - val tracerouteHandler: TracerouteHandler - - /** Access to the neighbor info handler. */ - val neighborInfoHandler: NeighborInfoHandler - - /** Access to the configuration flow manager. */ - val configFlowManager: MeshConfigFlowManager - - /** Access to the MQTT manager. */ - val mqttManager: MqttManager - - /** Access to the action handler. */ - val actionHandler: MeshActionHandler - - /** Access to the XModem file-transfer manager. */ - val xmodemManager: XModemManager -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessagingController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessagingController.kt new file mode 100644 index 0000000000..21a3cbbe16 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessagingController.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import org.meshtastic.core.model.DataPacket +import org.meshtastic.proto.SharedContact + +/** + * Messaging operations — sending data packets, reactions, and shared contacts. + * + * Mirrors the SDK's send/messaging surface. When the SDK is adopted, implementations delegate to `RadioClient.send()` / + * `RadioClient.sendText()`. + * + * @see RadioController which extends this interface for backward compatibility + */ +interface MessagingController { + + /** Sends a data packet to the mesh. */ + suspend fun sendMessage(packet: DataPacket) + + /** Sends an emoji reaction to a message. Awaits local DB persistence. */ + suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) + + /** Imports a shared contact into the firmware's NodeDB. */ + suspend fun importContact(contact: SharedContact) + + /** + * Sends our shared contact information (identity and public key) to the firmware's NodeDB. + * + * @param nodeNum The destination node number. + * @return `true` if the radio accepted the contact, `false` on timeout or failure. + */ + suspend fun sendSharedContact(nodeNum: Int): Boolean +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeController.kt new file mode 100644 index 0000000000..02903dcd39 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeController.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +/** + * Node management operations — favorite, ignore, mute, and remove nodes. + * + * Mirrors the node management subset of the SDK's `AdminApi` (setFavorite, setIgnored, toggleMuted). When the SDK is + * adopted, implementations delegate to `RadioClient.admin.setFavorite(NodeId, Boolean)` etc. + * + * @see RadioController which extends this interface for backward compatibility + */ +interface NodeController { + + /** + * Sets the favorite status of a node on the radio. + * + * Idempotent: a no-op if the node is already in the requested state. Mirrors the SDK's `setFavorite(NodeId, + * Boolean)` — an explicit target state rather than a toggle, so concurrent callers can't race a read-modify-write. + */ + suspend fun setFavorite(nodeNum: Int, favorite: Boolean) + + /** + * Sets the ignore status of a node on the radio. + * + * Idempotent, like [setFavorite]. Mirrors the SDK's `setIgnored(NodeId, Boolean)`. + */ + suspend fun setIgnored(nodeNum: Int, ignored: Boolean) + + /** Toggles the mute status of a node on the radio. Mirrors the SDK's `toggleMuted(NodeId)`. */ + suspend fun toggleMuted(nodeNum: Int) + + /** Removes a node from the mesh by its node number. */ + suspend fun removeByNodenum(packetId: Int, nodeNum: Int) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt index 80c1c5e538..6bf214f233 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.repository import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node -import org.meshtastic.core.model.NodeInfo import org.meshtastic.core.model.util.NodeIdLookup import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FirmwareEdition @@ -36,8 +35,8 @@ interface NodeManager : NodeIdLookup { /** Reactive map of all nodes by their number. */ val nodeDBbyNodeNum: Map - /** Reactive map of all nodes by their ID string. */ - val nodeDBbyID: Map + /** Look up a node by its user ID string (e.g. `"!a1b2c3d4"`). */ + fun getNodeById(id: String): Node? /** Whether the node database is ready. */ val isNodeDbReady: StateFlow @@ -75,9 +74,6 @@ interface NodeManager : NodeIdLookup { /** Returns the local node ID. */ fun getMyId(): String - /** Returns a list of all known nodes. */ - fun getNodes(): List - /** Processes a received user packet. */ fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) @@ -97,13 +93,13 @@ interface NodeManager : NodeIdLookup { fun updateNodeStatus(nodeNum: Int, status: String?) /** Updates a node using a transformation function. */ - fun updateNode(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, transform: (Node) -> Node) + fun updateNode(nodeNum: Int, channel: Int = 0, transform: (Node) -> Node) /** Removes a node from the in-memory database by its number. */ fun removeByNodenum(nodeNum: Int) /** Installs node information from a ProtoNodeInfo object. */ - fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) + fun installNodeInfo(info: ProtoNodeInfo) /** Inserts hardware metadata for a node. */ fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt index 85afeea79d..5805d57c6c 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NotificationManager.kt @@ -16,6 +16,13 @@ */ package org.meshtastic.core.repository +/** + * Platform-agnostic notification dispatch primitive. Posts opaque [Notification] records, cancels by id, or wipes all + * active notifications. Intended as the lowest layer of the notification stack. + * + * Domain-specific notification builders (mesh message arrivals, low-battery alerts, etc.) live in + * [MeshNotificationManager], which composes over this dispatcher. + */ interface NotificationManager { fun dispatch(notification: Notification) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt index cd73b7f9b5..cbb6322f2c 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt @@ -26,7 +26,7 @@ interface PacketHandler { fun sendToRadio(p: ToRadio) /** Adds a mesh packet to the queue for sending. */ - fun sendToRadio(packet: MeshPacket) + suspend fun sendToRadio(packet: MeshPacket) /** * Adds a mesh packet to the queue and suspends until the radio acknowledges it via [QueueStatus]. diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/QueryController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/QueryController.kt new file mode 100644 index 0000000000..39f72bc879 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/QueryController.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import org.meshtastic.core.model.Position + +/** + * Mesh query operations — position, traceroute, telemetry, user info, and metadata. + * + * These are "pull" operations that request data from remote nodes. When the SDK is adopted, implementations delegate to + * `RadioClient.telemetry` and `RadioClient.routing` sub-APIs. + * + * @see RadioController which extends this interface for backward compatibility + */ +interface QueryController { + + /** Requests device metadata from a remote node. */ + suspend fun refreshMetadata(destNum: Int) + + /** Requests the current GPS position from a remote node. */ + suspend fun requestPosition(destNum: Int, currentPosition: Position) + + /** Requests detailed user info from a remote node. */ + suspend fun requestUserInfo(destNum: Int) + + /** Initiates a traceroute request to a remote node. */ + suspend fun requestTraceroute(requestId: Int, destNum: Int) + + /** Requests telemetry data from a remote node. */ + suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) + + /** Requests neighbor information (detected nodes) from a remote node. */ + suspend fun requestNeighborInfo(requestId: Int, destNum: Int) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt new file mode 100644 index 0000000000..646c3cf131 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.proto.ClientNotification + +/** + * Central interface for controlling the radio and mesh network. + * + * This is a composite interface that extends the focused sub-interfaces below. Feature modules that need the full + * surface inject [RadioController]; modules that need only a subset can inject the narrower interface for better + * testability and clearer dependency intent. + * + * **Sub-interfaces (mirrors SDK's layered API design):** + * - [AdminController] — config, channels, owner, device lifecycle (→ SDK `AdminApi`) + * - [MessagingController] — send packets, reactions, contacts (→ SDK `RadioClient.send*`) + * - [NodeController] — favorite, ignore, mute, remove nodes (→ SDK `AdminApi` node ops) + * - [QueryController] — telemetry, traceroute, position queries (→ SDK `TelemetryApi` / `RoutingApi`) + * + * When migrating to the SDK, each sub-interface becomes a thin adapter over the corresponding SDK API. The composite + * [RadioController] can then be deprecated and consumers migrated to the narrower interfaces one at a time. + */ +interface RadioController : + AdminController, + MessagingController, + NodeController, + QueryController, + ConnectionStateProvider { + /** + * Flow of notifications from the radio client. + * + * These represent high-level events like "Handshake completed" or "Channel configuration updated." + */ + val clientNotification: StateFlow + + /** Clears the current [clientNotification]. */ + fun clearClientNotification() + + /** + * Generates a unique packet ID for a new request. + * + * @return A unique 32-bit integer. + */ + fun generatePacketId(): Int + + /** Starts providing the phone's location to the mesh. */ + fun startProvideLocation() + + /** Stops providing the phone's location to the mesh. */ + fun stopProvideLocation() + + /** + * Changes the device address (e.g., BLE MAC, IP address) we are communicating with. + * + * Suspends until the database has been switched, the in-memory node DB cleared, and the transport reconfigured. + * Callers that depend on the device switch being effective before their next call (e.g. OTA disconnect-then-delay + * sequences) can rely on this ordering. + * + * @param address The new device identifier. + */ + suspend fun setDeviceAddress(address: String) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ResponseProviders.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ResponseProviders.kt new file mode 100644 index 0000000000..cad6b9dada --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ResponseProviders.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.service.TracerouteResponse + +/** + * Read-only provider of traceroute response state. + * + * Inject in ViewModels that display traceroute results. The write side ([ServiceRepository.setTracerouteResponse]) is + * restricted to handlers. + */ +interface TracerouteResponseProvider { + /** The most recent traceroute result, or null if none pending. */ + val tracerouteResponse: StateFlow + + /** Clears the current traceroute response (consumed by UI after display). */ + fun clearTracerouteResponse() +} + +/** + * Read-only provider of neighbor info response state. + * + * Inject in ViewModels that display neighbor info results. + */ +interface NeighborInfoResponseProvider { + /** The most recent neighbor info response (formatted string), or null. */ + val neighborInfoResponse: StateFlow + + /** Clears the current neighbor info response. */ + fun clearNeighborInfoResponse() +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt deleted file mode 100644 index 5cd61b6714..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt +++ /dev/null @@ -1,39 +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 . - */ -package org.meshtastic.core.repository - -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Node - -/** Interface for broadcasting service-level events to the application. */ -interface ServiceBroadcasts { - /** Subscribes a receiver to mesh broadcasts. */ - fun subscribeReceiver(receiverName: String, packageName: String) - - /** Broadcasts received data to the application. */ - fun broadcastReceivedData(dataPacket: DataPacket) - - /** Broadcasts that the radio connection state has changed. */ - fun broadcastConnection() - - /** Broadcasts that node information has changed. */ - fun broadcastNodeChange(node: Node) - - /** Broadcasts that the status of a message has changed. */ - fun broadcastMessageStatus(packetId: Int, status: MessageStatus) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index 2a09e95c8b..1d0e9b5d69 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt @@ -20,7 +20,6 @@ import co.touchlab.kermit.Severity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.MeshPacket @@ -40,7 +39,11 @@ import org.meshtastic.proto.MeshPacket * @see RadioInterfaceService.connectionState */ @Suppress("TooManyFunctions") -interface ServiceRepository { +interface ServiceRepository : + ConnectionStateProvider, + TracerouteResponseProvider, + NeighborInfoResponseProvider, + ServiceStateWriter { /** * Canonical app-level connection state. * @@ -56,7 +59,7 @@ interface ServiceRepository { * * @see RadioInterfaceService.connectionState */ - val connectionState: StateFlow + override val connectionState: StateFlow /** * Updates the canonical app-level connection state. @@ -66,7 +69,7 @@ interface ServiceRepository { * * @param connectionState The new [ConnectionState]. */ - fun setConnectionState(connectionState: ConnectionState) + override fun setConnectionState(connectionState: ConnectionState) /** * Reactive flow of high-level client notifications. @@ -80,10 +83,10 @@ interface ServiceRepository { * * @param notification The [ClientNotification] to display or act upon. */ - fun setClientNotification(notification: ClientNotification?) + override fun setClientNotification(notification: ClientNotification?) /** Clears the current client notification. */ - fun clearClientNotification() + override fun clearClientNotification() /** * Reactive flow of human-readable error messages. @@ -98,10 +101,10 @@ interface ServiceRepository { * @param text The error message text. * @param severity The [Severity] level of the error. */ - fun setErrorMessage(text: String, severity: Severity = Severity.Error) + override fun setErrorMessage(text: String, severity: Severity) /** Clears the current error message. */ - fun clearErrorMessage() + override fun clearErrorMessage() /** * Reactive flow of connection progress messages. @@ -115,7 +118,7 @@ interface ServiceRepository { * * @param text The progress description (e.g., "Downloading Node DB..."). */ - fun setConnectionProgress(text: String) + override fun setConnectionProgress(text: String) /** * Flow of all raw [MeshPacket] objects received from the mesh. @@ -133,41 +136,31 @@ interface ServiceRepository { * * @param packet The received [MeshPacket]. */ - suspend fun emitMeshPacket(packet: MeshPacket) + override suspend fun emitMeshPacket(packet: MeshPacket) /** Reactive flow of the most recent traceroute result. */ - val tracerouteResponse: StateFlow + override val tracerouteResponse: StateFlow /** * Sets the traceroute response. * * @param value The [TracerouteResponse] result. */ - fun setTracerouteResponse(value: TracerouteResponse?) + override fun setTracerouteResponse(value: TracerouteResponse?) /** Clears the current traceroute response. */ - fun clearTracerouteResponse() + override fun clearTracerouteResponse() /** Reactive flow of the most recent neighbor info response (formatted string). */ - val neighborInfoResponse: StateFlow + override val neighborInfoResponse: StateFlow /** * Sets the neighbor info response. * * @param value The human-readable neighbor info string. */ - fun setNeighborInfoResponse(value: String?) + override fun setNeighborInfoResponse(value: String?) /** Clears the current neighbor info response. */ - fun clearNeighborInfoResponse() - - /** Flow of service actions requested by the UI (e.g., "Favorite Node", "Mute Node"). */ - val serviceAction: Flow - - /** - * Dispatches a service action to be handled by the background service. - * - * @param action The [ServiceAction] to perform. - */ - suspend fun onServiceAction(action: ServiceAction) + override fun clearNeighborInfoResponse() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceStateWriter.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceStateWriter.kt new file mode 100644 index 0000000000..6d97c323f6 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceStateWriter.kt @@ -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 . + */ +package org.meshtastic.core.repository + +import co.touchlab.kermit.Severity +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.service.TracerouteResponse +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.MeshPacket + +/** + * Write-side interface for service state mutations. + * + * Only background handlers, managers, and the service layer should inject this interface. UI/ViewModel code should + * never write service state directly — it observes via [ConnectionStateProvider], [TracerouteResponseProvider], or + * [NeighborInfoResponseProvider]. + */ +interface ServiceStateWriter { + /** Updates the canonical app-level connection state. Only [MeshConnectionManager] should call this. */ + fun setConnectionState(connectionState: ConnectionState) + + /** Sets the current client notification. */ + fun setClientNotification(notification: ClientNotification?) + + /** Clears the current client notification. */ + fun clearClientNotification() + + /** Sets an error message to be displayed. */ + fun setErrorMessage(text: String, severity: Severity = Severity.Error) + + /** Clears the current error message. */ + fun clearErrorMessage() + + /** Sets the connection progress message. */ + fun setConnectionProgress(text: String) + + /** Emits a mesh packet into the shared flow. */ + suspend fun emitMeshPacket(packet: MeshPacket) + + /** Sets the traceroute response. */ + fun setTracerouteResponse(value: TracerouteResponse?) + + /** Sets the neighbor info response. */ + fun setNeighborInfoResponse(value: String?) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt index e28e759807..92e1416258 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/di/CoreRepositoryModule.kt @@ -19,11 +19,11 @@ package org.meshtastic.core.repository.di import org.koin.core.annotation.Module import org.koin.core.annotation.Provided import org.koin.core.annotation.Single -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.MessageQueue import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.usecase.SendMessageUseCase import org.meshtastic.core.repository.usecase.SendMessageUseCaseImpl diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt index 7fc17c2a9e..0ad1fab06a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt @@ -20,14 +20,16 @@ import co.touchlab.kermit.Logger import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Capabilities +import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.Node -import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.MessageQueue import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.proto.Config import kotlin.random.Random @@ -44,7 +46,7 @@ import kotlin.random.Random * This implementation is platform-agnostic and relies on injected repositories and controllers. */ interface SendMessageUseCase { - suspend operator fun invoke(text: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) + suspend operator fun invoke(text: String, contactKey: String = "0${NodeAddress.ID_BROADCAST}", replyId: Int? = null) } @Suppress("TooGenericExceptionCaught") @@ -65,17 +67,18 @@ class SendMessageUseCaseImpl( */ @Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod") override suspend operator fun invoke(text: String, contactKey: String, replyId: Int?) { - val channel = contactKey[0].digitToIntOrNull() - val dest = if (channel != null) contactKey.substring(1) else contactKey + val parsedKey = ContactKey(contactKey) + val channel = parsedKey.channelOrNull + val dest = parsedKey.addressString val ourNode = nodeRepository.ourNodeInfo.value - val fromId = ourNode?.user?.id ?: DataPacket.ID_LOCAL + val fromId = ourNode?.user?.id ?: NodeAddress.ID_LOCAL // Direct message side-effects: share the contact's public key (PKI) or // favorite the node (legacy) before sending the first message. PKI DMs use // channel == PKC_CHANNEL_INDEX (8); legacy DMs have no channel prefix // (channel == null). Both formats target a specific node. - val isDirectMessage = channel == null || channel == DataPacket.PKC_CHANNEL_INDEX + val isDirectMessage = channel == null || channel == NodeAddress.PKC_CHANNEL_INDEX if (isDirectMessage) { val destNode = nodeRepository.getNode(dest) val fwVersion = ourNode?.metadata?.firmware_version @@ -130,7 +133,7 @@ class SendMessageUseCaseImpl( private suspend fun favoriteNode(node: Node) { try { - radioController.favoriteNode(node.num) + radioController.setFavorite(node.num, favorite = true) } catch (ex: Exception) { Logger.e(ex) { "Favorite node error" } } diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt index c65812c016..95e716654a 100644 --- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt +++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt @@ -20,8 +20,8 @@ import dev.mokkery.MockMode import dev.mokkery.mock import io.kotest.matchers.shouldBe import kotlinx.coroutines.test.runTest -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.MessageQueue import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.testing.FakeAppPreferences @@ -68,7 +68,7 @@ class SendMessageUseCaseTest { appPreferences.homoglyph.setHomoglyphEncodingEnabled(false) // Act - useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null) + useCase("Hello broadcast", "0${NodeAddress.ID_BROADCAST}", null) // Assert radioController.favoritedNodes.size shouldBe 0 @@ -133,7 +133,7 @@ class SendMessageUseCaseTest { val originalText = "\u0410pple" // Cyrillic A // Act - useCase(originalText, "0${DataPacket.ID_BROADCAST}", null) + useCase(originalText, "0${NodeAddress.ID_BROADCAST}", null) // Assert // Verified by observing that no exception is thrown and coverage is hit. @@ -156,7 +156,7 @@ class SendMessageUseCaseTest { appPreferences.homoglyph.setHomoglyphEncodingEnabled(false) // Act — PKI DM: channel 8 + node ID - useCase("PKI direct message", "${DataPacket.PKC_CHANNEL_INDEX}!70fdde9b", null) + useCase("PKI direct message", "${NodeAddress.PKC_CHANNEL_INDEX}!70fdde9b", null) // Assert — sendSharedContact should be called for PKI DMs radioController.sentSharedContacts.size shouldBe 1 @@ -205,7 +205,7 @@ class SendMessageUseCaseTest { appPreferences.homoglyph.setHomoglyphEncodingEnabled(false) // Act — PKI DM with firmware that doesn't support verified contacts - useCase("Old PKI DM", "${DataPacket.PKC_CHANNEL_INDEX}!abcdef01", null) + useCase("Old PKI DM", "${NodeAddress.PKC_CHANNEL_INDEX}!abcdef01", null) // Assert — PKI DMs should not trigger legacy favoriting (that's only for channel==null) radioController.sentSharedContacts.size shouldBe 0 diff --git a/core/service/README.md b/core/service/README.md index 84352b0889..2be3c83943 100644 --- a/core/service/README.md +++ b/core/service/README.md @@ -8,17 +8,17 @@ The `:core:service` module contains the abstractions and client-side logic for i ## Key Components -### 1. `ServiceClient` -The main entry point for other parts of the app (or third-party apps) to bind to and interact with the mesh service via AIDL. +### 1. `MeshService` +Android foreground service entry point that hosts the orchestrator lifecycle. ### 2. `ServiceRepository` A high-level repository that wraps the service connection and exposes reactive `Flow`s for connection status and data arrival. ### 3. `ConnectionState` -An enum representing the current state of the radio connection (`Connected`, `Disconnected`, `DeviceSleep`, etc.). +Represents the current state of the radio connection (`Connected`, `Disconnected`, `DeviceSleep`, etc.). -### 4. `ServiceAction` -Defines Intent actions for starting, stopping, and interacting with the background service. +### 4. `RadioControllerImpl` +The in-process `RadioController` composition root (Desktop, iOS, and single-process Android). It assembles four focused sub-controllers — `AdminControllerImpl`, `MessagingControllerImpl`, `NodeControllerImpl`, `QueryControllerImpl` — via Kotlin interface delegation, and owns the cross-cutting concerns (connection state, packet-id, location, device-address switching). Commands are direct suspend calls to `CommandSender`; admin sends are fire-and-forget (the device is the source of truth). Config writes use the `editSettings { }` transaction. ## Dependency Graph @@ -28,7 +28,6 @@ Defines Intent actions for starting, stopping, and interacting with the backgrou graph TB :core:service[service]:::kmp-library :core:service -.-> :core:testing - :core:service --> :core:api :core:service --> :core:repository :core:service -.-> :core:common :core:service -.-> :core:data diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index 59f7d3f959..c7d8624e4d 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -45,7 +45,6 @@ kotlin { } androidMain.dependencies { - api(projects.core.api) implementation(libs.androidx.core.ktx) implementation(libs.androidx.work.runtime.ktx) implementation(libs.koin.android) diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt deleted file mode 100644 index 4dd27dbf4e..0000000000 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt +++ /dev/null @@ -1,42 +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 . - */ -package org.meshtastic.core.service - -import org.junit.runner.RunWith -import org.meshtastic.core.service.testing.FakeIMeshService -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -/** Test to verify that the AIDL contract is correctly implemented by our test harness. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class IMeshServiceContractTest { - - @Test - fun `verify fake implementation matches aidl contract`() { - val service: IMeshService = FakeIMeshService() - - // Basic verification that we can call methods and get expected results - assertEquals("fake_id", service.myId) - assertEquals(1234, service.packetId) - assertEquals("CONNECTED", service.connectionState()) - assertNotNull(service.nodes) - } -} diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt similarity index 97% rename from core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt rename to core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt index a4a3b0fe30..b0e8b9bae5 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImplTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt @@ -33,7 +33,7 @@ import kotlin.test.assertNull @RunWith(AndroidJUnit4::class) @Config(sdk = [34]) -class MeshServiceNotificationsImplTest { +class MeshNotificationManagerImplTest { private lateinit var context: Context private lateinit var systemNotificationManager: NotificationManager @@ -55,7 +55,7 @@ class MeshServiceNotificationsImplTest { NotificationChannels.LEGACY_CATEGORY_IDS.forEach(::createChannel) val notifications = - MeshServiceNotificationsImpl( + MeshNotificationManagerImpl( context = context, packetRepository = lazy { error("Not used in this test") }, nodeRepository = lazy { error("Not used in this test") }, diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt deleted file mode 100644 index 16a9a000c1..0000000000 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt +++ /dev/null @@ -1,135 +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 . - */ -package org.meshtastic.core.service - -import android.app.Application -import android.content.Context -import androidx.test.core.app.ApplicationProvider -import co.touchlab.kermit.Severity -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asFlow -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.model.service.TracerouteResponse -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.MeshPacket -import org.robolectric.RobolectricTestRunner -import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config -import kotlin.test.assertEquals - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class ServiceBroadcastsTest { - - private lateinit var context: Context - private val serviceRepository = FakeServiceRepository() - private lateinit var broadcasts: ServiceBroadcasts - - @Before - fun setUp() { - context = ApplicationProvider.getApplicationContext() - broadcasts = ServiceBroadcasts(context, serviceRepository) - serviceRepository.setConnectionState(ConnectionState.Connected) - } - - @Test - fun `broadcastConnection sends uppercase state string for ATAK`() { - broadcasts.broadcastConnection() - - val shadowApp = shadowOf(context as Application) - val intent = shadowApp.broadcastIntents.find { it.action == ACTION_MESH_CONNECTED } - assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED)) - } - - @Test - fun `broadcastConnection sends legacy connection intent`() { - broadcasts.broadcastConnection() - - val shadowApp = shadowOf(context as Application) - val intent = shadowApp.broadcastIntents.find { it.action == ACTION_CONNECTION_CHANGED } - assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED)) - assertEquals(true, intent?.getBooleanExtra("connected", false)) - } - - private class FakeServiceRepository : ServiceRepository { - override val connectionState = MutableStateFlow(ConnectionState.Disconnected) - override val clientNotification = MutableStateFlow(null) - override val errorMessage = MutableStateFlow(null) - override val connectionProgress = MutableStateFlow(null) - private val meshPackets = MutableSharedFlow() - override val meshPacketFlow: Flow = meshPackets.asFlow() - override val tracerouteResponse = MutableStateFlow(null) - override val neighborInfoResponse = MutableStateFlow(null) - private val serviceActions = MutableSharedFlow() - override val serviceAction: Flow = serviceActions - - override fun setConnectionState(connectionState: ConnectionState) { - this.connectionState.value = connectionState - } - - override fun setClientNotification(notification: ClientNotification?) { - clientNotification.value = notification - } - - override fun clearClientNotification() { - clientNotification.value = null - } - - override fun setErrorMessage(text: String, severity: Severity) { - errorMessage.value = text - } - - override fun clearErrorMessage() { - errorMessage.value = null - } - - override fun setConnectionProgress(text: String) { - connectionProgress.value = text - } - - override suspend fun emitMeshPacket(packet: MeshPacket) { - meshPackets.emit(packet) - } - - override fun setTracerouteResponse(value: TracerouteResponse?) { - tracerouteResponse.value = value - } - - override fun clearTracerouteResponse() { - tracerouteResponse.value = null - } - - override fun setNeighborInfoResponse(value: String?) { - neighborInfoResponse.value = value - } - - override fun clearNeighborInfoResponse() { - neighborInfoResponse.value = null - } - - override suspend fun onServiceAction(action: ServiceAction) { - serviceActions.emit(action) - } - } -} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt index 639c6af3f9..acf155dc2d 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt @@ -36,11 +36,13 @@ import org.meshtastic.proto.Position as ProtoPosition class AndroidMeshLocationManager(private val context: Application, private val locationRepository: LocationRepository) : MeshLocationManager { private lateinit var scope: CoroutineScope + private var sendPositionFn: (suspend (ProtoPosition) -> Unit)? = null private var locationFlow: Job? = null @SuppressLint("MissingPermission") - override fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) { + override fun start(scope: CoroutineScope, sendPositionFn: suspend (ProtoPosition) -> Unit) { this.scope = scope + this.sendPositionFn = sendPositionFn if (locationFlow?.isActive == true) return if (context.hasLocationPermission()) { @@ -70,6 +72,17 @@ class AndroidMeshLocationManager(private val context: Application, private val l } } + override fun restart() { + val fn = sendPositionFn + if (fn == null || !::scope.isInitialized) { + // start() hasn't been called yet — the connection manager wires us up on first myNodeInfo emission via + // the shouldProvideNodeLocation pref. Nothing to restart until that happens. + Logger.d { "restart() before start() — no-op until MeshConnectionManagerImpl wires location" } + return + } + start(scope, fn) + } + override fun stop() { if (locationFlow?.isActive == true) { Logger.i { "Stopping location requests" } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt index e59e3c623b..1d01c23571 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidNotificationManager.kt @@ -49,7 +49,7 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan * [org.meshtastic.core.service.MeshService.onCreate] on the main thread. The CMP [getString] helper uses * [kotlinx.coroutines.runBlocking] which can fail in that context, crashing the entire service startup chain. * Instead, channels are lazily ensured before the first [dispatch] call. Note that - * [MeshServiceNotificationsImpl.initChannels] already creates a superset of these channels when the orchestrator + * [MeshNotificationManagerImpl.initChannels] already creates a superset of these channels when the orchestrator * starts, so this lazy path is only a safety net for notifications dispatched before orchestrator initialization. */ private var channelsInitialized = false @@ -79,7 +79,7 @@ class AndroidNotificationManager(private val context: Context) : NotificationMan return NotificationChannel(channelConfig.id, getString(nameRes), channelConfig.importance) } - // Keep category-to-channel mapping aligned with MeshServiceNotificationsImpl.NotificationType IDs. + // Keep category-to-channel mapping aligned with MeshNotificationManagerImpl.NotificationType IDs. private fun Notification.Category.channelConfig(): ChannelConfig = when (this) { Notification.Category.Message -> ChannelConfig( diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt deleted file mode 100644 index af7cb85c20..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ /dev/null @@ -1,223 +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 . - */ -package org.meshtastic.core.service - -import android.content.Context -import android.content.Intent -import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.StateFlow -import org.koin.core.annotation.Single -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.proto.Channel -import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.SharedContact -import org.meshtastic.proto.User - -/** - * Android [RadioController] implementation that delegates to the bound [MeshService] via AIDL. - * - * All radio commands are forwarded through [AndroidServiceRepository.meshService]. If the service is not yet bound, - * commands are silently dropped with a warning log. - */ -@Single -@Suppress("TooManyFunctions") -class AndroidRadioControllerImpl( - private val context: Context, - private val serviceRepository: AndroidServiceRepository, - private val nodeRepository: NodeRepository, -) : RadioController { - - /** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */ - override val connectionState: StateFlow - get() = serviceRepository.connectionState - - override val clientNotification: StateFlow - get() = serviceRepository.clientNotification - - override suspend fun sendMessage(packet: DataPacket) { - val svc = serviceRepository.meshService - if (svc == null) { - Logger.w { "sendMessage: meshService is null, dropping packet" } - return - } - svc.send(packet) - } - - override fun clearClientNotification() { - serviceRepository.clearClientNotification() - } - - override suspend fun favoriteNode(nodeNum: Int) { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef)) - } - - override suspend fun sendSharedContact(nodeNum: Int): Boolean { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - val contact = - SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified) - val action = ServiceAction.SendContact(contact) - serviceRepository.onServiceAction(action) - return action.result.await() - } - - override suspend fun setLocalConfig(config: Config) { - serviceRepository.meshService?.setConfig(config.encode()) - } - - override suspend fun setLocalChannel(channel: Channel) { - serviceRepository.meshService?.setChannel(channel.encode()) - } - - override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { - serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode()) - } - - override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { - serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode()) - } - - override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { - serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode()) - } - - override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { - serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode()) - } - - override suspend fun setFixedPosition(destNum: Int, position: Position) { - serviceRepository.meshService?.setFixedPosition(destNum, position) - } - - override suspend fun setRingtone(destNum: Int, ringtone: String) { - serviceRepository.meshService?.setRingtone(destNum, ringtone) - } - - override suspend fun setCannedMessages(destNum: Int, messages: String) { - serviceRepository.meshService?.setCannedMessages(destNum, messages) - } - - override suspend fun getOwner(destNum: Int, packetId: Int) { - serviceRepository.meshService?.getRemoteOwner(packetId, destNum) - } - - override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { - serviceRepository.meshService?.getRemoteConfig(packetId, destNum, configType) - } - - override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) { - serviceRepository.meshService?.getModuleConfig(packetId, destNum, moduleConfigType) - } - - override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) { - serviceRepository.meshService?.getRemoteChannel(packetId, destNum, index) - } - - override suspend fun getRingtone(destNum: Int, packetId: Int) { - serviceRepository.meshService?.getRingtone(packetId, destNum) - } - - override suspend fun getCannedMessages(destNum: Int, packetId: Int) { - serviceRepository.meshService?.getCannedMessages(packetId, destNum) - } - - override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { - serviceRepository.meshService?.getDeviceConnectionStatus(packetId, destNum) - } - - override suspend fun reboot(destNum: Int, packetId: Int) { - serviceRepository.meshService?.requestReboot(packetId, destNum) - } - - override suspend fun rebootToDfu(nodeNum: Int) { - serviceRepository.meshService?.rebootToDfu(nodeNum) - } - - override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { - serviceRepository.meshService?.requestRebootOta(requestId, destNum, mode, hash) - } - - override suspend fun shutdown(destNum: Int, packetId: Int) { - serviceRepository.meshService?.requestShutdown(packetId, destNum) - } - - override suspend fun factoryReset(destNum: Int, packetId: Int) { - serviceRepository.meshService?.requestFactoryReset(packetId, destNum) - } - - override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) { - serviceRepository.meshService?.requestNodedbReset(packetId, destNum, preserveFavorites) - } - - override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) { - serviceRepository.meshService?.removeByNodenum(packetId, nodeNum) - } - - override suspend fun requestPosition(destNum: Int, currentPosition: Position) { - serviceRepository.meshService?.requestPosition(destNum, currentPosition) - } - - override suspend fun requestUserInfo(destNum: Int) { - serviceRepository.meshService?.requestUserInfo(destNum) - } - - override suspend fun requestTraceroute(requestId: Int, destNum: Int) { - serviceRepository.meshService?.requestTraceroute(requestId, destNum) - } - - override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { - serviceRepository.meshService?.requestTelemetry(requestId, destNum, typeValue) - } - - override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) { - serviceRepository.meshService?.requestNeighborInfo(requestId, destNum) - } - - override suspend fun beginEditSettings(destNum: Int) { - serviceRepository.meshService?.beginEditSettings(destNum) - } - - override suspend fun commitEditSettings(destNum: Int) { - serviceRepository.meshService?.commitEditSettings(destNum) - } - - override fun getPacketId(): Int = - serviceRepository.meshService?.getPacketId() ?: error("Cannot generate packet ID: meshService is not bound") - - override fun startProvideLocation() { - serviceRepository.meshService?.startProvideLocation() - } - - override fun stopProvideLocation() { - serviceRepository.meshService?.stopProvideLocation() - } - - override fun setDeviceAddress(address: String) { - @Suppress("DEPRECATION") // Internal use: routes address change through AIDL binder - serviceRepository.meshService?.setDeviceAddress(address) - // Ensure service is running/restarted to handle the new address - val intent = Intent().apply { setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") } - context.startForegroundService(intent) - } -} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt index dca0fb415f..e8cf54a632 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt @@ -17,22 +17,21 @@ package org.meshtastic.core.service import org.koin.core.annotation.Single +import org.meshtastic.core.repository.ConnectionStateProvider +import org.meshtastic.core.repository.NeighborInfoResponseProvider import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.ServiceStateWriter +import org.meshtastic.core.repository.TracerouteResponseProvider -/** - * Android-specific [ServiceRepository] that extends [ServiceRepositoryImpl] with AIDL service binding. - * - * The base class provides all reactive state management (connection state, error messages, mesh packets, etc.) in pure - * KMP code. This subclass adds the [IMeshService] reference needed by [AndroidRadioControllerImpl] and the AIDL binder - * in `MeshService`. - */ -@Single(binds = [ServiceRepository::class, AndroidServiceRepository::class]) -@Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding -class AndroidServiceRepository : ServiceRepositoryImpl() { - var meshService: IMeshService? = null - private set - - fun setMeshService(service: IMeshService?) { - meshService = service - } -} +/** Android DI binding of the shared [ServiceRepositoryImpl]. */ +@Single( + binds = + [ + ServiceRepository::class, + ConnectionStateProvider::class, + TracerouteResponseProvider::class, + NeighborInfoResponseProvider::class, + ServiceStateWriter::class, + ], +) +class AndroidServiceRepository : ServiceRepositoryImpl() diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt deleted file mode 100644 index 425b19fe2b..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt +++ /dev/null @@ -1,39 +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 . - */ -package org.meshtastic.core.service - -import org.meshtastic.core.api.MeshtasticIntent - -const val PREFIX = "com.geeksville.mesh" - -const val ACTION_NODE_CHANGE = MeshtasticIntent.ACTION_NODE_CHANGE -const val ACTION_MESH_CONNECTED = MeshtasticIntent.ACTION_MESH_CONNECTED -const val ACTION_MESH_DISCONNECTED = MeshtasticIntent.ACTION_MESH_DISCONNECTED - -@Suppress("DEPRECATION") // Intentionally re-exported for backward-compat broadcast in ServiceBroadcasts -const val ACTION_CONNECTION_CHANGED = MeshtasticIntent.ACTION_CONNECTION_CHANGED -const val ACTION_MESSAGE_STATUS = MeshtasticIntent.ACTION_MESSAGE_STATUS - -fun actionReceived(portNum: String) = "$PREFIX.RECEIVED.$portNum" - -// Standard EXTRA bundle definitions -const val EXTRA_CONNECTED = MeshtasticIntent.EXTRA_CONNECTED - -const val EXTRA_PAYLOAD = MeshtasticIntent.EXTRA_PAYLOAD -const val EXTRA_NODEINFO = MeshtasticIntent.EXTRA_NODEINFO -const val EXTRA_PACKET_ID = MeshtasticIntent.EXTRA_PACKET_ID -const val EXTRA_STATUS = MeshtasticIntent.EXTRA_STATUS diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt index be9ce11300..d78a9822b1 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MarkAsReadReceiver.kt @@ -26,7 +26,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.PacketRepository /** A [BroadcastReceiver] that handles "Mark as read" actions from notifications. */ @@ -36,14 +36,14 @@ class MarkAsReadReceiver : private val packetRepository: PacketRepository by inject() - private val serviceNotifications: MeshServiceNotifications by inject() + private val serviceNotifications: MeshNotificationManager by inject() private val dispatchers: CoroutineDispatchers by inject() private val scope by lazy { CoroutineScope(dispatchers.io + SupervisorJob()) } companion object { - const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ" + const val MARK_AS_READ_ACTION = "org.meshtastic.app.MARK_AS_READ" const val CONTACT_KEY = "contact_key" } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt similarity index 98% rename from core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt rename to core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt index c1c3964b77..35aa695942 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt @@ -42,12 +42,12 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI -import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.SERVICE_NOTIFY_ID @@ -106,11 +106,11 @@ import kotlin.time.Duration.Companion.minutes */ @Suppress("TooManyFunctions", "LongParameterList", "LargeClass") @Single -class MeshServiceNotificationsImpl( +class MeshNotificationManagerImpl( private val context: Context, private val packetRepository: Lazy, private val nodeRepository: Lazy, -) : MeshServiceNotifications { +) : MeshNotificationManager { private val notificationManager = checkNotNull(context.getSystemService()) { "NotificationManager not found" } @@ -121,7 +121,7 @@ class MeshServiceNotificationsImpl( private const val MAX_HISTORY_MESSAGES = 10 private const val MIN_CONTEXT_MESSAGES = 3 private const val SNIPPET_LENGTH = 30 - private const val GROUP_KEY_MESSAGES = "com.geeksville.mesh.GROUP_MESSAGES" + private const val GROUP_KEY_MESSAGES = "org.meshtastic.app.GROUP_MESSAGES" private const val SUMMARY_ID = 1 private const val PERSON_ICON_SIZE = 128 private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f @@ -420,7 +420,7 @@ class MeshServiceNotificationsImpl( val history = packetRepository.value .getMessagesFrom(contactKey, includeFiltered = false) { nodeId -> - if (nodeId == DataPacket.ID_LOCAL) { + if (nodeId == NodeAddress.ID_LOCAL) { ourNode ?: nodeRepository.value.getNode(nodeId) } else { nodeRepository.value.getNode(nodeId.orEmpty()) @@ -461,7 +461,7 @@ class MeshServiceNotificationsImpl( val me = Person.Builder() .setName(meName) - .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL) + .setKey(ourNode?.user?.id ?: NodeAddress.ID_LOCAL) .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } } .build() @@ -573,7 +573,7 @@ class MeshServiceNotificationsImpl( val me = Person.Builder() .setName(meName) - .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL) + .setKey(ourNode?.user?.id ?: NodeAddress.ID_LOCAL) .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } } .build() diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index cf636923ac..79928ee744 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -27,71 +27,35 @@ import android.os.IBinder import android.os.PowerManager import androidx.core.app.ServiceCompat import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import org.koin.android.ext.android.inject import org.meshtastic.core.common.hasLocationPermission -import org.meshtastic.core.common.util.toRemoteExceptions -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceVersion -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.RadioNotConnectedException -import org.meshtastic.core.model.util.anonymize -import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConnectionManager -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.SERVICE_NOTIFY_ID -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.PortNum /** * Android foreground service that hosts the Meshtastic mesh radio connection. * * Acts as the lifecycle anchor for the [MeshServiceOrchestrator], which manages all manager initialization and - * connection state. Exposes an AIDL binder for external client integration via [core:api]. + * connection state. */ -// IMeshService is deprecated but still required for AIDL binding -@Suppress("TooManyFunctions", "LargeClass", "DEPRECATION") +@Suppress("LargeClass") class MeshService : Service() { private val radioInterfaceService: RadioInterfaceService by inject() - private val serviceRepository: ServiceRepository by inject() - - private val serviceBroadcasts: ServiceBroadcasts by inject() - - private val nodeManager: NodeManager by inject() - - private val commandSender: CommandSender by inject() - - private val locationManager: MeshLocationManager by inject() - private val connectionManager: MeshConnectionManager by inject() - private val notifications: MeshServiceNotifications by inject() + private val notifications: MeshNotificationManager by inject() /** Android-typed accessor for the foreground service notification. */ - private val androidNotifications: MeshServiceNotificationsImpl - get() = notifications as MeshServiceNotificationsImpl + private val androidNotifications: MeshNotificationManagerImpl + get() = notifications as MeshNotificationManagerImpl private val orchestrator: MeshServiceOrchestrator by inject() - private val router: MeshRouter by inject() - - private val dispatchers: CoroutineDispatchers by inject() - - private val serviceJob = Job() - private val serviceScope by lazy { CoroutineScope(dispatchers.io + serviceJob) } - private var isServiceInitialized = false /** @@ -102,23 +66,9 @@ class MeshService : Service() { */ private var wakeLock: PowerManager.WakeLock? = null - private val myNodeNum: Int - get() = nodeManager.myNodeNum.value ?: throw RadioNotConnectedException() - companion object { - fun actionReceived(portNum: Int): String { - val portType = PortNum.fromValue(portNum) - val portStr = portType?.toString() ?: portNum.toString() - return actionReceived(portStr) - } - fun createIntent(context: Context) = Intent(context, MeshService::class.java) - fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) { - service.setDeviceAddress(address) - startService(context) - } - val minDeviceVersion = DeviceVersion(DeviceVersion.MIN_FW_VERSION) val absoluteMinDeviceVersion = DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION) @@ -133,14 +83,6 @@ class MeshService : Service() { orchestrator.start() isServiceInitialized = true } catch (e: IllegalStateException) { - // Koin throws IllegalStateException when the DI graph is not yet initialized. - // This can happen if the system restarts the service (e.g. after a crash or on boot) - // before Application.onCreate() has finished setting up Koin. - // In release builds, R8 may merge Koin's InstanceCreationException with unrelated - // exception classes (observed as io.ktor.http.URLDecodeException), so we cannot rely - // on the exception type alone. We catch IllegalStateException narrowly around the - // orchestrator/DI access — not around super.onCreate() — so framework exceptions - // still propagate normally. Logger.e(e) { "MeshService: DI not ready, stopping service" } stopSelf() return @@ -155,8 +97,8 @@ class MeshService : Service() { return START_NOT_STICKY } - val a = radioInterfaceService.getDeviceAddress() - val wantForeground = a != null && a != "n" + val address = radioInterfaceService.getDeviceAddress() + val wantForeground = address != null && address != "n" connectionManager.updateStatusNotification() val notification = androidNotifications.getServiceNotification() @@ -187,14 +129,11 @@ class MeshService : Service() { } private fun startForegroundSafely(notification: android.app.Notification, foregroundServiceType: Int) { - @Suppress("TooGenericExceptionCaught") try { ServiceCompat.startForeground(this, SERVICE_NOTIFY_ID, notification, foregroundServiceType) } catch (ex: android.app.ForegroundServiceStartNotAllowedException) { Logger.e(ex) { "ForegroundServiceStartNotAllowedException: OS restricted background start." } } catch (ex: SecurityException) { - // On Android 14+ starting a location FGS from the background can fail with SecurityException - // if the app is not in an allowed state. Retry without the location type if that was requested. val connectedDeviceOnly = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE @@ -257,7 +196,8 @@ class MeshService : Service() { Logger.i { "Mesh service: onTaskRemoved" } } - override fun onBind(intent: Intent?): IBinder = binder + // Required by Service — this is a started service (not bound), so always returns null. + override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { Logger.i { "Destroying mesh service" } @@ -266,188 +206,6 @@ class MeshService : Service() { if (isServiceInitialized) { orchestrator.stop() } - serviceJob.cancel() super.onDestroy() } - - private val binder = - object : IMeshService.Stub() { - @Suppress("OVERRIDE_DEPRECATION") - override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { - Logger.d { "Passing through device change to radio service: ${deviceAddr?.anonymize}" } - router.actionHandler.handleUpdateLastAddress(deviceAddr) - radioInterfaceService.setDeviceAddress(deviceAddr) - } - - override fun subscribeReceiver(packageName: String, receiverName: String) { - serviceBroadcasts.subscribeReceiver(receiverName, packageName) - } - - @Suppress("OVERRIDE_DEPRECATION") - override fun getUpdateStatus(): Int = -4 - - @Suppress("OVERRIDE_DEPRECATION") - override fun startFirmwareUpdate() { - // No-op: firmware update is handled by the in-app OTA system. - } - - override fun getMyNodeInfo(): MyNodeInfo? = nodeManager.getMyNodeInfo() - - override fun getMyId(): String = nodeManager.getMyId() - - override fun getPacketId(): Int = commandSender.generatePacketId() - - override fun setOwner(u: MeshUser) = toRemoteExceptions { - router.actionHandler.handleSetOwner(u, myNodeNum) - } - - override fun setRemoteOwner(id: Int, destNum: Int, payload: ByteArray) = toRemoteExceptions { - router.actionHandler.handleSetRemoteOwner(id, destNum, payload) - } - - override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleGetRemoteOwner(id, destNum) - } - - override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) } - - override fun getConfig(): ByteArray = toRemoteExceptions { commandSender.getCachedLocalConfig().encode() } - - override fun setConfig(payload: ByteArray) = toRemoteExceptions { - router.actionHandler.handleSetConfig(payload, myNodeNum) - } - - override fun setRemoteConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { - router.actionHandler.handleSetRemoteConfig(id, num, payload) - } - - override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { - router.actionHandler.handleGetRemoteConfig(id, destNum, config) - } - - override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { - router.actionHandler.handleSetModuleConfig(id, num, payload) - } - - override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { - router.actionHandler.handleGetModuleConfig(id, destNum, config) - } - - override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions { - router.actionHandler.handleSetRingtone(destNum, ringtone) - } - - override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleGetRingtone(id, destNum) - } - - override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions { - router.actionHandler.handleSetCannedMessages(destNum, messages) - } - - override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleGetCannedMessages(id, destNum) - } - - override fun setChannel(payload: ByteArray?) = toRemoteExceptions { - router.actionHandler.handleSetChannel(payload, myNodeNum) - } - - override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions { - router.actionHandler.handleSetRemoteChannel(id, num, payload) - } - - override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions { - router.actionHandler.handleGetRemoteChannel(id, destNum, index) - } - - override fun beginEditSettings(destNum: Int) = toRemoteExceptions { - router.actionHandler.handleBeginEditSettings(destNum) - } - - override fun commitEditSettings(destNum: Int) = toRemoteExceptions { - router.actionHandler.handleCommitEditSettings(destNum) - } - - override fun getChannelSet(): ByteArray = toRemoteExceptions { - commandSender.getCachedChannelSet().encode() - } - - override fun getNodes(): List = nodeManager.getNodes() - - override fun connectionState(): String = serviceRepository.connectionState.value.toString() - - override fun startProvideLocation() { - locationManager.start(serviceScope) { commandSender.sendPosition(it) } - } - - override fun stopProvideLocation() { - locationManager.stop() - } - - override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions { - val myNodeNum = nodeManager.myNodeNum.value - if (myNodeNum != null) { - router.actionHandler.handleRemoveByNodenum(nodeNum, requestId, myNodeNum) - } else { - nodeManager.removeByNodenum(nodeNum) - } - } - - override fun requestUserInfo(destNum: Int) = toRemoteExceptions { - if (destNum != myNodeNum) { - commandSender.requestUserInfo(destNum) - } - } - - override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { - router.actionHandler.handleRequestPosition(destNum, position, myNodeNum) - } - - override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions { - commandSender.setFixedPosition(destNum, position) - } - - override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions { - commandSender.requestTraceroute(requestId, destNum) - } - - override fun requestNeighborInfo(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRequestNeighborInfo(requestId, destNum) - } - - override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRequestShutdown(requestId, destNum) - } - - override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRequestReboot(requestId, destNum) - } - - override fun rebootToDfu(destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRebootToDfu(destNum) - } - - override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRequestFactoryReset(requestId, destNum) - } - - override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) = - toRemoteExceptions { - router.actionHandler.handleRequestNodedbReset(requestId, destNum, preserveFavorites) - } - - override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleGetDeviceConnectionStatus(requestId, destNum) - } - - override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) = toRemoteExceptions { - router.actionHandler.handleRequestTelemetry(requestId, destNum, type) - } - - override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) = - toRemoteExceptions { - router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) - } - } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt deleted file mode 100644 index 4bb322ad70..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt +++ /dev/null @@ -1,105 +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 . - */ -package org.meshtastic.core.service - -import android.content.Context -import android.content.Context.BIND_ABOVE_CLIENT -import android.content.Context.BIND_AUTO_CREATE -import androidx.lifecycle.DefaultLifecycleObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.launch -import org.koin.core.annotation.Factory -import org.meshtastic.core.common.util.SequentialJob - -/** A Activity-lifecycle-aware [ServiceClient] that binds [MeshService] once the Activity is started. */ -@Factory -@Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding -class MeshServiceClient( - private val context: Context, - private val serviceRepository: AndroidServiceRepository, - private val serviceSetupJob: SequentialJob, -) : ServiceClient(IMeshService.Stub::asInterface), - DefaultLifecycleObserver { - - private val lifecycleOwner: LifecycleOwner = context as LifecycleOwner - - init { - Logger.d { "Adding self as LifecycleObserver for $lifecycleOwner" } - lifecycleOwner.lifecycle.addObserver(this) - } - - // region ServiceClient overrides - - override fun onConnected(service: IMeshService) { - serviceSetupJob.launch(lifecycleOwner.lifecycleScope) { - serviceRepository.setMeshService(service) - Logger.d { "connected to mesh service, connectionState=${serviceRepository.connectionState.value}" } - } - } - - override fun onDisconnected() { - serviceSetupJob.cancel() - serviceRepository.setMeshService(null) - } - - // endregion - - // region DefaultLifecycleObserver overrides - - override fun onStart(owner: LifecycleOwner) { - super.onStart(owner) - Logger.d { "Lifecycle: ON_START" } - - owner.lifecycleScope.launch { - try { - bindMeshService() - } catch (ex: BindFailedException) { - Logger.e { "Bind of MeshService failed: ${ex.message}" } - } - } - } - - override fun onStop(owner: LifecycleOwner) { - super.onStop(owner) - Logger.d { "Lifecycle: ON_STOP" } - close() - } - - override fun onDestroy(owner: LifecycleOwner) { - super.onDestroy(owner) - Logger.d { "Lifecycle: ON_DESTROY" } - - owner.lifecycle.removeObserver(this) - Logger.d { "Removed self as LifecycleObserver to $lifecycleOwner" } - } - - // endregion - - @Suppress("TooGenericExceptionCaught") - private suspend fun bindMeshService() { - Logger.d { "Binding to mesh service!" } - try { - MeshService.startService(context) - } catch (ex: Exception) { - Logger.e { "Failed to start service from activity - but ignoring because bind will work: ${ex.message}" } - } - - connect(context, MeshService.createIntent(context), BIND_AUTO_CREATE or BIND_ABOVE_CLIENT) - } -} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceStarter.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceStarter.kt index 3b7bedacbc..6b0e719703 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceStarter.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceStarter.kt @@ -27,9 +27,8 @@ import org.meshtastic.core.service.worker.ServiceKeepAliveWorker // / Helper function to start running our service fun MeshService.Companion.startService(context: Context) { - // Before binding we want to explicitly create - so the service stays alive forever (so it can keep - // listening for the bluetooth packets arriving from the radio. And when they arrive forward them - // to Signal or whatever. + // We explicitly start the service as a foreground service so it stays alive for the duration of the radio + // connection — keeping the BLE/TCP/serial link active and forwarding packets to the mesh network. Logger.i { "Trying to start service debug=${false}" } val intent = createIntent(context) diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt index 7233740148..433fdb7e0f 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt @@ -26,20 +26,20 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.RadioController +import kotlin.coroutines.cancellation.CancellationException /** * Handles inline emoji reaction actions from message notifications. * - * Uses [goAsync] to keep the process alive while the coroutine dispatches the reaction through [ServiceRepository], + * Uses [goAsync] to keep the process alive while the coroutine dispatches the reaction through [RadioController], * matching the pattern used by [ReplyReceiver] and [MarkAsReadReceiver]. */ class ReactionReceiver : BroadcastReceiver(), KoinComponent { - private val serviceRepository: ServiceRepository by inject() + private val radioController: RadioController by inject() private val dispatchers: CoroutineDispatchers by inject() @@ -56,7 +56,9 @@ class ReactionReceiver : val pendingResult = goAsync() scope.launch { try { - serviceRepository.onServiceAction(ServiceAction.Reaction(reaction, replyId, contactKey)) + radioController.sendReaction(reaction, replyId, contactKey) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Logger.e(e) { "Error sending reaction" } } finally { diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt index c7f57eba20..6e295ade64 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt @@ -26,9 +26,10 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshNotificationManager +import org.meshtastic.core.repository.RadioController /** * A [BroadcastReceiver] that handles inline replies from notifications. @@ -42,7 +43,7 @@ class ReplyReceiver : KoinComponent { private val radioController: RadioController by inject() - private val meshServiceNotifications: MeshServiceNotifications by inject() + private val meshServiceNotifications: MeshNotificationManager by inject() private val dispatchers: CoroutineDispatchers by inject() @@ -75,9 +76,8 @@ class ReplyReceiver : private suspend fun sendMessage(str: String, contactKey: String) { // contactKey: unique contact key filter (channel)+(nodeId) - val channel = contactKey.getOrNull(0)?.digitToIntOrNull() - val dest = if (channel != null) contactKey.substring(1) else contactKey - val p = DataPacket(dest, channel ?: 0, str) + val parsedKey = ContactKey(contactKey) + val p = DataPacket(parsedKey.addressString, parsedKey.channel, str) radioController.sendMessage(p) } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt deleted file mode 100644 index d63c5f2edb..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt +++ /dev/null @@ -1,164 +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 . - */ -package org.meshtastic.core.service - -import android.content.Context -import android.content.Intent -import android.os.Parcelable -import co.touchlab.kermit.Logger -import org.koin.core.annotation.Single -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.util.toPIIString -import org.meshtastic.core.repository.ServiceRepository -import java.util.Locale -import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcasts - -@Single -class ServiceBroadcasts(private val context: Context, private val serviceRepository: ServiceRepository) : - SharedServiceBroadcasts { - // A mapping of receiver class name to package name - used for explicit broadcasts. - // ConcurrentHashMap because subscribeReceiver() is called from AIDL binder threads - // while explicitBroadcast() iterates from coroutine contexts. - private val clientPackages = java.util.concurrent.ConcurrentHashMap() - - override fun subscribeReceiver(receiverName: String, packageName: String) { - clientPackages[receiverName] = packageName - } - - /** Broadcast some received data Payload will be a DataPacket */ - override fun broadcastReceivedData(dataPacket: DataPacket) { - val action = MeshService.actionReceived(dataPacket.dataType) - explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, dataPacket)) - - // Also broadcast with the numeric port number for backwards compatibility with some apps - val numericAction = actionReceived(dataPacket.dataType.toString()) - if (numericAction != action) { - explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, dataPacket)) - } - } - - override fun broadcastNodeChange(node: Node) { - Logger.d { "Broadcasting node change ${node.user.toPIIString()}" } - val legacy = node.toLegacy() - val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, legacy) - explicitBroadcast(intent) - } - - private fun Node.toLegacy(): NodeInfo = NodeInfo( - num = num, - user = - org.meshtastic.core.model.MeshUser( - id = user.id, - longName = user.long_name, - shortName = user.short_name, - hwModel = user.hw_model, - role = user.role.value, - ), - position = - org.meshtastic.core.model - .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 = - org.meshtastic.core.model.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 = org.meshtastic.core.model.EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0), - hopsAway = hopsAway, - nodeStatus = nodeStatus, - ) - - fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN) - - override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) { - if (packetId == 0) { - Logger.d { "Ignoring anonymous packet status" } - } else { - // Do not log, contains PII possibly - // MeshService.Logger.d { "Broadcasting message status $p" } - val intent = - Intent(ACTION_MESSAGE_STATUS).apply { - putExtra(EXTRA_PACKET_ID, packetId) - putExtra(EXTRA_STATUS, status as Parcelable) - } - explicitBroadcast(intent) - } - } - - /** Broadcast our current connection status */ - override fun broadcastConnection() { - val connectionState = serviceRepository.connectionState.value - // ATAK expects a String: "CONNECTED" or "DISCONNECTED" - // It uses equalsIgnoreCase, but we'll use uppercase to be specific. - val stateStr = connectionState.toString().uppercase(Locale.ROOT) - - val intent = Intent(ACTION_MESH_CONNECTED).apply { putExtra(EXTRA_CONNECTED, stateStr) } - explicitBroadcast(intent) - - if (connectionState == ConnectionState.Disconnected) { - explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED)) - } - - // Restore legacy action for other consumers (e.g. ATAK plugins) - val legacyIntent = - Intent(ACTION_CONNECTION_CHANGED).apply { - putExtra(EXTRA_CONNECTED, stateStr) - // Legacy boolean extra often expected by older implementations - putExtra("connected", connectionState == ConnectionState.Connected) - } - explicitBroadcast(legacyIntent) - } - - /** - * See com.geeksville.mesh broadcast intents. - * - * RECEIVED_OPAQUE for data received from other nodes - * NODE_CHANGE for new IDs appearing or disappearing - * ACTION_MESH_CONNECTED for losing/gaining connection to the packet radio - * Note: this is not the same as RadioInterfaceService.RADIO_CONNECTED_ACTION, - * because it implies we have assembled a valid node db. - */ - private fun explicitBroadcast(intent: Intent) { - context.sendBroadcast( - intent, - ) // We also do a regular (not explicit broadcast) so any context-registered receivers will work - clientPackages.forEach { - intent.setClassName(it.value, it.key) - context.sendBroadcast(intent) - } - } -} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceClient.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceClient.kt deleted file mode 100644 index c7c1e01f49..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceClient.kt +++ /dev/null @@ -1,145 +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 . - */ -package org.meshtastic.core.service - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.os.IInterface -import co.touchlab.kermit.Logger -import kotlinx.coroutines.delay -import org.meshtastic.core.common.util.exceptionReporter -import java.io.Closeable -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -class BindFailedException : Exception("bindService failed") - -/** - * A generic helper for binding to an Android Service via AIDL. Handles connection lifecycle, thread safety for initial - * binding, and automatic retry for common race conditions. - * - * @param T The type of the AIDL interface. - * @param stubFactory A factory function to convert an [IBinder] to the interface type. - */ -open class ServiceClient(private val stubFactory: (IBinder) -> T) : Closeable { - - private companion object { - const val BIND_RETRY_DELAY_MS = 500L - } - - /** The currently bound service instance, or null if not connected. */ - var serviceP: T? = null - - /** - * Returns the bound service instance. If not currently connected, this will block the current thread until the - * connection is established. - * - * @throws IllegalStateException If [connect] has not been called. - * @throws IllegalStateException If the service is not bound after waiting. - */ - val service: T - get() { - waitConnect() - return checkNotNull(serviceP) { "Service not bound" } - } - - private var context: Context? = null - private var isClosed = true - - private val lock = ReentrantLock() - private val condition = lock.newCondition() - - /** - * Blocks the current thread until the service is connected. - * - * @throws IllegalStateException If [connect] has not been called. - */ - fun waitConnect() { - lock.withLock { - check(context != null) { "Connect must be called before waitConnect" } - - if (serviceP == null) { - condition.await() - } - } - } - - /** - * Initiates a binding to the service. - * - * @param c The context to use for binding. - * @param intent The intent used to identify the service. - * @param flags Binding flags (e.g., [Context.BIND_AUTO_CREATE]). - * @throws BindFailedException If the initial bind call fails twice. - */ - suspend fun connect(c: Context, intent: Intent, flags: Int) { - context = c - if (isClosed) { - isClosed = false - if (!c.bindService(intent, connection, flags)) { - // Handle potential race condition on quick re-bind - Logger.w { "Initial bind failed, retrying after delay..." } - delay(BIND_RETRY_DELAY_MS) - if (!c.bindService(intent, connection, flags)) { - throw BindFailedException() - } - } - } else { - Logger.w { "Ignoring rebind attempt for already active service connection" } - } - } - - override fun close() { - isClosed = true - try { - context?.unbindService(connection) - } catch (ex: IllegalArgumentException) { - Logger.w(ex) { "Ignoring error during unbind: service might have already been cleaned up" } - } - serviceP = null - context = null - } - - /** Called on the main thread when the service is connected. */ - open fun onConnected(service: T) {} - - /** Called on the main thread when the service connection is lost. */ - open fun onDisconnected() {} - - private val connection = - object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, binder: IBinder) = exceptionReporter { - if (!isClosed) { - val s = stubFactory(binder) - serviceP = s - onConnected(s) - - lock.withLock { condition.signalAll() } - } else { - Logger.w { "Service connected after close was called; ignoring stale connection" } - } - } - - override fun onServiceDisconnected(name: ComponentName?) = exceptionReporter { - serviceP = null - onDisconnected() - } - } -} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt index f5104739c2..ff5edf523c 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt @@ -16,9 +16,85 @@ */ package org.meshtastic.core.service.di +import android.content.Context +import kotlinx.coroutines.CoroutineScope import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Module +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.repository.AdminController +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.MeshMessageProcessor +import org.meshtastic.core.repository.MeshPrefs +import org.meshtastic.core.repository.MessagingController +import org.meshtastic.core.repository.NodeController +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.PlatformAnalytics +import org.meshtastic.core.repository.QueryController +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioController +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.service.MeshService +import org.meshtastic.core.service.RadioControllerImpl +import org.meshtastic.core.service.startService @Module @ComponentScan("org.meshtastic.core.service") -class CoreServiceAndroidModule +class CoreServiceAndroidModule { + @Suppress("LongParameterList") + @Single( + binds = + [ + RadioController::class, + AdminController::class, + MessagingController::class, + NodeController::class, + QueryController::class, + ], + ) + fun radioController( + context: Context, + serviceRepository: ServiceRepository, + nodeRepository: NodeRepository, + commandSender: CommandSender, + nodeManager: NodeManager, + radioInterfaceService: RadioInterfaceService, + locationManager: MeshLocationManager, + packetRepository: Lazy, + dataHandler: Lazy, + analytics: PlatformAnalytics, + meshPrefs: MeshPrefs, + uiPrefs: UiPrefs, + databaseManager: DatabaseManager, + notificationManager: NotificationManager, + messageProcessor: Lazy, + radioConfigRepository: RadioConfigRepository, + @Named("ServiceScope") scope: CoroutineScope, + ): RadioController = RadioControllerImpl( + serviceRepository = serviceRepository, + nodeRepository = nodeRepository, + commandSender = commandSender, + nodeManager = nodeManager, + radioInterfaceService = radioInterfaceService, + locationManager = locationManager, + packetRepository = packetRepository, + dataHandler = dataHandler, + analytics = analytics, + meshPrefs = meshPrefs, + uiPrefs = uiPrefs, + databaseManager = databaseManager, + notificationManager = notificationManager, + messageProcessor = messageProcessor, + radioConfigRepository = radioConfigRepository, + scope = scope, + onDeviceAddressChanged = { MeshService.startService(context) }, + ) +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt deleted file mode 100644 index 3549aff6e1..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt +++ /dev/null @@ -1,128 +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 . - */ -@file:Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding - -package org.meshtastic.core.service.testing - -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.service.IMeshService - -/** - * A fake implementation of [IMeshService] for testing purposes. This also serves as a contract verification: if the - * AIDL changes, this class will fail to compile. - * - * Developers can use this to mock the MeshService in their unit tests. - */ -@Suppress("TooManyFunctions", "EmptyFunctionBlock") -open class FakeIMeshService : IMeshService.Stub() { - override fun subscribeReceiver(packageName: String?, receiverName: String?) {} - - override fun setOwner(user: MeshUser?) {} - - override fun setRemoteOwner(requestId: Int, destNum: Int, payload: ByteArray?) {} - - override fun getRemoteOwner(requestId: Int, destNum: Int) {} - - override fun getMyId(): String = "fake_id" - - override fun getPacketId(): Int = 1234 - - override fun send(packet: DataPacket?) {} - - override fun getNodes(): List = emptyList() - - override fun getConfig(): ByteArray = byteArrayOf() - - override fun setConfig(payload: ByteArray?) {} - - override fun setRemoteConfig(requestId: Int, destNum: Int, payload: ByteArray?) {} - - override fun getRemoteConfig(requestId: Int, destNum: Int, configTypeValue: Int) {} - - override fun setModuleConfig(requestId: Int, destNum: Int, payload: ByteArray?) {} - - override fun getModuleConfig(requestId: Int, destNum: Int, moduleConfigTypeValue: Int) {} - - override fun setRingtone(destNum: Int, ringtone: String?) {} - - override fun getRingtone(requestId: Int, destNum: Int) {} - - override fun setCannedMessages(destNum: Int, messages: String?) {} - - override fun getCannedMessages(requestId: Int, destNum: Int) {} - - override fun setChannel(payload: ByteArray?) {} - - override fun setRemoteChannel(requestId: Int, destNum: Int, payload: ByteArray?) {} - - override fun getRemoteChannel(requestId: Int, destNum: Int, channelIndex: Int) {} - - override fun beginEditSettings(destNum: Int) {} - - override fun commitEditSettings(destNum: Int) {} - - override fun removeByNodenum(requestID: Int, nodeNum: Int) {} - - override fun requestPosition(destNum: Int, position: Position?) {} - - override fun setFixedPosition(destNum: Int, position: Position?) {} - - override fun requestTraceroute(requestId: Int, destNum: Int) {} - - override fun requestNeighborInfo(requestId: Int, destNum: Int) {} - - override fun requestShutdown(requestId: Int, destNum: Int) {} - - override fun requestReboot(requestId: Int, destNum: Int) {} - - override fun requestFactoryReset(requestId: Int, destNum: Int) {} - - override fun rebootToDfu(destNum: Int) {} - - override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {} - - override fun getChannelSet(): ByteArray = byteArrayOf() - - override fun connectionState(): String = "CONNECTED" - - @Suppress("OVERRIDE_DEPRECATION") - override fun setDeviceAddress(deviceAddr: String?): Boolean = true - - override fun getMyNodeInfo(): MyNodeInfo? = null - - @Suppress("OVERRIDE_DEPRECATION") - override fun startFirmwareUpdate() {} - - @Suppress("OVERRIDE_DEPRECATION") - override fun getUpdateStatus(): Int = 0 - - override fun startProvideLocation() {} - - override fun stopProvideLocation() {} - - override fun requestUserInfo(destNum: Int) {} - - override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) {} - - override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {} - - override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {} -} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/SendMessageWorker.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/SendMessageWorker.kt index c12957eb70..1be87b1cfa 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/SendMessageWorker.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/SendMessageWorker.kt @@ -22,8 +22,8 @@ import androidx.work.WorkerParameters import org.koin.android.annotation.KoinWorker import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioController @KoinWorker class SendMessageWorker( diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/ServiceKeepAliveWorker.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/ServiceKeepAliveWorker.kt index 55ed704a5e..cc7ee223cb 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/ServiceKeepAliveWorker.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/worker/ServiceKeepAliveWorker.kt @@ -26,7 +26,7 @@ import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import co.touchlab.kermit.Logger import org.koin.android.annotation.KoinWorker -import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.SERVICE_NOTIFY_ID import org.meshtastic.core.resources.R.drawable import org.meshtastic.core.service.MeshService @@ -41,7 +41,7 @@ import org.meshtastic.core.service.startService class ServiceKeepAliveWorker( appContext: Context, workerParams: WorkerParameters, - private val serviceNotifications: MeshServiceNotifications, + private val serviceNotifications: MeshNotificationManager, ) : CoroutineWorker(appContext, workerParams) { override suspend fun getForegroundInfo(): ForegroundInfo { @@ -78,7 +78,7 @@ class ServiceKeepAliveWorker( serviceNotifications.initChannels() // We create a generic "Resuming" notification. - // We use "my_service" which matches NotificationType.ServiceState.channelId in MeshServiceNotificationsImpl + // We use "my_service" which matches NotificationType.ServiceState.channelId in MeshNotificationManagerImpl return NotificationCompat.Builder(applicationContext, "my_service") .setSmallIcon(drawable.meshtastic_ic_notification) diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt new file mode 100644 index 0000000000..488c929e0a --- /dev/null +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.service + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.model.Position +import org.meshtastic.core.repository.AdminController +import org.meshtastic.core.repository.AdminEditScope +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.RadioConfigRepository +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.User + +/** + * [AdminController] implementation: local/remote configuration, channels, owner, device lifecycle, and the + * [editSettings] transaction. + * + * Focused collaborator of [RadioControllerImpl]. Builds [AdminMessage] protos directly and delegates to [CommandSender] + * for transport, mirroring the SDK's `AdminApiImpl` pattern. Config/channel writes use fire-and-forget optimistic local + * persistence ([handledLaunch]): the device is the source of truth and re-sends its full config on every connection, so + * persistence is a cache optimization, not a correctness requirement. + */ +@Suppress("TooManyFunctions") +internal class AdminControllerImpl( + private val commandSender: CommandSender, + private val nodeManager: NodeManager, + private val radioConfigRepository: RadioConfigRepository, + private val scope: CoroutineScope, +) : AdminController { + + private val myNodeNum: Int + get() = nodeManager.myNodeNum.value ?: 0 + + // ── Owner ─────────────────────────────────────────────────────────────── + + override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_owner = user) } + nodeManager.handleReceivedUser(destNum, user) + } + + override suspend fun getOwner(destNum: Int, packetId: Int) { + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { AdminMessage(get_owner_request = true) } + } + + // ── Configuration ───────────────────────────────────────────────────────── + + override suspend fun setLocalConfig(config: Config) { + commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = config) } + scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } + } + + override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_config = config) } + if (destNum == nodeManager.myNodeNum.value) { + scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } + } + } + + override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { + if (configType == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) { + AdminMessage(get_device_metadata_request = true) + } else { + AdminMessage(get_config_request = AdminMessage.ConfigType.fromValue(configType)) + } + } + } + + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_module_config = config) } + if (destNum == nodeManager.myNodeNum.value) { + config.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) } + scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } + } + } + + override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) { + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { + AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(moduleConfigType)) + } + } + + // ── Channels ──────────────────────────────────────────────────────────── + + override suspend fun setLocalChannel(channel: Channel) { + commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = channel) } + scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } + } + + override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_channel = channel) } + if (destNum == nodeManager.myNodeNum.value) { + scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } + } + } + + override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) { + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { + AdminMessage(get_channel_request = index + 1) + } + } + + // ── Ringtone & Canned Messages ───────────────────────────────────────── + + override suspend fun setRingtone(destNum: Int, ringtone: String) { + commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) } + } + + override suspend fun getRingtone(destNum: Int, packetId: Int) { + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { AdminMessage(get_ringtone_request = true) } + } + + override suspend fun setCannedMessages(destNum: Int, messages: String) { + commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) } + } + + override suspend fun getCannedMessages(destNum: Int, packetId: Int) { + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { + AdminMessage(get_canned_message_module_messages_request = true) + } + } + + // ── Position ──────────────────────────────────────────────────────────── + + override suspend fun setFixedPosition(destNum: Int, position: Position) { + commandSender.setFixedPosition(destNum, position) + } + + // ── Device Status & Lifecycle ─────────────────────────────────────────── + + override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { + AdminMessage(get_device_connection_status_request = true) + } + } + + override suspend fun reboot(destNum: Int, packetId: Int) { + Logger.i { "Reboot requested for node $destNum" } + commandSender.sendAdmin(destNum, packetId) { AdminMessage(reboot_seconds = DEFAULT_DELAY_SECONDS) } + } + + override suspend fun rebootToDfu(nodeNum: Int) { + commandSender.sendAdmin(nodeNum) { AdminMessage(enter_dfu_mode_request = true) } + } + + override suspend fun requestRebootOta(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 suspend fun shutdown(destNum: Int, packetId: Int) { + commandSender.sendAdmin(destNum, packetId) { AdminMessage(shutdown_seconds = DEFAULT_DELAY_SECONDS) } + } + + override suspend fun factoryReset(destNum: Int, packetId: Int) { + Logger.i { "Factory reset requested for node $destNum" } + commandSender.sendAdmin(destNum, packetId) { AdminMessage(factory_reset_device = 1) } + } + + override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) { + commandSender.sendAdmin(destNum, packetId) { AdminMessage(nodedb_reset = preserveFavorites) } + } + + // ── Edit Settings (transactional) ─────────────────────────────────────── + + override suspend fun editSettings(destNum: Int, block: suspend AdminEditScope.() -> Unit) { + commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) } + EditSettingsSession(destNum).block() + commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) } + } + + /** Binds the [AdminEditScope] operations to a fixed destination, delegating to this controller's set* methods. */ + private inner class EditSettingsSession(private val destNum: Int) : AdminEditScope { + override suspend fun setOwner(user: User) = setOwner(destNum, user, commandSender.generatePacketId()) + + override suspend fun setConfig(config: Config) = setConfig(destNum, config, commandSender.generatePacketId()) + + override suspend fun setModuleConfig(config: ModuleConfig) = + setModuleConfig(destNum, config, commandSender.generatePacketId()) + + override suspend fun setChannel(channel: Channel) = + setRemoteChannel(destNum, channel, commandSender.generatePacketId()) + + override suspend fun setFixedPosition(position: Position) = + this@AdminControllerImpl.setFixedPosition(destNum, position) + } + + private companion object { + private const val DEFAULT_DELAY_SECONDS = 5 + } +} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt deleted file mode 100644 index a4c95d8cd5..0000000000 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ /dev/null @@ -1,237 +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 . - */ -package org.meshtastic.core.service - -import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.Channel -import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.SharedContact -import org.meshtastic.proto.User - -/** - * Platform-agnostic [RadioController] implementation that delegates directly to service-layer handlers. - * - * Unlike [AndroidRadioControllerImpl], which routes every call through the AIDL [IMeshService] binder, this - * implementation talks directly to [CommandSender], [MeshRouter.actionHandler], [ServiceRepository], and [NodeManager]. - * This is the correct implementation for any target where the service runs in-process (Desktop, iOS, or Android in - * single-process mode). - * - * This eliminates the need for [NoopRadioController] on non-Android targets. - */ -@Suppress("TooManyFunctions", "LongParameterList") -class DirectRadioControllerImpl( - private val serviceRepository: ServiceRepository, - private val nodeRepository: NodeRepository, - private val commandSender: CommandSender, - private val router: MeshRouter, - private val nodeManager: NodeManager, - private val radioInterfaceService: RadioInterfaceService, - private val locationManager: MeshLocationManager, -) : RadioController { - - private val actionHandler - get() = router.actionHandler - - private val myNodeNum: Int - get() = nodeManager.myNodeNum.value ?: 0 - - /** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */ - override val connectionState: StateFlow - get() = serviceRepository.connectionState - - override val clientNotification: StateFlow - get() = serviceRepository.clientNotification - - override suspend fun sendMessage(packet: DataPacket) { - actionHandler.handleSend(packet, myNodeNum) - } - - override fun clearClientNotification() { - serviceRepository.clearClientNotification() - } - - override suspend fun favoriteNode(nodeNum: Int) { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef)) - } - - override suspend fun sendSharedContact(nodeNum: Int): Boolean { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - val contact = - SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified) - val action = ServiceAction.SendContact(contact) - serviceRepository.onServiceAction(action) - return action.result.await() - } - - override suspend fun setLocalConfig(config: Config) { - actionHandler.handleSetConfig(config.encode(), myNodeNum) - } - - override suspend fun setLocalChannel(channel: Channel) { - actionHandler.handleSetChannel(channel.encode(), myNodeNum) - } - - override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { - actionHandler.handleSetRemoteOwner(packetId, destNum, user.encode()) - } - - override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { - actionHandler.handleSetRemoteConfig(packetId, destNum, config.encode()) - } - - override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { - actionHandler.handleSetModuleConfig(packetId, destNum, config.encode()) - } - - override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { - actionHandler.handleSetRemoteChannel(packetId, destNum, channel.encode()) - } - - override suspend fun setFixedPosition(destNum: Int, position: Position) { - commandSender.setFixedPosition(destNum, position) - } - - override suspend fun setRingtone(destNum: Int, ringtone: String) { - actionHandler.handleSetRingtone(destNum, ringtone) - } - - override suspend fun setCannedMessages(destNum: Int, messages: String) { - actionHandler.handleSetCannedMessages(destNum, messages) - } - - override suspend fun getOwner(destNum: Int, packetId: Int) { - actionHandler.handleGetRemoteOwner(packetId, destNum) - } - - override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { - actionHandler.handleGetRemoteConfig(packetId, destNum, configType) - } - - override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) { - actionHandler.handleGetModuleConfig(packetId, destNum, moduleConfigType) - } - - override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) { - actionHandler.handleGetRemoteChannel(packetId, destNum, index) - } - - override suspend fun getRingtone(destNum: Int, packetId: Int) { - actionHandler.handleGetRingtone(packetId, destNum) - } - - override suspend fun getCannedMessages(destNum: Int, packetId: Int) { - actionHandler.handleGetCannedMessages(packetId, destNum) - } - - override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { - actionHandler.handleGetDeviceConnectionStatus(packetId, destNum) - } - - override suspend fun reboot(destNum: Int, packetId: Int) { - actionHandler.handleRequestReboot(packetId, destNum) - } - - override suspend fun rebootToDfu(nodeNum: Int) { - actionHandler.handleRebootToDfu(nodeNum) - } - - override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { - actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) - } - - override suspend fun shutdown(destNum: Int, packetId: Int) { - actionHandler.handleRequestShutdown(packetId, destNum) - } - - override suspend fun factoryReset(destNum: Int, packetId: Int) { - actionHandler.handleRequestFactoryReset(packetId, destNum) - } - - override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) { - actionHandler.handleRequestNodedbReset(packetId, destNum, preserveFavorites) - } - - override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) { - val myNode = nodeManager.myNodeNum.value - if (myNode != null) { - actionHandler.handleRemoveByNodenum(nodeNum, packetId, myNode) - } else { - nodeManager.removeByNodenum(nodeNum) - } - } - - override suspend fun requestPosition(destNum: Int, currentPosition: Position) { - actionHandler.handleRequestPosition(destNum, currentPosition, myNodeNum) - } - - override suspend fun requestUserInfo(destNum: Int) { - if (destNum != myNodeNum) { - commandSender.requestUserInfo(destNum) - } - } - - override suspend fun requestTraceroute(requestId: Int, destNum: Int) { - commandSender.requestTraceroute(requestId, destNum) - } - - override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { - actionHandler.handleRequestTelemetry(requestId, destNum, typeValue) - } - - override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) { - actionHandler.handleRequestNeighborInfo(requestId, destNum) - } - - override suspend fun beginEditSettings(destNum: Int) { - actionHandler.handleBeginEditSettings(destNum) - } - - override suspend fun commitEditSettings(destNum: Int) { - actionHandler.handleCommitEditSettings(destNum) - } - - override fun getPacketId(): Int = commandSender.generatePacketId() - - override fun startProvideLocation() { - // Location provision requires a scope — typically managed by the orchestrator. - // On platforms without GPS hardware (desktop), this is a no-op via the injected locationManager. - } - - override fun stopProvideLocation() { - locationManager.stop() - } - - override fun setDeviceAddress(address: String) { - actionHandler.handleUpdateLastAddress(address) - radioInterfaceService.setDeviceAddress(address) - } -} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index ab107e18b3..789d8334c8 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -18,6 +18,7 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity +import kotlinx.atomicfu.atomic import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel @@ -31,11 +32,10 @@ import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServerManager @@ -52,11 +52,10 @@ import org.meshtastic.core.takserver.TAKServerManager @Single class MeshServiceOrchestrator( private val radioInterfaceService: RadioInterfaceService, - private val serviceRepository: ServiceRepository, + private val serviceStateWriter: ServiceStateWriter, private val nodeManager: NodeManager, private val messageProcessor: MeshMessageProcessor, - private val router: MeshRouter, - private val serviceNotifications: MeshServiceNotifications, + private val serviceNotifications: MeshNotificationManager, private val takServerManager: TAKServerManager, private val takMeshIntegration: TAKMeshIntegration, private val takPrefs: TakPrefs, @@ -66,27 +65,38 @@ class MeshServiceOrchestrator( ) { // Per-start coroutine scope. A fresh scope is created on each start() and cancelled on stop(), so all collectors // launched from start() are torn down cleanly and do not accumulate across start/stop/start cycles. - private var scope: CoroutineScope? = null + // Held in an atomic ref so concurrent start()/stop() callers serialize on compareAndSet rather than racing through + // a check-then-set on a plain var. + private val scopeRef = atomic(null) /** Whether the orchestrator is currently running. */ val isRunning: Boolean - get() = scope?.isActive == true + get() = scopeRef.value?.isActive == true /** * Starts the mesh service components and wires up data flows. * * This is the KMP equivalent of `MeshService.onCreate()`. It connects to the radio and wires incoming radio data to - * the message processor and service actions to the router's action handler. + * the message processor. */ fun start() { - if (isRunning) { - Logger.d { "start() called while already running, ignoring" } - return + val newScope = CoroutineScope(SupervisorJob() + dispatchers.default) + // Atomic claim — if another thread already installed a live scope, abandon ours. + if (!scopeRef.compareAndSet(expect = null, update = newScope)) { + val existing = scopeRef.value + if (existing?.isActive == true) { + Logger.d { "start() called while already running, ignoring" } + return + } + // The slot held a dead scope (post-stop). CAS-replace it to avoid racing with another caller. + if (!scopeRef.compareAndSet(expect = existing, update = newScope)) { + Logger.d { "start() lost race replacing dead scope, ignoring" } + newScope.cancel() + return + } } Logger.i { "Starting mesh service orchestrator" } - val newScope = CoroutineScope(SupervisorJob() + dispatchers.default) - scope = newScope // Drop any bytes that piled up in the service's receivedData channel since the last stop(). The channel // outlives the orchestrator's per-start scope, so without this drain a stop/start cycle would replay stale @@ -126,14 +136,7 @@ class MeshServiceOrchestrator( .launchIn(newScope) radioInterfaceService.connectionError - .onEach { errorMessage -> serviceRepository.setErrorMessage(errorMessage, Severity.Warn) } - .launchIn(newScope) - - // Each action is dispatched in its own supervised coroutine so that a failure in one - // action (e.g. a timeout in sendAdminAwait) cannot terminate the collector and silently - // drop all subsequent service actions for the rest of the session. - serviceRepository.serviceAction - .onEach { action -> newScope.handledLaunch { router.actionHandler.onServiceAction(action) } } + .onEach { errorMessage -> serviceStateWriter.setErrorMessage(errorMessage, Severity.Warn) } .launchIn(newScope) nodeManager.loadCachedNodeDB() @@ -158,7 +161,6 @@ class MeshServiceOrchestrator( CoroutineScope(SupervisorJob() + dispatchers.default).launch { runCatching { radioInterfaceService.disconnect() } } - scope?.cancel() - scope = null + scopeRef.getAndSet(null)?.cancel() } } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt new file mode 100644 index 0000000000..b4573aa28b --- /dev/null +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.service + +import co.touchlab.kermit.Logger +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.safeCatching +import org.meshtastic.core.model.ContactKey +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.Reaction +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.DataPair +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MessagingController +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.PlatformAnalytics +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User + +/** + * [MessagingController] implementation: sends data packets, reactions, and shared contacts. + * + * Focused collaborator of [RadioControllerImpl]. Mirrors the SDK's `RadioClient.send*` surface — when the SDK is + * adopted this becomes a thin adapter over `RadioClient`. + */ +internal class MessagingControllerImpl( + private val commandSender: CommandSender, + private val nodeManager: NodeManager, + private val nodeRepository: NodeRepository, + private val dataHandler: Lazy, + private val analytics: PlatformAnalytics, + private val packetRepository: Lazy, +) : MessagingController { + + private val myNodeNum: Int + get() = nodeManager.myNodeNum.value ?: 0 + + override suspend fun sendMessage(packet: DataPacket) { + commandSender.sendData(packet) + dataHandler.value.rememberDataPacket(packet, myNodeNum, false) + val bytes = packet.bytes ?: ByteString.EMPTY + analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", packet.dataType)) + } + + override suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) { + val myNum = nodeManager.myNodeNum.value ?: return + val parsedKey = ContactKey(contactKey) + val channel = parsedKey.channel + val destId = parsedKey.addressString + val dataPacket = + DataPacket( + to = destId, + dataType = PortNum.TEXT_MESSAGE_APP.value, + bytes = emoji.encodeToByteArray().toByteString(), + channel = channel, + replyId = replyId, + wantAck = true, + emoji = EMOJI_INDICATOR, + ) + .apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: NodeAddress.ID_LOCAL } + commandSender.sendData(dataPacket) + val user = nodeManager.nodeDBbyNodeNum[myNum]?.user ?: User(id = nodeManager.getMyId()) + packetRepository.value.insertReaction( + Reaction( + replyId = replyId, + user = user, + emoji = emoji, + timestamp = nowMillis, + snr = 0f, + rssi = 0, + hopsAway = 0, + packetId = dataPacket.id, + status = MessageStatus.QUEUED, + to = destId, + channel = channel, + ), + myNum, + ) + } + + override suspend fun sendSharedContact(nodeNum: Int): Boolean { + val myNum = nodeManager.myNodeNum.value ?: return false + val nodeDef = nodeRepository.getNode(NodeAddress.numToDefaultId(nodeNum)) + val contact = + SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified) + return safeCatching { commandSender.sendAdminAwait(myNum) { AdminMessage(add_contact = contact) } } + .getOrDefault(false) + } + + override suspend fun importContact(contact: SharedContact) { + val myNum = nodeManager.myNodeNum.value ?: return + val user = contact.user + if (contact.node_num == 0 || user == null) { + Logger.w { "importContact rejected: missing node_num or user (node_num=${contact.node_num})" } + return + } + // Importing a contact (e.g. scanning their QR code) is itself an act of manual verification, + // so mark it verified regardless of the incoming flag. + val verifiedContact = contact.copy(manually_verified = true) + commandSender.sendAdmin(myNum) { AdminMessage(add_contact = verifiedContact) } + nodeManager.handleReceivedUser(contact.node_num, user, manuallyVerified = true) + } + + private companion object { + private const val EMOJI_INDICATOR = 1 + } +} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt new file mode 100644 index 0000000000..e1418445db --- /dev/null +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.service + +import kotlinx.coroutines.CoroutineScope +import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NodeController +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.proto.AdminMessage + +/** + * [NodeController] implementation: favorite, ignore, mute, and remove nodes. + * + * Focused collaborator of [RadioControllerImpl]. Favorite/ignore are idempotent (no-op when already in the requested + * state), mirroring the SDK's `AdminApi.setFavorite`/`setIgnored`. + */ +internal class NodeControllerImpl( + private val commandSender: CommandSender, + private val nodeManager: NodeManager, + private val packetRepository: Lazy, + private val scope: CoroutineScope, +) : NodeController { + + override suspend fun setFavorite(nodeNum: Int, favorite: Boolean) { + val myNum = nodeManager.myNodeNum.value ?: return + val node = nodeManager.nodeDBbyNodeNum[nodeNum] ?: return + if (node.isFavorite != favorite) { + commandSender.sendAdmin(myNum) { + if (favorite) { + AdminMessage(set_favorite_node = node.num) + } else { + AdminMessage(remove_favorite_node = node.num) + } + } + nodeManager.updateNode(node.num) { it.copy(isFavorite = favorite) } + } + } + + override suspend fun setIgnored(nodeNum: Int, ignored: Boolean) { + val myNum = nodeManager.myNodeNum.value ?: return + val node = nodeManager.nodeDBbyNodeNum[nodeNum] ?: return + if (node.isIgnored != ignored) { + commandSender.sendAdmin(myNum) { + if (ignored) AdminMessage(set_ignored_node = node.num) else AdminMessage(remove_ignored_node = node.num) + } + nodeManager.updateNode(node.num) { it.copy(isIgnored = ignored) } + scope.handledLaunch { packetRepository.value.updateFilteredBySender(node.user.id, ignored) } + } + } + + override suspend fun toggleMuted(nodeNum: Int) { + val myNum = nodeManager.myNodeNum.value ?: return + val node = nodeManager.nodeDBbyNodeNum[nodeNum] ?: return + commandSender.sendAdmin(myNum) { AdminMessage(toggle_muted_node = node.num) } + nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } + } + + override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) { + nodeManager.removeByNodenum(nodeNum) + val myNum = nodeManager.myNodeNum.value ?: return + commandSender.sendAdmin(myNum, packetId) { AdminMessage(remove_by_nodenum = nodeNum) } + } +} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/QueryControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/QueryControllerImpl.kt new file mode 100644 index 0000000000..8eb8def189 --- /dev/null +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/QueryControllerImpl.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.service + +import org.meshtastic.core.model.Position +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.QueryController +import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.proto.AdminMessage + +/** + * [QueryController] implementation: position, traceroute, telemetry, user info, and metadata "pull" queries. + * + * Focused collaborator of [RadioControllerImpl]. Mirrors the SDK's `TelemetryApi`/`RoutingApi` surface. + */ +internal class QueryControllerImpl( + private val commandSender: CommandSender, + private val nodeManager: NodeManager, + private val uiPrefs: UiPrefs, +) : QueryController { + + private val myNodeNum: Int + get() = nodeManager.myNodeNum.value ?: 0 + + override suspend fun refreshMetadata(destNum: Int) { + commandSender.sendAdmin(destNum, wantResponse = true) { AdminMessage(get_device_metadata_request = true) } + } + + override suspend fun requestPosition(destNum: Int, currentPosition: Position) { + if (destNum == nodeManager.myNodeNum.value) return + val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value + // Position(0.0, 0.0, 0) is the protocol-level "no position" sentinel. + val resolvedPosition = + if (provideLocation) { + currentPosition.takeIf { it.isValid() } + ?: 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, resolvedPosition) + } + + override suspend fun requestUserInfo(destNum: Int) { + if (destNum != nodeManager.myNodeNum.value) { + commandSender.requestUserInfo(destNum) + } + } + + override suspend fun requestTraceroute(requestId: Int, destNum: Int) { + commandSender.requestTraceroute(requestId, destNum) + } + + override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { + commandSender.requestTelemetry(requestId, destNum, typeValue) + } + + override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) { + commandSender.requestNeighborInfo(requestId, destNum) + } +} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt new file mode 100644 index 0000000000..0daded9f3f --- /dev/null +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.service + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.repository.AdminController +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.MeshMessageProcessor +import org.meshtastic.core.repository.MeshPrefs +import org.meshtastic.core.repository.MessagingController +import org.meshtastic.core.repository.NodeController +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.PlatformAnalytics +import org.meshtastic.core.repository.QueryController +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioController +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.proto.ClientNotification + +/** + * Platform-agnostic [RadioController] composition root for any target where the service runs in-process (Desktop, iOS, + * or Android in single-process mode). + * + * Rather than implementing every command itself, this class **assembles** four focused collaborators — one per + * sub-interface — and delegates to them via Kotlin interface delegation, mirroring the SDK's layered API design + * ([AdminController] → `AdminApi`, [MessagingController] → `RadioClient.send*`, [NodeController]/[QueryController] → + * `AdminApi`/`TelemetryApi`/`RoutingApi`). When the SDK is adopted, each collaborator becomes a thin adapter and this + * class is the seam where they are wired together. + * + * Only the cross-cutting concerns that don't belong to any single sub-interface live here directly: connection-state + * surfacing, packet-id generation, location provisioning, and device-address switching. + */ +@Suppress("LongParameterList") +class RadioControllerImpl( + private val serviceRepository: ServiceRepository, + nodeRepository: NodeRepository, + private val commandSender: CommandSender, + private val nodeManager: NodeManager, + private val radioInterfaceService: RadioInterfaceService, + private val locationManager: MeshLocationManager, + packetRepository: Lazy, + dataHandler: Lazy, + analytics: PlatformAnalytics, + private val meshPrefs: MeshPrefs, + uiPrefs: UiPrefs, + private val databaseManager: DatabaseManager, + private val notificationManager: NotificationManager, + private val messageProcessor: Lazy, + radioConfigRepository: RadioConfigRepository, + scope: CoroutineScope, + private val onDeviceAddressChanged: (() -> Unit)? = null, +) : RadioController, + AdminController by AdminControllerImpl(commandSender, nodeManager, radioConfigRepository, scope), + MessagingController by MessagingControllerImpl( + commandSender, + nodeManager, + nodeRepository, + dataHandler, + analytics, + packetRepository, + ), + NodeController by NodeControllerImpl(commandSender, nodeManager, packetRepository, scope), + QueryController by QueryControllerImpl(commandSender, nodeManager, uiPrefs) { + + // ── Connection State ──────────────────────────────────────────────────── + + override val connectionState: StateFlow + get() = serviceRepository.connectionState + + override val clientNotification: StateFlow + get() = serviceRepository.clientNotification + + override fun clearClientNotification() { + serviceRepository.clearClientNotification() + } + + // ── Packet ID & Location ──────────────────────────────────────────────── + + override fun generatePacketId(): Int = commandSender.generatePacketId() + + override fun startProvideLocation() { + locationManager.restart() + } + + override fun stopProvideLocation() { + locationManager.stop() + } + + // ── Device Address ────────────────────────────────────────────────────── + + override suspend fun setDeviceAddress(address: String) { + switchDevice(address) + radioInterfaceService.setDeviceAddress(address) + onDeviceAddressChanged?.invoke() + } + + private suspend fun switchDevice(deviceAddr: String) { + val currentAddr = meshPrefs.deviceAddress.value + if (deviceAddr != currentAddr) { + Logger.i { "Device address changed, switching database and clearing node DB" } + meshPrefs.setDeviceAddress(deviceAddr) + nodeManager.clear() + messageProcessor.value.clearEarlyPackets() + databaseManager.switchActiveDatabase(deviceAddr) + notificationManager.cancelAll() + nodeManager.loadCachedNodeDB() + } + } +} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt index 5ad5c2d003..235d3349ab 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt @@ -18,15 +18,12 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import co.touchlab.kermit.Severity -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.receiveAsFlow import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.ClientNotification @@ -37,7 +34,7 @@ import org.meshtastic.proto.MeshPacket * * Manages reactive state for connection status, error messages, mesh packets, and service actions using only * KMP-compatible primitives (StateFlow, SharedFlow, Channel, Kermit Logger). This implementation can be used directly - * on any KMP target — Android extends it with AIDL binding via [AndroidServiceRepository]. + * on any KMP target. */ @Suppress("TooManyFunctions") open class ServiceRepositoryImpl : ServiceRepository { @@ -118,11 +115,4 @@ open class ServiceRepositoryImpl : ServiceRepository { override fun clearNeighborInfoResponse() { setNeighborInfoResponse(null) } - - private val _serviceAction = Channel() - override val serviceAction: Flow = _serviceAction.receiveAsFlow() - - override suspend fun onServiceAction(action: ServiceAction) { - _serviceAction.send(action) - } } diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt deleted file mode 100644 index b93aac1a95..0000000000 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt +++ /dev/null @@ -1,167 +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 . - */ -package org.meshtastic.core.service - -import dev.mokkery.MockMode -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.mock -import dev.mokkery.verify -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshActionHandler -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.User -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertSame -import kotlin.test.assertTrue - -class DirectRadioControllerImplTest { - - private val nodeRepository: NodeRepository = mock(MockMode.autofill) - private val commandSender: CommandSender = mock(MockMode.autofill) - private val router: MeshRouter = mock(MockMode.autofill) - private val actionHandler: MeshActionHandler = mock(MockMode.autofill) - private val nodeManager: NodeManager = mock(MockMode.autofill) - private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) - private val locationManager: MeshLocationManager = mock(MockMode.autofill) - - private fun createController( - serviceRepository: ServiceRepository = ServiceRepositoryImpl(), - myNodeNum: Int? = 1234, - ): DirectRadioControllerImpl { - every { router.actionHandler } returns actionHandler - every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) - return DirectRadioControllerImpl( - serviceRepository = serviceRepository, - nodeRepository = nodeRepository, - commandSender = commandSender, - router = router, - nodeManager = nodeManager, - radioInterfaceService = radioInterfaceService, - locationManager = locationManager, - ) - } - - @Test - fun connectionStateAndClientNotificationDelegateToServiceRepository() { - val serviceRepository = ServiceRepositoryImpl() - val controller = createController(serviceRepository = serviceRepository) - val notification = ClientNotification() - - assertSame(serviceRepository.connectionState, controller.connectionState) - assertSame(serviceRepository.clientNotification, controller.clientNotification) - - serviceRepository.setConnectionState(ConnectionState.Connecting) - serviceRepository.setClientNotification(notification) - - assertEquals(ConnectionState.Connecting, controller.connectionState.value) - assertSame(notification, controller.clientNotification.value) - - controller.clearClientNotification() - - assertNull(serviceRepository.clientNotification.value) - } - - @Test - fun sendMessageDelegatesToActionHandlerWithLocalNodeNumber() = runTest { - val controller = createController(myNodeNum = 456) - val packet = DataPacket(to = DataPacket.ID_BROADCAST, channel = 1, text = "ping") - - controller.sendMessage(packet) - - verify { actionHandler.handleSend(packet, 456) } - } - - @Test - fun sendSharedContactEmitsActionAndWaitsForResult() = runTest { - val serviceRepository = ServiceRepositoryImpl() - val controller = createController(serviceRepository = serviceRepository) - val nodeNum = 321 - val user = User(id = DataPacket.nodeNumToDefaultId(nodeNum), long_name = "Remote Node", short_name = "RN") - val node = Node(num = nodeNum, user = user, manuallyVerified = true) - every { nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) } returns node - - val emittedAction = async { serviceRepository.serviceAction.first() } - val sendResult = async { controller.sendSharedContact(nodeNum) } - - val action = emittedAction.await() - assertTrue(action is ServiceAction.SendContact) - assertEquals(node.num, action.contact.node_num) - assertEquals(node.user, action.contact.user) - assertEquals(node.manuallyVerified, action.contact.manually_verified) - - action.result.complete(true) - - assertTrue(sendResult.await()) - } - - @Test - fun requestConfigOperationsDelegateToActionHandler() = runTest { - val controller = createController() - - controller.getOwner(destNum = 101, packetId = 1) - controller.getConfig(destNum = 102, configType = 2, packetId = 3) - controller.getModuleConfig(destNum = 103, moduleConfigType = 4, packetId = 5) - controller.getChannel(destNum = 104, index = 6, packetId = 7) - controller.getRingtone(destNum = 105, packetId = 8) - controller.getCannedMessages(destNum = 106, packetId = 9) - controller.getDeviceConnectionStatus(destNum = 107, packetId = 10) - - verify { actionHandler.handleGetRemoteOwner(1, 101) } - verify { actionHandler.handleGetRemoteConfig(3, 102, 2) } - verify { actionHandler.handleGetModuleConfig(5, 103, 4) } - verify { actionHandler.handleGetRemoteChannel(7, 104, 6) } - verify { actionHandler.handleGetRingtone(8, 105) } - verify { actionHandler.handleGetCannedMessages(9, 106) } - verify { actionHandler.handleGetDeviceConnectionStatus(10, 107) } - } - - @Test - fun stopProvideLocationDelegatesToLocationManager() { - val controller = createController() - - controller.stopProvideLocation() - - verify { locationManager.stop() } - } - - @Test - fun setDeviceAddressUpdatesLastAddressAndTransportAddress() { - val controller = createController() - - controller.setDeviceAddress("tcp:192.168.1.1") - - verify { actionHandler.handleUpdateLastAddress("tcp:192.168.1.1") } - verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } - } -} diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index 31178449c5..274b23d942 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -32,15 +32,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshActionHandler import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioInterfaceService @@ -61,10 +57,8 @@ class MeshServiceOrchestratorTest { private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill) private val commandSender: CommandSender = mock(MockMode.autofill) - private val router: MeshRouter = mock(MockMode.autofill) - private val actionHandler: MeshActionHandler = mock(MockMode.autofill) private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill) - private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill) + private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill) private val takServerManager: TAKServerManager = mock(MockMode.autofill) private val takPrefs: TakPrefs = mock(MockMode.autofill) private val nodeRepository: NodeRepository = mock(MockMode.autofill) @@ -81,19 +75,16 @@ class MeshServiceOrchestratorTest { private fun createOrchestrator( receivedData: MutableSharedFlow = MutableSharedFlow(), connectionError: MutableSharedFlow = MutableSharedFlow(), - serviceAction: MutableSharedFlow = MutableSharedFlow(), takEnabledFlow: MutableStateFlow = MutableStateFlow(false), takRunningFlow: MutableStateFlow = MutableStateFlow(false), ): MeshServiceOrchestrator { every { radioInterfaceService.receivedData } returns receivedData every { radioInterfaceService.connectionError } returns connectionError - every { serviceRepository.serviceAction } returns serviceAction every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig()) every { takPrefs.isTakServerEnabled } returns takEnabledFlow every { takServerManager.isRunning } returns takRunningFlow every { takServerManager.inboundMessages } returns MutableSharedFlow() - every { router.actionHandler } returns actionHandler every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) val takMeshIntegration = @@ -107,10 +98,9 @@ class MeshServiceOrchestratorTest { return MeshServiceOrchestrator( radioInterfaceService = radioInterfaceService, - serviceRepository = serviceRepository, + serviceStateWriter = serviceRepository, nodeManager = nodeManager, messageProcessor = messageProcessor, - router = router, serviceNotifications = serviceNotifications, takServerManager = takServerManager, takMeshIntegration = takMeshIntegration, @@ -187,21 +177,6 @@ class MeshServiceOrchestratorTest { orchestrator.stop() } - @Test - fun testServiceActionDispatchedToActionHandler() { - val serviceAction = MutableSharedFlow(extraBufferCapacity = 1) - - val orchestrator = createOrchestrator(serviceAction = serviceAction) - orchestrator.start() - - val action = ServiceAction.Favorite(Node(num = 42)) - serviceAction.tryEmit(action) - - verifySuspend { actionHandler.onServiceAction(action) } - - orchestrator.stop() - } - @Test fun testStartIsIdempotent() { val orchestrator = createOrchestrator() diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt new file mode 100644 index 0000000000..ae4045bd7f --- /dev/null +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.service + +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.atLeast +import dev.mokkery.verify.VerifyMode.Companion.exactly +import dev.mokkery.verifySuspend +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.MeshMessageProcessor +import org.meshtastic.core.repository.MeshPrefs +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository +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.RadioInterfaceService +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class RadioControllerImplTest { + + private val nodeRepository: NodeRepository = mock(MockMode.autofill) + private val commandSender: CommandSender = mock(MockMode.autofill) + private val nodeManager: NodeManager = mock(MockMode.autofill) + private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) + private val locationManager: MeshLocationManager = mock(MockMode.autofill) + private val packetRepository: PacketRepository = mock(MockMode.autofill) + private val dataHandler: MeshDataHandler = mock(MockMode.autofill) + private val analytics: PlatformAnalytics = mock(MockMode.autofill) + private val meshPrefs: MeshPrefs = mock(MockMode.autofill) + private val uiPrefs: UiPrefs = mock(MockMode.autofill) + private val databaseManager: DatabaseManager = mock(MockMode.autofill) + private val notificationManager: NotificationManager = mock(MockMode.autofill) + private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill) + private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) + + private val testScope = TestScope() + + private fun createController( + serviceRepository: ServiceRepository = ServiceRepositoryImpl(), + myNodeNum: Int? = 1234, + ): RadioControllerImpl { + every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) + every { meshPrefs.deviceAddress } returns MutableStateFlow(null) + return RadioControllerImpl( + serviceRepository = serviceRepository, + nodeRepository = nodeRepository, + commandSender = commandSender, + nodeManager = nodeManager, + radioInterfaceService = radioInterfaceService, + locationManager = locationManager, + packetRepository = lazy { packetRepository }, + dataHandler = lazy { dataHandler }, + analytics = analytics, + meshPrefs = meshPrefs, + uiPrefs = uiPrefs, + databaseManager = databaseManager, + notificationManager = notificationManager, + messageProcessor = lazy { messageProcessor }, + radioConfigRepository = radioConfigRepository, + scope = testScope, + ) + } + + @Test + fun connectionStateAndClientNotificationDelegateToServiceRepository() { + val serviceRepository = ServiceRepositoryImpl() + val controller = createController(serviceRepository = serviceRepository) + val notification = ClientNotification() + + assertSame(serviceRepository.connectionState, controller.connectionState) + assertSame(serviceRepository.clientNotification, controller.clientNotification) + + serviceRepository.setConnectionState(ConnectionState.Connecting) + serviceRepository.setClientNotification(notification) + + assertEquals(ConnectionState.Connecting, controller.connectionState.value) + assertSame(notification, controller.clientNotification.value) + + controller.clearClientNotification() + + assertNull(serviceRepository.clientNotification.value) + } + + @Test + fun sendMessageDelegatesToCommandSender() = runTest { + val controller = createController(myNodeNum = 456) + val packet = DataPacket(to = NodeAddress.ID_BROADCAST, channel = 1, text = "ping") + + controller.sendMessage(packet) + + verifySuspend { commandSender.sendData(packet) } + verifySuspend { dataHandler.rememberDataPacket(packet, 456, false) } + } + + @Test + fun sendSharedContactCallsCommandSenderAdminAwait() = runTest { + val controller = createController() + val nodeNum = 321 + val user = User(id = NodeAddress.numToDefaultId(nodeNum), long_name = "Remote Node", short_name = "RN") + val node = Node(num = nodeNum, user = user, manuallyVerified = true) + every { nodeRepository.getNode(NodeAddress.numToDefaultId(nodeNum)) } returns node + everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true + + val result = controller.sendSharedContact(nodeNum) + + assertTrue(result) + verifySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } + } + + @Test + fun requestConfigOperationsDelegateToCommandSender() = runTest { + val controller = createController() + + controller.getOwner(destNum = 101, packetId = 1) + controller.getConfig(destNum = 102, configType = 2, packetId = 3) + controller.getModuleConfig(destNum = 103, moduleConfigType = 4, packetId = 5) + controller.getChannel(destNum = 104, index = 6, packetId = 7) + controller.getRingtone(destNum = 105, packetId = 8) + controller.getCannedMessages(destNum = 106, packetId = 9) + controller.getDeviceConnectionStatus(destNum = 107, packetId = 10) + + // All delegate to commandSender.sendAdmin + verifySuspend(atLeast(7)) { commandSender.sendAdmin(any(), any(), any(), any()) } + } + + @Test + fun stopProvideLocationDelegatesToLocationManager() { + val controller = createController() + + controller.stopProvideLocation() + + verify { locationManager.stop() } + } + + @Test + fun setDeviceAddressSwitchesDatabaseAndTransport() = runTest { + val controller = createController() + every { meshPrefs.deviceAddress } returns MutableStateFlow("old:addr") + + controller.setDeviceAddress("tcp:192.168.1.1") + testScope.advanceUntilIdle() + + // Verify ordering: switchDevice completes before transport reconfiguration + verifySuspend { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + verifySuspend { databaseManager.switchActiveDatabase("tcp:192.168.1.1") } + verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } + } + + @Test + fun setDeviceAddressSkipsSwitchWhenAddressUnchanged() = runTest { + val controller = createController() + every { meshPrefs.deviceAddress } returns MutableStateFlow("tcp:192.168.1.1") + + controller.setDeviceAddress("tcp:192.168.1.1") + testScope.advanceUntilIdle() + + // switchDevice should skip when addresses match, but transport still reconfigures + verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } + verifySuspend(exactly(0)) { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + } + + @Test + fun sendReactionPersistsToDatabase() = runTest { + val controller = createController() + val user = User(id = "!abcd1234", long_name = "Test", short_name = "T") + val node = Node(num = 1234, user = user) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(1234 to node) + every { nodeManager.getMyId() } returns "!abcd1234" + + controller.sendReaction(emoji = "👍", replyId = 42, contactKey = "0!dest5678") + + // Reaction must be persisted (not fire-and-forget) + verifySuspend { commandSender.sendData(any()) } + verifySuspend { packetRepository.insertReaction(any(), any()) } + } + + @Test + fun setFavoriteSendsAdminAndUpdatesState() = runTest { + val controller = createController() + val node = Node(num = 99, user = User(id = "!node99"), isFavorite = false) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) + + controller.setFavorite(99, favorite = true) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + verify { nodeManager.updateNode(any(), any(), any()) } + } + + @Test + fun setFavoriteIsNoOpWhenAlreadyInRequestedState() = runTest { + val controller = createController() + val node = Node(num = 99, user = User(id = "!node99"), isFavorite = true) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) + + controller.setFavorite(99, favorite = true) + + verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } + verify(exactly(0)) { nodeManager.updateNode(any(), any(), any()) } + } + + @Test + fun setIgnoredSendsAdminUpdatesStateAndFiltersPackets() = runTest { + val controller = createController() + val node = Node(num = 99, user = User(id = "!node99"), isIgnored = false) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) + + controller.setIgnored(99, ignored = true) + testScope.advanceUntilIdle() + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + verify { nodeManager.updateNode(any(), any(), any()) } + verifySuspend { packetRepository.updateFilteredBySender("!node99", true) } + } + + @Test + fun toggleMutedSendsAdminAndUpdatesState() = runTest { + val controller = createController() + val node = Node(num = 99, user = User(id = "!node99"), isMuted = false) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) + + controller.toggleMuted(99) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + verify { nodeManager.updateNode(any(), any(), any()) } + } + + @Test + fun nodeManagementReturnsEarlyWhenMyNodeNumIsNull() = runTest { + val controller = createController(myNodeNum = null) + + controller.setFavorite(99, favorite = true) + controller.setIgnored(99, ignored = true) + controller.toggleMuted(99) + + verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } + } + + @Test + fun removeByNodenumAlwaysRemovesLocallyAndSendsAdminWhenConnected() = runTest { + val controller = createController() + + controller.removeByNodenum(packetId = 1, nodeNum = 55) + + verifySuspend { nodeManager.removeByNodenum(55) } + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + } + + @Test + fun removeByNodenumRemovesLocallyEvenWhenDisconnected() = runTest { + val controller = createController(myNodeNum = null) + + controller.removeByNodenum(packetId = 1, nodeNum = 55) + + verifySuspend { nodeManager.removeByNodenum(55) } + // No admin message sent when disconnected + verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } + } + + @Test + fun rebootSendsAdminMessageWithDelay() = runTest { + val controller = createController() + + controller.reboot(destNum = 101, packetId = 7) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + } + + @Test + fun shutdownSendsAdminMessage() = runTest { + val controller = createController() + + controller.shutdown(destNum = 101, packetId = 8) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + } + + @Test + fun factoryResetSendsAdminMessage() = runTest { + val controller = createController() + + controller.factoryReset(destNum = 101, packetId = 9) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + } + + @Test + fun nodedbResetSendsAdminMessage() = runTest { + val controller = createController() + + controller.nodedbReset(destNum = 101, packetId = 10, preserveFavorites = true) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + } + + @Test + fun refreshMetadataSendsAdminWithWantResponse() = runTest { + val controller = createController() + + controller.refreshMetadata(destNum = 101) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + } + + @Test + fun importContactSendsAdminAndUpdatesNodeManager() = runTest { + val controller = createController() + // A QR-scanned contact arrives with manually_verified = false (proto default). + val contact = SharedContact(node_num = 42, user = User(id = "!0000002a", long_name = "Test")) + + controller.importContact(contact) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + // Importing is an act of manual verification, so the node is recorded as verified. + verify { nodeManager.handleReceivedUser(42, any(), any(), true) } + } + + @Test + fun importContactReturnsEarlyWhenDisconnected() = runTest { + val controller = createController(myNodeNum = null) + val contact = SharedContact(node_num = 42, user = User(id = "!0000002a")) + + controller.importContact(contact) + + verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } + } +} diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/ServiceRepositoryImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/ServiceRepositoryImplTest.kt index bcf3819eda..0ba6b40295 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/ServiceRepositoryImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/ServiceRepositoryImplTest.kt @@ -26,7 +26,6 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeoutOrNull import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.model.service.TracerouteResponse import kotlin.test.Test import kotlin.test.assertEquals @@ -47,13 +46,11 @@ class ServiceRepositoryImplTest { assertNull(repository.neighborInfoResponse.value) val initialMeshPacket = async { withTimeoutOrNull(1) { repository.meshPacketFlow.first() } } - val initialServiceAction = async { withTimeoutOrNull(1) { repository.serviceAction.first() } } runCurrent() advanceTimeBy(1) assertNull(initialMeshPacket.await()) - assertNull(initialServiceAction.await()) } @Test @@ -68,18 +65,6 @@ class ServiceRepositoryImplTest { assertEquals(ConnectionState.Connecting, repository.connectionState.value) } - @Test - fun onServiceActionEmitsThroughFlow() = runTest { - val repository = ServiceRepositoryImpl() - val action = ServiceAction.GetDeviceMetadata(destNum = 42) - val emittedAction = async { repository.serviceAction.first() } - - runCurrent() - repository.onServiceAction(action) - - assertEquals(action, emittedAction.await()) - } - @Test fun setErrorMessageEmitsAndCanBeCleared() = runTest { val repository = ServiceRepositoryImpl() diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt index d9c5aeeb12..18fd5677e8 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.Capabilities import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeRepository @@ -248,12 +249,14 @@ class TAKMeshIntegration( try { val dataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = NodeAddress.ID_BROADCAST, bytes = wirePayload.toByteString(), dataType = PortNum.ATAK_PLUGIN_V2.value, ) commandSender.sendData(dataPacket) Logger.d { "Sent V2 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" } + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e } catch (e: Exception) { // Something other than size — radio not connected, queue full, etc. Logger.e(e) { @@ -291,12 +294,14 @@ class TAKMeshIntegration( try { val dataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = NodeAddress.ID_BROADCAST, bytes = wirePayload.toByteString(), dataType = PortNum.ATAK_PLUGIN.value, ) commandSender.sendData(dataPacket) Logger.d { "Sent V1 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" } + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e } catch (e: Exception) { Logger.e(e) { "Failed to send v1 TAKPacket to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}" diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt index d4dde246af..70a40a023b 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TakMeshTestRunner.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.CommandSender import org.meshtastic.proto.PortNum @@ -187,13 +188,15 @@ class TakMeshTestRunner(private val commandSender: CommandSender) { try { val dataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = NodeAddress.ID_BROADCAST, bytes = wirePayload.toByteString(), dataType = PortNum.ATAK_PLUGIN_V2.value, ) commandSender.sendData(dataPacket) Logger.i { "TAK Test: $name → ${wirePayload.size}B (xml=${xml.length}B)" } return TakTestResult(name, xml.length, wirePayload.size, true) + } catch (e: kotlin.coroutines.cancellation.CancellationException) { + throw e } catch (e: Exception) { Logger.w(e) { "TAK Test: $name send failed: ${e.message}" } return TakTestResult(name, xml.length, wirePayload.size, false, "Send failed: ${e.message}") diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt index 847bb847c5..0831d2231f 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -34,7 +34,6 @@ import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.Position -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshConfigHandler @@ -112,7 +111,7 @@ class TAKMeshIntegrationTest { private class FakeCommandSender : CommandSender { val sentPackets = mutableListOf() - override fun sendData(p: DataPacket) { + override suspend fun sendData(p: DataPacket) { sentPackets.add(p) } @@ -124,7 +123,12 @@ class TAKMeshIntegrationTest { override fun generatePacketId(): Int = 1 - override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {} + override suspend fun sendAdmin( + destNum: Int, + requestId: Int, + wantResponse: Boolean, + initFn: () -> AdminMessage, + ) {} override suspend fun sendAdminAwait( destNum: Int, @@ -133,19 +137,19 @@ class TAKMeshIntegrationTest { initFn: () -> AdminMessage, ): Boolean = true - override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {} + override suspend fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {} - override fun requestPosition(destNum: Int, currentPosition: Position) {} + override suspend fun requestPosition(destNum: Int, currentPosition: Position) {} - override fun setFixedPosition(destNum: Int, pos: Position) {} + override suspend fun setFixedPosition(destNum: Int, pos: Position) {} - override fun requestUserInfo(destNum: Int) {} + override suspend fun requestUserInfo(destNum: Int) {} - override fun requestTraceroute(requestId: Int, destNum: Int) {} + override suspend fun requestTraceroute(requestId: Int, destNum: Int) {} - override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {} + override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {} - override fun requestNeighborInfo(requestId: Int, destNum: Int) {} + override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {} } private class FakeServiceRepository : ServiceRepository { @@ -187,10 +191,6 @@ class TAKMeshIntegrationTest { override fun setNeighborInfoResponse(value: String?) {} override fun clearNeighborInfoResponse() {} - - override val serviceAction: Flow = MutableSharedFlow() - - override suspend fun onServiceAction(action: ServiceAction) {} } private class FakeMeshConfigHandler : MeshConfigHandler { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt index 1e75310589..5693f112f7 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt @@ -18,6 +18,8 @@ package org.meshtastic.core.testing import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.database.DatabaseConstants.MAX_CACHE_LIMIT +import org.meshtastic.core.database.DatabaseConstants.MIN_CACHE_LIMIT /** A test double for [DatabaseManager] that provides a simple implementation and tracks calls. */ class FakeDatabaseManager : @@ -40,7 +42,7 @@ class FakeDatabaseManager : override fun getCurrentCacheLimit(): Int = _cacheLimit.value override fun setCacheLimit(limit: Int) { - _cacheLimit.value = limit + _cacheLimit.value = limit.coerceIn(MIN_CACHE_LIMIT, MAX_CACHE_LIMIT) } override suspend fun switchActiveDatabase(address: String?) { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt similarity index 91% rename from core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt rename to core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt index 4f0a4b1530..98d3915240 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshNotificationManager.kt @@ -18,13 +18,13 @@ package org.meshtastic.core.testing import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node -import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry -/** A test double for [MeshServiceNotifications] that provides a no-op implementation. */ +/** A test double for [MeshNotificationManager] that provides a no-op implementation. */ @Suppress("TooManyFunctions", "EmptyFunctionBlock") -class FakeMeshServiceNotifications : MeshServiceNotifications { +class FakeMeshNotificationManager : MeshNotificationManager { override fun clearNotifications() {} override fun initChannels() {} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt index cfdc64f4f2..40ce54d3f4 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt @@ -27,7 +27,7 @@ class FakeMeshService { val serviceRepository = FakeServiceRepository() val radioController = FakeRadioController() val radioInterfaceService = FakeRadioInterfaceService() - val notifications = FakeMeshServiceNotifications() + val notifications = FakeMeshNotificationManager() val transport = FakeRadioTransport() val logRepository = FakeMeshLogRepository() val packetRepository = FakePacketRepository() diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 4c5092080b..5ce42d269e 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -20,7 +20,8 @@ import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket 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.Channel import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Config @@ -36,7 +37,7 @@ class FakeRadioController : RadioController { /** Canonical app-level connection state, mirroring [ServiceRepository][connectionState] semantics. */ - private val _connectionState = mutableStateFlow(ConnectionState.Connected) + private val _connectionState = mutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow = _connectionState private val _clientNotification = mutableStateFlow(null) @@ -47,8 +48,7 @@ class FakeRadioController : val sentSharedContacts = mutableListOf() var throwOnSend: Boolean = false var lastSetDeviceAddress: String? = null - var beginEditSettingsCalled = false - var commitEditSettingsCalled = false + var editSettingsCalled = false var startProvideLocationCalled = false var stopProvideLocationCalled = false @@ -59,8 +59,7 @@ class FakeRadioController : sentSharedContacts.clear() throwOnSend = false lastSetDeviceAddress = null - beginEditSettingsCalled = false - commitEditSettingsCalled = false + editSettingsCalled = false startProvideLocationCalled = false stopProvideLocationCalled = false } @@ -75,8 +74,8 @@ class FakeRadioController : _clientNotification.value = null } - override suspend fun favoriteNode(nodeNum: Int) { - favoritedNodes.add(nodeNum) + override suspend fun setFavorite(nodeNum: Int, favorite: Boolean) { + if (favorite) favoritedNodes.add(nodeNum) else favoritedNodes.remove(nodeNum) } override suspend fun sendSharedContact(nodeNum: Int): Boolean { @@ -84,6 +83,16 @@ class FakeRadioController : return true } + override suspend fun setIgnored(nodeNum: Int, ignored: Boolean) {} + + override suspend fun toggleMuted(nodeNum: Int) {} + + override suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) {} + + override suspend fun importContact(contact: org.meshtastic.proto.SharedContact) {} + + override suspend fun refreshMetadata(destNum: Int) {} + override suspend fun setLocalConfig(config: Config) {} override suspend fun setLocalChannel(channel: Channel) {} @@ -140,15 +149,27 @@ class FakeRadioController : override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {} - override suspend fun beginEditSettings(destNum: Int) { - beginEditSettingsCalled = true - } + override suspend fun editSettings(destNum: Int, block: suspend AdminEditScope.() -> Unit) { + editSettingsCalled = true + val scope = + object : AdminEditScope { + override suspend fun setOwner(user: User) = setOwner(destNum, user, generatePacketId()) + + override suspend fun setConfig(config: Config) = setConfig(destNum, config, generatePacketId()) + + override suspend fun setModuleConfig(config: ModuleConfig) = + setModuleConfig(destNum, config, generatePacketId()) + + override suspend fun setChannel(channel: Channel) = + setRemoteChannel(destNum, channel, generatePacketId()) - override suspend fun commitEditSettings(destNum: Int) { - commitEditSettingsCalled = true + override suspend fun setFixedPosition(position: Position) = + this@FakeRadioController.setFixedPosition(destNum, position) + } + scope.block() } - override fun getPacketId(): Int = 1 + override fun generatePacketId(): Int = 1 override fun startProvideLocation() { startProvideLocationCalled = true @@ -158,7 +179,7 @@ class FakeRadioController : stopProvideLocationCalled = true } - override fun setDeviceAddress(address: String) { + override suspend fun setDeviceAddress(address: String) { lastSetDeviceAddress = address } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt index 494586e08c..192d4728df 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.ClientNotification @@ -96,11 +95,4 @@ class FakeServiceRepository : ServiceRepository { override fun clearNeighborInfoResponse() { _neighborInfoResponse.value = null } - - private val _serviceAction = MutableSharedFlow(replay = 1) - override val serviceAction: Flow = _serviceAction - - override suspend fun onServiceAction(action: ServiceAction) { - _serviceAction.emit(action) - } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt index 807a1584d4..9e2c696148 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt @@ -30,6 +30,8 @@ import org.meshtastic.core.resources.a11y_node_offline import org.meshtastic.core.resources.a11y_node_online import org.meshtastic.core.resources.a11y_node_role import org.meshtastic.core.resources.a11y_node_signal +import org.meshtastic.core.resources.now +import org.meshtastic.core.resources.unknown import org.meshtastic.core.ui.util.formatAgo private const val MILLIS_PER_SECOND = 1000L @@ -48,6 +50,8 @@ internal data class NodeDescriptionStrings( val battery: String, val distanceAway: String, val signal: String, + val unknown: String, + val now: String, ) /** Resolves [NodeDescriptionStrings] from Compose string resources. */ @@ -62,6 +66,8 @@ internal fun rememberNodeDescriptionStrings(): NodeDescriptionStrings = NodeDesc battery = stringResource(Res.string.a11y_node_battery, 0), distanceAway = stringResource(Res.string.a11y_node_distance_away, "%s"), signal = stringResource(Res.string.a11y_node_signal, "%s"), + unknown = stringResource(Res.string.unknown), + now = stringResource(Res.string.now), ) /** Builds a TalkBack-friendly description aggregating node state. Shared between [NodeItem] and [NodeItemCompact]. */ @@ -91,7 +97,7 @@ internal fun buildNodeDescription( if (lastHeard > 0) { val timeText = if (lastHeardIsRelative) { - formatAgo(lastHeard) + formatAgo(lastHeard, strings.unknown, strings.now) } else { DateFormatter.formatDateTime(lastHeard.toLong() * MILLIS_PER_SECOND) } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index 5198bd3a1a..b5be998251 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt @@ -18,8 +18,8 @@ package org.meshtastic.core.ui.qr import androidx.lifecycle.ViewModel import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.ui.util.getChannelList import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt index d00ab5f3c3..45a3f725e9 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt @@ -22,19 +22,18 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.Node -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.SharedContact @KoinViewModel -class SharedContactViewModel(nodeRepository: NodeRepository, private val serviceRepository: ServiceRepository) : +class SharedContactViewModel(nodeRepository: NodeRepository, private val radioController: RadioController) : ViewModel() { val unfilteredNodes: StateFlow> = nodeRepository.getNodes().stateInWhileSubscribed(initialValue = emptyList()) fun addSharedContact(sharedContact: SharedContact) = - viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.ImportContact(sharedContact)) } + viewModelScope.launch { radioController.importContact(sharedContact) } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt index 895e4e47b4..bffcd8541d 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/FormatAgo.kt @@ -16,10 +16,11 @@ */ package org.meshtastic.core.ui.util +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getString import org.meshtastic.core.resources.now import org.meshtastic.core.resources.unknown import kotlin.time.Duration.Companion.milliseconds @@ -29,22 +30,29 @@ import kotlin.time.Duration.Companion.seconds /** * Formats a given Unix timestamp (in seconds) into a relative "time ago" string. * - * For durations less than a minute, it returns "now". For longer durations, it uses DateFormatter to generate a + * For durations less than a minute, it returns [nowText]. For longer durations, it uses DateFormatter to generate a * concise, localized representation (e.g., "5m ago", "2h ago"). * * @param lastSeenUnixSeconds The Unix timestamp in seconds to be formatted. + * @param unknownText Text to display when the timestamp is invalid (≤ 0). + * @param nowText Text to display when the duration is less than a minute. * @return A [String] representing the relative time that has passed. */ -fun formatAgo(lastSeenUnixSeconds: Int): String { - if (lastSeenUnixSeconds <= 0) return getString(Res.string.unknown) +fun formatAgo(lastSeenUnixSeconds: Int, unknownText: String, nowText: String): String { + if (lastSeenUnixSeconds <= 0) return unknownText val lastSeenDuration = lastSeenUnixSeconds.seconds val currentDuration = nowMillis.milliseconds val diff = (currentDuration - lastSeenDuration).absoluteValue return if (diff < 1.minutes) { - getString(Res.string.now) + nowText } else { DateFormatter.formatRelativeTime(lastSeenDuration.inWholeMilliseconds) } } + +/** Composable convenience overload that resolves string resources automatically. */ +@Composable +fun formatAgo(lastSeenUnixSeconds: Int): String = + formatAgo(lastSeenUnixSeconds, stringResource(Res.string.unknown), stringResource(Res.string.now)) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt index f4d15d3d9c..af2b74d49c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt @@ -27,15 +27,15 @@ import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.Config import org.meshtastic.proto.LocalConfig /** - * Derived, UI-friendly summary of the device connection state. Combines [ServiceRepository.connectionState] with + * Derived, UI-friendly summary of the device connection state. Combines [ConnectionStateProvider.connectionState] with * "region unset" to surface the MUST_SET_REGION case that otherwise needs a separate boolean flag in the UI layer. */ enum class ConnectionStatus { @@ -58,7 +58,7 @@ enum class ConnectionStatus { @KoinViewModel class ConnectionsViewModel( radioConfigRepository: RadioConfigRepository, - serviceRepository: ServiceRepository, + connectionStateProvider: ConnectionStateProvider, nodeRepository: NodeRepository, private val uiPrefs: UiPrefs, ) : ViewModel() { @@ -66,7 +66,7 @@ class ConnectionsViewModel( val localConfig: StateFlow = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig()) - val connectionState = serviceRepository.connectionState + val connectionState = connectionStateProvider.connectionState val myNodeInfo: StateFlow = nodeRepository.myNodeInfo diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index e0d895226b..84a1bce2df 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -43,7 +43,6 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.EventEdition import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.TracerouteMapAvailability import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.service.TracerouteResponse @@ -55,6 +54,7 @@ import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs @@ -198,7 +198,7 @@ class UIViewModel( } fun setDeviceAddress(address: String) { - radioController.setDeviceAddress(address) + safeLaunch(tag = "setDeviceAddress") { radioController.setDeviceAddress(address) } } val unreadMessageCount = diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt index 40e84eb9cb..cc6f03e3e1 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt @@ -35,6 +35,8 @@ class BuildNodeDescriptionTest { battery = "battery 0%", distanceAway = "%s away", signal = "signal %s", + unknown = "unknown", + now = "now", ) private fun describe( diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt index 2ce3077c77..77dbe36a7f 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.meshtastic.core.model.Node import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.proto.SharedContact import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -39,12 +39,12 @@ class SharedContactViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() private lateinit var viewModel: SharedContactViewModel private val nodeRepository = FakeNodeRepository() - private val serviceRepository = FakeServiceRepository() + private val radioController = FakeRadioController() @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) - viewModel = SharedContactViewModel(nodeRepository, serviceRepository) + viewModel = SharedContactViewModel(nodeRepository, radioController) } @AfterTest @@ -59,7 +59,7 @@ class SharedContactViewModelTest { @Test fun `unfilteredNodes reflects repository updates`() = runTest(testDispatcher) { - viewModel = SharedContactViewModel(nodeRepository, serviceRepository) + viewModel = SharedContactViewModel(nodeRepository, radioController) viewModel.unfilteredNodes.test { assertEquals(emptyList(), awaitItem()) diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt index fe4af069d9..ac4b8391a4 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt @@ -59,7 +59,7 @@ class ConnectionsViewModelTest { viewModel = ConnectionsViewModel( radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, + connectionStateProvider = serviceRepository, nodeRepository = nodeRepository, uiPrefs = uiPrefs, ) diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 261abeeaeb..c877d43c75 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -32,6 +32,7 @@ import io.ktor.client.plugins.logging.Logging import io.ktor.client.request.url import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json +import org.koin.core.qualifier.named import org.koin.dsl.module import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource @@ -39,28 +40,35 @@ import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases -import org.meshtastic.core.model.RadioController import org.meshtastic.core.network.HttpClientDefaults import org.meshtastic.core.network.KermitHttpLogger import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.network.service.ApiService import org.meshtastic.core.network.service.ApiServiceImpl +import org.meshtastic.core.repository.AdminController import org.meshtastic.core.repository.AppWidgetUpdater +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.LocationRepository 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.MessageQueue +import org.meshtastic.core.repository.MessagingController +import org.meshtastic.core.repository.NeighborInfoResponseProvider +import org.meshtastic.core.repository.NodeController import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PlatformAnalytics +import org.meshtastic.core.repository.QueryController +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioTransportFactory -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.service.DirectRadioControllerImpl +import org.meshtastic.core.repository.ServiceStateWriter +import org.meshtastic.core.repository.TracerouteResponseProvider +import org.meshtastic.core.service.RadioControllerImpl import org.meshtastic.core.service.ServiceRepositoryImpl import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.desktop.DesktopNotificationManager -import org.meshtastic.desktop.notification.DesktopMeshServiceNotifications +import org.meshtastic.desktop.notification.DesktopMeshNotificationManager import org.meshtastic.desktop.notification.DesktopOS import org.meshtastic.desktop.notification.LinuxNotificationSender import org.meshtastic.desktop.notification.MacOSNotificationSender @@ -77,7 +85,6 @@ import org.meshtastic.desktop.stub.NoopMeshLocationManager import org.meshtastic.desktop.stub.NoopMeshWorkerManager import org.meshtastic.desktop.stub.NoopPhoneLocationProvider import org.meshtastic.desktop.stub.NoopPlatformAnalytics -import org.meshtastic.desktop.stub.NoopServiceBroadcasts import org.meshtastic.feature.docs.ai.AIDocAssistant import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant import org.meshtastic.feature.docs.translation.DocTranslationService @@ -157,6 +164,10 @@ fun desktopModule() = module { @Suppress("LongMethod") private fun desktopPlatformStubsModule() = module { single { ServiceRepositoryImpl() } + single { get() } + single { get() } + single { get() } + single { get() } single { DesktopRadioTransportFactory( dispatchers = get(), @@ -166,16 +177,29 @@ private fun desktopPlatformStubsModule() = module { ) } single { - DirectRadioControllerImpl( + RadioControllerImpl( serviceRepository = get(), nodeRepository = get(), commandSender = get(), - router = get(), nodeManager = get(), radioInterfaceService = get(), locationManager = get(), + packetRepository = lazy { get() }, + dataHandler = lazy { get() }, + analytics = get(), + meshPrefs = get(), + uiPrefs = get(), + databaseManager = get(), + notificationManager = get(), + messageProcessor = lazy { get() }, + radioConfigRepository = get(), + scope = get(qualifier = named("ServiceScope")), ) } + single { get() } + single { get() } + single { get() } + single { get() } single { when (DesktopOS.current()) { DesktopOS.Linux -> LinuxNotificationSender() @@ -185,9 +209,8 @@ private fun desktopPlatformStubsModule() = module { } single { DesktopNotificationManager(prefs = get(), nativeSender = get()) } single { get() } - single { DesktopMeshServiceNotifications(notificationManager = get()) } + single { DesktopMeshNotificationManager(notificationManager = get()) } single { NoopPlatformAnalytics() } - single { NoopServiceBroadcasts() } single { NoopAppWidgetUpdater() } single { NoopMeshWorkerManager() } single { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) } @@ -222,7 +245,7 @@ private fun desktopPlatformStubsModule() = module { if (DesktopBuildConfig.IS_DEBUG) { install(Logging) { logger = KermitHttpLogger - level = LogLevel.BODY + level = LogLevel.INFO } } } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt similarity index 96% rename from desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt rename to desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt index 4cda00251b..0923580328 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshNotificationManager.kt @@ -18,7 +18,7 @@ package org.meshtastic.desktop.notification import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node -import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshNotificationManager import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.resources.Res @@ -31,7 +31,7 @@ import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Telemetry /** - * Desktop implementation of [MeshServiceNotifications]. + * Desktop implementation of [MeshNotificationManager]. * * Converts mesh-layer notification events into domain [Notification] objects and dispatches them through * [NotificationManager], which ultimately surfaces them as Compose Desktop tray notifications. @@ -42,7 +42,7 @@ import org.meshtastic.proto.Telemetry * `@ComponentScan("org.meshtastic.desktop")` in [DesktopDiModule][org.meshtastic.desktop.di.DesktopDiModule]. */ @Suppress("TooManyFunctions") -class DesktopMeshServiceNotifications(private val notificationManager: NotificationManager) : MeshServiceNotifications { +class DesktopMeshNotificationManager(private val notificationManager: NotificationManager) : MeshNotificationManager { override fun clearNotifications() { notificationManager.cancelAll() } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt index 3888b0af3e..06cac81ee4 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt @@ -23,9 +23,9 @@ import kotlinx.coroutines.launch import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MessageQueue import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioController /** * Desktop implementation of [MessageQueue]. diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index 081735e259..f8b96faba6 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -28,12 +28,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.emptyFlow import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Node import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.DataPair @@ -43,7 +40,6 @@ import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshWorkerManager import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.mqtt.ConnectionState as MqttConnectionState import org.meshtastic.proto.Position as ProtoPosition @@ -122,18 +118,6 @@ class NoopPlatformAnalytics : PlatformAnalytics { override val isPlatformServicesAvailable: Boolean = false } -class NoopServiceBroadcasts : ServiceBroadcasts { - override fun subscribeReceiver(receiverName: String, packageName: String) {} - - override fun broadcastReceivedData(dataPacket: DataPacket) {} - - override fun broadcastConnection() {} - - override fun broadcastNodeChange(node: Node) {} - - override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) {} -} - class NoopAppWidgetUpdater : AppWidgetUpdater { override suspend fun updateAll() {} } @@ -147,7 +131,9 @@ class NoopMeshWorkerManager : MeshWorkerManager { } class NoopMeshLocationManager : MeshLocationManager { - override fun start(scope: CoroutineScope, sendPositionFn: (ProtoPosition) -> Unit) {} + override fun start(scope: CoroutineScope, sendPositionFn: suspend (ProtoPosition) -> Unit) {} + + override fun restart() {} override fun stop() {} } diff --git a/docs/en/developer/architecture.md b/docs/en/developer/architecture.md index 75476e1f36..15ef7a1248 100644 --- a/docs/en/developer/architecture.md +++ b/docs/en/developer/architecture.md @@ -2,11 +2,12 @@ title: Architecture parent: Developer Guide nav_order: 1 -last_updated: 2026-05-13 +last_updated: 2026-05-29 aliases: - layers - module-architecture - kmp + - radio-control --- # Architecture @@ -119,6 +120,44 @@ The project uses **Koin** with annotation processing: - Feature modules export their own `Feature*Module` class - App/Desktop compose all modules in their root DI configuration +## Radio Control + +Features issue radio commands through `RadioController` (`core:repository`), a composite of four +focused sub-interfaces so callers can depend on just the slice they need: + +| Sub-interface | Responsibility | +|---------------|---------------| +| `AdminController` | Config, channels, owner, device lifecycle, `editSettings { }` transactions | +| `MessagingController` | Send packets, reactions, shared contacts | +| `NodeController` | Favorite, ignore, mute, remove nodes | +| `QueryController` | Telemetry, traceroute, position/user-info queries | + +`RadioControllerImpl` (`core:service`) is the in-process composition root for all targets +(Desktop, iOS, single-process Android). It assembles the four sub-controllers via Kotlin interface +delegation and adds the cross-cutting concerns (connection state, packet-id, location, +device-address switching). Commands are direct suspend calls; admin writes are fire-and-forget +because the device is the source of truth (local persistence is an optimistic cache). The layered +shape mirrors the [meshtastic-sdk](https://github.com/meshtastic/meshtastic-sdk) +`AdminApi`/`TelemetryApi` design to ease a future SDK migration. + +## Service Repository + +`ServiceRepository` is the reactive bridge between the mesh service and all feature/UI layers. +It is decomposed into focused provider interfaces following the Interface Segregation Principle: + +| Interface | Responsibility | +|-----------|---------------| +| `ConnectionStateProvider` | Read-only `connectionState: StateFlow` | +| `TracerouteResponseProvider` | Traceroute response state + clear | +| `NeighborInfoResponseProvider` | Neighbor info response state + clear | +| `ServiceStateWriter` | Write-side for handlers (set*, emit*, clear*) | + +`ServiceRepository` extends all four interfaces — consumers inject the narrowest interface +they actually need. For example, `ContactsViewModel` injects only `ConnectionStateProvider` +rather than the entire `ServiceRepository`, preventing accidental access to write operations +from UI code. `RadioController` also extends `ConnectionStateProvider` so VMs that already +inject a controller sub-interface can read connection state without a separate dependency. + ## Navigation Navigation uses **Navigation 3** with typed routes: diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt index 1f3958f3f8..0754671d47 100644 --- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt +++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt @@ -25,10 +25,10 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.ble.BluetoothRepository import org.meshtastic.core.datastore.RecentAddressesDataSource -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.network.repository.NetworkRepository import org.meshtastic.core.network.repository.UsbRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt index 4114f7b953..5cac9bf7d0 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt @@ -38,9 +38,9 @@ import org.meshtastic.core.ble.MeshtasticBleConstants import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.datastore.model.RecentAddress import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.network.repository.NetworkRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository @@ -287,7 +287,7 @@ open class ScannerViewModel( fun changeDeviceAddress(address: String) { Logger.i { "Attempting to change device address to ${address.anonymize()}" } - radioController.setDeviceAddress(address) + safeLaunch(tag = "changeDeviceAddress") { radioController.setDeviceAddress(address) } } fun addRecentAddress(address: String, name: String) { diff --git a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt index 7c65cc0d0e..03bad540de 100644 --- a/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt +++ b/feature/connections/src/commonTest/kotlin/org/meshtastic/feature/connections/ScannerViewModelTest.kt @@ -22,13 +22,16 @@ import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.meshtastic.core.datastore.RecentAddressesDataSource import org.meshtastic.core.network.repository.DiscoveredService import org.meshtastic.core.network.repository.NetworkRepository @@ -40,6 +43,7 @@ import org.meshtastic.core.testing.FakeServiceRepository import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.DiscoveredDevices import org.meshtastic.feature.connections.model.GetDiscoveredDevicesUseCase +import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -48,6 +52,7 @@ import kotlin.test.assertNotNull @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class ScannerViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() private lateinit var viewModel: ScannerViewModel private val serviceRepository = FakeServiceRepository() private val radioController = FakeRadioController() @@ -79,6 +84,8 @@ class ScannerViewModelTest { @BeforeTest fun setUp() { + Dispatchers.setMain(testDispatcher) + every { radioInterfaceService.isMockTransport() } returns false every { radioInterfaceService.currentDeviceAddressFlow } returns MutableStateFlow(null) @@ -101,15 +108,20 @@ class ScannerViewModelTest { networkRepository = networkRepository, dispatchers = org.meshtastic.core.di.CoroutineDispatchers( - io = UnconfinedTestDispatcher(), - main = UnconfinedTestDispatcher(), - default = UnconfinedTestDispatcher(), + io = testDispatcher, + main = testDispatcher, + default = testDispatcher, ), uiPrefs = uiPrefs, bleScanner = bleScanner, ) } + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun testInitialization() { assertNotNull(viewModel) @@ -141,8 +153,9 @@ class ScannerViewModelTest { } @Test - fun `changeDeviceAddress calls radioController`() { + fun `changeDeviceAddress calls radioController`() = runTest { viewModel.changeDeviceAddress("test_address") + testScheduler.advanceUntilIdle() assertEquals("test_address", radioController.lastSetDeviceAddress) } diff --git a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt index 73a10a8bbe..34c3744fef 100644 --- a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt +++ b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/JvmScannerViewModel.kt @@ -18,8 +18,8 @@ package org.meshtastic.feature.connections import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.datastore.RecentAddressesDataSource -import org.meshtastic.core.model.RadioController import org.meshtastic.core.network.repository.NetworkRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index 7c0c7f2722..13c90d31d0 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -46,10 +46,10 @@ import org.meshtastic.core.datastore.BootloaderWarningDataSource import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.DeviceHardwareRepository import org.meshtastic.core.repository.FirmwareReleaseRepository 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 diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt index c5bbb9c0ec..4b8235f6eb 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateHandler.kt @@ -20,8 +20,8 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioController /** Handles firmware updates via USB Mass Storage (UF2). */ @Single diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt index 842917d421..d540277720 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/UsbUpdateSupport.kt @@ -22,8 +22,8 @@ import kotlinx.coroutines.delay import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.firmware_update_downloading_percent diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index 6342aa5fcd..84e6bb9968 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -29,8 +29,8 @@ import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.firmware_update_connecting_attempt @@ -190,14 +190,14 @@ class Esp32OtaUpdateHandler( val myInfo = nodeRepository.myNodeInfo.value ?: return val myNodeNum = myInfo.myNodeNum Logger.i { "ESP32 OTA: Triggering reboot OTA mode $mode with hash" } - radioController.requestRebootOta(radioController.getPacketId(), myNodeNum, mode, hash) + radioController.requestRebootOta(radioController.generatePacketId(), myNodeNum, mode, hash) } /** * Disconnect the mesh service BLE connection to free up the GATT for OTA. Setting device address to "n" (NOP * interface) cleanly disconnects without reconnection attempts. */ - private fun disconnectMeshService() { + private suspend fun disconnectMeshService() { Logger.i { "ESP32 OTA: Disconnecting mesh service for OTA" } radioController.setDeviceAddress("n") } diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt index ccebb40a5f..52eccf3800 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuHandler.kt @@ -30,7 +30,7 @@ import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.firmware_update_connecting_attempt diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt index 0a26fd13eb..84b5cfa410 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/DefaultFirmwareUpdateManagerTest.kt @@ -26,8 +26,8 @@ import org.meshtastic.core.ble.BleConnectionFactory import org.meshtastic.core.ble.BleScanner import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.feature.firmware.ota.Esp32OtaUpdateHandler import org.meshtastic.feature.firmware.ota.dfu.SecureDfuHandler diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index fdfd3f05a0..3c94aa76b0 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -26,13 +26,15 @@ import kotlinx.coroutines.flow.mapLatest import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node -import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.any import org.meshtastic.core.resources.eight_hours @@ -142,19 +144,17 @@ open class BaseMapViewModel( } open fun getUser(userId: String?) = - nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST) + nodeRepository.getUser(userId ?: org.meshtastic.core.model.NodeAddress.ID_BROADCAST) fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) fun deleteWaypoint(id: Int) = safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) } - fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { + fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${NodeAddress.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) - val channel = contactKey[0].digitToIntOrNull() - val dest = if (channel != null) contactKey.substring(1) else contactKey - - val p = DataPacket(dest, channel ?: 0, wpt) + val parsedKey = ContactKey(contactKey) + val p = DataPacket(parsedKey.addressString, parsedKey.channel, wpt) if (wpt.id != 0) sendDataPacket(p) } @@ -162,7 +162,7 @@ open class BaseMapViewModel( safeLaunch(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) } } - fun generatePacketId(): Int = radioController.getPacketId() + fun generatePacketId(): Int = radioController.generatePacketId() data class MapFilterState( val onlyFavorites: Boolean, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt index bcebdabf62..767cc66202 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt @@ -17,10 +17,10 @@ package org.meshtastic.feature.map import org.koin.core.annotation.KoinViewModel -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.RadioController @KoinViewModel class SharedMapViewModel( diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index 336de2a44d..bc935be256 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.test.setMain import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.testing.FakeNodeRepository @@ -196,7 +197,7 @@ class BaseMapViewModelTest { } private fun waypointPacket(id: Int, expire: Int): DataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = NodeAddress.ID_BROADCAST, channel = 0, waypoint = Waypoint(id = id, name = "Waypoint $id", expire = expire), ) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt index 89ac0ef45b..c6b91bc40c 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -72,8 +72,9 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.util.getChannel import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.message_input_label @@ -150,8 +151,9 @@ fun MessageScreen( // Derived state, memoized for performance val channelInfo = remember(contactKey, channels) { - val index = contactKey.firstOrNull()?.digitToIntOrNull() - val id = contactKey.substring(1) + val parsedKey = ContactKey(contactKey) + val index = parsedKey.channelOrNull + val id = parsedKey.addressString val name = index?.let { channels.getChannel(it)?.name } // channels can be null initially Triple(index, id, name) } @@ -162,14 +164,14 @@ fun MessageScreen( val title = remember(nodeId, channelName, viewModel) { when (nodeId) { - DataPacket.ID_BROADCAST -> channelName + NodeAddress.ID_BROADCAST -> channelName else -> viewModel.getUser(nodeId).long_name } } val isMismatchKey = remember(channelIndex, nodeId, viewModel) { - channelIndex == DataPacket.PKC_CHANNEL_INDEX && viewModel.getNode(nodeId).mismatchKey + channelIndex == NodeAddress.PKC_CHANNEL_INDEX && viewModel.getNode(nodeId).mismatchKey } val inSelectionMode by remember { derivedStateOf { selectedMessageIds.value.isNotEmpty() } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index 3f92f3cbf7..0afd24b1c1 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -56,6 +56,7 @@ import kotlinx.coroutines.launch 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.feature.messaging.component.MessageItem import org.meshtastic.feature.messaging.component.MessageStatusDialog @@ -344,7 +345,7 @@ private fun RenderPagedChatMessageRow( message.emojis.any { reaction -> ( reaction.user.id == ourNode.user.id || - reaction.user.id == org.meshtastic.core.model.DataPacket.ID_LOCAL + reaction.user.id == org.meshtastic.core.model.NodeAddress.ID_LOCAL ) && reaction.emoji == emoji } if (!hasReacted) { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index ca29b38421..cd48ad5cf9 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -34,18 +34,18 @@ import kotlinx.coroutines.flow.update import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.ContactSettings -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node -import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.CustomEmojiPrefs import org.meshtastic.core.repository.HomoglyphPrefs +import org.meshtastic.core.repository.MessagingController import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.QuickChatActionRepository import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.usecase.SendMessageUseCase import org.meshtastic.core.ui.viewmodel.safeLaunch @@ -59,7 +59,8 @@ class MessageViewModel( private val nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, quickChatActionRepository: QuickChatActionRepository, - private val serviceRepository: ServiceRepository, + private val connectionStateProvider: ConnectionStateProvider, + private val messagingController: MessagingController, private val packetRepository: PacketRepository, private val uiPrefs: UiPrefs, private val customEmojiPrefs: CustomEmojiPrefs, @@ -72,7 +73,7 @@ class MessageViewModel( val ourNodeInfo = nodeRepository.ourNodeInfo - val connectionState = serviceRepository.connectionState + val connectionState = connectionStateProvider.connectionState val nodeList: StateFlow> = nodeRepository.getNodes().stateInWhileSubscribed(initialValue = emptyList()) @@ -109,10 +110,11 @@ class MessageViewModel( get() = customEmojiPrefs.customEmojiFrequency.value ?.split(",") - ?.associate { entry -> - entry.split("=", limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].toInt() } ?: ("" to 0) + ?.mapNotNull { entry -> + val parts = entry.split("=", limit = 2) + val count = parts.getOrNull(1)?.toIntOrNull() + if (parts.size == 2 && parts[0].isNotEmpty() && count != null) parts[0] to count else null } - ?.toList() ?.sortedByDescending { it.second } ?.map { it.first } ?.take(6) ?: listOf("👍", "👎", "😂", "🔥", "❤️", "😮") @@ -195,9 +197,9 @@ class MessageViewModel( } } - fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) + fun getNode(userId: String?) = nodeRepository.getNode(userId ?: NodeAddress.ID_BROADCAST) - fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) + fun getUser(userId: String?) = nodeRepository.getUser(userId ?: NodeAddress.ID_BROADCAST) /** * Sends a message to a contact or channel. @@ -212,13 +214,12 @@ class MessageViewModel( * broadcasting on channel 0. * @param replyId The ID of the message this is a reply to, if any. */ - fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) { + fun sendMessage(str: String, contactKey: String = "0${NodeAddress.ID_BROADCAST}", replyId: Int? = null) { safeLaunch(tag = "sendMessage") { sendMessageUseCase.invoke(str, contactKey, replyId) } } - fun sendReaction(emoji: String, replyId: Int, contactKey: String) = safeLaunch(tag = "sendReaction") { - serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) - } + fun sendReaction(emoji: String, replyId: Int, contactKey: String) = + safeLaunch(tag = "sendReaction") { messagingController.sendReaction(emoji, replyId, contactKey) } fun deleteMessages(uuidList: List) = safeLaunch(context = ioDispatcher, tag = "deleteMessages") { packetRepository.deleteMessages(uuidList) } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt index 4d89b342c2..8fd585d373 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/MessageScreenComponents.kt @@ -64,9 +64,9 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.QuickChatAction -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.alert_bell_text import org.meshtastic.core.resources.cancel_reply @@ -353,7 +353,7 @@ private fun MessageTopBarActions( onToggleShowFiltered: () -> Unit, onNavigateToFilterSettings: () -> Unit, ) { - if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) { + if (channelIndex == NodeAddress.PKC_CHANNEL_INDEX) { NodeKeyStatusIcon(hasPKC = true, mismatchKey = mismatchKey) } var expanded by remember { mutableStateOf(false) } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index d11fc1ae9e..e1adf4ea17 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -55,8 +55,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.Reaction import org.meshtastic.core.model.getStringResFrom import org.meshtastic.core.model.util.getShortDateTime @@ -146,7 +146,7 @@ internal fun ReactionRow( items(emojiGroups.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value - val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } + val localReaction = reactions.find { it.user.id == NodeAddress.ID_LOCAL || it.user.id == myId } ReactionItem( emoji = emoji, emojiCount = reactions.size, @@ -236,7 +236,7 @@ internal fun ReactionDialog( items(groupedEmojis.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value - val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } + val localReaction = reactions.find { it.user.id == NodeAddress.ID_LOCAL || it.user.id == myId } val isSending = localReaction?.status == MessageStatus.QUEUED || localReaction?.status == MessageStatus.ENROUTE Text( @@ -268,7 +268,7 @@ internal fun ReactionDialog( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - val isLocal = reaction.user.id == myId || reaction.user.id == DataPacket.ID_LOCAL + val isLocal = reaction.user.id == myId || reaction.user.id == NodeAddress.ID_LOCAL val displayName = if (isLocal) { "${reaction.user.long_name} (${stringResource(Res.string.you)})" diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt index 4979845486..513a0e8483 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactItem.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.model.Contact +import org.meshtastic.core.model.ContactKey import org.meshtastic.core.ui.component.SecurityIcon import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.VolumeOff @@ -138,8 +139,7 @@ private fun ContactHeader( val isBroadcast = with(contact.contactKey) { getOrNull(1) == '^' || endsWith("^all") || endsWith("^broadcast") } if (isBroadcast && channels != null) { - val channelIndex = contact.contactKey[0].digitToIntOrNull() - channelIndex?.let { index -> SecurityIcon(channels, index) } + ContactKey(contact.contactKey).channelOrNull?.let { index -> SecurityIcon(channels, index) } } Text( diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt index 63389dc5a6..3a3e75a232 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt @@ -70,6 +70,7 @@ import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Contact +import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.model.util.formatMuteRemainingTime @@ -212,7 +213,7 @@ fun ContactsScreen( val onNodeChipClick: (Contact) -> Unit = { contact -> if (contact.contactKey.contains("!")) { // if it's a node, look up the nodeNum including the ! - val nodeKey = contact.contactKey.substring(1) + val nodeKey = ContactKey(contact.contactKey).addressString val node = viewModel.getNode(nodeKey) onNavigateToNodeDetails(node.num) } else { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index d846ba2609..17bea1132b 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -28,14 +28,18 @@ import kotlinx.coroutines.flow.map import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.Contact +import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.core.model.isBroadcast +import org.meshtastic.core.model.isFromLocal import org.meshtastic.core.model.util.getChannel +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.ChannelSet @@ -46,11 +50,11 @@ class ContactsViewModel( private val nodeRepository: NodeRepository, private val packetRepository: PacketRepository, radioConfigRepository: RadioConfigRepository, - serviceRepository: ServiceRepository, + connectionStateProvider: ConnectionStateProvider, ) : ViewModel() { val ourNodeInfo = nodeRepository.ourNodeInfo - val connectionState = serviceRepository.connectionState + val connectionState = connectionStateProvider.connectionState val unreadCountTotal = packetRepository.getUnreadCountTotal().stateInWhileSubscribed(0) @@ -80,7 +84,7 @@ class ContactsViewModel( // Add empty channel placeholders (always show Broadcast contacts, even when empty) val placeholder = (0 until channelSet.settings.size).associate { ch -> - val contactKey = "$ch${DataPacket.ID_BROADCAST}" + val contactKey = ContactKey.broadcast(ch).value val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch) contactKey to data } @@ -89,14 +93,13 @@ class ContactsViewModel( val contactKey = entry.key val packetData = entry.value // Determine if this is my message (originated on this device) - val fromLocal = - (packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId)) - val toBroadcast = packetData.to == DataPacket.ID_BROADCAST + val fromLocal = packetData.isFromLocal(myNodeNum) + val toBroadcast = packetData.isBroadcast // grab usernames from NodeInfo val userId = if (fromLocal) packetData.to else packetData.from - val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) - val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) + val user = nodeRepository.getUser(userId ?: NodeAddress.ID_BROADCAST) + val node = nodeRepository.getNode(userId ?: NodeAddress.ID_BROADCAST) val shortName = user.short_name val longName = @@ -136,13 +139,13 @@ class ContactsViewModel( val channelSet = params.channelSet val settings = params.settings val myId = params.myId + val myNodeNum = params.myNodeNum packetRepository.getContactsPaged().map { pagingData -> pagingData.map { packetData: DataPacket -> // Determine if this is my message (originated on this device) - val fromLocal = - (packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId)) - val toBroadcast = packetData.to == DataPacket.ID_BROADCAST + val fromLocal = packetData.isFromLocal(myNodeNum) + val toBroadcast = packetData.isBroadcast // Reconstruct contactKey exactly as rememberDataPacket() computes it: // For outgoing or broadcast: use the "to" field (recipient / ^all) @@ -152,8 +155,8 @@ class ContactsViewModel( // grab usernames from NodeInfo val userId = if (fromLocal) packetData.to else packetData.from - val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) - val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) + val user = nodeRepository.getUser(userId ?: NodeAddress.ID_BROADCAST) + val node = nodeRepository.getNode(userId ?: NodeAddress.ID_BROADCAST) val shortName = user.short_name val longName = @@ -185,7 +188,7 @@ class ContactsViewModel( } .cachedIn(viewModelScope) - fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) + fun getNode(userId: String?) = nodeRepository.getNode(userId ?: NodeAddress.ID_BROADCAST) fun deleteContacts(contacts: List) = safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) } diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt index 80877834b6..bc6d29f36e 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt @@ -27,7 +27,6 @@ import dev.mokkery.mock import dev.mokkery.verifySuspend import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -35,13 +34,13 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.ContactSettings -import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.CustomEmojiPrefs import org.meshtastic.core.repository.HomoglyphPrefs +import org.meshtastic.core.repository.MessagingController import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.QuickChatActionRepository import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.usecase.SendMessageUseCase import org.meshtastic.core.testing.FakeNodeRepository @@ -64,7 +63,8 @@ class MessageViewModelTest { private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val quickChatActionRepository: QuickChatActionRepository = mock(MockMode.autofill) private val packetRepository: PacketRepository = mock(MockMode.autofill) - private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val connectionStateProvider: ConnectionStateProvider = mock(MockMode.autofill) + private val messagingController: MessagingController = mock(MockMode.autofill) private val sendMessageUseCase: SendMessageUseCase = mock(MockMode.autofill) private val customEmojiPrefs: CustomEmojiPrefs = mock(MockMode.autofill) private val homoglyphPrefs: HomoglyphPrefs = mock(MockMode.autofill) @@ -95,8 +95,7 @@ class MessageViewModelTest { every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig()) every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile()) - every { serviceRepository.serviceAction } returns emptyFlow() - every { serviceRepository.connectionState } returns connectionStateFlow + every { connectionStateProvider.connectionState } returns connectionStateFlow every { customEmojiPrefs.customEmojiFrequency } returns customEmojiFrequencyFlow every { homoglyphPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false) @@ -117,8 +116,9 @@ class MessageViewModelTest { nodeRepository = nodeRepository, radioConfigRepository = radioConfigRepository, quickChatActionRepository = quickChatActionRepository, + connectionStateProvider = connectionStateProvider, + messagingController = messagingController, packetRepository = packetRepository, - serviceRepository = serviceRepository, sendMessageUseCase = sendMessageUseCase, customEmojiPrefs = customEmojiPrefs, homoglyphEncodingPrefs = homoglyphPrefs, @@ -192,13 +192,13 @@ class MessageViewModelTest { @Test fun testSendReaction() = runTest { - everySuspend { serviceRepository.onServiceAction(any()) } returns Unit + everySuspend { messagingController.sendReaction(any(), any(), any()) } returns Unit viewModel.sendReaction("❤️", 123, "0!12345678") advanceUntilIdle() - verifySuspend { serviceRepository.onServiceAction(ServiceAction.Reaction("❤️", 123, "0!12345678")) } + verifySuspend { messagingController.sendReaction("❤️", 123, "0!12345678") } } @Test diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt index db49d6bad6..a7985bbb0e 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModelTest.kt @@ -29,9 +29,9 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.proto.ChannelSet import kotlin.test.AfterTest @@ -48,13 +48,13 @@ class ContactsViewModelTest { private val nodeRepository = FakeNodeRepository() private val packetRepository: PacketRepository = mock(MockMode.autofill) private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) - private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val connectionStateProvider: ConnectionStateProvider = mock(MockMode.autofill) @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) - every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) + every { connectionStateProvider.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) every { packetRepository.getUnreadCountTotal() } returns MutableStateFlow(0) every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet()) @@ -63,7 +63,7 @@ class ContactsViewModelTest { nodeRepository = nodeRepository, packetRepository = packetRepository, radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, + connectionStateProvider = connectionStateProvider, ) } @@ -83,7 +83,8 @@ class ContactsViewModelTest { every { packetRepository.getUnreadCountTotal() } returns countFlow // Re-init VM - viewModel = ContactsViewModel(nodeRepository, packetRepository, radioConfigRepository, serviceRepository) + viewModel = + ContactsViewModel(nodeRepository, packetRepository, radioConfigRepository, connectionStateProvider) viewModel.unreadCountTotal.test { assertEquals(0, awaitItem()) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 75dac0f6af..7641103fc2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -51,9 +51,9 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.Base64Factory import org.meshtastic.core.common.util.MetricFormatter -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.util.formatUptime import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.a11y_label_value @@ -214,7 +214,7 @@ private fun NodeIdentificationRow(node: Node) { Row(modifier = Modifier.fillMaxWidth()) { InfoItem( label = stringResource(Res.string.node_id), - value = DataPacket.nodeNumToDefaultId(node.num), + value = NodeAddress.numToDefaultId(node.num), icon = MeshtasticIcons.DeviceNumbers, modifier = Modifier.weight(1f), ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt index 1ea4636857..0c1b695d77 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt @@ -17,18 +17,15 @@ package org.meshtastic.feature.node.detail import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Position -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.neighbor_info @@ -62,60 +59,50 @@ constructor( snackbarManager.showSnackbar(message = text.resolve()) } - override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) { - scope.launch(ioDispatcher) { - Logger.i { "Requesting UserInfo for '$destNum'" } - radioController.requestUserInfo(destNum) - showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName)) - } + override suspend fun requestUserInfo(destNum: Int, longName: String) { + Logger.i { "Requesting UserInfo for '$destNum'" } + radioController.requestUserInfo(destNum) + showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName)) } - override fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) { - scope.launch(ioDispatcher) { - Logger.i { "Requesting NeighborInfo for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestNeighborInfo(packetId, destNum) - _lastRequestNeighborTimes.update { it + (destNum to nowMillis) } - showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName)) - } + override suspend fun requestNeighborInfo(destNum: Int, longName: String) { + Logger.i { "Requesting NeighborInfo for '$destNum'" } + val packetId = radioController.generatePacketId() + radioController.requestNeighborInfo(packetId, destNum) + _lastRequestNeighborTimes.update { it + (destNum to nowMillis) } + showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName)) } - override fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) { - scope.launch(ioDispatcher) { - Logger.i { "Requesting position for '$destNum'" } - radioController.requestPosition(destNum, position) - showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName)) - } + override suspend fun requestPosition(destNum: Int, longName: String, position: Position) { + Logger.i { "Requesting position for '$destNum'" } + radioController.requestPosition(destNum, position) + showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName)) } - override fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) { - scope.launch(ioDispatcher) { - Logger.i { "Requesting telemetry for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestTelemetry(packetId, destNum, type.ordinal) + override suspend fun requestTelemetry(destNum: Int, longName: String, type: TelemetryType) { + Logger.i { "Requesting telemetry for '$destNum'" } + val packetId = radioController.generatePacketId() + radioController.requestTelemetry(packetId, destNum, type.ordinal) - val typeRes = - when (type) { - TelemetryType.DEVICE -> Res.string.request_device_metrics - TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics - TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics - TelemetryType.POWER -> Res.string.request_power_metrics - TelemetryType.LOCAL_STATS -> Res.string.signal_quality - TelemetryType.HOST -> Res.string.request_host_metrics - TelemetryType.PAX -> Res.string.request_pax_metrics - } + val typeRes = + when (type) { + TelemetryType.DEVICE -> Res.string.request_device_metrics + TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics + TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics + TelemetryType.POWER -> Res.string.request_power_metrics + TelemetryType.LOCAL_STATS -> Res.string.signal_quality + TelemetryType.HOST -> Res.string.request_host_metrics + TelemetryType.PAX -> Res.string.request_pax_metrics + } - showFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)) - } + showFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)) } - override fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) { - scope.launch(ioDispatcher) { - Logger.i { "Requesting traceroute for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestTraceroute(packetId, destNum) - _lastTracerouteTime.value = nowMillis - showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName)) - } + override suspend fun requestTraceroute(destNum: Int, longName: String) { + Logger.i { "Requesting traceroute for '$destNum'" } + val packetId = radioController.generatePacketId() + radioController.requestTraceroute(packetId, destNum) + _lastTracerouteTime.value = nowMillis + showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName)) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt index fe6e2b57c6..f7be9901d2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/HandleNodeAction.kt @@ -37,8 +37,6 @@ internal fun handleNodeAction( when (action) { is NodeDetailAction.Navigate -> onNavigate(action.route) - is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action) - is NodeDetailAction.OpenRemoteAdmin -> viewModel.openRemoteAdmin(action.nodeNum) is NodeDetailAction.RefreshMetadata -> viewModel.refreshMetadata(action.nodeNum) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt deleted file mode 100644 index 3535511ff5..0000000000 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt +++ /dev/null @@ -1,83 +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 . - */ -package org.meshtastic.feature.node.detail - -import kotlinx.coroutines.CoroutineScope -import org.koin.core.annotation.Single -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.TelemetryType -import org.meshtastic.feature.node.component.NodeMenuAction - -@Single -class NodeDetailActions -constructor( - private val nodeManagementActions: NodeManagementActions, - private val nodeRequestActions: NodeRequestActions, -) { - fun handleNodeMenuAction(scope: CoroutineScope, action: NodeMenuAction) { - when (action) { - is NodeMenuAction.Remove -> nodeManagementActions.removeNode(scope, action.node.num) - - is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(scope, action.node) - - is NodeMenuAction.Mute -> nodeManagementActions.muteNode(scope, action.node) - - is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(scope, action.node) - - is NodeMenuAction.RequestUserInfo -> - nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.long_name) - - is NodeMenuAction.RequestNeighborInfo -> - nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.long_name) - - is NodeMenuAction.RequestPosition -> - nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.long_name) - - is NodeMenuAction.RequestTelemetry -> - nodeRequestActions.requestTelemetry(scope, action.node.num, action.node.user.long_name, action.type) - - is NodeMenuAction.TraceRoute -> - nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.long_name) - - else -> {} - } - } - - fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) { - nodeManagementActions.setNodeNotes(scope, nodeNum, notes) - } - - fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) { - nodeRequestActions.requestPosition(scope, destNum, longName, position) - } - - fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) { - nodeRequestActions.requestUserInfo(scope, destNum, longName) - } - - fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) { - nodeRequestActions.requestNeighborInfo(scope, destNum, longName) - } - - fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) { - nodeRequestActions.requestTelemetry(scope, destNum, longName, type) - } - - fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) { - nodeRequestActions.requestTraceroute(scope, destNum, longName) - } -} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 1b64f2555a..14681bd34f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -34,13 +34,12 @@ import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase import org.meshtastic.core.domain.usecase.session.EnsureSessionResult import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.SessionStatus -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute -import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.QueryController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.connect_radio_for_remote_admin @@ -82,7 +81,7 @@ class NodeDetailViewModel( private val savedStateHandle: SavedStateHandle, private val nodeManagementActions: NodeManagementActions, private val nodeRequestActions: NodeRequestActions, - private val serviceRepository: ServiceRepository, + private val queryController: QueryController, private val getNodeDetailsUseCase: GetNodeDetailsUseCase, private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase, private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase, @@ -144,39 +143,47 @@ class NodeDetailViewModel( is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node) is NodeMenuAction.RequestUserInfo -> - nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name) + viewModelScope.launch { + nodeRequestActions.requestUserInfo(action.node.num, action.node.user.long_name) + } is NodeMenuAction.RequestNeighborInfo -> - nodeRequestActions.requestNeighborInfo(viewModelScope, action.node.num, action.node.user.long_name) + viewModelScope.launch { + nodeRequestActions.requestNeighborInfo(action.node.num, action.node.user.long_name) + } is NodeMenuAction.RequestPosition -> - nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.long_name) + viewModelScope.launch { + nodeRequestActions.requestPosition(action.node.num, action.node.user.long_name) + } is NodeMenuAction.RequestTelemetry -> - nodeRequestActions.requestTelemetry( - viewModelScope, - action.node.num, - action.node.user.long_name, - action.type, - ) + viewModelScope.launch { + nodeRequestActions.requestTelemetry(action.node.num, action.node.user.long_name, action.type) + } is NodeMenuAction.TraceRoute -> - nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.long_name) + viewModelScope.launch { + nodeRequestActions.requestTraceroute(action.node.num, action.node.user.long_name) + } else -> {} } } - fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) } + /** + * Re-fetch device metadata (firmware/edition/role) for [destNum]. Refreshes the session passkey as a side effect. + */ + fun refreshMetadata(destNum: Int) = viewModelScope.launch { queryController.refreshMetadata(destNum) } /** * Ensure a remote-admin session passkey is fresh, then request navigation to the remote-admin screen. Surfaces a * snackbar with the appropriate guidance on [EnsureSessionResult.Disconnected] or [EnsureSessionResult.Timeout]. */ fun openRemoteAdmin(destNum: Int) { - if (isEnsuringSession.value) return + // Atomic check-and-flip prevents a double-tap from queuing two passkey exchanges + two navigation events. + if (!isEnsuringSession.compareAndSet(expect = false, update = true)) return viewModelScope.launch { - isEnsuringSession.value = true try { when (ensureRemoteAdminSession(destNum)) { EnsureSessionResult.AlreadyActive, @@ -199,19 +206,14 @@ class NodeDetailViewModel( } } - /** - * Re-fetch device metadata (firmware/edition/role) for [destNum]. Refreshes the session passkey as a side effect. - */ - fun refreshMetadata(destNum: Int) = onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) - fun setNodeNotes(nodeNum: Int, notes: String) { - nodeManagementActions.setNodeNotes(viewModelScope, nodeNum, notes) + viewModelScope.launch { nodeManagementActions.setNodeNotes(nodeNum, notes) } } /** Returns the type-safe navigation route for a direct message to this node. */ fun getDirectMessageRoute(node: Node, ourNode: Node?): String { val hasPKC = ourNode?.hasPKC == true && node.hasPKC - val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel + val channel = if (hasPKC) NodeAddress.PKC_CHANNEL_INDEX else node.channel return "${channel}${node.user.id}" } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index 17046f3a7c..dc6e577500 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -21,12 +21,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.Node -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.favorite_add @@ -41,12 +38,12 @@ import org.meshtastic.core.resources.remove import org.meshtastic.core.resources.remove_node_text import org.meshtastic.core.resources.unmute import org.meshtastic.core.ui.util.AlertManager +import kotlin.coroutines.cancellation.CancellationException @Single open class NodeManagementActions constructor( private val nodeRepository: NodeRepository, - private val serviceRepository: ServiceRepository, private val radioController: RadioController, private val alertManager: AlertManager, ) { @@ -55,19 +52,17 @@ constructor( titleRes = Res.string.remove, messageRes = Res.string.remove_node_text, onConfirm = { - removeNode(scope, node.num) + scope.launch { removeNode(node.num) } onAfterRemove() }, ) } - open fun removeNode(scope: CoroutineScope, nodeNum: Int) { - scope.launch(ioDispatcher) { - Logger.i { "Removing node '$nodeNum'" } - val packetId = radioController.getPacketId() - radioController.removeByNodenum(packetId, nodeNum) - nodeRepository.deleteNode(nodeNum) - } + open suspend fun removeNode(nodeNum: Int) { + Logger.i { "Removing node '$nodeNum'" } + val packetId = radioController.generatePacketId() + radioController.removeByNodenum(packetId, nodeNum) + nodeRepository.deleteNode(nodeNum) } open fun requestIgnoreNode(scope: CoroutineScope, node: Node) { @@ -77,13 +72,13 @@ constructor( alertManager.showAlert( titleRes = Res.string.ignore, message = message, - onConfirm = { ignoreNode(scope, node) }, + onConfirm = { scope.launch { setIgnored(node.num, !node.isIgnored) } }, ) } } - open fun ignoreNode(scope: CoroutineScope, node: Node) { - scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Ignore(node)) } + open suspend fun setIgnored(nodeNum: Int, ignored: Boolean) { + radioController.setIgnored(nodeNum, ignored) } open fun requestMuteNode(scope: CoroutineScope, node: Node) { @@ -93,13 +88,13 @@ constructor( alertManager.showAlert( titleRes = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications, message = message, - onConfirm = { muteNode(scope, node) }, + onConfirm = { scope.launch { toggleMuted(node.num) } }, ) } } - open fun muteNode(scope: CoroutineScope, node: Node) { - scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Mute(node)) } + open suspend fun toggleMuted(nodeNum: Int) { + radioController.toggleMuted(nodeNum) } open fun requestFavoriteNode(scope: CoroutineScope, node: Node) { @@ -112,22 +107,22 @@ constructor( alertManager.showAlert( titleRes = Res.string.favorite, message = message, - onConfirm = { favoriteNode(scope, node) }, + onConfirm = { scope.launch { setFavorite(node.num, !node.isFavorite) } }, ) } } - open fun favoriteNode(scope: CoroutineScope, node: Node) { - scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Favorite(node)) } + open suspend fun setFavorite(nodeNum: Int, favorite: Boolean) { + radioController.setFavorite(nodeNum, favorite) } - open fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) { - scope.launch(ioDispatcher) { - try { - nodeRepository.setNodeNotes(nodeNum, notes) - } catch (ex: Exception) { - Logger.e(ex) { "Set node notes error" } - } + open suspend fun setNodeNotes(nodeNum: Int, notes: String) { + try { + nodeRepository.setNodeNotes(nodeNum, notes) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Logger.e(ex) { "Set node notes error" } } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt index 3c396d8a9b..051811ce13 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt @@ -16,7 +16,6 @@ */ package org.meshtastic.feature.node.detail -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.Position import org.meshtastic.core.model.TelemetryType @@ -26,18 +25,13 @@ interface NodeRequestActions { val lastTracerouteTime: StateFlow val lastRequestNeighborTimes: StateFlow> - fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) + suspend fun requestUserInfo(destNum: Int, longName: String) - fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) + suspend fun requestNeighborInfo(destNum: Int, longName: String) - fun requestPosition( - scope: CoroutineScope, - destNum: Int, - longName: String, - position: Position = Position(0.0, 0.0, 0), - ) + suspend fun requestPosition(destNum: Int, longName: String, position: Position = Position(0.0, 0.0, 0)) - fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) + suspend fun requestTelemetry(destNum: Int, longName: String, type: TelemetryType) - fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) + suspend fun requestTraceroute(destNum: Int, longName: String) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index a6b0914269..6d028991e8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -28,17 +28,17 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.NodeListDensity import org.meshtastic.core.model.NodeSortOption -import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.AdminController +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.DeviceHardwareRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeManagementActions import org.meshtastic.feature.node.detail.NodeRequestActions @@ -52,8 +52,8 @@ class NodeListViewModel( private val savedStateHandle: SavedStateHandle, private val nodeRepository: NodeRepository, private val radioConfigRepository: RadioConfigRepository, - private val serviceRepository: ServiceRepository, - private val radioController: RadioController, + private val connectionStateProvider: ConnectionStateProvider, + private val adminController: AdminController, private val radioInterfaceService: RadioInterfaceService, private val deviceHardwareRepository: DeviceHardwareRepository, val nodeManagementActions: NodeManagementActions, @@ -68,7 +68,7 @@ class NodeListViewModel( val totalNodeCount = nodeRepository.totalNodeCount.stateInWhileSubscribed(initialValue = 0) - val connectionState = serviceRepository.connectionState + val connectionState = connectionStateProvider.connectionState val deviceType: StateFlow = radioInterfaceService.currentDeviceAddressFlow @@ -184,7 +184,7 @@ class NodeListViewModel( radioConfigRepository.replaceAllSettings(channelSet.settings) val newLoraConfig = channelSet.lora_config if (newLoraConfig != null) { - radioController.setLocalConfig(Config(lora = newLoraConfig)) + adminController.setLocalConfig(Config(lora = newLoraConfig)) } } @@ -200,13 +200,13 @@ class NodeListViewModel( fun getDirectMessageRoute(node: Node): String { val ourNode = ourNodeInfo.value val hasPKC = ourNode?.hasPKC == true && node.hasPKC - val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel + val channel = if (hasPKC) NodeAddress.PKC_CHANNEL_INDEX else node.channel return "${channel}${node.user.id}" } /** Initiates a trace route request to the specified node. */ fun traceRoute(node: Node) { - nodeRequestActions.requestTraceroute(viewModelScope, node.num, node.user.long_name) + viewModelScope.launch { nodeRequestActions.requestTraceroute(node.num, node.user.long_name) } } companion object { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 96bdf82c8f..2608b6d32b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import okio.ByteString.Companion.decodeBase64 @@ -51,7 +52,7 @@ import org.meshtastic.core.model.util.UnitConversions import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.TracerouteResponseProvider import org.meshtastic.core.repository.TracerouteSnapshotRepository import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.okay @@ -80,7 +81,7 @@ open class MetricsViewModel( @InjectedParam val destNum: Int, protected val dispatchers: CoroutineDispatchers, private val meshLogRepository: MeshLogRepository, - private val serviceRepository: ServiceRepository, + private val tracerouteResponseProvider: TracerouteResponseProvider, private val nodeRepository: NodeRepository, private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, private val nodeRequestActions: NodeRequestActions, @@ -191,7 +192,7 @@ open class MetricsViewModel( if (cached != null) return cached val overlay = - serviceRepository.tracerouteResponse.value + tracerouteResponseProvider.tracerouteResponse.value ?.takeIf { it.requestId == requestId } ?.let { response -> TracerouteOverlay( @@ -211,7 +212,7 @@ open class MetricsViewModel( fun tracerouteSnapshotPositions(logUuid: String) = tracerouteSnapshotRepository.getSnapshotPositions(logUuid) - fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse() + fun clearTracerouteResponse() = tracerouteResponseProvider.clearTracerouteResponse() fun positionedNodeNums(): Set = nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.numSet() @@ -220,7 +221,7 @@ open class MetricsViewModel( init { safeLaunch(tag = "tracerouteCollector") { - serviceRepository.tracerouteResponse.filterNotNull().collect { response -> + tracerouteResponseProvider.tracerouteResponse.filterNotNull().collect { response -> val overlay = TracerouteOverlay( requestId = response.requestId, @@ -243,25 +244,29 @@ open class MetricsViewModel( fun requestPosition() { (manualNodeId.value ?: nodeIdFromRoute)?.let { - nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.long_name ?: "") + viewModelScope.launch { nodeRequestActions.requestPosition(it, state.value.node?.user?.long_name ?: "") } } } fun requestTelemetry(type: TelemetryType) { (manualNodeId.value ?: nodeIdFromRoute)?.let { - nodeRequestActions.requestTelemetry(viewModelScope, it, state.value.node?.user?.long_name ?: "", type) + viewModelScope.launch { + nodeRequestActions.requestTelemetry(it, state.value.node?.user?.long_name ?: "", type) + } } } fun requestTraceroute() { (manualNodeId.value ?: nodeIdFromRoute)?.let { - nodeRequestActions.requestTraceroute(viewModelScope, it, state.value.node?.user?.long_name ?: "") + viewModelScope.launch { nodeRequestActions.requestTraceroute(it, state.value.node?.user?.long_name ?: "") } } } fun requestNeighborInfo() { (manualNodeId.value ?: nodeIdFromRoute)?.let { - nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.long_name ?: "") + viewModelScope.launch { + nodeRequestActions.requestNeighborInfo(it, state.value.node?.user?.long_name ?: "") + } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt index 62de4973ca..d236740787 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeDetailAction.kt @@ -17,7 +17,6 @@ package org.meshtastic.feature.node.model import org.meshtastic.core.model.Node -import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.navigation.Route import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.proto.Config @@ -25,8 +24,6 @@ import org.meshtastic.proto.Config sealed interface NodeDetailAction { data class Navigate(val route: Route) : NodeDetailAction - data class TriggerServiceAction(val action: ServiceAction) : NodeDetailAction - data class HandleNodeMenuAction(val action: NodeMenuAction) : NodeDetailAction /** Open the remote-administration screen, ensuring a fresh session passkey first. */ diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt index c7504dfe45..276cb4be9f 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/HandleNodeActionTest.kt @@ -34,7 +34,7 @@ import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCas import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.Node import org.meshtastic.core.model.SessionStatus -import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.QueryController import org.meshtastic.core.ui.util.SnackbarManager import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase @@ -51,7 +51,7 @@ class HandleNodeActionTest { private val testDispatcher = UnconfinedTestDispatcher() private val nodeManagementActions: NodeManagementActions = mock() private val nodeRequestActions: NodeRequestActions = mock() - private val serviceRepository: ServiceRepository = mock() + private val queryController: QueryController = mock() private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock() private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock() @@ -93,7 +93,7 @@ class HandleNodeActionTest { savedStateHandle = SavedStateHandle(mapOf("destNum" to 1234)), nodeManagementActions = nodeManagementActions, nodeRequestActions = nodeRequestActions, - serviceRepository = serviceRepository, + queryController = queryController, getNodeDetailsUseCase = getNodeDetailsUseCase, ensureRemoteAdminSession = ensureRemoteAdminSession, observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus, diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt index fe15acfe4e..e1bf663be2 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt @@ -42,7 +42,7 @@ import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatu import org.meshtastic.core.model.Node import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.navigation.SettingsRoute -import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.QueryController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.connect_radio_for_remote_admin @@ -64,7 +64,7 @@ class NodeDetailViewModelTest { private lateinit var viewModel: NodeDetailViewModel private val nodeManagementActions: NodeManagementActions = mock() private val nodeRequestActions: NodeRequestActions = mock() - private val serviceRepository: ServiceRepository = mock() + private val queryController: QueryController = mock() private val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock() private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase = mock() private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock() @@ -97,7 +97,7 @@ class NodeDetailViewModelTest { savedStateHandle = SavedStateHandle(if (nodeId != null) mapOf("destNum" to nodeId) else emptyMap()), nodeManagementActions = nodeManagementActions, nodeRequestActions = nodeRequestActions, - serviceRepository = serviceRepository, + queryController = queryController, getNodeDetailsUseCase = getNodeDetailsUseCase, ensureRemoteAdminSession = ensureRemoteAdminSession, observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus, @@ -158,11 +158,11 @@ class NodeDetailViewModelTest { @Test fun `handleNodeMenuAction delegates to nodeRequestActions for Traceroute`() = runTest(testDispatcher) { val node = Node(num = 1234, user = User(id = "!1234", long_name = "Test Node")) - every { nodeRequestActions.requestTraceroute(any(), any(), any()) } returns Unit + everySuspend { nodeRequestActions.requestTraceroute(any(), any()) } returns Unit viewModel.handleNodeMenuAction(NodeMenuAction.TraceRoute(node)) - verify { nodeRequestActions.requestTraceroute(any(), 1234, "Test Node") } + verifySuspend { nodeRequestActions.requestTraceroute(1234, "Test Node") } } @Test diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 4e65cf2907..fcfb7ce58b 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import org.meshtastic.core.model.Node -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.core.ui.util.AlertManager @@ -36,7 +35,6 @@ import kotlin.test.assertTrue class NodeManagementActionsTest { private val nodeRepository = FakeNodeRepository() - private val serviceRepository = mock(MockMode.autofill) private val radioController = FakeRadioController() private val alertManager = mock(MockMode.autofill) private val testDispatcher = StandardTestDispatcher() @@ -45,7 +43,6 @@ class NodeManagementActionsTest { private val actions = NodeManagementActions( nodeRepository = nodeRepository, - serviceRepository = serviceRepository, radioController = radioController, alertManager = alertManager, ) @@ -77,7 +74,6 @@ class NodeManagementActionsTest { val actionsWithRealAlert = NodeManagementActions( nodeRepository = nodeRepository, - serviceRepository = serviceRepository, radioController = radioController, alertManager = realAlertManager, ) diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt index deb3363076..eb9ff41a32 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt @@ -28,8 +28,8 @@ import kotlinx.coroutines.test.runTest import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.testing.FakeDeviceHardwareRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeRadioController @@ -50,7 +50,7 @@ class NodeListViewModelTest { private lateinit var radioController: FakeRadioController private lateinit var radioInterfaceService: FakeRadioInterfaceService private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) - private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val connectionStateProvider: ConnectionStateProvider = mock(MockMode.autofill) private val nodeFilterPreferences: NodeFilterPreferences = mock(MockMode.autofill) private val nodeManagementActions: NodeManagementActions = mock(MockMode.autofill) private val nodeRequestActions: NodeRequestActions = mock(MockMode.autofill) @@ -64,7 +64,7 @@ class NodeListViewModelTest { every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig()) every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(org.meshtastic.proto.DeviceProfile()) - every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) + every { connectionStateProvider.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) every { nodeFilterPreferences.nodeSortOption } returns MutableStateFlow(NodeSortOption.LAST_HEARD) every { nodeFilterPreferences.includeUnknown } returns MutableStateFlow(true) @@ -83,8 +83,8 @@ class NodeListViewModelTest { savedStateHandle = SavedStateHandle(), nodeRepository = nodeRepository, radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, - radioController = radioController, + connectionStateProvider = connectionStateProvider, + adminController = radioController, radioInterfaceService = radioInterfaceService, deviceHardwareRepository = FakeDeviceHardwareRepository(), nodeManagementActions = nodeManagementActions, @@ -120,7 +120,7 @@ class NodeListViewModelTest { @Test fun `connectionState reflects serviceRepository state`() = runTest { val stateFlow = MutableStateFlow(ConnectionState.Disconnected) - every { serviceRepository.connectionState } returns stateFlow + every { connectionStateProvider.connectionState } returns stateFlow val vm = createViewModel() vm.connectionState.test { diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt index 13a72eaf68..c520ac9d46 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -40,7 +40,7 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.TracerouteResponseProvider import org.meshtastic.core.repository.TracerouteSnapshotRepository import org.meshtastic.feature.node.detail.NodeDetailUiState import org.meshtastic.feature.node.detail.NodeRequestActions @@ -66,7 +66,7 @@ class MetricsViewModelTest { private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) private val meshLogRepository: MeshLogRepository = mock() - private val serviceRepository: ServiceRepository = mock() + private val tracerouteResponseProvider: TracerouteResponseProvider = mock() private val nodeRepository: NodeRepository = mock() private val tracerouteSnapshotRepository: TracerouteSnapshotRepository = mock() private val nodeRequestActions: NodeRequestActions = mock() @@ -81,7 +81,7 @@ class MetricsViewModelTest { Dispatchers.setMain(testDispatcher) // Default setup for flows - every { serviceRepository.tracerouteResponse } returns MutableStateFlow(null) + every { tracerouteResponseProvider.tracerouteResponse } returns MutableStateFlow(null) every { nodeRequestActions.lastTracerouteTime } returns MutableStateFlow(null) every { nodeRequestActions.lastRequestNeighborTimes } returns MutableStateFlow(emptyMap()) every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) @@ -96,7 +96,7 @@ class MetricsViewModelTest { destNum = destNum, dispatchers = dispatchers, meshLogRepository = meshLogRepository, - serviceRepository = serviceRepository, + tracerouteResponseProvider = tracerouteResponseProvider, nodeRepository = nodeRepository, tracerouteSnapshotRepository = tracerouteSnapshotRepository, nodeRequestActions = nodeRequestActions, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index fa54c8992e..1c846f1b2a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt @@ -32,24 +32,17 @@ import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase -import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase -import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase -import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase -import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase -import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCase -import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase -import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeListDensity -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.MeshLogPrefs import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed @@ -66,14 +59,7 @@ class SettingsViewModel( private val databaseManager: DatabaseManager, private val meshLogPrefs: MeshLogPrefs, private val notificationPrefs: NotificationPrefs, - private val setThemeUseCase: SetThemeUseCase, - private val setLocaleUseCase: SetLocaleUseCase, - private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, - private val setProvideLocationUseCase: SetProvideLocationUseCase, - private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase, private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase, - private val setNotificationSettingsUseCase: SetNotificationSettingsUseCase, - private val meshLocationUseCase: MeshLocationUseCase, private val exportDataUseCase: ExportDataUseCase, private val isOtaCapableUseCase: IsOtaCapableUseCase, private val fileService: FileService, @@ -106,11 +92,11 @@ class SettingsViewModel( .stateInWhileSubscribed(initialValue = false) fun startProvidingLocation() { - meshLocationUseCase.startProvidingLocation() + radioController.startProvideLocation() } fun stopProvidingLocation() { - meshLocationUseCase.stopProvidingLocation() + radioController.stopProvideLocation() } private val _excludedModulesUnlocked = MutableStateFlow(false) @@ -125,7 +111,7 @@ class SettingsViewModel( val dbCacheLimit: StateFlow = databaseManager.cacheLimit fun setDbCacheLimit(limit: Int) { - setDatabaseCacheLimitUseCase(limit) + databaseManager.setCacheLimit(limit) } // Notifications @@ -133,11 +119,11 @@ class SettingsViewModel( val nodeEventsEnabled = notificationPrefs.nodeEventsEnabled val lowBatteryEnabled = notificationPrefs.lowBatteryEnabled - fun setMessagesEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setMessagesEnabled(enabled) + fun setMessagesEnabled(enabled: Boolean) = notificationPrefs.setMessagesEnabled(enabled) - fun setNodeEventsEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setNodeEventsEnabled(enabled) + fun setNodeEventsEnabled(enabled: Boolean) = notificationPrefs.setNodeEventsEnabled(enabled) - fun setLowBatteryEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setLowBatteryEnabled(enabled) + fun setLowBatteryEnabled(enabled: Boolean) = notificationPrefs.setLowBatteryEnabled(enabled) // MeshLog retention period (bounded by MeshLogPrefsImpl constants) private val _meshLogRetentionDays = MutableStateFlow(meshLogPrefs.retentionDays.value) @@ -157,20 +143,20 @@ class SettingsViewModel( } fun setProvideLocation(value: Boolean) { - myNodeNum?.let { setProvideLocationUseCase(it, value) } + myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) } } fun setTheme(theme: Int) { - setThemeUseCase(theme) + uiPrefs.setTheme(theme) } /** Set the application locale. Empty string means system default. */ fun setLocale(languageTag: String) { - setLocaleUseCase(languageTag) + uiPrefs.setLocale(languageTag) } fun showAppIntro() { - setAppIntroCompletedUseCase(false) + uiPrefs.setAppIntroCompleted(false) } fun unlockExcludedModules() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt index 136131241e..1d8b542872 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt @@ -22,11 +22,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.util.toChannelSet import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.ui.util.getChannelList import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 4b9fdcdd1b..796e0cc8f6 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -46,8 +46,6 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase import org.meshtastic.core.domain.usecase.settings.RadioResponseResult -import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase -import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MqttConnectionState import org.meshtastic.core.model.MqttProbeStatus @@ -123,8 +121,6 @@ open class RadioConfigViewModel( private val mapConsentPrefs: MapConsentPrefs, private val analyticsPrefs: AnalyticsPrefs, private val homoglyphEncodingPrefs: HomoglyphPrefs, - private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase, - private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase, protected val importProfileUseCase: ImportProfileUseCase, protected val exportProfileUseCase: ExportProfileUseCase, protected val exportSecurityConfigUseCase: ExportSecurityConfigUseCase, @@ -139,13 +135,13 @@ open class RadioConfigViewModel( val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed fun toggleAnalyticsAllowed() { - toggleAnalyticsUseCase() + analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value) } val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.homoglyphEncodingEnabled fun toggleHomoglyphCharactersEncodingEnabled() { - toggleHomoglyphEncodingUseCase() + homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value) } /** MQTT proxy connection state for the settings UI. */ diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index 1f010b4381..f39ee49197 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt @@ -46,14 +46,7 @@ import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.util.CommonUri import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase -import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase -import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase -import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase -import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase -import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCase -import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase -import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MeshLog import org.meshtastic.core.repository.FileService @@ -110,14 +103,7 @@ class SettingsViewModelTest { every { isOtaCapableUseCase() } returns flowOf(true) val uiPrefs = appPreferences.ui - val setThemeUseCase = SetThemeUseCase(uiPrefs) - val setLocaleUseCase = SetLocaleUseCase(uiPrefs) - val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs) - val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs) - val setDatabaseCacheLimitUseCase = SetDatabaseCacheLimitUseCase(databaseManager) val setMeshLogSettingsUseCase = SetMeshLogSettingsUseCase(meshLogRepository, appPreferences.meshLog) - val setNotificationSettingsUseCase = SetNotificationSettingsUseCase(notificationPrefs) - val meshLocationUseCase = MeshLocationUseCase(radioController) val exportDataUseCase = ExportDataUseCase(nodeRepository, meshLogRepository) viewModel = @@ -130,14 +116,7 @@ class SettingsViewModelTest { databaseManager = databaseManager, meshLogPrefs = appPreferences.meshLog, notificationPrefs = notificationPrefs, - setThemeUseCase = setThemeUseCase, - setLocaleUseCase = setLocaleUseCase, - setAppIntroCompletedUseCase = setAppIntroCompletedUseCase, - setProvideLocationUseCase = setProvideLocationUseCase, - setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase, setMeshLogSettingsUseCase = setMeshLogSettingsUseCase, - setNotificationSettingsUseCase = setNotificationSettingsUseCase, - meshLocationUseCase = meshLocationUseCase, exportDataUseCase = exportDataUseCase, isOtaCapableUseCase = isOtaCapableUseCase, fileService = fileService, @@ -158,15 +137,15 @@ class SettingsViewModelTest { @Test fun `isConnected flow emits updates using Turbine`() = runTest { viewModel.isConnected.test { - expectMostRecentItem() shouldBe true // Default in FakeRadioController is Connected (true) - - radioController.setConnectionState(ConnectionState.Disconnected) - runCurrent() - expectMostRecentItem() shouldBe false + expectMostRecentItem() shouldBe false // Default in FakeRadioController is Disconnected radioController.setConnectionState(ConnectionState.Connected) runCurrent() expectMostRecentItem() shouldBe true + + radioController.setConnectionState(ConnectionState.Disconnected) + runCurrent() + expectMostRecentItem() shouldBe false cancelAndIgnoreRemainingEvents() } } @@ -224,7 +203,7 @@ class SettingsViewModelTest { } @Test - fun `meshLocationUseCase calls work`() { + fun `startProvidingLocation and stopProvidingLocation delegate to RadioController`() { viewModel.startProvidingLocation() radioController.startProvideLocationCalled shouldBe true diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt index 8990246bf9..5371ed5e35 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt @@ -41,8 +41,6 @@ import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase -import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase -import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.repository.AnalyticsPrefs import org.meshtastic.core.repository.FileService @@ -82,8 +80,6 @@ class ProfileRoundTripTest { private val mapConsentPrefs: MapConsentPrefs = mock(MockMode.autofill) private val analyticsPrefs: AnalyticsPrefs = mock(MockMode.autofill) private val homoglyphEncodingPrefs: HomoglyphPrefs = mock(MockMode.autofill) - private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mock(MockMode.autofill) - private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mock(MockMode.autofill) private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mock(MockMode.autofill) private val installProfileUseCase: InstallProfileUseCase = mock(MockMode.autofill) private val radioConfigUseCase: RadioConfigUseCase = mock(MockMode.autofill) @@ -125,8 +121,6 @@ class ProfileRoundTripTest { mapConsentPrefs = mapConsentPrefs, analyticsPrefs = analyticsPrefs, homoglyphEncodingPrefs = homoglyphEncodingPrefs, - toggleAnalyticsUseCase = toggleAnalyticsUseCase, - toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase, importProfileUseCase = ImportProfileUseCase(), exportProfileUseCase = ExportProfileUseCase(), exportSecurityConfigUseCase = exportSecurityConfigUseCase, diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 0375d390f5..47e214f1b8 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -45,8 +45,6 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase import org.meshtastic.core.domain.usecase.settings.RadioResponseResult -import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase -import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.Node import org.meshtastic.core.repository.AnalyticsPrefs @@ -91,8 +89,6 @@ class RadioConfigViewModelTest { private val analyticsPrefs: AnalyticsPrefs = mock(MockMode.autofill) private val homoglyphEncodingPrefs: HomoglyphPrefs = mock(MockMode.autofill) - private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mock(MockMode.autofill) - private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mock(MockMode.autofill) private val importProfileUseCase: ImportProfileUseCase = mock(MockMode.autofill) private val exportProfileUseCase: ExportProfileUseCase = mock(MockMode.autofill) private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mock(MockMode.autofill) @@ -148,8 +144,6 @@ class RadioConfigViewModelTest { mapConsentPrefs = mapConsentPrefs, analyticsPrefs = analyticsPrefs, homoglyphEncodingPrefs = homoglyphEncodingPrefs, - toggleAnalyticsUseCase = toggleAnalyticsUseCase, - toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase, importProfileUseCase = importProfileUseCase, exportProfileUseCase = exportProfileUseCase, exportSecurityConfigUseCase = exportSecurityConfigUseCase, @@ -183,21 +177,23 @@ class RadioConfigViewModelTest { } @Test - fun `toggleAnalyticsAllowed calls useCase`() { - every { toggleAnalyticsUseCase() } returns Unit + fun `toggleAnalyticsAllowed calls prefs`() { + every { analyticsPrefs.analyticsAllowed } returns MutableStateFlow(true) + every { analyticsPrefs.setAnalyticsAllowed(false) } returns Unit viewModel.toggleAnalyticsAllowed() - verify { toggleAnalyticsUseCase() } + verify { analyticsPrefs.setAnalyticsAllowed(false) } } @Test - fun `toggleHomoglyphCharactersEncodingEnabled calls useCase`() { - every { toggleHomoglyphEncodingUseCase() } returns Unit + fun `toggleHomoglyphCharactersEncodingEnabled calls prefs`() { + every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(true) + every { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(false) } returns Unit viewModel.toggleHomoglyphCharactersEncodingEnabled() - verify { toggleHomoglyphEncodingUseCase() } + verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(false) } } @Test @@ -415,8 +411,6 @@ class RadioConfigViewModelTest { mapConsentPrefs = mapConsentPrefs, analyticsPrefs = analyticsPrefs, homoglyphEncodingPrefs = homoglyphEncodingPrefs, - toggleAnalyticsUseCase = toggleAnalyticsUseCase, - toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase, importProfileUseCase = importProfileUseCase, exportProfileUseCase = exportProfileUseCase, exportSecurityConfigUseCase = exportSecurityConfigUseCase, diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt index f86acb8c10..0dfefd6d06 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt @@ -32,8 +32,8 @@ import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.onlineTimeThreshold +import org.meshtastic.core.repository.ConnectionStateProvider import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.LocalStats data class LocalStatsWidgetUiState( @@ -77,13 +77,13 @@ data class LocalStatsWidgetUiState( ) @Single -class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepository: ServiceRepository) { +class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, connectionStateProvider: ConnectionStateProvider) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) val state: StateFlow = combine( - serviceRepository.connectionState, + connectionStateProvider.connectionState, nodeRepository.nodeDBbyNum .map { nodes -> val online = nodes.values.count { it.lastHeard > onlineTimeThreshold() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 926272e0d4..2195424aea 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -284,7 +284,6 @@ jna = { module = "net.java.dev.jna:jna", version = "5.18.1" } # TAK takpacket-sdk-kmp = { module = "org.meshtastic:takpacket-sdk", version.ref = "takpacket-sdk" } -takpacket-sdk-jvm = { module = "org.meshtastic:takpacket-sdk-jvm", version.ref = "takpacket-sdk" } [plugins] @@ -297,7 +296,6 @@ compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "com compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } diff --git a/jitpack.yml b/jitpack.yml index b3935efcbd..a261188b35 100644 --- a/jitpack.yml +++ b/jitpack.yml @@ -3,5 +3,5 @@ jdk: before_install: - ./gradlew --stop install: - - ./gradlew :core:proto:publishToMavenLocal :core:model:publishToMavenLocal :core:api:publishToMavenLocal --no-daemon --stacktrace -Dorg.gradle.jvmargs="-Xmx4g -XX:+UseParallelGC" + - ./gradlew :core:proto:publishToMavenLocal :core:model:publishToMavenLocal --no-daemon --stacktrace -Dorg.gradle.jvmargs="-Xmx4g -XX:+UseParallelGC" group: org.meshtastic diff --git a/settings.gradle.kts b/settings.gradle.kts index 1f6629f922..e3f695fe24 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -129,7 +129,6 @@ include( ":feature:wifi-provision", ":desktopApp", ":androidApp", - ":core:api", ":core:barcode", ":feature:widget", ":screenshot-tests",