From 69883e9306d4a5e7c0b90b299b5386ca2d916337 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 23 May 2026 13:45:11 -0500 Subject: [PATCH 01/34] refactor: remove AIDL API and modernize radio architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the deprecated AIDL/IPC API surface and perform deep architectural modernization of the radio command pipeline, aligning with the meshtastic-sdk AdminApiImpl pattern for future SDK migration. Key changes: 1. AIDL Removal & Infrastructure Cleanup - Delete core:api module and all AIDL interfaces - Remove ServiceBroadcasts + CommonParcelable infrastructure - Remove core:api from CI workflow lint/publish steps 2. Model Modernization - Introduce NodeAddress sealed class with type-safe addressing - Remove deprecated DataPacket constants in favor of NodeAddress - Consolidate dual node maps into single source with getNodeById - Split large model files, deduplicate NodeEntity, flatten RadioController 3. Service Layer Refactoring (SDK-aligned) - Remove ServiceAction sealed class, use direct suspend calls - Convert CommandSender & MeshActionHandler to suspend APIs - Merge MeshActionHandler into DirectRadioControllerImpl (ViewModel → RadioController → CommandSender, no intermediate layer) - Build AdminMessage protos directly with typed protos end-to-end - Apply structured concurrency to NodeRequestActions/NodeManagementActions - Fix CancellationException handling throughout Architecture (before → after): ViewModel → RadioController → Handler → CommandSender → PacketHandler ViewModel → RadioController → CommandSender → PacketHandler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 9 +- .../kotlin/org/meshtastic/app/map/MapView.kt | 3 +- .../org/meshtastic/app/map/MapViewModel.kt | 4 +- androidApp/src/main/AndroidManifest.xml | 10 +- .../kotlin/org/meshtastic/app/MainActivity.kt | 17 +- .../org/meshtastic/app/service/Fakes.kt | 4 +- .../src/main/kotlin/RootConventionPlugin.kt | 5 +- codecov.yml | 2 - core/api/README.md | 79 --- core/api/build.gradle.kts | 52 -- core/api/src/main/AndroidManifest.xml | 1 - .../org/meshtastic/core/model/DataPacket.aidl | 3 - .../org/meshtastic/core/model/MeshUser.aidl | 3 - .../org/meshtastic/core/model/MyNodeInfo.aidl | 3 - .../org/meshtastic/core/model/NodeInfo.aidl | 3 - .../org/meshtastic/core/model/Position.aidl | 3 - .../meshtastic/core/service/IMeshService.aidl | 207 ------ .../meshtastic/core/api/MeshtasticIntent.kt | 85 --- core/common/build.gradle.kts | 1 - .../core/common/util/CompatExtensions.kt | 18 - .../core/common/util/ExceptionsAndroid.kt | 34 - .../core/common/util/Parcelable.android.kt | 31 - .../meshtastic/core/common/util/Parcelable.kt | 58 -- .../meshtastic/core/common/util/NoopStubs.kt | 35 -- .../core/common/util/Parcelable.jvm.kt | 55 -- .../core/data/manager/CommandSenderImpl.kt | 56 +- .../core/data/manager/HistoryManagerImpl.kt | 2 +- .../data/manager/MeshActionHandlerImpl.kt | 404 ------------ .../data/manager/MeshConfigFlowManagerImpl.kt | 5 +- .../data/manager/MeshConnectionManagerImpl.kt | 13 +- .../core/data/manager/MeshDataHandlerImpl.kt | 86 +-- .../data/manager/MeshMessageProcessorImpl.kt | 8 +- .../core/data/manager/MeshRouterImpl.kt | 5 - .../data/manager/NeighborInfoHandlerImpl.kt | 5 - .../core/data/manager/NodeManagerImpl.kt | 87 +-- .../core/data/manager/PacketHandlerImpl.kt | 34 +- .../manager/StoreForwardPacketHandlerImpl.kt | 8 +- .../data/repository/NodeRepositoryImpl.kt | 10 +- .../data/repository/PacketRepositoryImpl.kt | 13 +- .../data/manager/MeshActionHandlerImplTest.kt | 587 ------------------ .../manager/MeshConfigFlowManagerImplTest.kt | 12 +- .../manager/MeshConnectionManagerImplTest.kt | 18 +- .../core/data/manager/MeshDataHandlerTest.kt | 82 +-- .../manager/MeshMessageProcessorImplTest.kt | 4 +- .../core/data/manager/MeshRouterImplTest.kt | 60 +- .../core/data/manager/NodeManagerImplTest.kt | 22 +- .../data/manager/PacketHandlerImplTest.kt | 3 - .../manager/TelemetryPacketHandlerImplTest.kt | 15 +- .../StoreForwardPacketHandlerImplTest.kt | 9 +- .../core/database/dao/MigrationTest.kt | 3 +- .../core/database/entity/NodeEntity.kt | 77 +-- .../meshtastic/core/database/entity/Packet.kt | 3 +- .../core/database/ConvertersTest.kt | 3 +- .../core/database/dao/CommonPacketDaoTest.kt | 17 +- core/domain/README.md | 1 - .../EnsureRemoteAdminSessionUseCase.kt | 7 +- .../usecase/settings/MeshLocationUseCase.kt | 34 - .../settings/SetAppIntroCompletedUseCase.kt | 27 - .../settings/SetDatabaseCacheLimitUseCase.kt | 30 - .../usecase/settings/SetLocaleUseCase.kt | 27 - .../SetNotificationSettingsUseCase.kt | 30 - .../settings/SetProvideLocationUseCase.kt | 27 - .../usecase/settings/SetThemeUseCase.kt | 27 - .../settings/ToggleAnalyticsUseCase.kt | 28 - .../ToggleHomoglyphEncodingUseCase.kt | 28 - .../EnsureRemoteAdminSessionUseCaseTest.kt | 30 +- .../settings/MeshLocationUseCaseTest.kt | 46 -- .../SetDatabaseCacheLimitUseCaseTest.kt | 49 -- .../SetNotificationSettingsUseCaseTest.kt | 58 -- .../settings/ToggleAnalyticsUseCaseTest.kt | 48 -- .../ToggleHomoglyphEncodingUseCaseTest.kt | 48 -- core/model/README.md | 2 +- core/model/build.gradle.kts | 1 - .../meshtastic/core/model/AdminController.kt | 117 ++++ .../org/meshtastic/core/model/Contact.kt | 6 +- .../org/meshtastic/core/model/DataPacket.kt | 90 +-- .../meshtastic/core/model/DeviceMetrics.kt | 46 ++ .../core/model/EnvironmentMetrics.kt | 55 ++ .../org/meshtastic/core/model/MeshUser.kt | 54 ++ .../core/model/MessagingController.kt | 45 ++ .../org/meshtastic/core/model/MyNodeInfo.kt | 6 +- .../kotlin/org/meshtastic/core/model/Node.kt | 2 +- .../org/meshtastic/core/model/NodeAddress.kt | 134 ++++ .../meshtastic/core/model/NodeController.kt | 40 ++ .../org/meshtastic/core/model/NodeInfo.kt | 275 -------- .../org/meshtastic/core/model/Position.kt | 76 +++ .../meshtastic/core/model/RadioController.kt | 292 +-------- .../core/model/RequestController.kt | 46 ++ .../core/model/service/ServiceAction.kt | 49 -- .../core/model/util/ByteStringSerializer.kt | 11 - .../core/model/util/MeshDataMapper.kt | 3 +- .../core/model/util/WireExtensions.kt | 2 +- .../meshtastic/core/model/DataPacketTest.kt | 4 +- .../core/model/util/MeshDataMapperTest.kt | 6 +- .../core/network/repository/UsbManager.kt | 2 +- .../core/network/radio/MockRadioTransport.kt | 4 +- core/repository/README.md | 1 - .../core/repository/CommandSender.kt | 18 +- .../core/repository/HistoryManager.kt | 2 +- .../core/repository/MeshActionHandler.kt | 119 ---- .../core/repository/MeshConnectionManager.kt | 2 +- .../core/repository/MeshLocationManager.kt | 10 +- ...ications.kt => MeshNotificationManager.kt} | 2 +- .../meshtastic/core/repository/MeshRouter.kt | 3 - .../meshtastic/core/repository/NodeManager.kt | 12 +- .../core/repository/PacketHandler.kt | 2 +- .../core/repository/ServiceBroadcasts.kt | 39 -- .../core/repository/ServiceRepository.kt | 11 - .../repository/usecase/SendMessageUseCase.kt | 7 +- .../usecase/SendMessageUseCaseTest.kt | 10 +- core/service/README.md | 5 +- core/service/build.gradle.kts | 1 - .../core/service/IMeshServiceContractTest.kt | 42 -- ....kt => MeshNotificationManagerImplTest.kt} | 4 +- .../core/service/ServiceBroadcastsTest.kt | 135 ---- .../service/AndroidMeshLocationManager.kt | 10 +- .../service/AndroidNotificationManager.kt | 4 +- .../service/AndroidRadioControllerImpl.kt | 223 ------- .../core/service/AndroidServiceRepository.kt | 20 +- .../org/meshtastic/core/service/Constants.kt | 39 -- .../core/service/MarkAsReadReceiver.kt | 6 +- ...Impl.kt => MeshNotificationManagerImpl.kt} | 16 +- .../meshtastic/core/service/MeshService.kt | 261 +------- .../core/service/MeshServiceClient.kt | 105 ---- .../core/service/ReactionReceiver.kt | 12 +- .../meshtastic/core/service/ReplyReceiver.kt | 4 +- .../core/service/ServiceBroadcasts.kt | 164 ----- .../meshtastic/core/service/ServiceClient.kt | 145 ----- .../service/di/CoreServiceAndroidModule.kt | 65 +- .../core/service/testing/FakeIMeshService.kt | 128 ---- .../service/worker/ServiceKeepAliveWorker.kt | 6 +- .../core/service/DirectRadioControllerImpl.kt | 351 ++++++++--- .../core/service/MeshServiceOrchestrator.kt | 11 +- .../core/service/ServiceRepositoryImpl.kt | 12 +- .../service/DirectRadioControllerImplTest.kt | 195 ++++-- .../service/MeshServiceOrchestratorTest.kt | 26 +- .../core/service/ServiceRepositoryImplTest.kt | 15 - .../core/takserver/TAKMeshIntegration.kt | 9 +- .../core/takserver/TakMeshTestRunner.kt | 5 +- .../core/takserver/TAKMeshIntegrationTest.kt | 28 +- .../core/testing/FakeDatabaseManager.kt | 4 +- ...ions.kt => FakeMeshNotificationManager.kt} | 6 +- .../core/testing/FakeMeshService.kt | 2 +- .../core/testing/FakeRadioController.kt | 10 + .../core/testing/FakeServiceRepository.kt | 8 - .../core/ui/share/SharedContactViewModel.kt | 7 +- .../ui/share/SharedContactViewModelTest.kt | 8 +- .../desktop/di/DesktopKoinModule.kt | 21 +- ...s.kt => DesktopMeshNotificationManager.kt} | 6 +- .../org/meshtastic/desktop/stub/NoopStubs.kt | 20 +- .../feature/map/BaseMapViewModel.kt | 5 +- .../feature/map/BaseMapViewModelTest.kt | 3 +- .../meshtastic/feature/messaging/Message.kt | 6 +- .../feature/messaging/MessageListPaged.kt | 3 +- .../feature/messaging/MessageViewModel.kt | 16 +- .../component/MessageScreenComponents.kt | 4 +- .../feature/messaging/component/Reaction.kt | 8 +- .../messaging/ui/contact/ContactsViewModel.kt | 27 +- .../feature/messaging/MessageViewModelTest.kt | 10 +- .../node/component/NodeDetailsSection.kt | 4 +- .../node/detail/CommonNodeRequestActions.kt | 83 ++- .../feature/node/detail/HandleNodeAction.kt | 2 - .../feature/node/detail/NodeDetailActions.kt | 83 --- .../node/detail/NodeDetailViewModel.kt | 46 +- .../node/detail/NodeManagementActions.kt | 51 +- .../feature/node/detail/NodeRequestActions.kt | 16 +- .../feature/node/list/NodeListViewModel.kt | 6 +- .../feature/node/metrics/MetricsViewModel.kt | 13 +- .../feature/node/model/NodeDetailAction.kt | 3 - .../node/detail/HandleNodeActionTest.kt | 6 +- .../node/detail/NodeDetailViewModelTest.kt | 10 +- .../node/detail/NodeManagementActionsTest.kt | 4 - .../feature/settings/SettingsViewModel.kt | 34 +- .../settings/radio/RadioConfigViewModel.kt | 8 +- .../feature/settings/SettingsViewModelTest.kt | 23 +- .../settings/radio/ProfileRoundTripTest.kt | 6 - .../radio/RadioConfigViewModelTest.kt | 22 +- jitpack.yml | 2 +- settings.gradle.kts | 1 - 179 files changed, 1709 insertions(+), 5462 deletions(-) delete mode 100644 core/api/README.md delete mode 100644 core/api/build.gradle.kts delete mode 100644 core/api/src/main/AndroidManifest.xml delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl delete mode 100644 core/api/src/main/kotlin/org/meshtastic/core/api/MeshtasticIntent.kt delete mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/ExceptionsAndroid.kt delete mode 100644 core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/Parcelable.android.kt delete mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Parcelable.kt delete mode 100644 core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/Parcelable.jvm.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminController.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceMetrics.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/EnvironmentMetrics.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshUser.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessagingController.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeAddress.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeController.kt delete mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/Position.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/RequestController.kt delete mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/service/ServiceAction.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt rename core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/{MeshServiceNotifications.kt => MeshNotificationManager.kt} (98%) delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt delete mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt rename core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/{MeshServiceNotificationsImplTest.kt => MeshNotificationManagerImplTest.kt} (97%) delete mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt rename core/service/src/androidMain/kotlin/org/meshtastic/core/service/{MeshServiceNotificationsImpl.kt => MeshNotificationManagerImpl.kt} (98%) delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceClient.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt rename core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/{FakeMeshServiceNotifications.kt => FakeMeshNotificationManager.kt} (91%) rename desktopApp/src/main/kotlin/org/meshtastic/desktop/notification/{DesktopMeshServiceNotifications.kt => DesktopMeshNotificationManager.kt} (96%) delete mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt 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/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index cebaf39316..006969ad66 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -87,6 +87,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 @@ -433,7 +434,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 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..679e42df8f 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -50,6 +50,7 @@ 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.NodeAddress import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.NodeRepository @@ -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 d6d296ea19..46692ee805 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -161,16 +161,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/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/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/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/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/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..30de98254a 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,7 +34,6 @@ 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.proto.DeviceMetadata import org.meshtastic.proto.FileInfo @@ -52,7 +51,6 @@ class MeshConfigFlowManagerImpl( private val nodeRepository: NodeRepository, private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, - private val serviceBroadcasts: ServiceBroadcasts, 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" } @@ -191,7 +189,6 @@ class MeshConfigFlowManagerImpl( nodeManager.setNodeDbReady(true) nodeManager.setAllowNodeDbWrites(true) serviceRepository.setConnectionState(ConnectionState.Connected) - serviceBroadcasts.broadcastConnection() connectionManager.value.onNodeDbReady() } } 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..f23822c0d3 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,7 +53,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 @@ -84,9 +88,8 @@ class MeshDataHandlerImpl( private val packetHandler: PacketHandler, private val serviceRepository: ServiceRepository, 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) { @@ -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..edb698737a 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 @@ -217,10 +217,8 @@ class MeshMessageProcessorImpl( 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 @@ -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 index fe58735da6..64c07d17b1 100644 --- 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 @@ -17,7 +17,6 @@ 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 @@ -37,7 +36,6 @@ class MeshRouterImpl( 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 @@ -58,9 +56,6 @@ class MeshRouterImpl( 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/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 2975341cca..4d22c566c0 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 @@ -26,7 +26,6 @@ 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.proto.MeshPacket import org.meshtastic.proto.NeighborInfo @@ -35,7 +34,6 @@ import org.meshtastic.proto.NeighborInfo class NeighborInfoHandlerImpl( private val nodeManager: NodeManager, private val serviceRepository: ServiceRepository, - private val serviceBroadcasts: ServiceBroadcasts, private val nodeRepository: NodeRepository, ) : NeighborInfoHandler { @@ -58,9 +56,6 @@ 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] 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..bb5e140d4f 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 @@ -28,20 +28,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 +54,16 @@ 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()) override val nodeDBbyNodeNum: Map get() = _nodeDBbyNodeNum.value - override val nodeDBbyID: Map - get() = _nodeDBbyID.value + override fun getNodeById(id: String): Node? = _nodeDBbyNodeNum.value.values.firstOrNull { it.user.id == id } override val isNodeDbReady = MutableStateFlow(false) override val allowNodeDbWrites = MutableStateFlow(false) @@ -105,9 +96,6 @@ class NodeManagerImpl( 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) if (myNodeNum.value == null) { myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum } @@ -116,7 +104,6 @@ class NodeManagerImpl( override fun clear() { _nodeDBbyNodeNum.value = persistentMapOf() - _nodeDBbyID.value = persistentMapOf() isNodeDbReady.value = false allowNodeDbWrites.value = false myNodeNum.value = null @@ -149,21 +136,13 @@ class NodeManagerImpl( return _nodeDBbyNodeNum.value[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) } } + _nodeDBbyNodeNum.update { it.remove(nodeNum) } } internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = _nodeDBbyNodeNum.value[n] ?: run { - val userId = DataPacket.nodeNumToDefaultId(n) + val userId = NodeAddress.numToDefaultId(n) val defaultUser = User( id = userId, @@ -175,7 +154,7 @@ 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. @@ -187,17 +166,10 @@ class NodeManagerImpl( map.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 +259,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 +306,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) + _nodeDBbyNodeNum.value[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..ad3f7fda83 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 @@ -47,7 +45,6 @@ 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 @@ -61,7 +58,6 @@ 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, @@ -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,12 @@ 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) + override suspend fun sendToRadio(packet: MeshPacket) { + queueMutex.withLock { + queueStopped = false + queuedPackets.add(packet) + startPacketQueueLocked() + } } @Suppress("TooGenericExceptionCaught", "SwallowedException") @@ -247,7 +226,6 @@ class PacketHandlerImpl( getDataPacketById(packetId)?.let { p -> if (p.status == m) return@handledLaunch packetRepository.value.updateMessageStatus(p, m) - serviceBroadcasts.broadcastMessageStatus(packetId, m) } } } 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 6504faf80f..33e761727b 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, @@ -99,7 +98,7 @@ class StoreForwardPacketHandlerImpl( encryptedPayload = sfpp.message.toByteArray(), to = if (sfpp.encapsulated_to == 0) { - DataPacket.NODENUM_BROADCAST + NodeAddress.NODENUM_BROADCAST } else { sfpp.encapsulated_to }, @@ -125,7 +124,6 @@ class StoreForwardPacketHandlerImpl( rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, myNodeNum = nodeManager.myNodeNum.value ?: 0, ) - serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status) } } @@ -177,7 +175,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/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt index 14cc42b302..fb0077177a 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 @@ -153,13 +153,13 @@ class NodeRepositoryImpl( val fallbackId = userId.takeLast(last4) val defaultLong = - if (userId == DataPacket.ID_LOCAL) { + if (NodeAddress.fromString(userId) is NodeAddress.Local) { ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local" } else { "Meshtastic $fallbackId" } val defaultShort = - if (userId == DataPacket.ID_LOCAL) { + if (NodeAddress.fromString(userId) is NodeAddress.Local) { 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..52aa1be617 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 @@ -37,6 +37,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 @@ -339,13 +340,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 +354,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 +374,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/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..7cfa5663ae 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) @@ -101,7 +99,6 @@ class MeshConfigFlowManagerImplTest { nodeRepository = nodeRepository, radioConfigRepository = radioConfigRepository, serviceRepository = serviceRepository, - serviceBroadcasts = serviceBroadcasts, 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/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..7e4551bd6d 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) @@ -96,7 +95,6 @@ class MeshDataHandlerTest { packetHandler = packetHandler, serviceRepository = 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..10f7c2312e 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 @@ -251,7 +251,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 +273,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 index bce47d266c..91e3610a3c 100644 --- 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 @@ -19,14 +19,7 @@ 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 @@ -47,7 +40,6 @@ class MeshRouterImplTest { 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 = @@ -70,7 +62,6 @@ class MeshRouterImplTest { 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 @@ -83,7 +74,6 @@ class MeshRouterImplTest { neighborInfoHandlerLazy = TrackingLazy { neighborInfoHandler } configFlowManagerLazy = TrackingLazy { configFlowManager } mqttManagerLazy = TrackingLazy { mqttManager } - actionHandlerLazy = TrackingLazy { actionHandler } xmodemManagerLazy = TrackingLazy { xmodemManager } router = @@ -94,36 +84,10 @@ class MeshRouterImplTest { 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() @@ -131,31 +95,22 @@ class MeshRouterImplTest { router.tracerouteHandler.recordStartTime(77) assertTrue(tracerouteHandlerLazy.isInitialized()) - assertFalse(actionHandlerLazy.isInitialized()) + assertFalse(dataHandlerLazy.isInitialized()) verify { tracerouteHandler.recordStartTime(77) } } @Test - fun `admin command routing uses the action handler lazily`() { + fun `handlers are initialized independently`() { assertAllHandlersUninitialized() - router.actionHandler.handleGetRemoteConfig(id = 42, destNum = 67890, config = 7) - - assertTrue(actionHandlerLazy.isInitialized()) + router.dataHandler + assertTrue(dataHandlerLazy.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) + assertFalse(tracerouteHandlerLazy.isInitialized()) - assertTrue(actionHandlerLazy.isInitialized()) - assertFalse(dataHandlerLazy.isInitialized()) + router.configHandler + assertTrue(configHandlerLazy.isInitialized()) assertFalse(tracerouteHandlerLazy.isInitialized()) - verifySuspend { actionHandler.onServiceAction(action) } } private fun assertAllHandlersUninitialized() { @@ -165,7 +120,6 @@ class MeshRouterImplTest { assertFalse(neighborInfoHandlerLazy.isInitialized()) assertFalse(configFlowManagerLazy.isInitialized()) assertFalse(mqttManagerLazy.isInitialized()) - assertFalse(actionHandlerLazy.isInitialized()) assertFalse(xmodemManagerLazy.isInitialized()) } 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..83c96052c8 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 @@ -21,11 +21,10 @@ import dev.mokkery.mock import kotlinx.coroutines.test.TestScope import okio.ByteString import okio.ByteString.Companion.toByteString -import org.meshtastic.core.model.DataPacket 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 +42,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 +49,7 @@ class NodeManagerImplTest { @BeforeTest fun setUp() { - nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope) + nodeManager = NodeManagerImpl(nodeRepository, notificationManager, testScope) } @Test @@ -62,7 +60,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 +190,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 +216,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 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/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 9bf237733d..6a322193f8 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..48f41170a8 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,7 @@ 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, - ) - } + fun toModel() = node.toModel().copy(metadata = metadata?.proto, manuallyVerified = node.manuallyVerified) fun toEntity() = with(node) { NodeEntity( @@ -211,49 +181,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..5a8d94c281 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 @@ -29,9 +29,8 @@ import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.SessionStatus -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.MeshActionHandler 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/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/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..bd02bb3cf2 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 @@ -34,9 +34,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.ByteString import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.SessionStatus -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.MeshActionHandler 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/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..cb1b614c85 100644 --- a/core/model/README.md +++ b/core/model/README.md @@ -14,7 +14,7 @@ Models in this module use the `CommonParcelable` and `CommonParcelize` abstracti - **`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..d677d224c8 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -18,7 +18,6 @@ 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") } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminController.kt new file mode 100644 index 0000000000..992c526a6d --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminController.kt @@ -0,0 +1,117 @@ +/* + * 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 + +/** + * 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: org.meshtastic.proto.Config) + + /** Updates a local radio channel. Same fire-and-forget contract as [setLocalConfig]. */ + suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) + + // ── Remote configuration ──────────────────────────────────────────────── + + /** Updates the owner (user info) on a remote node. */ + suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) + + /** Updates the general configuration on a remote node. */ + suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) + + /** Updates a module configuration on a remote node. */ + suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) + + /** Updates a channel configuration on a remote node. */ + suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.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 ────────────────────────────────────────────────────────── + + /** Signals the start of a batch configuration session. */ + suspend fun beginEditSettings(destNum: Int) + + /** Commits all pending configuration changes in a batch session. */ + suspend fun commitEditSettings(destNum: Int) +} 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..5834304568 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( @@ -176,24 +123,5 @@ 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() - } + companion object } 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/EnvironmentMetrics.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EnvironmentMetrics.kt new file mode 100644 index 0000000000..53c362b65b --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EnvironmentMetrics.kt @@ -0,0 +1,55 @@ +/* + * 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() }, + 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..1b6efcf5f5 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshUser.kt @@ -0,0 +1,54 @@ +/* + * 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 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() + } +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessagingController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessagingController.kt new file mode 100644 index 0000000000..23fb832a7d --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessagingController.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +/** + * 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: org.meshtastic.proto.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/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..7f3b450769 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeAddress.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.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) -> { + val num = idToNum(id.removePrefix(NODE_ID_PREFIX)) + if (num != null) ByNum(num) else 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. */ + @Suppress("MagicNumber") + fun idToNum(id: String?): Int? = + runCatching { id?.removePrefix(NODE_ID_PREFIX)?.toLong(HEX_RADIX)?.toInt() }.getOrNull() + } +} + +/** + * 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 (first character). */ + val channel: Int + get() = value[0].digitToInt() + + /** The node address portion (everything after the channel digit). */ + val addressString: String + get() = value.substring(1) + + /** 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/NodeController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeController.kt new file mode 100644 index 0000000000..478c149a40 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeController.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.model + +/** + * 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 { + + /** Toggles the favorite status of a node on the radio. */ + suspend fun favoriteNode(nodeNum: Int) + + /** Toggles the ignore status of a node on the radio. */ + suspend fun ignoreNode(nodeNum: Int) + + /** Toggles the mute status of a node on the radio. */ + suspend fun muteNode(nodeNum: Int) + + /** Removes a node from the mesh by its node number. */ + suspend fun removeByNodenum(packetId: Int, nodeNum: Int) +} 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 index 84994e6288..517e4cf462 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt @@ -22,12 +22,24 @@ 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. + * 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) + * - [RequestController] — 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. */ -@Suppress("TooManyFunctions") -interface RadioController { +interface RadioController : + AdminController, + MessagingController, + NodeController, + RequestController { /** * Canonical app-level connection state, delegated from [ServiceRepository][connectionState]. * @@ -47,279 +59,9 @@ interface RadioController { */ 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. * diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RequestController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RequestController.kt new file mode 100644 index 0000000000..5b9ce8e238 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RequestController.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 + +/** + * Mesh request operations — position, traceroute, telemetry, user info, and metadata queries. + * + * 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 RequestController { + + /** 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/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/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/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/repository/README.md b/core/repository/README.md index edb71c7524..c94bbeb42e 100644 --- a/core/repository/README.md +++ b/core/repository/README.md @@ -51,7 +51,6 @@ src/ │ ├── RadioConfigRepository.kt │ ├── RadioInterfaceService.kt │ ├── RadioTransportCallback.kt / RadioTransportFactory.kt -│ ├── ServiceBroadcasts.kt │ ├── StoreForwardPacketHandler.kt │ ├── TelemetryPacketHandler.kt │ ├── TracerouteHandler.kt / TracerouteSnapshotRepository.kt 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/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 98% 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..d7d181c846 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 @@ -24,7 +24,7 @@ import org.meshtastic.proto.Telemetry const val SERVICE_NOTIFY_ID = 101 @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 index 490f507255..faec1d6d08 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt @@ -36,9 +36,6 @@ interface MeshRouter { /** 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/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/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/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..90555ceb05 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 @@ -160,14 +159,4 @@ interface ServiceRepository { /** 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) } 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..91fb9df2ea 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 @@ -23,6 +23,7 @@ import org.meshtastic.core.model.Capabilities 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.RadioController import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.MessageQueue @@ -44,7 +45,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") @@ -69,13 +70,13 @@ class SendMessageUseCaseImpl( val dest = if (channel != null) contactKey.substring(1) else contactKey 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 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..0ea1a49be1 100644 --- a/core/service/README.md +++ b/core/service/README.md @@ -8,8 +8,8 @@ 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. @@ -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..48ed22d565 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,12 @@ class AndroidMeshLocationManager(private val context: Application, private val l } } + override fun restart() { + val fn = sendPositionFn ?: return + if (!::scope.isInitialized) 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..dba8e14f22 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 @@ -19,20 +19,6 @@ package org.meshtastic.core.service import org.koin.core.annotation.Single import org.meshtastic.core.repository.ServiceRepository -/** - * 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]) +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..afaa0817fa 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,7 @@ class MeshService : Service() { Logger.i { "Mesh service: onTaskRemoved" } } - override fun onBind(intent: Intent?): IBinder = binder + override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { Logger.i { "Destroying mesh service" } @@ -266,188 +205,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/ReactionReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReactionReceiver.kt index 7233740148..0110af9616 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.model.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..278c4f65aa 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 @@ -28,7 +28,7 @@ import org.koin.core.component.inject import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MeshNotificationManager /** * A [BroadcastReceiver] that handles inline replies from notifications. @@ -42,7 +42,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() 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..9c3e29132e 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,72 @@ */ 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.model.RadioController +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.core.service.DirectRadioControllerImpl +import org.meshtastic.core.service.MeshService +import org.meshtastic.core.service.startService @Module @ComponentScan("org.meshtastic.core.service") -class CoreServiceAndroidModule +class CoreServiceAndroidModule { + @Suppress("LongParameterList") + @Single + 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 = DirectRadioControllerImpl( + 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/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/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt index a4c95d8cd5..8078d109a9 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -16,183 +16,369 @@ */ package org.meshtastic.core.service +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.common.database.DatabaseManager +import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.model.ConnectionState 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.RadioController -import org.meshtastic.core.model.service.ServiceAction +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.MeshLocationManager -import org.meshtastic.core.repository.MeshRouter +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.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Config import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.OTAMode +import org.meshtastic.proto.PortNum import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User /** - * Platform-agnostic [RadioController] implementation that delegates directly to service-layer handlers. + * Platform-agnostic [RadioController] implementation modeled after the SDK's `AdminApiImpl` pattern. + * + * This class is the single composition root for all radio commands. It builds [AdminMessage] protos directly and + * delegates to [CommandSender] for packet construction and transport — no intermediate handler layer, no ByteArray + * encode/decode boundaries. Business logic (optimistic persistence, node state updates, analytics) lives here. * - * 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, + private val packetRepository: Lazy, + 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, + private val scope: CoroutineScope, + private val onDeviceAddressChanged: (() -> Unit)? = null, ) : RadioController { - private val actionHandler - get() = router.actionHandler + companion object { + private const val DEFAULT_REBOOT_DELAY = 5 + private const val EMOJI_INDICATOR = 1 + } private val myNodeNum: Int get() = nodeManager.myNodeNum.value ?: 0 - /** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */ + // ── Connection State ──────────────────────────────────────────────────── + 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() } + // ── Messaging ─────────────────────────────────────────────────────────── + + 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 channel = contactKey[0].digitToInt() + val destId = contactKey.substring(1) + 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, + ) + } + + // ── Node Management ───────────────────────────────────────────────────── + override suspend fun favoriteNode(nodeNum: Int) { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef)) + val myNum = nodeManager.myNodeNum.value ?: return + val node = nodeManager.nodeDBbyNodeNum[nodeNum] ?: return + commandSender.sendAdmin(myNum) { + 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) } } + override suspend fun ignoreNode(nodeNum: Int) { + val myNum = nodeManager.myNodeNum.value ?: return + val node = nodeManager.nodeDBbyNodeNum[nodeNum] ?: return + val newIgnored = !node.isIgnored + commandSender.sendAdmin(myNum) { + if (newIgnored) AdminMessage(set_ignored_node = node.num) else AdminMessage(remove_ignored_node = node.num) + } + nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnored) } + scope.handledLaunch { packetRepository.value.updateFilteredBySender(node.user.id, newIgnored) } + } + + override suspend fun muteNode(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) } + } + + // ── Contacts ──────────────────────────────────────────────────────────── + override suspend fun sendSharedContact(nodeNum: Int): Boolean { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) + 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) - val action = ServiceAction.SendContact(contact) - serviceRepository.onServiceAction(action) - return action.result.await() + return safeCatching { commandSender.sendAdminAwait(myNum) { AdminMessage(add_contact = contact) } } + .getOrDefault(false) } - override suspend fun setLocalConfig(config: Config) { - actionHandler.handleSetConfig(config.encode(), myNodeNum) + override suspend fun importContact(contact: SharedContact) { + val myNum = nodeManager.myNodeNum.value ?: return + val verified = contact.copy(manually_verified = true) + commandSender.sendAdmin(myNum) { AdminMessage(add_contact = verified) } + nodeManager.handleReceivedUser(verified.node_num, verified.user ?: User(), manuallyVerified = true) } - override suspend fun setLocalChannel(channel: Channel) { - actionHandler.handleSetChannel(channel.encode(), myNodeNum) + // ── Device Metadata ───────────────────────────────────────────────────── + + override suspend fun refreshMetadata(destNum: Int) { + commandSender.sendAdmin(destNum, wantResponse = true) { AdminMessage(get_device_metadata_request = true) } } + // ── Owner ─────────────────────────────────────────────────────────────── + override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { - actionHandler.handleSetRemoteOwner(packetId, destNum, user.encode()) + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_owner = user) } + nodeManager.handleReceivedUser(destNum, user) } - override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { - actionHandler.handleSetRemoteConfig(packetId, destNum, config.encode()) + override suspend fun getOwner(destNum: Int, packetId: Int) { + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { AdminMessage(get_owner_request = true) } } - override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { - actionHandler.handleSetModuleConfig(packetId, destNum, config.encode()) - } + // ── Configuration ─────────────────────────────────────────────────────── + // Config and channel writes use fire-and-forget persistence (handledLaunch) intentionally. + // The device is the source of truth — it re-sends its full config on every connection. + // Local persistence is a cache optimization, not a correctness requirement. - override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { - actionHandler.handleSetRemoteChannel(packetId, destNum, channel.encode()) + override suspend fun setLocalConfig(config: Config) { + commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = config) } + scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } } - override suspend fun setFixedPosition(destNum: Int, position: Position) { - commandSender.setFixedPosition(destNum, position) + override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_config = config) } + if (destNum == myNodeNum) { + scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } + } } - override suspend fun setRingtone(destNum: Int, ringtone: String) { - actionHandler.handleSetRingtone(destNum, ringtone) + 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 setCannedMessages(destNum: Int, messages: String) { - actionHandler.handleSetCannedMessages(destNum, messages) + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_module_config = config) } + config.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) } + if (destNum == myNodeNum) { + scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } + } } - override suspend fun getOwner(destNum: Int, packetId: Int) { - actionHandler.handleGetRemoteOwner(packetId, destNum) + 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)) + } } - override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { - actionHandler.handleGetRemoteConfig(packetId, destNum, configType) + // ── Channels ──────────────────────────────────────────────────────────── + + override suspend fun setLocalChannel(channel: Channel) { + commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = channel) } + scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } } - override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) { - actionHandler.handleGetModuleConfig(packetId, destNum, moduleConfigType) + override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_channel = channel) } + if (destNum == myNodeNum) { + scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } + } } override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) { - actionHandler.handleGetRemoteChannel(packetId, destNum, index) + 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) { - actionHandler.handleGetRingtone(packetId, destNum) + 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) { - actionHandler.handleGetCannedMessages(packetId, destNum) + 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) + } + + override suspend fun requestPosition(destNum: Int, currentPosition: Position) { + if (destNum == myNodeNum) return + val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value + val resolvedPosition = + when { + provideLocation && currentPosition.isValid() -> currentPosition + + 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, resolvedPosition) + } + + // ── Device Status & Lifecycle ─────────────────────────────────────────── + override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { - actionHandler.handleGetDeviceConnectionStatus(packetId, destNum) + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { + AdminMessage(get_device_connection_status_request = true) + } } override suspend fun reboot(destNum: Int, packetId: Int) { - actionHandler.handleRequestReboot(packetId, destNum) + Logger.i { "Reboot requested for node $destNum" } + commandSender.sendAdmin(destNum, packetId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) } } override suspend fun rebootToDfu(nodeNum: Int) { - actionHandler.handleRebootToDfu(nodeNum) + commandSender.sendAdmin(nodeNum) { AdminMessage(enter_dfu_mode_request = true) } } override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { - actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) + 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) { - actionHandler.handleRequestShutdown(packetId, destNum) + commandSender.sendAdmin(destNum, packetId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) } } override suspend fun factoryReset(destNum: Int, packetId: Int) { - actionHandler.handleRequestFactoryReset(packetId, destNum) + 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) { - actionHandler.handleRequestNodedbReset(packetId, destNum, preserveFavorites) + commandSender.sendAdmin(destNum, packetId) { AdminMessage(nodedb_reset = 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) - } + // ── Edit Settings (transactional) ─────────────────────────────────────── + + override suspend fun beginEditSettings(destNum: Int) { + commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) } } - override suspend fun requestPosition(destNum: Int, currentPosition: Position) { - actionHandler.handleRequestPosition(destNum, currentPosition, myNodeNum) + override suspend fun commitEditSettings(destNum: Int) { + commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) } } + // ── Telemetry & Discovery ─────────────────────────────────────────────── + override suspend fun requestUserInfo(destNum: Int) { if (destNum != myNodeNum) { commandSender.requestUserInfo(destNum) @@ -204,34 +390,45 @@ class DirectRadioControllerImpl( } override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { - actionHandler.handleRequestTelemetry(requestId, destNum, typeValue) + commandSender.requestTelemetry(requestId, destNum, typeValue) } override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) { - actionHandler.handleRequestNeighborInfo(requestId, destNum) + commandSender.requestNeighborInfo(requestId, destNum) } - override suspend fun beginEditSettings(destNum: Int) { - actionHandler.handleBeginEditSettings(destNum) - } - - override suspend fun commitEditSettings(destNum: Int) { - actionHandler.handleCommitEditSettings(destNum) - } + // ── Packet ID & Location ──────────────────────────────────────────────── 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. + locationManager.restart() } override fun stopProvideLocation() { locationManager.stop() } + // ── Device Address ────────────────────────────────────────────────────── + override fun setDeviceAddress(address: String) { - actionHandler.handleUpdateLastAddress(address) - radioInterfaceService.setDeviceAddress(address) + scope.launch { + 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/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index ab107e18b3..da2e78e01a 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 @@ -31,8 +31,8 @@ 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.MeshNotificationManager import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository @@ -56,7 +56,7 @@ class MeshServiceOrchestrator( 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, @@ -129,13 +129,6 @@ class MeshServiceOrchestrator( .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) } } - .launchIn(newScope) - 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 index b93aac1a95..ecd4a97503 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt @@ -19,24 +19,35 @@ 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 kotlinx.coroutines.async +import dev.mokkery.verify.VerifyMode.Companion.atLeast +import dev.mokkery.verifySuspend import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first +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.service.ServiceAction +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshActionHandler +import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshRouter +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.User import kotlin.test.Test @@ -49,26 +60,44 @@ 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 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, ): DirectRadioControllerImpl { - every { router.actionHandler } returns actionHandler every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) + every { meshPrefs.deviceAddress } returns MutableStateFlow(null) return DirectRadioControllerImpl( serviceRepository = serviceRepository, nodeRepository = nodeRepository, commandSender = commandSender, - router = router, 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, ) } @@ -93,40 +122,33 @@ class DirectRadioControllerImplTest { } @Test - fun sendMessageDelegatesToActionHandlerWithLocalNodeNumber() = runTest { + fun sendMessageDelegatesToCommandSender() = runTest { val controller = createController(myNodeNum = 456) - val packet = DataPacket(to = DataPacket.ID_BROADCAST, channel = 1, text = "ping") + val packet = DataPacket(to = NodeAddress.ID_BROADCAST, channel = 1, text = "ping") controller.sendMessage(packet) - verify { actionHandler.handleSend(packet, 456) } + verifySuspend { commandSender.sendData(packet) } + verifySuspend { dataHandler.rememberDataPacket(packet, 456, false) } } @Test - fun sendSharedContactEmitsActionAndWaitsForResult() = runTest { - val serviceRepository = ServiceRepositoryImpl() - val controller = createController(serviceRepository = serviceRepository) + fun sendSharedContactCallsCommandSenderAdminAwait() = runTest { + val controller = createController() val nodeNum = 321 - val user = User(id = DataPacket.nodeNumToDefaultId(nodeNum), long_name = "Remote Node", short_name = "RN") + 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(DataPacket.nodeNumToDefaultId(nodeNum)) } returns node + every { nodeRepository.getNode(NodeAddress.numToDefaultId(nodeNum)) } returns node + everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true - val emittedAction = async { serviceRepository.serviceAction.first() } - val sendResult = async { controller.sendSharedContact(nodeNum) } + val result = 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()) + assertTrue(result) + verifySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } } @Test - fun requestConfigOperationsDelegateToActionHandler() = runTest { + fun requestConfigOperationsDelegateToCommandSender() = runTest { val controller = createController() controller.getOwner(destNum = 101, packetId = 1) @@ -137,13 +159,8 @@ class DirectRadioControllerImplTest { 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) } + // All delegate to commandSender.sendAdmin + verifySuspend(atLeast(7)) { commandSender.sendAdmin(any(), any(), any(), any()) } } @Test @@ -156,12 +173,114 @@ class DirectRadioControllerImplTest { } @Test - fun setDeviceAddressUpdatesLastAddressAndTransportAddress() { + 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() - verify { actionHandler.handleUpdateLastAddress("tcp:192.168.1.1") } + // switchDevice should skip when addresses match, but transport still reconfigures verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } + verifySuspend(atLeast(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 favoriteNodeSendsAdminAndUpdatesState() = runTest { + val controller = createController() + val node = Node(num = 99, user = User(id = "!node99"), isFavorite = false) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) + + controller.favoriteNode(99) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + verify { nodeManager.updateNode(any(), any(), any()) } + } + + @Test + fun ignoreNodeSendsAdminUpdatesStateAndFiltersPackets() = runTest { + val controller = createController() + val node = Node(num = 99, user = User(id = "!node99"), isIgnored = false) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) + + controller.ignoreNode(99) + testScope.advanceUntilIdle() + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + verify { nodeManager.updateNode(any(), any(), any()) } + verifySuspend { packetRepository.updateFilteredBySender("!node99", true) } + } + + @Test + fun muteNodeSendsAdminAndUpdatesState() = runTest { + val controller = createController() + val node = Node(num = 99, user = User(id = "!node99"), isMuted = false) + every { nodeManager.nodeDBbyNodeNum } returns mapOf(99 to node) + + controller.muteNode(99) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + verify { nodeManager.updateNode(any(), any(), any()) } + } + + @Test + fun nodeManagementReturnsEarlyWhenMyNodeNumIsNull() = runTest { + val controller = createController(myNodeNum = null) + + controller.favoriteNode(99) + controller.ignoreNode(99) + controller.muteNode(99) + + verifySuspend(atLeast(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(atLeast(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } } } 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..8d64a9cbfe 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,12 @@ 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.MeshNotificationManager import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioInterfaceService @@ -62,9 +59,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 +77,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 = @@ -187,21 +180,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/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 ce14df9cda..01e5fe8775 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 @@ -242,12 +243,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) { @@ -285,12 +288,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 b422a8a122..877f72942d 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 @@ -178,13 +179,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 f12c817a6f..614f548653 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..fcd864915a 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 @@ -84,6 +84,16 @@ class FakeRadioController : return true } + override suspend fun ignoreNode(nodeNum: Int) {} + + override suspend fun muteNode(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(nodeNum: Int) {} + override suspend fun setLocalConfig(config: Config) {} override suspend fun setLocalChannel(channel: Channel) {} 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/share/SharedContactViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/share/SharedContactViewModel.kt index d00ab5f3c3..337f4609a9 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.model.RadioController import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.ServiceRepository 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/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/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 261abeeaeb..e8631ad834 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 @@ -48,19 +49,18 @@ import org.meshtastic.core.network.service.ApiServiceImpl import org.meshtastic.core.repository.AppWidgetUpdater 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.NotificationManager import org.meshtastic.core.repository.PlatformAnalytics 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.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 +77,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 @@ -170,10 +169,19 @@ private fun desktopPlatformStubsModule() = module { 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 { @@ -185,9 +193,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()) } 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/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/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..bc066bda34 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 @@ -28,6 +28,7 @@ import org.meshtastic.core.common.util.ioDispatcher 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.model.RadioController import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.repository.MapPrefs @@ -142,14 +143,14 @@ 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 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..394533fcf5 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,8 @@ 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.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 @@ -162,14 +162,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..9656d09872 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,10 +34,10 @@ 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.model.RadioController import org.meshtastic.core.repository.CustomEmojiPrefs import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.NodeRepository @@ -60,6 +60,7 @@ class MessageViewModel( radioConfigRepository: RadioConfigRepository, quickChatActionRepository: QuickChatActionRepository, private val serviceRepository: ServiceRepository, + private val radioController: RadioController, private val packetRepository: PacketRepository, private val uiPrefs: UiPrefs, private val customEmojiPrefs: CustomEmojiPrefs, @@ -195,9 +196,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 +213,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") { radioController.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/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index d846ba2609..4332a0ea9b 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,9 +28,13 @@ 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.NodeRepository import org.meshtastic.core.repository.PacketRepository @@ -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..0fd2eda0be 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,7 +34,7 @@ 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.model.RadioController import org.meshtastic.core.repository.CustomEmojiPrefs import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.PacketRepository @@ -65,6 +64,7 @@ class MessageViewModelTest { private val quickChatActionRepository: QuickChatActionRepository = mock(MockMode.autofill) private val packetRepository: PacketRepository = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val radioController: RadioController = 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,7 +95,6 @@ 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 { customEmojiPrefs.customEmojiFrequency } returns customEmojiFrequencyFlow @@ -119,6 +118,7 @@ class MessageViewModelTest { quickChatActionRepository = quickChatActionRepository, packetRepository = packetRepository, serviceRepository = serviceRepository, + radioController = radioController, sendMessageUseCase = sendMessageUseCase, customEmojiPrefs = customEmojiPrefs, homoglyphEncodingPrefs = homoglyphPrefs, @@ -192,13 +192,13 @@ class MessageViewModelTest { @Test fun testSendReaction() = runTest { - everySuspend { serviceRepository.onServiceAction(any()) } returns Unit + everySuspend { radioController.sendReaction(any(), any(), any()) } returns Unit viewModel.sendReaction("❤️", 123, "0!12345678") advanceUntilIdle() - verifySuspend { serviceRepository.onServiceAction(ServiceAction.Reaction("❤️", 123, "0!12345678")) } + verifySuspend { radioController.sendReaction("❤️", 123, "0!12345678") } } @Test 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..0742c11c5d 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,14 +17,11 @@ 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 @@ -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.getPacketId() + 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.getPacketId() + 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.getPacketId() + 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..d984eaf980 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.RadioController 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.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 radioController: RadioController, private val getNodeDetailsUseCase: GetNodeDetailsUseCase, private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase, private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase, @@ -144,30 +143,38 @@ 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 { radioController.refreshMetadata(destNum) } /** * Ensure a remote-admin session passkey is fresh, then request navigation to the remote-admin screen. Surfaces a @@ -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..0fabdd025a 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.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.getPacketId() + 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 { ignoreNode(node.num) } }, ) } } - open fun ignoreNode(scope: CoroutineScope, node: Node) { - scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Ignore(node)) } + open suspend fun ignoreNode(nodeNum: Int) { + radioController.ignoreNode(nodeNum) } 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 { muteNode(node.num) } }, ) } } - open fun muteNode(scope: CoroutineScope, node: Node) { - scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Mute(node)) } + open suspend fun muteNode(nodeNum: Int) { + radioController.muteNode(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 { favoriteNode(node.num) } }, ) } } - open fun favoriteNode(scope: CoroutineScope, node: Node) { - scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Favorite(node)) } + open suspend fun favoriteNode(nodeNum: Int) { + radioController.favoriteNode(nodeNum) } - 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..7953eace94 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,9 +28,9 @@ 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 @@ -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..16e81f1f67 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 @@ -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..c3fc677a33 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 @@ -33,8 +33,8 @@ import kotlinx.coroutines.test.setMain import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.Node +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.SessionStatus -import org.meshtastic.core.repository.ServiceRepository 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 radioController: RadioController = 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, + radioController = radioController, 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..6760c91822 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 @@ -40,9 +40,9 @@ import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCas import org.meshtastic.core.domain.usecase.session.EnsureSessionResult import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.Node +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.navigation.SettingsRoute -import org.meshtastic.core.repository.ServiceRepository 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 radioController: RadioController = 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, + radioController = radioController, 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/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index fa54c8992e..f2423819d7 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,14 +32,7 @@ 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 @@ -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/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..e41e3e412c 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, @@ -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/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 28ce3c90e7..cc90bbd154 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", From 50cf2265611d690a870ba7d94ab23f0acf891fa4 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 23 May 2026 16:09:52 -0500 Subject: [PATCH 02/34] Change Ktor log level to INFO and add 'HttpClient' tag to KermitHttpLogger --- .github/copilot-instructions.md | 2 +- .github/workflows/publish-core.yml | 2 +- .github/workflows/reusable-check.yml | 2 +- .../src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt | 2 +- .../kotlin/org/meshtastic/core/network/KermitHttpLogger.kt | 7 ++----- .../kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt | 2 +- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f6f0e772ac..a0664c4baa 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 index 6bbf344f0e..f7fb7c4016 100644 --- a/.github/workflows/publish-core.yml +++ b/.github/workflows/publish-core.yml @@ -45,7 +45,7 @@ jobs: fi - name: Publish to GitHub Packages - run: ./gradlew :core:api:publish :core:model:publish :core:proto:publish + run: ./gradlew :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 906cc4da12..5a374e73bb 100644 --- a/.github/workflows/reusable-check.yml +++ b/.github/workflows/reusable-check.yml @@ -112,7 +112,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/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/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/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index e8631ad834..1ddeb1cf12 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -229,7 +229,7 @@ private fun desktopPlatformStubsModule() = module { if (DesktopBuildConfig.IS_DEBUG) { install(Logging) { logger = KermitHttpLogger - level = LogLevel.BODY + level = LogLevel.INFO } } } From d62c45981026a3a80561d6ea66e311a53fde6f16 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 23 May 2026 17:12:06 -0500 Subject: [PATCH 03/34] Add test coverage for refactored modules - NodeAddressTest: 37 tests covering sealed class parser, roundtrip, extensions (ContactKey, DataPacket), edge cases - CommandSenderImplTest: 20 tests covering packet ID generation, address resolution, sendData validation, admin messages, position - DirectRadioControllerImplTest: +8 tests for reboot/shutdown/factory reset, importContact, refreshMetadata - NodeManagerImplTest: +7 tests for getMyNodeInfo aggregation, getMyId, null telemetry handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/manager/CommandSenderImplTest.kt | 293 ++++++++++++++++++ .../core/data/manager/NodeManagerImplTest.kt | 111 +++++++ .../meshtastic/core/model/NodeAddressTest.kt | 252 +++++++++++++++ .../service/DirectRadioControllerImplTest.kt | 67 ++++ 4 files changed, 723 insertions(+) create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeAddressTest.kt 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/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index 83c96052c8..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,10 +17,14 @@ 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.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.repository.NodeRepository @@ -328,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/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..6c3a1d065f --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeAddressTest.kt @@ -0,0 +1,252 @@ +/* + * 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_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/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt index ecd4a97503..250df61e1f 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt @@ -49,6 +49,7 @@ 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 @@ -283,4 +284,70 @@ class DirectRadioControllerImplTest { // No admin message sent when disconnected verifySuspend(atLeast(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() + val contact = SharedContact(node_num = 42, user = User(id = "!0000002a", long_name = "Test")) + + controller.importContact(contact) + + verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } + verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) } + } + + @Test + fun importContactReturnsEarlyWhenDisconnected() = runTest { + val controller = createController(myNodeNum = null) + val contact = SharedContact(node_num = 42, user = User(id = "!0000002a")) + + controller.importContact(contact) + + verifySuspend(atLeast(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } + } } From 058a522c517ea009fbb6ad689a3e7707a53379b8 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 26 May 2026 13:10:39 -0500 Subject: [PATCH 04/34] build: Remove vestigial kotlin-android plugin references for AGP 9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With AGP 9's built-in Kotlin, org.jetbrains.kotlin.android is no longer applied to Android modules. Convention plugin hooks using withPlugin("org.jetbrains.kotlin.android") were dead code — replaced with withPlugin("com.android.application") and withPlugin("com.android.library") which correctly trigger for Android-only modules using built-in Kotlin. - KoinConventionPlugin: split into app + library hooks - AndroidRoomConventionPlugin: use com.android.library hook - KotlinXSerializationConventionPlugin: use app + library hooks - Remove kotlin-android from version catalog and root build.gradle.kts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/main/kotlin/AndroidRoomConventionPlugin.kt | 2 +- .../src/main/kotlin/KoinConventionPlugin.kt | 12 +++++++++++- .../kotlin/KotlinXSerializationConventionPlugin.kt | 8 +++++++- build.gradle.kts | 1 - gradle/libs.versions.toml | 1 - 5 files changed, 19 insertions(+), 5 deletions(-) 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.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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 790c8adef9..c8e952d519 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -297,7 +297,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" } From f4fab69aa8e524ab1138fa7b08b00b75ad53b2a1 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 26 May 2026 13:43:10 -0500 Subject: [PATCH 05/34] build: Remove publishing infrastructure and unify JDK to 21 No modules are published externally anymore (core:api was removed). Remove all publishing-related configuration: - Delete PublishingConventionPlugin and its registration - Remove publish-core.yml workflow - Strip publishing{} blocks from core/model and core/proto - Remove PUBLISHED_MODULES set and Java 17 compatibility logic - Unify all modules to JDK 21 toolchain and JVM target - Remove unused imports (JavaToolchainService, JavaLanguageVersion, Test) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-core.yml | 51 ----------------- build-logic/convention/build.gradle.kts | 5 -- .../main/kotlin/PublishingConventionPlugin.kt | 57 ------------------- .../meshtastic/buildlogic/KotlinAndroid.kt | 41 +++---------- core/model/build.gradle.kts | 14 ----- core/proto/build.gradle.kts | 14 ----- 6 files changed, 7 insertions(+), 175 deletions(-) delete mode 100644 .github/workflows/publish-core.yml delete mode 100644 build-logic/convention/src/main/kotlin/PublishingConventionPlugin.kt diff --git a/.github/workflows/publish-core.yml b/.github/workflows/publish-core.yml deleted file mode 100644 index f7fb7c4016..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:model:publish :core:proto:publish - env: - GITHUB_ACTOR: ${{ github.actor }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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/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/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/core/model/build.gradle.kts b/core/model/build.gradle.kts index d677d224c8..e26ff0a6c7 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -19,7 +19,6 @@ plugins { alias(libs.plugins.meshtastic.kmp.library) alias(libs.plugins.meshtastic.kotlinx.serialization) id("meshtastic.kmp.jvm.android") - id("meshtastic.publishing") } kotlin { @@ -55,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/proto/build.gradle.kts b/core/proto/build.gradle.kts index cccc661e45..81b24b5252 100644 --- a/core/proto/build.gradle.kts +++ b/core/proto/build.gradle.kts @@ -18,7 +18,6 @@ plugins { alias(libs.plugins.meshtastic.kmp.library) alias(libs.plugins.wire) - id("meshtastic.publishing") } kotlin { @@ -121,16 +120,3 @@ wire { prune("meshtastic.TakTalkMessage") prune("meshtastic.TakTalkRoomData") } - -// 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-") - } - } -} From 7e3bd85847b923e36fc866484ad28f643a356719 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 26 May 2026 14:36:50 -0500 Subject: [PATCH 06/34] cleanup: Remove legacy cruft and eliminate runBlocking from UI path - Remove x86/x86_64 from ABI filters (armeabi-v7a/arm64-v8a only) - Remove unused takpacket-sdk-jvm from version catalog - Raise core/proto minSdk from 21 to 26 (ATAK compat no longer needed) - Remove stale FIXME comment about foreground service in manifest - Replace Executors.newSingleThreadExecutor with Dispatchers.Default.asExecutor in BarcodeScannerProvider (removes manual thread pool management) - Convert formatAgo() to @Composable with stringResource(), eliminating runBlocking from the UI rendering path. Non-composable callers (map views, accessibility) use a 3-arg overload with pre-resolved strings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- androidApp/build.gradle.kts | 4 +- .../kotlin/org/meshtastic/app/map/MapView.kt | 64 ++++++++++--------- .../app/map/traceroute/TracerouteOsmMap.kt | 12 +++- androidApp/src/main/AndroidManifest.xml | 1 - .../core/barcode/BarcodeScannerProvider.kt | 8 +-- core/proto/build.gradle.kts | 3 - .../core/ui/component/BuildNodeDescription.kt | 8 ++- .../org/meshtastic/core/ui/util/FormatAgo.kt | 18 ++++-- .../ui/component/BuildNodeDescriptionTest.kt | 2 + gradle/libs.versions.toml | 1 - 10 files changed, 73 insertions(+), 48 deletions(-) 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/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 006969ad66..f8a5ac4591 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -114,9 +114,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 @@ -240,6 +242,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)) @@ -356,36 +361,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 - } - } } } @@ -447,7 +453,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 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/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 46692ee805..4ac716ff89 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -67,7 +67,6 @@ --> - 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/proto/build.gradle.kts b/core/proto/build.gradle.kts index 81b24b5252..9efaf35616 100644 --- a/core/proto/build.gradle.kts +++ b/core/proto/build.gradle.kts @@ -21,9 +21,6 @@ plugins { } kotlin { - // Override minSdk for ATAK compatibility (standard is 26) - androidLibrary { minSdk = 21 } - sourceSets { commonMain.dependencies { api(libs.wire.runtime) 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/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/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/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8e952d519..cd5a819e4f 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] From de665518a7a0bed98e09ce2b3813cbfb69d05645 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 26 May 2026 15:11:19 -0500 Subject: [PATCH 07/34] fix: Resolve lint errors and warnings - Wrap StateFlow.value read in remember{} to satisfy composition lint - Wrap derivedStateOf in remember{} (MapView PurgeTileSourceDialog) - Use mutableIntStateOf to avoid autoboxing (MapStyleDialog) - Use ResourcesCompat.getDrawable() instead of deprecated getDrawable() - Fix mixed indentation in MarkerClusterer.java Lint result: 0 errors, 10 warnings (down from 2 errors, 12 warnings) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/app/map/cluster/MarkerClusterer.java | 4 ++-- .../app/map/cluster/RadiusMarkerClusterer.java | 4 +++- .../src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt | 9 +++++---- androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt | 4 +++- 4 files changed, 13 insertions(+), 8 deletions(-) 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 f8a5ac4591..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 @@ -825,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() }, @@ -886,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/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 From 4adc6a6a5ee9a1fcbd723f8be1f8e17fda492e53 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 26 May 2026 15:14:35 -0500 Subject: [PATCH 08/34] build: Remove redundant OptimizeNonSkippingGroups flag This feature flag is now enabled by default in the Compose compiler, making the explicit opt-in unnecessary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/buildlogic/ComposeCompilerConfiguration.kt | 2 -- 1 file changed, 2 deletions(-) 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) } } From 913c1a98109834e52d4ce0fae3c43db5025eb10f Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 28 May 2026 07:26:20 -0500 Subject: [PATCH 09/34] fix: Address P1 code-review findings from AIDL-removal refactor - NodeAddress.idToNum: reject >32-bit hex instead of silently truncating; use toLongOrNull rather than runCatching; drop redundant double prefix-strip. - DirectRadioControllerImpl: compare destNum against the nullable myNodeNum.value (not the ?:0 getter) so local-side-effect branches don't fire spuriously for destNum=0 before connect. - DirectRadioControllerImpl.setModuleConfig: move node_status optimistic write inside the local-node guard so we don't overwrite remote status. - DirectRadioControllerImpl.sendReaction: use ContactKey wrapper instead of manual [0].digitToInt()/substring(1). - DirectRadioControllerImpl.importContact: respect incoming manually_verified flag; early-return on node_num==0 or null user. - DirectRadioControllerImpl.requestPosition: consolidate when-chain; document Position(0,0,0) as protocol "no position" sentinel. - ContactKey: accessors safe against empty value; non-digit first char defaults channel to 0 rather than throwing. - NodeDetailViewModel.openRemoteAdmin: atomic compareAndSet replaces check-then-act TOCTOU that allowed double-tap to queue two passkey exchanges + duplicate navigation events. - MessageViewModel.frequentEmojis: toIntOrNull + mapNotNull so a corrupted pref entry no longer crashes every recomposition. - AndroidMeshLocationManager.restart: log the silent-bail case (no-op until MeshConnectionManagerImpl wires us up). Co-Authored-By: Claude Opus 4.7 --- .../org/meshtastic/core/model/NodeAddress.kt | 24 +++++------ .../service/AndroidMeshLocationManager.kt | 9 +++- .../core/service/DirectRadioControllerImpl.kt | 43 +++++++++++-------- .../feature/messaging/MessageViewModel.kt | 7 +-- .../node/detail/NodeDetailViewModel.kt | 4 +- 5 files changed, 48 insertions(+), 39 deletions(-) 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 index 7f3b450769..d630b361ba 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeAddress.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeAddress.kt @@ -69,24 +69,22 @@ sealed class NodeAddress { /** 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) -> { - val num = idToNum(id.removePrefix(NODE_ID_PREFIX)) - if (num != null) ByNum(num) else ById(id) - } - + 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. */ + /** + * 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? = - runCatching { id?.removePrefix(NODE_ID_PREFIX)?.toLong(HEX_RADIX)?.toInt() }.getOrNull() + id?.removePrefix(NODE_ID_PREFIX)?.toLongOrNull(HEX_RADIX)?.takeIf { it in 0L..0xFFFFFFFFL }?.toInt() } } @@ -97,13 +95,13 @@ sealed class NodeAddress { */ @JvmInline value class ContactKey(val value: String) { - /** The channel index (first character). */ + /** The channel index (first character). Returns 0 if the key is empty or the first char is not a digit. */ val channel: Int - get() = value[0].digitToInt() + get() = value.firstOrNull()?.takeIf { it.isDigit() }?.digitToInt() ?: 0 - /** The node address portion (everything after the channel digit). */ + /** The node address portion (everything after the channel digit). Empty if the key is empty. */ val addressString: String - get() = value.substring(1) + get() = if (value.isEmpty()) "" else value.substring(1) /** Parsed [NodeAddress] for the contact. */ val address: NodeAddress 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 48ed22d565..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 @@ -73,8 +73,13 @@ class AndroidMeshLocationManager(private val context: Application, private val l } override fun restart() { - val fn = sendPositionFn ?: return - if (!::scope.isInitialized) return + 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) } 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 index 8078d109a9..c96d13e1cb 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -27,6 +27,7 @@ import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.ContactKey import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.NodeAddress @@ -120,8 +121,9 @@ class DirectRadioControllerImpl( override suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) { val myNum = nodeManager.myNodeNum.value ?: return - val channel = contactKey[0].digitToInt() - val destId = contactKey.substring(1) + val parsedKey = ContactKey(contactKey) + val channel = parsedKey.channel + val destId = parsedKey.addressString val dataPacket = DataPacket( to = destId, @@ -205,9 +207,13 @@ class DirectRadioControllerImpl( override suspend fun importContact(contact: SharedContact) { val myNum = nodeManager.myNodeNum.value ?: return - val verified = contact.copy(manually_verified = true) - commandSender.sendAdmin(myNum) { AdminMessage(add_contact = verified) } - nodeManager.handleReceivedUser(verified.node_num, verified.user ?: User(), manuallyVerified = true) + 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 + } + commandSender.sendAdmin(myNum) { AdminMessage(add_contact = contact) } + nodeManager.handleReceivedUser(contact.node_num, user, manuallyVerified = contact.manually_verified) } // ── Device Metadata ───────────────────────────────────────────────────── @@ -239,7 +245,7 @@ class DirectRadioControllerImpl( override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_config = config) } - if (destNum == myNodeNum) { + if (destNum == nodeManager.myNodeNum.value) { scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } } } @@ -256,8 +262,8 @@ class DirectRadioControllerImpl( override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_module_config = config) } - config.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) } - if (destNum == myNodeNum) { + if (destNum == nodeManager.myNodeNum.value) { + config.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) } scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } } } @@ -277,7 +283,7 @@ class DirectRadioControllerImpl( override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_channel = channel) } - if (destNum == myNodeNum) { + if (destNum == nodeManager.myNodeNum.value) { scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } } } @@ -315,17 +321,16 @@ class DirectRadioControllerImpl( } override suspend fun requestPosition(destNum: Int, currentPosition: Position) { - if (destNum == myNodeNum) return + 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 = - when { - provideLocation && currentPosition.isValid() -> currentPosition - - provideLocation -> - nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } - ?: Position(0.0, 0.0, 0) - - else -> Position(0.0, 0.0, 0) + 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) } @@ -380,7 +385,7 @@ class DirectRadioControllerImpl( // ── Telemetry & Discovery ─────────────────────────────────────────────── override suspend fun requestUserInfo(destNum: Int) { - if (destNum != myNodeNum) { + if (destNum != nodeManager.myNodeNum.value) { commandSender.requestUserInfo(destNum) } } 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 9656d09872..c51484fb1d 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 @@ -110,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("👍", "👎", "😂", "🔥", "❤️", "😮") 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 d984eaf980..336c410d91 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 @@ -181,9 +181,9 @@ class NodeDetailViewModel( * 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, From aec93250e8e36b815793463999498bcfa8d66cd7 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 28 May 2026 07:41:15 -0500 Subject: [PATCH 10/34] =?UTF-8?q?fix:=20Address=20P2=20code-review=20findi?= =?UTF-8?q?ngs=20=E2=80=94=20concurrency=20&=20API=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RadioController.setDeviceAddress is now suspend. Callers can rely on the device switch completing (DB switched, node DB cleared, transport reconfigured) before their next call — the old non-suspend impl wrapped the work in scope.launch{} and returned immediately, racing against follow-up operations like OTA disconnect-then-delay. - DirectRadioControllerImpl: drops the scope.launch wrapper. - FakeRadioController: matches the new suspend contract so tests no longer hide ordering bugs that production would exhibit. - UIViewModel / ScannerViewModel: wrap the suspend call in safeLaunch. - Esp32OtaUpdateHandler.disconnectMeshService: marked suspend (caller is already inside a withContext block). - MeshServiceOrchestrator: replace plain `var scope: CoroutineScope?` with an atomicfu AtomicRef and compareAndSet on start(). Concurrent start() invocations could otherwise both observe the slot empty, both allocate scopes, and the second overwrite the first — orphaning the first scope's collectors on radioInterfaceService.receivedData. - PacketHandlerImpl.sendToRadio: document the FIFO invariant. Mutex is not strictly fair across coroutines, but the only ordering callers rely on is within a single coroutine (e.g. InstallProfileUseCase issues beginEditSettings → install* → commitEditSettings sequentially in one suspend), where sequential mutex acquisition preserves order. Co-Authored-By: Claude Opus 4.7 --- .../core/data/manager/PacketHandlerImpl.kt | 6 +++++ .../meshtastic/core/model/RadioController.kt | 6 ++++- .../core/service/DirectRadioControllerImpl.kt | 11 +++----- .../core/service/MeshServiceOrchestrator.kt | 25 ++++++++++++------- .../core/testing/FakeRadioController.kt | 2 +- .../core/ui/viewmodel/UIViewModel.kt | 2 +- .../feature/connections/ScannerViewModel.kt | 2 +- .../connections/ScannerViewModelTest.kt | 3 ++- .../firmware/ota/Esp32OtaUpdateHandler.kt | 2 +- 9 files changed, 37 insertions(+), 22 deletions(-) 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 ad3f7fda83..f314bf26d2 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 @@ -103,6 +103,12 @@ class PacketHandlerImpl( } } + /** + * 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. `beginEditSettings` → … → `commitEditSettings` — 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 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 index 517e4cf462..aab4a8b54f 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt @@ -78,7 +78,11 @@ interface RadioController : /** * 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. */ - fun setDeviceAddress(address: String) + suspend fun setDeviceAddress(address: String) } 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 index c96d13e1cb..333718267f 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.database.DatabaseManager @@ -416,12 +415,10 @@ class DirectRadioControllerImpl( // ── Device Address ────────────────────────────────────────────────────── - override fun setDeviceAddress(address: String) { - scope.launch { - switchDevice(address) - radioInterfaceService.setDeviceAddress(address) - onDeviceAddressChanged?.invoke() - } + override suspend fun setDeviceAddress(address: String) { + switchDevice(address) + radioInterfaceService.setDeviceAddress(address) + onDeviceAddressChanged?.invoke() } private suspend fun switchDevice(deviceAddr: String) { 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 da2e78e01a..92a59fc457 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 @@ -66,11 +67,13 @@ 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. @@ -79,14 +82,19 @@ class MeshServiceOrchestrator( * the message processor and service actions to the router's action handler. */ 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). Replace it. + scopeRef.value = newScope } 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 @@ -151,7 +159,6 @@ class MeshServiceOrchestrator( CoroutineScope(SupervisorJob() + dispatchers.default).launch { runCatching { radioInterfaceService.disconnect() } } - scope?.cancel() - scope = null + scopeRef.getAndSet(null)?.cancel() } } 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 fcd864915a..a8805080fe 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 @@ -168,7 +168,7 @@ class FakeRadioController : stopProvideLocationCalled = true } - override fun setDeviceAddress(address: String) { + override suspend fun setDeviceAddress(address: String) { lastSetDeviceAddress = address } 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..3640dbc556 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 @@ -198,7 +198,7 @@ class UIViewModel( } fun setDeviceAddress(address: String) { - radioController.setDeviceAddress(address) + safeLaunch(tag = "setDeviceAddress") { radioController.setDeviceAddress(address) } } val unreadMessageCount = 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..25864c6a10 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 @@ -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..f311e7d318 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 @@ -141,8 +141,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/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..b0ae3e8971 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 @@ -197,7 +197,7 @@ class Esp32OtaUpdateHandler( * 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") } From e6b08fff65fad2bc2c3e79067c897f194e4f1832 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 28 May 2026 07:52:01 -0500 Subject: [PATCH 11/34] =?UTF-8?q?fix:=20Address=20P3=20+=20P4=20code-revie?= =?UTF-8?q?w=20findings=20=E2=80=94=20pre-existing=20bugs=20+=20perf=20reg?= =?UTF-8?q?ressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 — Pre-existing bugs surfaced when files were moved: - MeshUser.hwModelString: the unconditional replace('p', '.') mangles any hardware enum name containing a literal 'p' (HELTEC_WIRELESS_PAPER had become 'heltec-wireless-.a.er'). Use a regex bounded by digits so version markers like RAK4631_V1P0 → rak4631-v1.0 still work and other 'p's are preserved. - EnvironmentMetrics.relativeHumidity: document the special-case `it != 0.0f` filter. It's intentional — firmware reports 0%RH when the humidity sensor isn't fitted and a real outdoor 0%RH reading is physically implausible. Other fields don't get this guard because their zero values (0V, 0A, 0°C) are meaningful sensor data. P4 — Performance regressions introduced by the AIDL refactor: - NodeManagerImpl: restore the O(1) byId index. The refactor dropped the secondary nodeDBbyID map and made getNodeById a linear scan over the whole node DB. getNodeById is called per-incoming-packet from MeshDataHandlerImpl (three times — shouldFilterMessage, handleNotificationMessage, getSenderName) and per-outbound send from CommandSenderImpl.resolveNodeNum.ById. The new NodeIndex holder maintains byNum and byId together in a single atomic ref so concurrent readers never see an inconsistent snapshot. - NodeWithRelations.toModel: inline directly to a single Node(...) constructor call. The previous form did `node.toModel().copy(metadata=…, manuallyVerified=…)` which allocated the Node twice per DB row — wasteful on every nodeDBbyNum emission. - NodeRepositoryImpl.getUser: replace two NodeAddress.fromString(userId) calls with a single string equality check against NodeAddress.ID_LOCAL. getUser is on the hot path for paged contacts and message arrival. Co-Authored-By: Claude Opus 4.7 --- .../core/data/manager/NodeManagerImpl.kt | 68 +++++++++++++++---- .../data/repository/NodeRepositoryImpl.kt | 7 +- .../core/database/entity/NodeEntity.kt | 27 +++++++- .../core/model/EnvironmentMetrics.kt | 3 + .../org/meshtastic/core/model/MeshUser.kt | 12 +++- 5 files changed, 98 insertions(+), 19 deletions(-) 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 bb5e140d4f..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 @@ -58,12 +59,53 @@ class NodeManagerImpl( @Named("ServiceScope") private val scope: CoroutineScope, ) : NodeManager { - private val _nodeDBbyNodeNum = 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 fun getNodeById(id: String): Node? = _nodeDBbyNodeNum.value.values.firstOrNull { it.user.id == id } + override fun getNodeById(id: String): Node? = nodeIndex.value.byId[id] override val isNodeDbReady = MutableStateFlow(false) override val allowNodeDbWrites = MutableStateFlow(false) @@ -95,7 +137,7 @@ class NodeManagerImpl( override fun loadCachedNodeDB() { scope.handledLaunch { val nodes = nodeRepository.nodeDBbyNum.first() - _nodeDBbyNodeNum.value = persistentMapOf().putAll(nodes) + nodeIndex.value = NodeIndex.fromByNum(nodes) if (myNodeNum.value == null) { myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum } @@ -103,7 +145,7 @@ class NodeManagerImpl( } override fun clear() { - _nodeDBbyNodeNum.value = persistentMapOf() + nodeIndex.value = NodeIndex() isNodeDbReady.value = false allowNodeDbWrites.value = false myNodeNum.value = null @@ -112,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, @@ -133,14 +175,14 @@ 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 removeByNodenum(nodeNum: Int) { - _nodeDBbyNodeNum.update { it.remove(nodeNum) } + 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 = NodeAddress.numToDefaultId(n) val defaultUser = @@ -159,11 +201,11 @@ class NodeManagerImpl( // 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 @@ -309,6 +351,6 @@ class NodeManagerImpl( override fun toNodeID(nodeNum: Int): String = if (nodeNum == NodeAddress.NODENUM_BROADCAST) { NodeAddress.ID_BROADCAST } else { - _nodeDBbyNodeNum.value[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum) + nodeIndex.value.byNum[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum) } } 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 fb0077177a..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 @@ -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 (NodeAddress.fromString(userId) is NodeAddress.Local) { + if (isLocal) { ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local" } else { "Meshtastic $fallbackId" } val defaultShort = - if (NodeAddress.fromString(userId) is NodeAddress.Local) { + if (isLocal) { ourNodeInfo.value?.user?.short_name?.takeIf { it.isNotBlank() } ?: "Local" } else { fallbackId 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 48f41170a8..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 @@ -41,7 +41,32 @@ data class NodeWithRelations( @Relation(entity = MetadataEntity::class, parentColumns = ["num"], entityColumns = ["num"]) val metadata: MetadataEntity?, ) { - fun toModel() = node.toModel().copy(metadata = metadata?.proto, manuallyVerified = node.manuallyVerified) + // 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( 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 index 53c362b65b..2ed7788cc8 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EnvironmentMetrics.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/EnvironmentMetrics.kt @@ -39,6 +39,9 @@ data class EnvironmentMetrics( 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 }, 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 index 1b6efcf5f5..860f20f3e5 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshUser.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshUser.kt @@ -41,14 +41,20 @@ data class MeshUser( ) : 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 + * 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('p', '.').lowercase() + hwModel.name.replace('_', '-').replace(VERSION_P_REGEX, "$1.$2").lowercase() } + + companion object { + private val VERSION_P_REGEX = Regex("(\\d)p(\\d)", RegexOption.IGNORE_CASE) + } } From 8ae937b063db9ac79df6e895b32cfefeb0d44055 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 28 May 2026 08:11:21 -0500 Subject: [PATCH 12/34] refactor: Move RadioController interfaces to core:repository and split into focused sub-interfaces Relocates RadioController, AdminController, MessagingController, NodeController, and RequestController from core:model to core:repository where they belong (alongside their consumers and the ServiceRepository they mirror). Splits the monolithic RadioController into 4 focused sub-interfaces following the Interface Segregation Principle: - AdminController: config, channels, device lifecycle - MessagingController: send packets, reactions, contacts - NodeController: favorite, ignore, mute, remove - RequestController: traceroute, telemetry, position queries RadioController remains as a composite extending all four for backward compatibility. Feature modules can now inject the narrower interface for better testability. Also fixes ScannerViewModelTest by adding Dispatchers.setMain/resetMain to match the pattern used by all other ViewModel tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/app/map/MapViewModel.kt | 2 +- .../org/meshtastic/app/map/MapViewModel.kt | 2 +- .../session/EnsureRemoteAdminSessionUseCase.kt | 2 +- .../usecase/settings/AdminActionsUseCase.kt | 2 +- .../settings/CleanNodeDatabaseUseCase.kt | 2 +- .../usecase/settings/InstallProfileUseCase.kt | 2 +- .../usecase/settings/IsOtaCapableUseCase.kt | 2 +- .../usecase/settings/RadioConfigUseCase.kt | 2 +- .../EnsureRemoteAdminSessionUseCaseTest.kt | 2 +- .../settings/IsOtaCapableUseCaseTest.kt | 2 +- .../core/repository}/AdminController.kt | 4 +++- .../core/repository/MeshNotificationManager.kt | 6 ++++++ .../core/repository}/MessagingController.kt | 4 +++- .../core/repository}/NodeController.kt | 2 +- .../core/repository/NotificationManager.kt | 7 +++++++ .../core/repository}/RadioController.kt | 3 ++- .../core/repository}/RequestController.kt | 4 +++- .../core/repository/di/CoreRepositoryModule.kt | 2 +- .../repository/usecase/SendMessageUseCase.kt | 2 +- .../core/service/ReactionReceiver.kt | 2 +- .../meshtastic/core/service/ReplyReceiver.kt | 2 +- .../service/di/CoreServiceAndroidModule.kt | 2 +- .../core/service/worker/SendMessageWorker.kt | 2 +- .../core/service/DirectRadioControllerImpl.kt | 2 +- .../core/testing/FakeRadioController.kt | 2 +- .../core/ui/qr/ScannedQrCodeViewModel.kt | 2 +- .../core/ui/share/SharedContactViewModel.kt | 2 +- .../core/ui/viewmodel/UIViewModel.kt | 2 +- .../meshtastic/desktop/di/DesktopKoinModule.kt | 2 +- .../desktop/radio/DesktopMessageQueue.kt | 2 +- .../connections/AndroidScannerViewModel.kt | 2 +- .../feature/connections/ScannerViewModel.kt | 2 +- .../connections/ScannerViewModelTest.kt | 18 +++++++++++++++--- .../feature/connections/JvmScannerViewModel.kt | 2 +- .../firmware/FirmwareUpdateViewModel.kt | 2 +- .../feature/firmware/UsbUpdateHandler.kt | 2 +- .../feature/firmware/UsbUpdateSupport.kt | 2 +- .../firmware/ota/Esp32OtaUpdateHandler.kt | 2 +- .../firmware/ota/dfu/SecureDfuHandler.kt | 2 +- .../DefaultFirmwareUpdateManagerTest.kt | 2 +- .../meshtastic/feature/map/BaseMapViewModel.kt | 2 +- .../feature/map/SharedMapViewModel.kt | 2 +- .../feature/messaging/MessageViewModel.kt | 2 +- .../feature/messaging/MessageViewModelTest.kt | 2 +- .../node/detail/CommonNodeRequestActions.kt | 2 +- .../feature/node/detail/NodeDetailViewModel.kt | 2 +- .../node/detail/NodeManagementActions.kt | 2 +- .../feature/node/list/NodeListViewModel.kt | 2 +- .../node/detail/HandleNodeActionTest.kt | 2 +- .../node/detail/NodeDetailViewModelTest.kt | 2 +- .../feature/settings/SettingsViewModel.kt | 2 +- .../settings/channel/ChannelViewModel.kt | 2 +- 52 files changed, 84 insertions(+), 52 deletions(-) rename core/{model/src/commonMain/kotlin/org/meshtastic/core/model => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/AdminController.kt (98%) rename core/{model/src/commonMain/kotlin/org/meshtastic/core/model => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/MessagingController.kt (95%) rename core/{model/src/commonMain/kotlin/org/meshtastic/core/model => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/NodeController.kt (97%) rename core/{model/src/commonMain/kotlin/org/meshtastic/core/model => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/RadioController.kt (97%) rename core/{model/src/commonMain/kotlin/org/meshtastic/core/model => repository/src/commonMain/kotlin/org/meshtastic/core/repository}/RequestController.kt (95%) 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/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 679e42df8f..0e36f2699a 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -51,11 +51,11 @@ 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.NodeAddress -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.repository.UiPrefs import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.map.BaseMapViewModel 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 5a8d94c281..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 @@ -29,8 +29,8 @@ import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.SessionManager import kotlin.time.Duration.Companion.seconds 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..4c5f38f965 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. 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..ab73435e35 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. */ 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..f276b5e310 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,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.DeviceProfile import org.meshtastic.proto.LocalConfig 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/RadioConfigUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt index 838617b2ed..15698119c5 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 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 bd02bb3cf2..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 @@ -34,8 +34,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.ByteString import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.SessionManager import kotlin.test.Test 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/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt similarity index 98% rename from core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminController.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt index 992c526a6d..56197d3b91 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt @@ -14,7 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.model +package org.meshtastic.core.repository + +import org.meshtastic.core.model.Position /** * Device configuration and control operations. diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt index d7d181c846..26eaf0289a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt @@ -23,6 +23,12 @@ 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 MeshNotificationManager { fun clearNotifications() diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessagingController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessagingController.kt similarity index 95% rename from core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessagingController.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessagingController.kt index 23fb832a7d..e41fead0ef 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessagingController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessagingController.kt @@ -14,7 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.model +package org.meshtastic.core.repository + +import org.meshtastic.core.model.DataPacket /** * Messaging operations — sending data packets, reactions, and shared contacts. diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeController.kt similarity index 97% rename from core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeController.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeController.kt index 478c149a40..769c56151e 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeController.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.model +package org.meshtastic.core.repository /** * Node management operations — favorite, ignore, mute, and remove nodes. 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/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt similarity index 97% rename from core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt index aab4a8b54f..465c9753ce 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt @@ -14,9 +14,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.model +package org.meshtastic.core.repository import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.core.model.ConnectionState import org.meshtastic.proto.ClientNotification /** diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RequestController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RequestController.kt similarity index 95% rename from core/model/src/commonMain/kotlin/org/meshtastic/core/model/RequestController.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RequestController.kt index 5b9ce8e238..44ac1b6af5 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RequestController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RequestController.kt @@ -14,7 +14,9 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.model +package org.meshtastic.core.repository + +import org.meshtastic.core.model.Position /** * Mesh request operations — position, traceroute, telemetry, user info, and metadata queries. 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 91fb9df2ea..cc5a917a83 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 @@ -24,11 +24,11 @@ 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.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.proto.Config import kotlin.random.Random 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 0110af9616..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,7 +26,7 @@ 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.RadioController +import org.meshtastic.core.repository.RadioController import kotlin.coroutines.cancellation.CancellationException /** 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 278c4f65aa..4ffc998e72 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 @@ -27,8 +27,8 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MeshNotificationManager +import org.meshtastic.core.repository.RadioController /** * A [BroadcastReceiver] that handles inline replies from notifications. 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 9c3e29132e..78d46e25dc 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 @@ -23,7 +23,6 @@ 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.model.RadioController import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.MeshLocationManager @@ -35,6 +34,7 @@ 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.RadioController import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs 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/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt index 333718267f..8dfacb9c11 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -31,7 +31,6 @@ 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.RadioController import org.meshtastic.core.model.Reaction import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.DataPair @@ -45,6 +44,7 @@ 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.RadioController import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs 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 a8805080fe..41400bd398 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,7 @@ 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.RadioController import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Config 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 337f4609a9..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,8 +22,8 @@ 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.RadioController import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.SharedContact 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 3640dbc556..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 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 1ddeb1cf12..71f1f7eb17 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -40,7 +40,6 @@ 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 @@ -54,6 +53,7 @@ import org.meshtastic.core.repository.MeshWorkerManager import org.meshtastic.core.repository.MessageQueue import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PlatformAnalytics +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioTransportFactory import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.service.DirectRadioControllerImpl 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/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 25864c6a10..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 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 f311e7d318..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) 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 b0ae3e8971..603fae5a3f 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 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 bc066bda34..f503026309 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 @@ -29,11 +29,11 @@ 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.model.RadioController 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 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/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index c51484fb1d..eaa3968b30 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 @@ -37,7 +37,6 @@ import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress -import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.CustomEmojiPrefs import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.NodeRepository @@ -45,6 +44,7 @@ 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.RadioController import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.usecase.SendMessageUseCase 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 0fd2eda0be..7ddb26a079 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 @@ -34,12 +34,12 @@ 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.RadioController import org.meshtastic.core.repository.CustomEmojiPrefs import org.meshtastic.core.repository.HomoglyphPrefs import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.QuickChatActionRepository import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.repository.usecase.SendMessageUseCase 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 0742c11c5d..917f71ff0e 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 @@ -24,8 +24,8 @@ import kotlinx.coroutines.flow.update import org.koin.core.annotation.Single 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 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 336c410d91..8960a78fe2 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 @@ -36,10 +36,10 @@ import org.meshtastic.core.domain.usecase.session.EnsureSessionResult import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.connect_radio_for_remote_admin 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 0fabdd025a..2f655f3ead 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 @@ -22,8 +22,8 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString 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 org.meshtastic.core.resources.Res import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.favorite_add 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 7953eace94..ae8a9651f5 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 @@ -33,10 +33,10 @@ 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.DeviceHardwareRepository import org.meshtastic.core.repository.NodeRepository 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.ui.viewmodel.stateInWhileSubscribed 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 c3fc677a33..7a7314975f 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 @@ -33,8 +33,8 @@ import kotlinx.coroutines.test.setMain import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.Node -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.SessionStatus +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.ui.util.SnackbarManager import org.meshtastic.feature.node.component.NodeMenuAction import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase 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 6760c91822..1040e20ef0 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 @@ -40,9 +40,9 @@ import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCas import org.meshtastic.core.domain.usecase.session.EnsureSessionResult import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase import org.meshtastic.core.model.Node -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.navigation.SettingsRoute +import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.connect_radio_for_remote_admin 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 f2423819d7..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 @@ -37,12 +37,12 @@ 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 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 From 19fe0797b655c86261f550ec7009ff14f52c11d7 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 28 May 2026 09:13:42 -0500 Subject: [PATCH 13/34] fix: Harden orchestrator start() race condition and strengthen test assertions - MeshServiceOrchestrator.start(): Replace non-atomic dead-scope replacement with a proper CAS loop to prevent duplicate startup from concurrent callers - DirectRadioControllerImplTest: Replace no-op atLeast(0) assertions with exactly(0) so early-return guards are actually verified - FakeRadioController: Default connectionState to Disconnected (matches real ServiceRepositoryImpl initial state); fix mismatched parameter name - SettingsViewModelTest: Update isConnected test to reflect corrected initial state - Remove stale core:api references from .skills/ documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .skills/project-overview/SKILL.md | 3 +-- .skills/testing-ci/SKILL.md | 2 +- .../meshtastic/core/service/MeshServiceOrchestrator.kt | 8 ++++++-- .../core/service/DirectRadioControllerImplTest.kt | 9 +++++---- .../org/meshtastic/core/testing/FakeRadioController.kt | 4 ++-- .../feature/settings/SettingsViewModelTest.kt | 10 +++++----- 6 files changed, 20 insertions(+), 16 deletions(-) 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/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 92a59fc457..80374cb8bb 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 @@ -90,8 +90,12 @@ class MeshServiceOrchestrator( Logger.d { "start() called while already running, ignoring" } return } - // The slot held a dead scope (post-stop). Replace it. - scopeRef.value = newScope + // 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" } 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 index 250df61e1f..f43a0c36bd 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt @@ -24,6 +24,7 @@ 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 @@ -197,7 +198,7 @@ class DirectRadioControllerImplTest { // switchDevice should skip when addresses match, but transport still reconfigures verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } - verifySuspend(atLeast(0)) { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + verifySuspend(exactly(0)) { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } } @Test @@ -261,7 +262,7 @@ class DirectRadioControllerImplTest { controller.ignoreNode(99) controller.muteNode(99) - verifySuspend(atLeast(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } + verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } } @Test @@ -282,7 +283,7 @@ class DirectRadioControllerImplTest { verifySuspend { nodeManager.removeByNodenum(55) } // No admin message sent when disconnected - verifySuspend(atLeast(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } + verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } } @Test @@ -348,6 +349,6 @@ class DirectRadioControllerImplTest { controller.importContact(contact) - verifySuspend(atLeast(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } + verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } } } 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 41400bd398..9cb62d947d 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 @@ -36,7 +36,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) @@ -92,7 +92,7 @@ class FakeRadioController : override suspend fun importContact(contact: org.meshtastic.proto.SharedContact) {} - override suspend fun refreshMetadata(nodeNum: Int) {} + override suspend fun refreshMetadata(destNum: Int) {} override suspend fun setLocalConfig(config: Config) {} 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 e41e3e412c..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 @@ -137,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() } } From 50cb557c77945d0f9412c5d6faaa15ec1b221875 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 28 May 2026 10:13:29 -0500 Subject: [PATCH 14/34] cleanup: Remove dead MeshRouter dependency from orchestrator and fix stale comments - MeshServiceOrchestrator: Remove unused MeshRouter injection (was a pure service-locator facade from the old action-dispatch pattern, never called) - MeshService.onBind(): Add clarifying comment that this is a started service - MeshServiceStarter: Replace stale 'binding' comment with accurate description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/core/service/MeshService.kt | 1 + .../kotlin/org/meshtastic/core/service/MeshServiceStarter.kt | 5 ++--- .../org/meshtastic/core/service/MeshServiceOrchestrator.kt | 4 +--- .../meshtastic/core/service/MeshServiceOrchestratorTest.kt | 3 --- 4 files changed, 4 insertions(+), 9 deletions(-) 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 afaa0817fa..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 @@ -196,6 +196,7 @@ class MeshService : Service() { Logger.i { "Mesh service: onTaskRemoved" } } + // Required by Service — this is a started service (not bound), so always returns null. override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { 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/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 80374cb8bb..0ba7973851 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 @@ -33,7 +33,6 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshNotificationManager -import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.ServiceRepository @@ -56,7 +55,6 @@ class MeshServiceOrchestrator( private val serviceRepository: ServiceRepository, private val nodeManager: NodeManager, private val messageProcessor: MeshMessageProcessor, - private val router: MeshRouter, private val serviceNotifications: MeshNotificationManager, private val takServerManager: TAKServerManager, private val takMeshIntegration: TAKMeshIntegration, @@ -79,7 +77,7 @@ class MeshServiceOrchestrator( * 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() { val newScope = CoroutineScope(SupervisorJob() + dispatchers.default) 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 8d64a9cbfe..d0704c4c5b 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 @@ -37,7 +37,6 @@ import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshMessageProcessor import org.meshtastic.core.repository.MeshNotificationManager -import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioInterfaceService @@ -58,7 +57,6 @@ 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 meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill) private val serviceNotifications: MeshNotificationManager = mock(MockMode.autofill) private val takServerManager: TAKServerManager = mock(MockMode.autofill) @@ -103,7 +101,6 @@ class MeshServiceOrchestratorTest { serviceRepository = serviceRepository, nodeManager = nodeManager, messageProcessor = messageProcessor, - router = router, serviceNotifications = serviceNotifications, takServerManager = takServerManager, takMeshIntegration = takMeshIntegration, From ad553f1139fbc45515faa4e0d9a47995a38909cd Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 28 May 2026 15:15:13 -0500 Subject: [PATCH 15/34] refactor: Remove MeshRouter service-locator facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeshRouter was a pure pass-through with zero routing logic — just 7 lazy property getters wrapping handler interfaces. Inline the actual handler dependencies directly into their consumers: - FromRadioPacketHandlerImpl: inject configFlowManager, configHandler, xmodemManager as Lazy params (was router.value.X) - MeshMessageProcessorImpl: inject dataHandler as Lazy (was router.value.dataHandler) Delete MeshRouter interface, MeshRouterImpl, and MeshRouterImplTest. Update tests to mock handlers directly instead of mocking the router. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manager/FromRadioPacketHandlerImpl.kt | 32 ++-- .../data/manager/MeshMessageProcessorImpl.kt | 6 +- .../core/data/manager/MeshRouterImpl.kt | 61 -------- .../manager/FromRadioPacketHandlerImplTest.kt | 11 +- .../manager/MeshMessageProcessorImplTest.kt | 5 +- .../core/data/manager/MeshRouterImplTest.kt | 143 ------------------ .../meshtastic/core/repository/MeshRouter.kt | 41 ----- 7 files changed, 27 insertions(+), 272 deletions(-) delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshRouterImplTest.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt 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..5d9e035f55 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.XModemManager import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.client_notification import org.meshtastic.core.resources.duplicated_public_key_title @@ -44,7 +46,9 @@ import org.meshtastic.proto.FromRadio @Single class FromRadioPacketHandlerImpl( private val serviceRepository: ServiceRepository, - private val router: Lazy, + 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) + serviceRepository.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,7 +110,7 @@ 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() } } } 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 edb698737a..275b9771ab 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,9 +36,9 @@ 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.proto.FromRadio @@ -55,7 +55,7 @@ class MeshMessageProcessorImpl( private val nodeManager: NodeManager, private val serviceRepository: ServiceRepository, private val meshLogRepository: Lazy, - private val router: Lazy, + private val dataHandler: Lazy, private val fromRadioDispatcher: FromRadioPacketHandler, @Named("ServiceScope") private val scope: CoroutineScope, ) : MeshMessageProcessor { @@ -253,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 { 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 64c07d17b1..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt +++ /dev/null @@ -1,61 +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.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 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 xmodemManager: XModemManager - get() = xmodemManagerLazy.value -} 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/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt index 10f7c2312e..7fde354e70 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, meshLogRepository = lazy { meshLogRepository }, - router = lazy { router }, + dataHandler = lazy { dataHandler }, fromRadioDispatcher = fromRadioDispatcher, scope = scope, ) 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 91e3610a3c..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshRouterImplTest.kt +++ /dev/null @@ -1,143 +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 kotlinx.coroutines.flow.MutableStateFlow -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 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 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 } - xmodemManagerLazy = TrackingLazy { xmodemManager } - - router = - MeshRouterImpl( - dataHandlerLazy = dataHandlerLazy, - configHandlerLazy = configHandlerLazy, - tracerouteHandlerLazy = tracerouteHandlerLazy, - neighborInfoHandlerLazy = neighborInfoHandlerLazy, - configFlowManagerLazy = configFlowManagerLazy, - mqttManagerLazy = mqttManagerLazy, - xmodemManagerLazy = xmodemManagerLazy, - ) - } - - @Test - fun `traceroute routing uses the traceroute handler lazily`() { - assertAllHandlersUninitialized() - - router.tracerouteHandler.recordStartTime(77) - - assertTrue(tracerouteHandlerLazy.isInitialized()) - assertFalse(dataHandlerLazy.isInitialized()) - verify { tracerouteHandler.recordStartTime(77) } - } - - @Test - fun `handlers are initialized independently`() { - assertAllHandlersUninitialized() - - router.dataHandler - assertTrue(dataHandlerLazy.isInitialized()) - assertFalse(configHandlerLazy.isInitialized()) - assertFalse(tracerouteHandlerLazy.isInitialized()) - - router.configHandler - assertTrue(configHandlerLazy.isInitialized()) - assertFalse(tracerouteHandlerLazy.isInitialized()) - } - - private fun assertAllHandlersUninitialized() { - assertFalse(dataHandlerLazy.isInitialized()) - assertFalse(configHandlerLazy.isInitialized()) - assertFalse(tracerouteHandlerLazy.isInitialized()) - assertFalse(neighborInfoHandlerLazy.isInitialized()) - assertFalse(configFlowManagerLazy.isInitialized()) - assertFalse(mqttManagerLazy.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/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 faec1d6d08..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt +++ /dev/null @@ -1,41 +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 XModem file-transfer manager. */ - val xmodemManager: XModemManager -} From b742d12895b6ea62fe340ec8a6593503270a090f Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 28 May 2026 15:44:33 -0500 Subject: [PATCH 16/34] refactor: Convert DeviceVersion to @JvmInline value class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-overhead wrapper eliminates boxing allocation for version comparisons. Move parsing logic to companion with pre-compiled regex for efficiency. Drop Logger side-effect from parsing (invalid versions already return 0 gracefully; callers that need diagnostics log independently). All DeviceVersionTest cases pass unchanged — equality, comparison, and edge-case handling preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/core/model/DeviceVersion.kt | 53 ++++++++----------- 1 file changed, 21 insertions(+), 32 deletions(-) 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() + } } } From e6a83368a3a512cd55373d8c565ac6cdd888058b Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 28 May 2026 16:48:38 -0500 Subject: [PATCH 17/34] test: Add NeighborInfoHandlerImpl unit tests Cover the newly-exposed handler (previously hidden behind MeshRouter): - Stores lastNeighborInfo only for own node - Ignores remote node packets - Sets response on serviceRepository - Handles null decoded gracefully - Includes duration when start time recorded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manager/NeighborInfoHandlerImplTest.kt | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImplTest.kt 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)) + } +} From 8aa0017d559bfb37ecb99351af1709015780ae8e Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 09:07:17 -0500 Subject: [PATCH 18/34] fix: Restore forced manual verification on contact import importContact previously marked imported contacts as manually_verified; the refactor dropped that, forwarding the proto default (false) instead. A QR-scanned contact arrives unverified, so importing it (scanning the code) is itself an act of manual verification and must be recorded as such. Force manually_verified = true on both the outgoing admin message and the local node update, restoring the original handleImportContact semantics, and assert the flag in the test. Also import the proto types in AdminController/MessagingController interfaces instead of using inline fully-qualified names. Co-Authored-By: Claude Opus 4.8 --- .../core/repository/AdminController.kt | 16 ++++++++++------ .../core/repository/MessagingController.kt | 3 ++- .../core/service/DirectRadioControllerImpl.kt | 7 +++++-- .../service/DirectRadioControllerImplTest.kt | 4 +++- 4 files changed, 20 insertions(+), 10 deletions(-) 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 index 56197d3b91..493180721a 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt @@ -17,6 +17,10 @@ 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. @@ -38,24 +42,24 @@ interface AdminController { * 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: org.meshtastic.proto.Config) + suspend fun setLocalConfig(config: Config) /** Updates a local radio channel. Same fire-and-forget contract as [setLocalConfig]. */ - suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) + suspend fun setLocalChannel(channel: Channel) // ── Remote configuration ──────────────────────────────────────────────── /** Updates the owner (user info) on a remote node. */ - suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) + suspend fun setOwner(destNum: Int, user: User, packetId: Int) /** Updates the general configuration on a remote node. */ - suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) + suspend fun setConfig(destNum: Int, config: Config, packetId: Int) /** Updates a module configuration on a remote node. */ - suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) + suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) /** Updates a channel configuration on a remote node. */ - suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) + suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) /** Sets a fixed position on a remote node. */ suspend fun setFixedPosition(destNum: Int, position: Position) 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 index e41fead0ef..21a3cbbe16 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessagingController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MessagingController.kt @@ -17,6 +17,7 @@ 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. @@ -35,7 +36,7 @@ interface MessagingController { suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) /** Imports a shared contact into the firmware's NodeDB. */ - suspend fun importContact(contact: org.meshtastic.proto.SharedContact) + suspend fun importContact(contact: SharedContact) /** * Sends our shared contact information (identity and public key) to the firmware's NodeDB. 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 index 8dfacb9c11..878a4e1758 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -211,8 +211,11 @@ class DirectRadioControllerImpl( Logger.w { "importContact rejected: missing node_num or user (node_num=${contact.node_num})" } return } - commandSender.sendAdmin(myNum) { AdminMessage(add_contact = contact) } - nodeManager.handleReceivedUser(contact.node_num, user, manuallyVerified = contact.manually_verified) + // 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) } // ── Device Metadata ───────────────────────────────────────────────────── 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 index f43a0c36bd..53406bfa87 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt @@ -334,12 +334,14 @@ class DirectRadioControllerImplTest { @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()) } - verify { nodeManager.handleReceivedUser(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 From 4970e45e142c1b0b7a5efcf099853ba2b34ac940 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 09:31:07 -0500 Subject: [PATCH 19/34] refactor: Consolidate contact-key parsing into ContactKey Six call sites hand-rolled the same fragile contact-key parse (`contactKey[0].digitToIntOrNull()` + `substring(1)`). Move that logic into the ContactKey value class introduced with this branch: - Add `channelOrNull: Int?` which preserves the semantically meaningful "no leading channel digit == legacy unprefixed DM" signal that the existing `channel: Int` collapses to 0. SendMessageUseCase relies on this distinction to classify direct messages, so the naive `channel` accessor would have been a regression. - Make `addressString` respect it: returns the whole key when there is no channel prefix (matching the old defensive behaviour) instead of blindly dropping the first character. Migrated SendMessageUseCase, BaseMapViewModel, ReplyReceiver, Message, Contacts, and ContactItem onto the typed accessors. Added ContactKey unit tests for the prefixed / unprefixed / empty cases. Also removed a dead empty `companion object` left on DataPacket after its constants moved to NodeAddress. Co-Authored-By: Claude Opus 4.8 --- .../org/meshtastic/core/model/DataPacket.kt | 2 -- .../org/meshtastic/core/model/NodeAddress.kt | 19 +++++++++++---- .../meshtastic/core/model/NodeAddressTest.kt | 23 +++++++++++++++++++ .../repository/usecase/SendMessageUseCase.kt | 6 +++-- .../meshtastic/core/service/ReplyReceiver.kt | 6 ++--- .../feature/map/BaseMapViewModel.kt | 7 +++--- .../meshtastic/feature/messaging/Message.kt | 6 +++-- .../messaging/ui/contact/ContactItem.kt | 4 ++-- .../feature/messaging/ui/contact/Contacts.kt | 3 ++- 9 files changed, 56 insertions(+), 20 deletions(-) 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 5834304568..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 @@ -122,6 +122,4 @@ data class DataPacket( val hopsAway: Int get() = if (hopStart == 0 || (hopLimit > hopStart)) -1 else hopStart - hopLimit - - companion object } 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 index d630b361ba..f6faa5d113 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeAddress.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeAddress.kt @@ -95,13 +95,24 @@ sealed class NodeAddress { */ @JvmInline value class ContactKey(val value: String) { - /** The channel index (first character). Returns 0 if the key is empty or the first char is not a digit. */ + /** + * 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() = value.firstOrNull()?.takeIf { it.isDigit() }?.digitToInt() ?: 0 + get() = channelOrNull ?: 0 - /** The node address portion (everything after the channel digit). Empty if the key is empty. */ + /** + * 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 (value.isEmpty()) "" else value.substring(1) + get() = if (channelOrNull != null) value.substring(1) else value /** Parsed [NodeAddress] for the contact. */ val address: NodeAddress 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 index 6c3a1d065f..0e0440640a 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeAddressTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeAddressTest.kt @@ -192,6 +192,29 @@ class NodeAddressTest { 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") 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 cc5a917a83..eac50d0150 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,6 +20,7 @@ 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 @@ -66,8 +67,9 @@ 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 ?: NodeAddress.ID_LOCAL 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 4ffc998e72..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,6 +26,7 @@ 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.repository.MeshNotificationManager import org.meshtastic.core.repository.RadioController @@ -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/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index f503026309..c07a6339b9 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,6 +26,7 @@ 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.NodeAddress @@ -152,10 +153,8 @@ open class BaseMapViewModel( 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) } 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 394533fcf5..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,6 +72,7 @@ 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.ContactKey import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.util.getChannel @@ -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) } 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 { From b97ff4d712316bf5f4f256254a8862e11ce7a74b Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 09:45:19 -0500 Subject: [PATCH 20/34] refactor: Idempotent node ops (setFavorite/setIgnored) matching SDK NodeController toggled favorite/ignore state by reading the current node then flipping it (read-modify-write), which races concurrent callers and forced a latent bug in SendMessageUseCase: it only ever wants to favorite a node, but called a toggle, so a node that became favorite between the guard and the call would have been un-favorited. Replace with explicit-target idempotent operations mirroring the SDK's AdminApi: - favoriteNode(num) -> setFavorite(num, Boolean) - ignoreNode(num) -> setIgnored(num, Boolean) - muteNode(num) -> toggleMuted(num) (mute is genuinely a firmware toggle) Impls no-op when the node is already in the requested state. Callers (NodeManagementActions confirm dialogs) pass !node.isFavorite from the state the UI showed the user; SendMessageUseCase passes favorite = true. Co-Authored-By: Claude Opus 4.8 --- .../core/repository/NodeController.kt | 21 +++++++++---- .../repository/usecase/SendMessageUseCase.kt | 2 +- .../core/service/DirectRadioControllerImpl.kt | 31 ++++++++++--------- .../service/DirectRadioControllerImplTest.kt | 30 ++++++++++++------ .../core/testing/FakeRadioController.kt | 8 ++--- .../node/detail/NodeManagementActions.kt | 18 +++++------ 6 files changed, 67 insertions(+), 43 deletions(-) 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 index 769c56151e..02903dcd39 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeController.kt @@ -26,14 +26,23 @@ package org.meshtastic.core.repository */ interface NodeController { - /** Toggles the favorite status of a node on the radio. */ - suspend fun favoriteNode(nodeNum: Int) + /** + * 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) - /** Toggles the ignore status of a node on the radio. */ - suspend fun ignoreNode(nodeNum: Int) + /** + * 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. */ - suspend fun muteNode(nodeNum: Int) + /** 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/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt index eac50d0150..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 @@ -133,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/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt index 878a4e1758..3dec3b2885 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -156,31 +156,34 @@ class DirectRadioControllerImpl( // ── Node Management ───────────────────────────────────────────────────── - override suspend fun favoriteNode(nodeNum: Int) { + override suspend fun setFavorite(nodeNum: Int, favorite: Boolean) { val myNum = nodeManager.myNodeNum.value ?: return val node = nodeManager.nodeDBbyNodeNum[nodeNum] ?: return - commandSender.sendAdmin(myNum) { - if (node.isFavorite) { - AdminMessage(remove_favorite_node = node.num) - } else { - AdminMessage(set_favorite_node = node.num) + 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) } } - nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } } - override suspend fun ignoreNode(nodeNum: Int) { + override suspend fun setIgnored(nodeNum: Int, ignored: Boolean) { val myNum = nodeManager.myNodeNum.value ?: return val node = nodeManager.nodeDBbyNodeNum[nodeNum] ?: return - val newIgnored = !node.isIgnored - commandSender.sendAdmin(myNum) { - if (newIgnored) AdminMessage(set_ignored_node = node.num) else AdminMessage(remove_ignored_node = node.num) + 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) } } - nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnored) } - scope.handledLaunch { packetRepository.value.updateFilteredBySender(node.user.id, newIgnored) } } - override suspend fun muteNode(nodeNum: Int) { + 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) } 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 index 53406bfa87..5b93ed6253 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt @@ -217,24 +217,36 @@ class DirectRadioControllerImplTest { } @Test - fun favoriteNodeSendsAdminAndUpdatesState() = runTest { + 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.favoriteNode(99) + controller.setFavorite(99, favorite = true) verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } verify { nodeManager.updateNode(any(), any(), any()) } } @Test - fun ignoreNodeSendsAdminUpdatesStateAndFiltersPackets() = runTest { + 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.ignoreNode(99) + controller.setIgnored(99, ignored = true) testScope.advanceUntilIdle() verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } @@ -243,12 +255,12 @@ class DirectRadioControllerImplTest { } @Test - fun muteNodeSendsAdminAndUpdatesState() = runTest { + 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.muteNode(99) + controller.toggleMuted(99) verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } verify { nodeManager.updateNode(any(), any(), any()) } @@ -258,9 +270,9 @@ class DirectRadioControllerImplTest { fun nodeManagementReturnsEarlyWhenMyNodeNumIsNull() = runTest { val controller = createController(myNodeNum = null) - controller.favoriteNode(99) - controller.ignoreNode(99) - controller.muteNode(99) + controller.setFavorite(99, favorite = true) + controller.setIgnored(99, ignored = true) + controller.toggleMuted(99) verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) } } 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 9cb62d947d..5fa459f0bf 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 @@ -75,8 +75,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,9 +84,9 @@ class FakeRadioController : return true } - override suspend fun ignoreNode(nodeNum: Int) {} + override suspend fun setIgnored(nodeNum: Int, ignored: Boolean) {} - override suspend fun muteNode(nodeNum: Int) {} + override suspend fun toggleMuted(nodeNum: Int) {} override suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) {} 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 2f655f3ead..73133b49ae 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 @@ -72,13 +72,13 @@ constructor( alertManager.showAlert( titleRes = Res.string.ignore, message = message, - onConfirm = { scope.launch { ignoreNode(node.num) } }, + onConfirm = { scope.launch { setIgnored(node.num, !node.isIgnored) } }, ) } } - open suspend fun ignoreNode(nodeNum: Int) { - radioController.ignoreNode(nodeNum) + open suspend fun setIgnored(nodeNum: Int, ignored: Boolean) { + radioController.setIgnored(nodeNum, ignored) } open fun requestMuteNode(scope: CoroutineScope, node: Node) { @@ -88,13 +88,13 @@ constructor( alertManager.showAlert( titleRes = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications, message = message, - onConfirm = { scope.launch { muteNode(node.num) } }, + onConfirm = { scope.launch { toggleMuted(node.num) } }, ) } } - open suspend fun muteNode(nodeNum: Int) { - radioController.muteNode(nodeNum) + open suspend fun toggleMuted(nodeNum: Int) { + radioController.toggleMuted(nodeNum) } open fun requestFavoriteNode(scope: CoroutineScope, node: Node) { @@ -107,13 +107,13 @@ constructor( alertManager.showAlert( titleRes = Res.string.favorite, message = message, - onConfirm = { scope.launch { favoriteNode(node.num) } }, + onConfirm = { scope.launch { setFavorite(node.num, !node.isFavorite) } }, ) } } - open suspend fun favoriteNode(nodeNum: Int) { - radioController.favoriteNode(nodeNum) + open suspend fun setFavorite(nodeNum: Int, favorite: Boolean) { + radioController.setFavorite(nodeNum, favorite) } open suspend fun setNodeNotes(nodeNum: Int, notes: String) { From 2c0012916bbce83da785ca45c5c46f7a4cf1c871 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 09:50:35 -0500 Subject: [PATCH 21/34] refactor: Replace begin/commitEditSettings with editSettings DSL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transactional config-write API exposed raw beginEditSettings / commitEditSettings pairs, so callers had to remember to commit and could leak a half-open session. Replace with a scoped builder mirroring the SDK's AdminApi.editSettings { }: radioController.editSettings(destNum) { setOwner(user); setConfig(config); setModuleConfig(...) } The new AdminEditScope binds operations to the session's destination, so InstallProfileUseCase no longer threads destNum + getPacketId() through every call — its install* helpers become AdminEditScope extensions, dropping a large amount of boilerplate. Co-Authored-By: Claude Opus 4.8 --- .../core/data/manager/PacketHandlerImpl.kt | 4 +- .../usecase/settings/InstallProfileUseCase.kt | 131 ++++++------------ .../settings/InstallProfileUseCaseTest.kt | 6 +- .../core/repository/AdminController.kt | 35 ++++- .../core/service/DirectRadioControllerImpl.kt | 19 ++- .../core/testing/FakeRadioController.kt | 28 ++-- 6 files changed, 113 insertions(+), 110 deletions(-) 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 f314bf26d2..ea69e75fbb 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 @@ -106,8 +106,8 @@ class PacketHandlerImpl( /** * 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. `beginEditSettings` → … → `commitEditSettings` — MUST be issued from a single coroutine; - * concurrent senders share FIFO only at the per-call grain. + * 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 { 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 f276b5e310..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,6 +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.repository.AdminEditScope import org.meshtastic.core.repository.RadioController import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceProfile @@ -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/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/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt index 493180721a..b5466797df 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt @@ -115,9 +115,36 @@ interface AdminController { // ── Batch edit ────────────────────────────────────────────────────────── - /** Signals the start of a batch configuration session. */ - suspend fun beginEditSettings(destNum: Int) + /** + * 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) - /** Commits all pending configuration changes in a batch session. */ - suspend fun commitEditSettings(destNum: Int) + /** Sets a fixed position on the session's node. */ + suspend fun setFixedPosition(position: Position) } 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 index 3dec3b2885..7ebe4e74ea 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -32,6 +32,7 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.Position import org.meshtastic.core.model.Reaction +import org.meshtastic.core.repository.AdminEditScope import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.MeshDataHandler @@ -379,12 +380,24 @@ class DirectRadioControllerImpl( // ── Edit Settings (transactional) ─────────────────────────────────────── - override suspend fun beginEditSettings(destNum: Int) { + 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) } } - override suspend fun commitEditSettings(destNum: Int) { - commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) } + /** Binds the [AdminEditScope] operations to a fixed destination, delegating to the controller's set* methods. */ + private inner class EditSettingsSession(private val destNum: Int) : AdminEditScope { + override suspend fun setOwner(user: User) = setOwner(destNum, user, getPacketId()) + + override suspend fun setConfig(config: Config) = setConfig(destNum, config, getPacketId()) + + override suspend fun setModuleConfig(config: ModuleConfig) = setModuleConfig(destNum, config, getPacketId()) + + override suspend fun setChannel(channel: Channel) = setRemoteChannel(destNum, channel, getPacketId()) + + override suspend fun setFixedPosition(position: Position) = + this@DirectRadioControllerImpl.setFixedPosition(destNum, position) } // ── Telemetry & Discovery ─────────────────────────────────────────────── 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 5fa459f0bf..2880ed5568 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,6 +20,7 @@ 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.repository.AdminEditScope import org.meshtastic.core.repository.RadioController import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification @@ -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 } @@ -150,12 +149,23 @@ 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, getPacketId()) + + override suspend fun setConfig(config: Config) = setConfig(destNum, config, getPacketId()) + + override suspend fun setModuleConfig(config: ModuleConfig) = + setModuleConfig(destNum, config, getPacketId()) + + override suspend fun setChannel(channel: Channel) = setRemoteChannel(destNum, channel, getPacketId()) - 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 From 6940b6afc81287e26bbeb1aac848a2485f7596cf Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 09:55:29 -0500 Subject: [PATCH 22/34] refactor: Split DirectRadioControllerImpl into focused sub-controllers DirectRadioControllerImpl had absorbed the old MeshActionHandler and grown into a ~450-line, 17-dependency God object spanning messaging, node management, configuration, device lifecycle, and address switching. Decompose the command logic into four cohesive collaborators, one per RadioController sub-interface, mirroring the SDK's layered API design: - AdminControllerImpl -> AdminController (~SDK AdminApiImpl) - MessagingControllerImpl-> MessagingController(~SDK RadioClient.send*) - NodeControllerImpl -> NodeController (~SDK AdminApi node ops) - RequestControllerImpl -> RequestController (~SDK Telemetry/RoutingApi) DirectRadioControllerImpl becomes a thin composition root that assembles the four via Kotlin interface delegation (`by`) and keeps only the cross-cutting concerns (connection state, packet-id, location, device address). Its constructor signature is unchanged, so DI wiring (Android + desktop) and the existing integration test pass untouched. Each collaborator is now independently testable and becomes a thin SDK adapter at migration time. Co-Authored-By: Claude Opus 4.8 --- .../core/service/AdminControllerImpl.kt | 216 ++++++++++ .../core/service/DirectRadioControllerImpl.kt | 387 ++---------------- .../core/service/MessagingControllerImpl.kt | 129 ++++++ .../core/service/NodeControllerImpl.kt | 79 ++++ .../core/service/RequestControllerImpl.kt | 76 ++++ 5 files changed, 533 insertions(+), 354 deletions(-) create mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt create mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt create mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt create mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/RequestControllerImpl.kt 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..2aad63dbe5 --- /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 [DirectRadioControllerImpl]. 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_REBOOT_DELAY) } + } + + 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_REBOOT_DELAY) } + } + + 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_REBOOT_DELAY = 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 index 7ebe4e74ea..837278c1b5 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt @@ -19,26 +19,16 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow -import okio.ByteString -import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.model.ConnectionState -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.Position -import org.meshtastic.core.model.Reaction -import org.meshtastic.core.repository.AdminEditScope +import org.meshtastic.core.repository.AdminController import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.DataPair 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 @@ -47,56 +37,55 @@ import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RequestController import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.OTAMode -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.SharedContact -import org.meshtastic.proto.User /** - * Platform-agnostic [RadioController] implementation modeled after the SDK's `AdminApiImpl` pattern. + * Platform-agnostic [RadioController] composition root for any target where the service runs in-process (Desktop, iOS, + * or Android in single-process mode). * - * This class is the single composition root for all radio commands. It builds [AdminMessage] protos directly and - * delegates to [CommandSender] for packet construction and transport — no intermediate handler layer, no ByteArray - * encode/decode boundaries. Business logic (optimistic persistence, node state updates, analytics) lives here. + * 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]/[RequestController] → + * `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. * - * This is the correct implementation for any target where the service runs in-process (Desktop, iOS, or Android in - * single-process mode). + * 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("TooManyFunctions", "LongParameterList") +@Suppress("LongParameterList") class DirectRadioControllerImpl( private val serviceRepository: ServiceRepository, - private val nodeRepository: NodeRepository, + nodeRepository: NodeRepository, private val commandSender: CommandSender, private val nodeManager: NodeManager, private val radioInterfaceService: RadioInterfaceService, private val locationManager: MeshLocationManager, - private val packetRepository: Lazy, - private val dataHandler: Lazy, - private val analytics: PlatformAnalytics, + packetRepository: Lazy, + dataHandler: Lazy, + analytics: PlatformAnalytics, private val meshPrefs: MeshPrefs, - private val uiPrefs: UiPrefs, + uiPrefs: UiPrefs, private val databaseManager: DatabaseManager, private val notificationManager: NotificationManager, private val messageProcessor: Lazy, - private val radioConfigRepository: RadioConfigRepository, - private val scope: CoroutineScope, + radioConfigRepository: RadioConfigRepository, + scope: CoroutineScope, private val onDeviceAddressChanged: (() -> Unit)? = null, -) : RadioController { - - companion object { - private const val DEFAULT_REBOOT_DELAY = 5 - private const val EMOJI_INDICATOR = 1 - } - - private val myNodeNum: Int - get() = nodeManager.myNodeNum.value ?: 0 +) : RadioController, + AdminController by AdminControllerImpl(commandSender, nodeManager, radioConfigRepository, scope), + MessagingController by MessagingControllerImpl( + commandSender, + nodeManager, + nodeRepository, + dataHandler, + analytics, + packetRepository, + ), + NodeController by NodeControllerImpl(commandSender, nodeManager, packetRepository, scope), + RequestController by RequestControllerImpl(commandSender, nodeManager, uiPrefs) { // ── Connection State ──────────────────────────────────────────────────── @@ -110,316 +99,6 @@ class DirectRadioControllerImpl( serviceRepository.clearClientNotification() } - // ── Messaging ─────────────────────────────────────────────────────────── - - 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, - ) - } - - // ── Node Management ───────────────────────────────────────────────────── - - 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) } - } - - // ── Contacts ──────────────────────────────────────────────────────────── - - 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) - } - - // ── Device Metadata ───────────────────────────────────────────────────── - - override suspend fun refreshMetadata(destNum: Int) { - commandSender.sendAdmin(destNum, wantResponse = true) { AdminMessage(get_device_metadata_request = true) } - } - - // ── 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 ─────────────────────────────────────────────────────── - // Config and channel writes use fire-and-forget persistence (handledLaunch) intentionally. - // The device is the source of truth — it re-sends its full config on every connection. - // Local persistence is a cache optimization, not a correctness requirement. - - 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) - } - - 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) - } - - // ── 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_REBOOT_DELAY) } - } - - 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_REBOOT_DELAY) } - } - - 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 the controller's set* methods. */ - private inner class EditSettingsSession(private val destNum: Int) : AdminEditScope { - override suspend fun setOwner(user: User) = setOwner(destNum, user, getPacketId()) - - override suspend fun setConfig(config: Config) = setConfig(destNum, config, getPacketId()) - - override suspend fun setModuleConfig(config: ModuleConfig) = setModuleConfig(destNum, config, getPacketId()) - - override suspend fun setChannel(channel: Channel) = setRemoteChannel(destNum, channel, getPacketId()) - - override suspend fun setFixedPosition(position: Position) = - this@DirectRadioControllerImpl.setFixedPosition(destNum, position) - } - - // ── Telemetry & Discovery ─────────────────────────────────────────────── - - 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) - } - // ── Packet ID & Location ──────────────────────────────────────────────── override fun getPacketId(): Int = commandSender.generatePacketId() 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..dea3077e19 --- /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 [DirectRadioControllerImpl]. 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..054183cc3a --- /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 [DirectRadioControllerImpl]. 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/RequestControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RequestControllerImpl.kt new file mode 100644 index 0000000000..d0d5a74c25 --- /dev/null +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RequestControllerImpl.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.RequestController +import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.proto.AdminMessage + +/** + * [RequestController] implementation: position, traceroute, telemetry, user info, and metadata "pull" queries. + * + * Focused collaborator of [DirectRadioControllerImpl]. Mirrors the SDK's `TelemetryApi`/`RoutingApi` surface. + */ +internal class RequestControllerImpl( + private val commandSender: CommandSender, + private val nodeManager: NodeManager, + private val uiPrefs: UiPrefs, +) : RequestController { + + 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) + } +} From c303515c588a1a4b4989192f863019ac101f8eaa Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 10:13:16 -0500 Subject: [PATCH 23/34] refactor: Extract shared RequestTimer from request handlers TracerouteHandlerImpl and NeighborInfoHandlerImpl each carried an identical copy of the request-timing machinery: an atomic persistentMapOf of start times, a recordStartTime that stamps nowMillis, and a "consume the start time, format a `Duration: N s` suffix, log completion" block plus a MILLIS_PER_SECOND constant. Extract it into a single internal RequestTimer with start() and appendDuration(). Both handlers now hold a private RequestTimer and delegate, dropping the duplicated field, imports, formatting, and constant. Behavior is unchanged (covered by the existing NeighborInfoHandlerImplTest duration case); adds focused RequestTimer unit tests for the recorded/not-recorded/single-use/independent-ids cases. Co-Authored-By: Claude Opus 4.8 --- .../data/manager/NeighborInfoHandlerImpl.kt | 27 +------- .../core/data/manager/RequestTimer.kt | 59 +++++++++++++++++ .../data/manager/TracerouteHandlerImpl.kt | 28 +-------- .../core/data/manager/RequestTimerTest.kt | 63 +++++++++++++++++++ 4 files changed, 128 insertions(+), 49 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/RequestTimer.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/RequestTimerTest.kt 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 4d22c566c0..c7af550ee0 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,12 +17,7 @@ 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 @@ -37,13 +32,11 @@ class NeighborInfoHandlerImpl( 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,8 +51,6 @@ class NeighborInfoHandlerImpl( // 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 -> @@ -71,20 +62,8 @@ class NeighborInfoHandlerImpl( val fromUser = nodeRepository.getUser(from) val formatted = "Neighbors of ${fromUser.long_name}:\n$neighbors" - val responseText = - if (start != null) { - val elapsedMs = nowMillis - start - val seconds = elapsedMs / MILLIS_PER_SECOND - Logger.i { "Neighbor info $requestId complete in $seconds s" } - "$formatted\n\nDuration: ${NumberFormatter.format(seconds, 1)} s" - } else { - formatted - } + val responseText = requestTimer.appendDuration(requestId, formatted, "Neighbor info") serviceRepository.setNeighborInfoResponse(responseText) } - - companion object { - private const val MILLIS_PER_SECOND = 1000.0 - } } 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..781bc94483 --- /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 [label]. Returns [text] unchanged when no start time was recorded for the id. + */ + fun appendDuration(requestId: Int, text: String, label: 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 { "$label $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/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index e3668df393..f229b55ff2 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,17 +16,11 @@ */ 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 @@ -48,11 +42,9 @@ class TracerouteHandlerImpl( @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,17 +77,7 @@ 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 @@ -111,8 +93,4 @@ class TracerouteHandlerImpl( ) } } - - companion object { - private const val MILLIS_PER_SECOND = 1000.0 - } } 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..a047d401cc --- /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", label = "Test")) + } + + @Test + fun appendDuration_afterStart_appendsDurationLine() { + val timer = RequestTimer() + timer.start(requestId = 7) + + val result = timer.appendDuration(requestId = 7, text = "base", label = "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", label = "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", label = "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", label = "Test") + assertTrue(timer.appendDuration(requestId = 2, text = "b", label = "Test").contains("Duration: ")) + } +} From 404adf1a0ca25c2af7f5c0b501c5b4ffbcf66c7b Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 11:32:07 -0500 Subject: [PATCH 24/34] docs: Add session handover entry for AIDL removal + RadioController work Per AGENTS.md governance ("update session_context.md at the end of every major task"). Records the AIDL/broadcast removal, the RadioController sub-controller split, typed addressing (NodeAddress/ContactKey), the idempotent node ops + editSettings DSL, the RequestTimer extraction, and the deliberate deferral of R4 (AdminResult) and R5 (NodeId) to the SDK migration. Co-Authored-By: Claude Opus 4.8 --- .agent_memory/session_context.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md index cb07fa3664..e212382c7e 100644 --- a/.agent_memory/session_context.md +++ b/.agent_memory/session_context.md @@ -3,6 +3,15 @@ # Do NOT edit or remove previous entries — stale state claims cause agent confusion. # Format: ## YYYY-MM-DD — +## 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-21 — Upgraded Chirpy to a fully-personalized Live Diagnostic Node & Mesh Assistant - Integrated `NodeRepository` into `GeminiNanoDocAssistant.kt` and the Google AI Koin dependency injection module (`GoogleAiModule.kt`). - Developed a dynamic live-state prompt formatting block within `buildPrompt(...)` that queries current hardware model, firmware version, connection status, GPS capability, channel utilization, airtime, battery level/voltage, user profile long/short names, and total registered mesh peer counts & active online peers directly from `NodeRepository`'s reactive flows. From 9f5b7d65c3f8544df565c50fe2d0577f6d10717b Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 11:35:26 -0500 Subject: [PATCH 25/34] docs: Refresh module READMEs after AIDL removal / controller refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The :core:service, :core:repository, and :core:model READMEs still described the removed AIDL/broadcast architecture and deleted types. - core/service: replace the `ServiceAction` (intent bus) section with DirectRadioControllerImpl and its four sub-controllers + editSettings. - core/repository: drop MeshActionHandler.kt / MeshRouter.kt from the source listing and add the RadioController sub-interfaces; fix the ServiceRepository sketch (no serviceAction/onServiceAction) and installConfig (List, not List). - core/model: the Parcelable/@Parcelize section is gone (Parcelable was removed from :core:common) — models are now @Serializable / value classes; replace deleted `NodeInfo` with `Node` and note NodeAddress/ContactKey typed addressing. Co-Authored-By: Claude Opus 4.8 --- core/model/README.md | 7 +++++-- core/repository/README.md | 23 +++++++++++++++-------- core/service/README.md | 6 +++--- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/core/model/README.md b/core/model/README.md index cb1b614c85..e2f96808a8 100644 --- a/core/model/README.md +++ b/core/model/README.md @@ -4,12 +4,15 @@ 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. diff --git a/core/repository/README.md b/core/repository/README.md index c94bbeb42e..22229a9c7a 100644 --- a/core/repository/README.md +++ b/core/repository/README.md @@ -29,17 +29,20 @@ src/ │ ├── 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 +│ ├── RequestController.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 @@ -90,14 +93,18 @@ interface ServiceRepository { val errorMessage: StateFlow val connectionProgress: StateFlow val meshPacketFlow: Flow - val tracerouteResponse: Flow<...> - val neighborInfoResponse: Flow<...> - val serviceAction: Flow + val tracerouteResponse: StateFlow + val neighborInfoResponse: StateFlow fun setConnectionState(state: ConnectionState) - fun emitMeshPacket(packet: MeshPacket) - fun onServiceAction(action: ServiceAction) + suspend fun emitMeshPacket(packet: MeshPacket) + fun setClientNotification(notification: ClientNotification?) + fun setTracerouteResponse(value: TracerouteResponse?) + // …setters/clearers for error, progress, neighbor-info } + +Radio commands are issued through `RadioController` (a composite of `AdminController`, +`MessagingController`, `NodeController`, `RequestController`) rather than an action/intent bus. ``` ### `NodeRepository` @@ -119,7 +126,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/service/README.md b/core/service/README.md index 0ea1a49be1..1f93dbebd7 100644 --- a/core/service/README.md +++ b/core/service/README.md @@ -15,10 +15,10 @@ Android foreground service entry point that hosts the orchestrator lifecycle. 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. `DirectRadioControllerImpl` +The in-process `RadioController` composition root (Desktop, iOS, and single-process Android). It assembles four focused sub-controllers — `AdminControllerImpl`, `MessagingControllerImpl`, `NodeControllerImpl`, `RequestControllerImpl` — 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 From 975b6f4c8c5b981c6297728a1351249ccb899329 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 11:41:49 -0500 Subject: [PATCH 26/34] docs: Document RadioController composition in architecture guide Add a "Radio Control" section to the developer architecture guide covering how features issue radio commands: the RadioController composite, its four sub-interfaces (Admin/Messaging/Node/Request), and DirectRadioControllerImpl as the in-process composition root that assembles them via interface delegation. Notes the fire-and-forget admin model and the deliberate alignment with the meshtastic-sdk API shape. Additive only (no stale-reference fix); bumps last_updated. Co-Authored-By: Claude Opus 4.8 --- docs/en/developer/architecture.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/en/developer/architecture.md b/docs/en/developer/architecture.md index 75476e1f36..d169b6a31e 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,26 @@ 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 | +| `RequestController` | Telemetry, traceroute, position/user-info queries | + +`DirectRadioControllerImpl` (`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. + ## Navigation Navigation uses **Navigation 3** with typed routes: From dd6ca400c997308b09f73ccf7d7ee6a49acfa9a1 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 12:50:28 -0500 Subject: [PATCH 27/34] refactor: Decompose ServiceRepository into focused provider interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply Interface Segregation Principle to ServiceRepository and RadioController consumers: New interfaces extracted from ServiceRepository: - ConnectionStateProvider: read-only connectionState access - TracerouteResponseProvider: traceroute response state + clear - NeighborInfoResponseProvider: neighbor info response state + clear - ServiceStateWriter: write-side for handlers (set*, emit*, clear*) RadioController now extends ConnectionStateProvider, and sub-controller interfaces (AdminController, MessagingController, NodeController, RequestController) are bound in DI for fine-grained injection. ViewModel narrowing: - MessageViewModel: RadioController+ServiceRepository → MessagingController+ConnectionStateProvider - NodeDetailViewModel: RadioController → RequestController - NodeListViewModel: RadioController+ServiceRepository → AdminController+ConnectionStateProvider - ContactsViewModel: ServiceRepository → ConnectionStateProvider - MetricsViewModel: ServiceRepository → TracerouteResponseProvider All tests updated to use narrowed interfaces. Koin DI bindings updated for both Android (@Single binds) and Desktop (manual single<> declarations). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../repository/ConnectionStateProvider.kt | 40 +++++++++++++ .../core/repository/RadioController.kt | 16 +---- .../core/repository/ResponseProviders.kt | 47 +++++++++++++++ .../core/repository/ServiceRepository.kt | 34 ++++++----- .../core/repository/ServiceStateWriter.kt | 59 +++++++++++++++++++ .../core/service/AndroidServiceRepository.kt | 15 ++++- .../service/di/CoreServiceAndroidModule.kt | 15 ++++- .../desktop/di/DesktopKoinModule.kt | 16 +++++ .../feature/messaging/MessageViewModel.kt | 12 ++-- .../messaging/ui/contact/ContactsViewModel.kt | 6 +- .../feature/messaging/MessageViewModelTest.kt | 18 +++--- .../ui/contact/ContactsViewModelTest.kt | 11 ++-- .../node/detail/NodeDetailViewModel.kt | 6 +- .../feature/node/list/NodeListViewModel.kt | 12 ++-- .../feature/node/metrics/MetricsViewModel.kt | 10 ++-- .../node/detail/HandleNodeActionTest.kt | 6 +- .../node/detail/NodeDetailViewModelTest.kt | 6 +- .../node/list/NodeListViewModelTest.kt | 12 ++-- .../node/metrics/MetricsViewModelTest.kt | 8 +-- 19 files changed, 265 insertions(+), 84 deletions(-) create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateProvider.kt create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ResponseProviders.kt create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceStateWriter.kt 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/RadioController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt index 465c9753ce..529346857d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt @@ -17,7 +17,6 @@ package org.meshtastic.core.repository import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.core.model.ConnectionState import org.meshtastic.proto.ClientNotification /** @@ -40,19 +39,8 @@ interface RadioController : AdminController, MessagingController, NodeController, - RequestController { - /** - * 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 - + RequestController, + ConnectionStateProvider { /** * Flow of notifications from the radio client. * 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/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index 90555ceb05..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 @@ -39,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. * @@ -55,7 +59,7 @@ interface ServiceRepository { * * @see RadioInterfaceService.connectionState */ - val connectionState: StateFlow + override val connectionState: StateFlow /** * Updates the canonical app-level connection state. @@ -65,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. @@ -79,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. @@ -97,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. @@ -114,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. @@ -132,31 +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() + 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/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt index dba8e14f22..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,8 +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 DI binding of the shared [ServiceRepositoryImpl]. */ -@Single(binds = [ServiceRepository::class]) +@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/di/CoreServiceAndroidModule.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/di/CoreServiceAndroidModule.kt index 78d46e25dc..a57cd454db 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 @@ -23,11 +23,14 @@ 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 @@ -36,6 +39,7 @@ import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RequestController import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.service.DirectRadioControllerImpl @@ -46,7 +50,16 @@ import org.meshtastic.core.service.startService @ComponentScan("org.meshtastic.core.service") class CoreServiceAndroidModule { @Suppress("LongParameterList") - @Single + @Single( + binds = + [ + RadioController::class, + AdminController::class, + MessagingController::class, + NodeController::class, + RequestController::class, + ], + ) fun radioController( context: Context, serviceRepository: ServiceRepository, 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 71f1f7eb17..bcffdcb1d1 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -45,17 +45,25 @@ 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.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.RadioController import org.meshtastic.core.repository.RadioTransportFactory +import org.meshtastic.core.repository.RequestController import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.ServiceStateWriter +import org.meshtastic.core.repository.TracerouteResponseProvider import org.meshtastic.core.service.DirectRadioControllerImpl import org.meshtastic.core.service.ServiceRepositoryImpl import org.meshtastic.desktop.DesktopBuildConfig @@ -156,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(), @@ -184,6 +196,10 @@ private fun desktopPlatformStubsModule() = module { scope = get(qualifier = named("ServiceScope")), ) } + single { get() } + single { get() } + single { get() } + single { get() } single { when (DesktopOS.current()) { DesktopOS.Linux -> LinuxNotificationSender() 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 eaa3968b30..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 @@ -37,15 +37,15 @@ import org.meshtastic.core.model.ContactSettings import org.meshtastic.core.model.Message import org.meshtastic.core.model.Node 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.RadioController -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,8 +59,8 @@ class MessageViewModel( private val nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, quickChatActionRepository: QuickChatActionRepository, - private val serviceRepository: ServiceRepository, - private val radioController: RadioController, + private val connectionStateProvider: ConnectionStateProvider, + private val messagingController: MessagingController, private val packetRepository: PacketRepository, private val uiPrefs: UiPrefs, private val customEmojiPrefs: CustomEmojiPrefs, @@ -73,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()) @@ -219,7 +219,7 @@ class MessageViewModel( } fun sendReaction(emoji: String, replyId: Int, contactKey: String) = - safeLaunch(tag = "sendReaction") { radioController.sendReaction(emoji, replyId, contactKey) } + 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/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index 4332a0ea9b..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 @@ -36,10 +36,10 @@ 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 @@ -50,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) 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 7ddb26a079..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 @@ -34,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.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.RadioController -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 @@ -63,8 +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 radioController: RadioController = 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,7 +95,7 @@ class MessageViewModelTest { every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig()) every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile()) - every { serviceRepository.connectionState } returns connectionStateFlow + every { connectionStateProvider.connectionState } returns connectionStateFlow every { customEmojiPrefs.customEmojiFrequency } returns customEmojiFrequencyFlow every { homoglyphPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false) @@ -116,9 +116,9 @@ class MessageViewModelTest { nodeRepository = nodeRepository, radioConfigRepository = radioConfigRepository, quickChatActionRepository = quickChatActionRepository, + connectionStateProvider = connectionStateProvider, + messagingController = messagingController, packetRepository = packetRepository, - serviceRepository = serviceRepository, - radioController = radioController, sendMessageUseCase = sendMessageUseCase, customEmojiPrefs = customEmojiPrefs, homoglyphEncodingPrefs = homoglyphPrefs, @@ -192,13 +192,13 @@ class MessageViewModelTest { @Test fun testSendReaction() = runTest { - everySuspend { radioController.sendReaction(any(), any(), any()) } returns Unit + everySuspend { messagingController.sendReaction(any(), any(), any()) } returns Unit viewModel.sendReaction("❤️", 123, "0!12345678") advanceUntilIdle() - verifySuspend { radioController.sendReaction("❤️", 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/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 8960a78fe2..2129058f52 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 @@ -39,7 +39,7 @@ import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute -import org.meshtastic.core.repository.RadioController +import org.meshtastic.core.repository.RequestController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText import org.meshtastic.core.resources.connect_radio_for_remote_admin @@ -81,7 +81,7 @@ class NodeDetailViewModel( private val savedStateHandle: SavedStateHandle, private val nodeManagementActions: NodeManagementActions, private val nodeRequestActions: NodeRequestActions, - private val radioController: RadioController, + private val requestController: RequestController, private val getNodeDetailsUseCase: GetNodeDetailsUseCase, private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase, private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase, @@ -174,7 +174,7 @@ class NodeDetailViewModel( /** * Re-fetch device metadata (firmware/edition/role) for [destNum]. Refreshes the session passkey as a side effect. */ - fun refreshMetadata(destNum: Int) = viewModelScope.launch { radioController.refreshMetadata(destNum) } + fun refreshMetadata(destNum: Int) = viewModelScope.launch { requestController.refreshMetadata(destNum) } /** * Ensure a remote-admin session passkey is fresh, then request navigation to the remote-admin screen. Surfaces a 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 ae8a9651f5..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 @@ -33,12 +33,12 @@ 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.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.RadioController 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)) } } 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 16e81f1f67..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 @@ -52,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 @@ -81,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, @@ -192,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( @@ -212,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() @@ -221,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, 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 7a7314975f..aaf26a7537 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.RadioController +import org.meshtastic.core.repository.RequestController 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 radioController: RadioController = mock() + private val requestController: RequestController = 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, - radioController = radioController, + requestController = requestController, 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 1040e20ef0..52af1674cd 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.RadioController +import org.meshtastic.core.repository.RequestController 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 radioController: RadioController = mock() + private val requestController: RequestController = 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, - radioController = radioController, + requestController = requestController, getNodeDetailsUseCase = getNodeDetailsUseCase, ensureRemoteAdminSession = ensureRemoteAdminSession, observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus, 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, From 1182ea0e14042a6b430fe44fb811dc5b45bff988 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 13:35:36 -0500 Subject: [PATCH 28/34] docs: Document ServiceRepository ISP decomposition in architecture guide Update core/repository README and architecture docs to reflect the new focused provider interfaces (ConnectionStateProvider, TracerouteResponseProvider, NeighborInfoResponseProvider, ServiceStateWriter) and ViewModel narrowing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- core/repository/README.md | 43 ++++++++++++++++++++++++------- docs/en/developer/architecture.md | 18 +++++++++++++ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/core/repository/README.md b/core/repository/README.md index 22229a9c7a..799d87ac02 100644 --- a/core/repository/README.md +++ b/core/repository/README.md @@ -22,7 +22,10 @@ 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 @@ -85,27 +88,49 @@ 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 - val clientNotification: StateFlow - val errorMessage: StateFlow - val connectionProgress: StateFlow - val meshPacketFlow: Flow +} + +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 setClientNotification(notification: ClientNotification?) fun setTracerouteResponse(value: TracerouteResponse?) - // …setters/clearers for error, progress, neighbor-info + 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 +} +``` + +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`, `RequestController`) rather than an action/intent bus. -``` ### `NodeRepository` diff --git a/docs/en/developer/architecture.md b/docs/en/developer/architecture.md index d169b6a31e..6d1f53a49d 100644 --- a/docs/en/developer/architecture.md +++ b/docs/en/developer/architecture.md @@ -140,6 +140,24 @@ because the device is the source of truth (local persistence is an optimistic ca 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: From b7e36a96c5e9ef17974e02c184bd5b02068d3361 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 14:03:30 -0500 Subject: [PATCH 29/34] refactor: Narrow read-only connection-state consumers to ConnectionStateProvider Following the ServiceRepository ISP decomposition, the two consumers that only read connectionState now inject ConnectionStateProvider instead of the full ServiceRepository: - ConnectionsViewModel (core:ui) - LocalStatsWidgetStateProvider (feature:widget) The remaining three full-ServiceRepository injections are left as-is because they use members not carried by any narrow provider: RadioConfigViewModel (meshPacketFlow), ScannerViewModel (connectionProgress), and UIViewModel (clientNotification + errorMessage reads). Extracting single-consumer providers for those would be over-segregation. Koin graph verifies on Android + desktop; ConnectionsViewModelTest green. Co-Authored-By: Claude Opus 4.8 --- .../meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt | 8 ++++---- .../core/ui/viewmodel/ConnectionsViewModelTest.kt | 2 +- .../meshtastic/feature/widget/LocalStatsWidgetState.kt | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) 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/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/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() } From 9dc1e500a42146172682ffceaac5073cbfaca15e Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 14:15:46 -0500 Subject: [PATCH 30/34] refactor: Adopt ServiceStateWriter / ConnectionStateProvider in handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ISP decomposition extracted ServiceStateWriter but left it with zero adopters — every write-side handler still injected the full ServiceRepository, able to read state it only writes. Complete the adoption: - 9 pure writers now inject ServiceStateWriter (param renamed serviceStateWriter): TracerouteHandlerImpl, NeighborInfoHandlerImpl, MeshMessageProcessorImpl, MeshConfigFlowManagerImpl, MeshConfigHandlerImpl, MeshDataHandlerImpl, FromRadioPacketHandlerImpl, MqttManagerImpl, MeshServiceOrchestrator. - PacketHandlerImpl reads only connectionState -> ConnectionStateProvider. The two genuinely mixed read+write holders (MeshConnectionManagerImpl, DirectRadioControllerImpl) keep the full ServiceRepository. Read-side flows with a single consumer (connectionProgress/clientNotification/ errorMessage/meshPacketFlow, mostly UIViewModel) are intentionally not extracted into providers — that would be over-segregation. Koin graph verifies on Android + desktop; core:data/core:service suites green. Co-Authored-By: Claude Opus 4.8 --- .../core/data/manager/FromRadioPacketHandlerImpl.kt | 8 ++++---- .../core/data/manager/MeshConfigFlowManagerImpl.kt | 6 +++--- .../core/data/manager/MeshConfigHandlerImpl.kt | 12 ++++++------ .../core/data/manager/MeshDataHandlerImpl.kt | 6 +++--- .../core/data/manager/MeshMessageProcessorImpl.kt | 6 +++--- .../meshtastic/core/data/manager/MqttManagerImpl.kt | 6 +++--- .../core/data/manager/NeighborInfoHandlerImpl.kt | 6 +++--- .../core/data/manager/PacketHandlerImpl.kt | 8 ++++---- .../core/data/manager/TracerouteHandlerImpl.kt | 6 +++--- .../data/manager/MeshConfigFlowManagerImplTest.kt | 2 +- .../core/data/manager/MeshConfigHandlerImplTest.kt | 2 +- .../core/data/manager/MeshDataHandlerTest.kt | 2 +- .../data/manager/MeshMessageProcessorImplTest.kt | 2 +- .../core/service/MeshServiceOrchestrator.kt | 6 +++--- .../core/service/MeshServiceOrchestratorTest.kt | 2 +- 15 files changed, 40 insertions(+), 40 deletions(-) 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 5d9e035f55..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 @@ -29,7 +29,7 @@ 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 @@ -45,7 +45,7 @@ import org.meshtastic.proto.FromRadio /** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */ @Single class FromRadioPacketHandlerImpl( - private val serviceRepository: ServiceRepository, + private val serviceStateWriter: ServiceStateWriter, private val configFlowManager: Lazy, private val configHandler: Lazy, private val xmodemManager: Lazy, @@ -85,7 +85,7 @@ class FromRadioPacketHandlerImpl( nodeInfo != null -> { configFlowManager.value.handleNodeInfo(nodeInfo) - serviceRepository.setConnectionProgress("Nodes (${configFlowManager.value.newNodeCount})") + serviceStateWriter.setConnectionProgress("Nodes (${configFlowManager.value.newNodeCount})") } configCompleteId != null -> configFlowManager.value.handleConfigComplete(configCompleteId) @@ -116,7 +116,7 @@ class FromRadioPacketHandlerImpl( } 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/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index 30de98254a..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,7 +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.ServiceRepository +import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.FileInfo import org.meshtastic.proto.FirmwareEdition @@ -50,7 +50,7 @@ class MeshConfigFlowManagerImpl( private val connectionManager: Lazy, private val nodeRepository: NodeRepository, private val radioConfigRepository: RadioConfigRepository, - private val serviceRepository: ServiceRepository, + private val serviceStateWriter: ServiceStateWriter, private val analytics: PlatformAnalytics, private val commandSender: CommandSender, private val heartbeatSender: DataLayerHeartbeatSender, @@ -188,7 +188,7 @@ class MeshConfigFlowManagerImpl( analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown") nodeManager.setNodeDbReady(true) nodeManager.setAllowNodeDbWrites(true) - serviceRepository.setConnectionState(ConnectionState.Connected) + 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/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index f23822c0d3..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 @@ -53,7 +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.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 @@ -86,7 +86,7 @@ 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 notificationManager: NotificationManager, private val serviceNotifications: MeshNotificationManager, @@ -255,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( 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 275b9771ab..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 @@ -40,7 +40,7 @@ import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.MeshMessageProcessor 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,7 +53,7 @@ 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 dataHandler: Lazy, private val fromRadioDispatcher: FromRadioPacketHandler, @@ -212,7 +212,7 @@ class MeshMessageProcessorImpl( } } - scope.handledLaunch { serviceRepository.emitMeshPacket(packet) } + scope.handledLaunch { serviceStateWriter.emitMeshPacket(packet) } myNodeNum?.let { myNum -> val from = packet.from 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 c7af550ee0..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 @@ -21,14 +21,14 @@ import org.koin.core.annotation.Single import org.meshtastic.core.repository.NeighborInfoHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository -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 serviceStateWriter: ServiceStateWriter, private val nodeRepository: NodeRepository, ) : NeighborInfoHandler { @@ -64,6 +64,6 @@ class NeighborInfoHandlerImpl( val responseText = requestTimer.appendDuration(requestId, formatted, "Neighbor info") - serviceRepository.setNeighborInfoResponse(responseText) + serviceStateWriter.setNeighborInfoResponse(responseText) } } 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 ea69e75fbb..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 @@ -41,11 +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.ServiceRepository import org.meshtastic.proto.FromRadio import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.QueueStatus @@ -60,7 +60,7 @@ class PacketHandlerImpl( private val packetRepository: Lazy, private val radioInterfaceService: RadioInterfaceService, private val meshLogRepository: Lazy, - private val serviceRepository: ServiceRepository, + private val connectionStateProvider: ConnectionStateProvider, @Named("ServiceScope") private val scope: CoroutineScope, ) : PacketHandler { @@ -191,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 { @@ -250,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/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt index f229b55ff2..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 @@ -25,7 +25,7 @@ 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 @@ -36,7 +36,7 @@ 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, @@ -81,7 +81,7 @@ class TracerouteHandlerImpl( val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0 - serviceRepository.setTracerouteResponse( + serviceStateWriter.setTracerouteResponse( TracerouteResponse( message = responseText, destinationNodeNum = destination, 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 7cfa5663ae..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 @@ -98,7 +98,7 @@ class MeshConfigFlowManagerImplTest { connectionManager = lazy { connectionManager }, nodeRepository = nodeRepository, radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, + serviceStateWriter = serviceRepository, analytics = analytics, commandSender = commandSender, heartbeatSender = DataLayerHeartbeatSender(packetHandler), 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/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt index 7e4551bd6d..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 @@ -93,7 +93,7 @@ class MeshDataHandlerTest { MeshDataHandlerImpl( nodeManager = nodeManager, packetHandler = packetHandler, - serviceRepository = serviceRepository, + serviceStateWriter = serviceRepository, packetRepository = lazy { packetRepository }, notificationManager = notificationManager, serviceNotifications = serviceNotifications, 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 7fde354e70..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 @@ -67,7 +67,7 @@ class MeshMessageProcessorImplTest { private fun createProcessor(scope: CoroutineScope): MeshMessageProcessorImpl = MeshMessageProcessorImpl( nodeManager = nodeManager, - serviceRepository = serviceRepository, + serviceStateWriter = serviceRepository, meshLogRepository = lazy { meshLogRepository }, dataHandler = lazy { dataHandler }, fromRadioDispatcher = fromRadioDispatcher, 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 0ba7973851..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 @@ -35,7 +35,7 @@ import org.meshtastic.core.repository.MeshMessageProcessor 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,7 +52,7 @@ 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 serviceNotifications: MeshNotificationManager, @@ -136,7 +136,7 @@ class MeshServiceOrchestrator( .launchIn(newScope) radioInterfaceService.connectionError - .onEach { errorMessage -> serviceRepository.setErrorMessage(errorMessage, Severity.Warn) } + .onEach { errorMessage -> serviceStateWriter.setErrorMessage(errorMessage, Severity.Warn) } .launchIn(newScope) nodeManager.loadCachedNodeDB() 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 d0704c4c5b..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 @@ -98,7 +98,7 @@ class MeshServiceOrchestratorTest { return MeshServiceOrchestrator( radioInterfaceService = radioInterfaceService, - serviceRepository = serviceRepository, + serviceStateWriter = serviceRepository, nodeManager = nodeManager, messageProcessor = messageProcessor, serviceNotifications = serviceNotifications, From 94bcec85b32bf949ddfd44e8dab4caf893eb500e Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 29 May 2026 14:37:54 -0500 Subject: [PATCH 31/34] refactor: Naming cleanup for clarity and Kotlin conventions Audit-driven renames: - DirectRadioControllerImpl -> RadioControllerImpl: the "Direct" prefix was vestigial (it once contrasted with the AIDL-routed AndroidRadioControllerImpl, now deleted); it is the sole impl and now matches its parts (AdminControllerImpl, etc.). - RadioController.getPacketId() -> generatePacketId(): a `get`-prefixed function that generates a fresh id each call violates the getter-is-idempotent convention; also aligns with CommandSender.generatePacketId(). - RequestController -> QueryController (+ Impl, + the `requestController` params/mocks): clearer intent for the pull/query surface; "request" was generic. - RequestTimer param `label` -> `logLabel`. - AdminControllerImpl DEFAULT_REBOOT_DELAY -> DEFAULT_DELAY_SECONDS (shared by reboot + shutdown; conveys the unit). Interface-consumer-safe; docs/READMEs/architecture guide updated to match. Koin graph verifies on Android + desktop; affected test suites green. Co-Authored-By: Claude Opus 4.8 --- .../core/data/manager/RequestTimer.kt | 6 ++--- .../core/data/manager/RequestTimerTest.kt | 12 +++++----- .../usecase/settings/AdminActionsUseCase.kt | 8 +++---- .../settings/CleanNodeDatabaseUseCase.kt | 2 +- .../usecase/settings/RadioConfigUseCase.kt | 22 +++++++++---------- core/repository/README.md | 4 ++-- ...equestController.kt => QueryController.kt} | 4 ++-- .../core/repository/RadioController.kt | 6 ++--- core/service/README.md | 4 ++-- .../service/di/CoreServiceAndroidModule.kt | 8 +++---- .../core/service/AdminControllerImpl.kt | 14 ++++++------ .../core/service/MessagingControllerImpl.kt | 2 +- .../core/service/NodeControllerImpl.kt | 4 ++-- ...ntrollerImpl.kt => QueryControllerImpl.kt} | 10 ++++----- ...ntrollerImpl.kt => RadioControllerImpl.kt} | 10 ++++----- ...ImplTest.kt => RadioControllerImplTest.kt} | 6 ++--- .../core/testing/FakeRadioController.kt | 11 +++++----- .../desktop/di/DesktopKoinModule.kt | 8 +++---- docs/en/developer/architecture.md | 4 ++-- .../firmware/ota/Esp32OtaUpdateHandler.kt | 2 +- .../feature/map/BaseMapViewModel.kt | 2 +- .../node/detail/CommonNodeRequestActions.kt | 6 ++--- .../node/detail/NodeDetailViewModel.kt | 6 ++--- .../node/detail/NodeManagementActions.kt | 2 +- .../node/detail/HandleNodeActionTest.kt | 6 ++--- .../node/detail/NodeDetailViewModelTest.kt | 6 ++--- 26 files changed, 88 insertions(+), 87 deletions(-) rename core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/{RequestController.kt => QueryController.kt} (93%) rename core/service/src/commonMain/kotlin/org/meshtastic/core/service/{RequestControllerImpl.kt => QueryControllerImpl.kt} (88%) rename core/service/src/commonMain/kotlin/org/meshtastic/core/service/{DirectRadioControllerImpl.kt => RadioControllerImpl.kt} (95%) rename core/service/src/commonTest/kotlin/org/meshtastic/core/service/{DirectRadioControllerImplTest.kt => RadioControllerImplTest.kt} (99%) 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 index 781bc94483..1dde1d6a8a 100644 --- 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 @@ -42,14 +42,14 @@ internal class RequestTimer { /** * Consumes the start time recorded for [requestId] and appends a `Duration: N s` line to [text], logging completion - * under [label]. Returns [text] unchanged when no start time was recorded for the id. + * under [logLabel]. Returns [text] unchanged when no start time was recorded for the id. */ - fun appendDuration(requestId: Int, text: String, label: String): String { + 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 { "$label $requestId complete in $seconds s" } + Logger.i { "$logLabel $requestId complete in $seconds s" } return "$text\n\nDuration: ${NumberFormatter.format(seconds, 1)} s" } 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 index a047d401cc..245effd152 100644 --- 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 @@ -26,7 +26,7 @@ class RequestTimerTest { fun appendDuration_withoutStart_returnsTextUnchanged() { val timer = RequestTimer() - assertEquals("base", timer.appendDuration(requestId = 1, text = "base", label = "Test")) + assertEquals("base", timer.appendDuration(requestId = 1, text = "base", logLabel = "Test")) } @Test @@ -34,7 +34,7 @@ class RequestTimerTest { val timer = RequestTimer() timer.start(requestId = 7) - val result = timer.appendDuration(requestId = 7, text = "base", label = "Test") + 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")) @@ -45,9 +45,9 @@ class RequestTimerTest { val timer = RequestTimer() timer.start(requestId = 7) - timer.appendDuration(requestId = 7, text = "first", label = "Test") + 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", label = "Test")) + assertEquals("second", timer.appendDuration(requestId = 7, text = "second", logLabel = "Test")) } @Test @@ -57,7 +57,7 @@ class RequestTimerTest { timer.start(requestId = 2) // Consuming one id must not affect the other. - timer.appendDuration(requestId = 1, text = "a", label = "Test") - assertTrue(timer.appendDuration(requestId = 2, text = "b", label = "Test").contains("Duration: ")) + timer.appendDuration(requestId = 1, text = "a", logLabel = "Test") + assertTrue(timer.appendDuration(requestId = 2, text = "b", logLabel = "Test").contains("Duration: ")) } } 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 4c5f38f965..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 @@ -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 ab73435e35..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 @@ -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/RadioConfigUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt index 15698119c5..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 @@ -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/repository/README.md b/core/repository/README.md index 799d87ac02..8f460b84e7 100644 --- a/core/repository/README.md +++ b/core/repository/README.md @@ -36,7 +36,7 @@ src/ │ ├── AdminController.kt ← config, channels, owner, device lifecycle, editSettings │ ├── MessagingController.kt ← send packets, reactions, contacts │ ├── NodeController.kt ← favorite, ignore, mute, remove nodes -│ ├── RequestController.kt ← telemetry, traceroute, position queries +│ ├── QueryController.kt ← telemetry, traceroute, position queries │ ├── CommandSender.kt │ ├── AdminPacketHandler.kt │ ├── FromRadioPacketHandler.kt @@ -130,7 +130,7 @@ 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`, `RequestController`) rather than an action/intent bus. +`MessagingController`, `NodeController`, `QueryController`) rather than an action/intent bus. ### `NodeRepository` diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RequestController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/QueryController.kt similarity index 93% rename from core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RequestController.kt rename to core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/QueryController.kt index 44ac1b6af5..39f72bc879 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RequestController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/QueryController.kt @@ -19,14 +19,14 @@ package org.meshtastic.core.repository import org.meshtastic.core.model.Position /** - * Mesh request operations — position, traceroute, telemetry, user info, and metadata queries. + * 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 RequestController { +interface QueryController { /** Requests device metadata from a remote node. */ suspend fun refreshMetadata(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 index 529346857d..646c3cf131 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioController.kt @@ -30,7 +30,7 @@ import org.meshtastic.proto.ClientNotification * - [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) - * - [RequestController] — telemetry, traceroute, position queries (→ SDK `TelemetryApi` / `RoutingApi`) + * - [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. @@ -39,7 +39,7 @@ interface RadioController : AdminController, MessagingController, NodeController, - RequestController, + QueryController, ConnectionStateProvider { /** * Flow of notifications from the radio client. @@ -56,7 +56,7 @@ interface RadioController : * * @return A unique 32-bit integer. */ - fun getPacketId(): Int + fun generatePacketId(): Int /** Starts providing the phone's location to the mesh. */ fun startProvideLocation() diff --git a/core/service/README.md b/core/service/README.md index 1f93dbebd7..2be3c83943 100644 --- a/core/service/README.md +++ b/core/service/README.md @@ -17,8 +17,8 @@ A high-level repository that wraps the service connection and exposes reactive ` ### 3. `ConnectionState` Represents the current state of the radio connection (`Connected`, `Disconnected`, `DeviceSleep`, etc.). -### 4. `DirectRadioControllerImpl` -The in-process `RadioController` composition root (Desktop, iOS, and single-process Android). It assembles four focused sub-controllers — `AdminControllerImpl`, `MessagingControllerImpl`, `NodeControllerImpl`, `RequestControllerImpl` — 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. +### 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 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 a57cd454db..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 @@ -36,14 +36,14 @@ 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.RequestController import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.service.DirectRadioControllerImpl import org.meshtastic.core.service.MeshService +import org.meshtastic.core.service.RadioControllerImpl import org.meshtastic.core.service.startService @Module @@ -57,7 +57,7 @@ class CoreServiceAndroidModule { AdminController::class, MessagingController::class, NodeController::class, - RequestController::class, + QueryController::class, ], ) fun radioController( @@ -78,7 +78,7 @@ class CoreServiceAndroidModule { messageProcessor: Lazy, radioConfigRepository: RadioConfigRepository, @Named("ServiceScope") scope: CoroutineScope, - ): RadioController = DirectRadioControllerImpl( + ): RadioController = RadioControllerImpl( serviceRepository = serviceRepository, nodeRepository = nodeRepository, commandSender = commandSender, 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 index 2aad63dbe5..488c929e0a 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt @@ -38,10 +38,10 @@ import org.meshtastic.proto.User * [AdminController] implementation: local/remote configuration, channels, owner, device lifecycle, and the * [editSettings] transaction. * - * Focused collaborator of [DirectRadioControllerImpl]. 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. + * 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( @@ -159,7 +159,7 @@ internal class AdminControllerImpl( override suspend fun reboot(destNum: Int, packetId: Int) { Logger.i { "Reboot requested for node $destNum" } - commandSender.sendAdmin(destNum, packetId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) } + commandSender.sendAdmin(destNum, packetId) { AdminMessage(reboot_seconds = DEFAULT_DELAY_SECONDS) } } override suspend fun rebootToDfu(nodeNum: Int) { @@ -174,7 +174,7 @@ internal class AdminControllerImpl( } override suspend fun shutdown(destNum: Int, packetId: Int) { - commandSender.sendAdmin(destNum, packetId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) } + commandSender.sendAdmin(destNum, packetId) { AdminMessage(shutdown_seconds = DEFAULT_DELAY_SECONDS) } } override suspend fun factoryReset(destNum: Int, packetId: Int) { @@ -211,6 +211,6 @@ internal class AdminControllerImpl( } private companion object { - private const val DEFAULT_REBOOT_DELAY = 5 + private const val DEFAULT_DELAY_SECONDS = 5 } } 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 index dea3077e19..b4573aa28b 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt @@ -42,7 +42,7 @@ import org.meshtastic.proto.User /** * [MessagingController] implementation: sends data packets, reactions, and shared contacts. * - * Focused collaborator of [DirectRadioControllerImpl]. Mirrors the SDK's `RadioClient.send*` surface — when the SDK is + * 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( 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 index 054183cc3a..e1418445db 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt @@ -27,8 +27,8 @@ import org.meshtastic.proto.AdminMessage /** * [NodeController] implementation: favorite, ignore, mute, and remove nodes. * - * Focused collaborator of [DirectRadioControllerImpl]. Favorite/ignore are idempotent (no-op when already in the - * requested state), mirroring the SDK's `AdminApi.setFavorite`/`setIgnored`. + * 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, diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RequestControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/QueryControllerImpl.kt similarity index 88% rename from core/service/src/commonMain/kotlin/org/meshtastic/core/service/RequestControllerImpl.kt rename to core/service/src/commonMain/kotlin/org/meshtastic/core/service/QueryControllerImpl.kt index d0d5a74c25..8eb8def189 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RequestControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/QueryControllerImpl.kt @@ -19,20 +19,20 @@ 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.RequestController +import org.meshtastic.core.repository.QueryController import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage /** - * [RequestController] implementation: position, traceroute, telemetry, user info, and metadata "pull" queries. + * [QueryController] implementation: position, traceroute, telemetry, user info, and metadata "pull" queries. * - * Focused collaborator of [DirectRadioControllerImpl]. Mirrors the SDK's `TelemetryApi`/`RoutingApi` surface. + * Focused collaborator of [RadioControllerImpl]. Mirrors the SDK's `TelemetryApi`/`RoutingApi` surface. */ -internal class RequestControllerImpl( +internal class QueryControllerImpl( private val commandSender: CommandSender, private val nodeManager: NodeManager, private val uiPrefs: UiPrefs, -) : RequestController { +) : QueryController { private val myNodeNum: Int get() = nodeManager.myNodeNum.value ?: 0 diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt similarity index 95% rename from core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt rename to core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt index 837278c1b5..0daded9f3f 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt @@ -34,10 +34,10 @@ 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.RequestController import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.ClientNotification @@ -48,7 +48,7 @@ import org.meshtastic.proto.ClientNotification * * 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]/[RequestController] → + * ([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. * @@ -56,7 +56,7 @@ import org.meshtastic.proto.ClientNotification * surfacing, packet-id generation, location provisioning, and device-address switching. */ @Suppress("LongParameterList") -class DirectRadioControllerImpl( +class RadioControllerImpl( private val serviceRepository: ServiceRepository, nodeRepository: NodeRepository, private val commandSender: CommandSender, @@ -85,7 +85,7 @@ class DirectRadioControllerImpl( packetRepository, ), NodeController by NodeControllerImpl(commandSender, nodeManager, packetRepository, scope), - RequestController by RequestControllerImpl(commandSender, nodeManager, uiPrefs) { + QueryController by QueryControllerImpl(commandSender, nodeManager, uiPrefs) { // ── Connection State ──────────────────────────────────────────────────── @@ -101,7 +101,7 @@ class DirectRadioControllerImpl( // ── Packet ID & Location ──────────────────────────────────────────────── - override fun getPacketId(): Int = commandSender.generatePacketId() + override fun generatePacketId(): Int = commandSender.generatePacketId() override fun startProvideLocation() { locationManager.restart() diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt similarity index 99% rename from core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt rename to core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt index 5b93ed6253..ae4045bd7f 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/DirectRadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -58,7 +58,7 @@ import kotlin.test.assertNull import kotlin.test.assertSame import kotlin.test.assertTrue -class DirectRadioControllerImplTest { +class RadioControllerImplTest { private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val commandSender: CommandSender = mock(MockMode.autofill) @@ -80,10 +80,10 @@ class DirectRadioControllerImplTest { private fun createController( serviceRepository: ServiceRepository = ServiceRepositoryImpl(), myNodeNum: Int? = 1234, - ): DirectRadioControllerImpl { + ): RadioControllerImpl { every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) every { meshPrefs.deviceAddress } returns MutableStateFlow(null) - return DirectRadioControllerImpl( + return RadioControllerImpl( serviceRepository = serviceRepository, nodeRepository = nodeRepository, commandSender = commandSender, 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 2880ed5568..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 @@ -153,14 +153,15 @@ class FakeRadioController : editSettingsCalled = true val scope = object : AdminEditScope { - override suspend fun setOwner(user: User) = setOwner(destNum, user, getPacketId()) + override suspend fun setOwner(user: User) = setOwner(destNum, user, generatePacketId()) - override suspend fun setConfig(config: Config) = setConfig(destNum, config, getPacketId()) + override suspend fun setConfig(config: Config) = setConfig(destNum, config, generatePacketId()) override suspend fun setModuleConfig(config: ModuleConfig) = - setModuleConfig(destNum, config, getPacketId()) + setModuleConfig(destNum, config, generatePacketId()) - override suspend fun setChannel(channel: Channel) = setRemoteChannel(destNum, channel, getPacketId()) + override suspend fun setChannel(channel: Channel) = + setRemoteChannel(destNum, channel, generatePacketId()) override suspend fun setFixedPosition(position: Position) = this@FakeRadioController.setFixedPosition(destNum, position) @@ -168,7 +169,7 @@ class FakeRadioController : scope.block() } - override fun getPacketId(): Int = 1 + override fun generatePacketId(): Int = 1 override fun startProvideLocation() { startProvideLocationCalled = true 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 bcffdcb1d1..c877d43c75 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -58,13 +58,13 @@ 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.RequestController import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.ServiceStateWriter import org.meshtastic.core.repository.TracerouteResponseProvider -import org.meshtastic.core.service.DirectRadioControllerImpl +import org.meshtastic.core.service.RadioControllerImpl import org.meshtastic.core.service.ServiceRepositoryImpl import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.desktop.DesktopNotificationManager @@ -177,7 +177,7 @@ private fun desktopPlatformStubsModule() = module { ) } single { - DirectRadioControllerImpl( + RadioControllerImpl( serviceRepository = get(), nodeRepository = get(), commandSender = get(), @@ -199,7 +199,7 @@ private fun desktopPlatformStubsModule() = module { single { get() } single { get() } single { get() } - single { get() } + single { get() } single { when (DesktopOS.current()) { DesktopOS.Linux -> LinuxNotificationSender() diff --git a/docs/en/developer/architecture.md b/docs/en/developer/architecture.md index 6d1f53a49d..15ef7a1248 100644 --- a/docs/en/developer/architecture.md +++ b/docs/en/developer/architecture.md @@ -130,9 +130,9 @@ focused sub-interfaces so callers can depend on just the slice they need: | `AdminController` | Config, channels, owner, device lifecycle, `editSettings { }` transactions | | `MessagingController` | Send packets, reactions, shared contacts | | `NodeController` | Favorite, ignore, mute, remove nodes | -| `RequestController` | Telemetry, traceroute, position/user-info queries | +| `QueryController` | Telemetry, traceroute, position/user-info queries | -`DirectRadioControllerImpl` (`core:service`) is the in-process composition root for all targets +`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 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 603fae5a3f..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 @@ -190,7 +190,7 @@ 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) } /** 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 c07a6339b9..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 @@ -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/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 917f71ff0e..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 @@ -67,7 +67,7 @@ constructor( override suspend fun requestNeighborInfo(destNum: Int, longName: String) { Logger.i { "Requesting NeighborInfo for '$destNum'" } - val packetId = radioController.getPacketId() + 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)) @@ -81,7 +81,7 @@ constructor( override suspend fun requestTelemetry(destNum: Int, longName: String, type: TelemetryType) { Logger.i { "Requesting telemetry for '$destNum'" } - val packetId = radioController.getPacketId() + val packetId = radioController.generatePacketId() radioController.requestTelemetry(packetId, destNum, type.ordinal) val typeRes = @@ -100,7 +100,7 @@ constructor( override suspend fun requestTraceroute(destNum: Int, longName: String) { Logger.i { "Requesting traceroute for '$destNum'" } - val packetId = radioController.getPacketId() + 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/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 2129058f52..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 @@ -39,7 +39,7 @@ import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.navigation.Route import org.meshtastic.core.navigation.SettingsRoute -import org.meshtastic.core.repository.RequestController +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 @@ -81,7 +81,7 @@ class NodeDetailViewModel( private val savedStateHandle: SavedStateHandle, private val nodeManagementActions: NodeManagementActions, private val nodeRequestActions: NodeRequestActions, - private val requestController: RequestController, + private val queryController: QueryController, private val getNodeDetailsUseCase: GetNodeDetailsUseCase, private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase, private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase, @@ -174,7 +174,7 @@ class NodeDetailViewModel( /** * Re-fetch device metadata (firmware/edition/role) for [destNum]. Refreshes the session passkey as a side effect. */ - fun refreshMetadata(destNum: Int) = viewModelScope.launch { requestController.refreshMetadata(destNum) } + 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 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 73133b49ae..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 @@ -60,7 +60,7 @@ constructor( open suspend fun removeNode(nodeNum: Int) { Logger.i { "Removing node '$nodeNum'" } - val packetId = radioController.getPacketId() + val packetId = radioController.generatePacketId() radioController.removeByNodenum(packetId, nodeNum) nodeRepository.deleteNode(nodeNum) } 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 aaf26a7537..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.RequestController +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 requestController: RequestController = 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, - requestController = requestController, + 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 52af1674cd..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.RequestController +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 requestController: RequestController = 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, - requestController = requestController, + queryController = queryController, getNodeDetailsUseCase = getNodeDetailsUseCase, ensureRemoteAdminSession = ensureRemoteAdminSession, observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus, From a5f60bce36ef462ac92b86c8a8531882236dce6b Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 30 May 2026 10:32:49 -0500 Subject: [PATCH 32/34] docs: log 2026-05-30 main-merge handover in session context Co-Authored-By: Claude Opus 4.8 --- .agent_memory/session_context.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md index a4204ebf44..711b8e8c71 100644 --- a/.agent_memory/session_context.md +++ b/.agent_memory/session_context.md @@ -3,6 +3,13 @@ # 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). From b0ee2de81834b8fc0be2a80319919bf5708408ba Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 31 May 2026 09:03:32 -0500 Subject: [PATCH 33/34] fix: prevent DB connection leaks with NonCancellable in PacketRepository Wrap database write operations (clearUnreadCount, clearAllUnreadCounts, updateLastReadMessage, insertRoomPacket) with NonCancellable to prevent connection pool leaks when the calling coroutine scope is cancelled mid-write. Found during release/2.8.0 integration testing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/repository/PacketRepositoryImpl.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 52aa1be617..5a9fdd822f 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 @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single @@ -90,13 +91,13 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() } override suspend fun clearUnreadCount(contact: String, timestamp: Long) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) } + withContext(dispatchers.io + NonCancellable) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) } override suspend fun clearAllUnreadCounts() = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearAllUnreadCounts() } + withContext(dispatchers.io + NonCancellable) { dbManager.currentDb.value.packetDao().clearAllUnreadCounts() } override suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) = - withContext(dispatchers.io) { + withContext(dispatchers.io + NonCancellable) { val dao = dbManager.currentDb.value.packetDao() val current = dao.getContactSettings(contact) val existingTimestamp = current?.lastReadMessageTimestamp ?: Long.MIN_VALUE @@ -116,7 +117,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, From e3a26df81b7b927635f1bd6040751f62a0e82ea6 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sun, 31 May 2026 09:13:56 -0500 Subject: [PATCH 34/34] style: fix formatting in PacketRepositoryImpl Sort imports alphabetically and format single-line lambdas properly to pass spotlessCheck and detekt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/core/data/repository/PacketRepositoryImpl.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 5a9fdd822f..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,11 +20,11 @@ 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 import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single @@ -91,7 +91,9 @@ 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 + NonCancellable) { 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 + NonCancellable) { dbManager.currentDb.value.packetDao().clearAllUnreadCounts() }