From 4f57e6509750bfeb4ebb227724d67ae73c034789 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 23 May 2026 13:45:11 -0500 Subject: [PATCH] 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 | 54 +- .../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 | 62 +- .../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 | 411 ++++++++---- .../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, 1739 insertions(+), 5492 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 028173a50..c08aa3ac0 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 cebaf3931..006969ad6 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 8a4a798a8..679e42df8 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 d6d296ea1..46692ee80 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 78e8ce559..962b4acd8 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 0da77c524..7e4046123 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 45cc867ba..5b2037cca 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 0bccd30ce..cd63704a7 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 4d2be1b40..000000000 --- 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 7a798e753..000000000 --- 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 94cbbcfc3..000000000 --- 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 b8a164056..000000000 --- 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 ba7153973..000000000 --- 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 1286d7c7f..000000000 --- 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 ab7c1c926..000000000 --- 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 be49bd57a..000000000 --- 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 f2307dd90..000000000 --- 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 ac0dbf648..000000000 --- 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 d535d4eff..ea932b26a 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 6ecdd18e1..1ed14a014 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 2e71fda0c..000000000 --- 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 0ae5ef693..000000000 --- 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 672594bb9..000000000 --- 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 621d52093..4d3b1b363 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 23e195b39..000000000 --- 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 24ababf14..d49d371d7 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 + fun resolveNodeNum(address: NodeAddress): Int = when (address) { + NodeAddress.Broadcast -> NodeAddress.NODENUM_BROADCAST - else -> { - val numericNum = - if (toId.startsWith(NODE_ID_PREFIX)) { - toId.substring(NODE_ID_START_INDEX).toLongOrNull(HEX_RADIX)?.toInt() - } else { - null - } - numericNum - ?: nodeManager.nodeDBbyID[toId]?.num - ?: throw IllegalArgumentException("Unknown node ID $toId") - } + NodeAddress.Local -> nodeManager.myNodeNum.value ?: 0 + + is NodeAddress.ByNum -> address.num + + is NodeAddress.ById -> + nodeManager.getNodeById(address.id)?.num + ?: throw IllegalArgumentException("Unknown node ID ${address.id}") } private fun buildMeshPacket( @@ -404,7 +401,7 @@ class CommandSenderImpl( var publicKey: ByteString = ByteString.EMPTY var actualChannel = channel - if (channel == DataPacket.PKC_CHANNEL_INDEX) { + if (channel == NodeAddress.PKC_CHANNEL_INDEX) { pkiEncrypted = true val destNode = nodeManager.nodeDBbyNodeNum[to] // Resolve the public key using the same fallback as Node.hasPKC: @@ -457,9 +454,6 @@ class CommandSenderImpl( private const val PACKET_ID_SHIFT_BITS = 32 private const val ADMIN_CHANNEL_NAME = "admin" - private const val NODE_ID_PREFIX = "!" - private const val NODE_ID_START_INDEX = 1 - private const val HEX_RADIX = 16 private const val DEFAULT_HOP_LIMIT = 3 } 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 7ea4d7cf0..ab8283660 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 e16852d25..000000000 --- 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 9e3238186..30de98254 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 a62cb5bed..0db2490db 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 96edbe41f..f23822c0d 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 e93ea478e..edb698737 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 fe58735da..64c07d17b 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 2975341cc..4d22c566c 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 e054f1a2c..bb5e140d4 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 aa62b76b9..ad3f7fda8 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 6504faf80..33e761727 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 14cc42b30..fb0077177 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 c47fe5bf1..52aa1be61 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 816c0934a..000000000 --- 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 af0925d38..7cfa5663a 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 fadd19542..541c4cead 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 5327449e9..7e4551bd6 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 580e4c8b8..10f7c2312 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 bce47d266..91e3610a3 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) - - assertTrue(actionHandlerLazy.isInitialized()) - assertFalse(dataHandlerLazy.isInitialized()) assertFalse(tracerouteHandlerLazy.isInitialized()) - verifySuspend { actionHandler.onServiceAction(action) } + + router.configHandler + assertTrue(configHandlerLazy.isInitialized()) + assertFalse(tracerouteHandlerLazy.isInitialized()) } 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 509066867..83c96052c 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 e0bda6075..9d9f7310a 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 d93bc12c0..05739aca0 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 9bf237733..6a322193f 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 451a62174..3177f21d8 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 b30a4306f..48f41170a 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 5a16fd7b1..ae2603625 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 28792ea0b..2ddd87fc7 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 4116cb99f..933dcf6e1 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 c855cff2c..47da7b870 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 7f93b09d3..5a8d94c28 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 0352372ec..000000000 --- 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 cc3a1a37e..000000000 --- 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 8d3018266..000000000 --- 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 6e994f4ef..000000000 --- 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 c72c447bc..000000000 --- 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 d768ba009..000000000 --- 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 58d260e32..000000000 --- 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 2ba306411..000000000 --- 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 feee58393..000000000 --- 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 aa4f0e2eb..bd02bb3cf 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 8c58505de..000000000 --- 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 ec5258785..000000000 --- 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 23431f816..000000000 --- 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 f563def74..000000000 --- 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 c37998ae9..000000000 --- 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 43dbdf392..cb1b614c8 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 cb3d908d2..d677d224c 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 000000000..992c526a6 --- /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 ffe57a708..8a7563a3b 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 4214dd62c..583430456 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 000000000..963712db7 --- /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 000000000..53c362b65 --- /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 000000000..1b6efcf5f --- /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 000000000..23fb832a7 --- /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 1d3df2fad..9c15cc6a4 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 159385415..70efcae00 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 000000000..7f3b45076 --- /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 000000000..478c149a4 --- /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 3a3deddd5..000000000 --- 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 000000000..87a2b9ab3 --- /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 84994e628..517e4cf46 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 000000000..5b9ce8e23 --- /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 9ffe944d4..000000000 --- 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 25e19bbef..9e220f57f 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 4df932c50..d1f6dc6db 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 aed84d208..d5d4e3eef 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 d386482b3..f16930929 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 6e88b38af..9fe3ab2fa 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 543552cb6..09f7ac72f 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 b94eeffbf..b72881603 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 edb71c752..c94bbeb42 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 a6b58bb48..1e4c398bf 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 0087dde97..1cf46034f 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 873e1c76b..000000000 --- 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 9d898a333..a39053954 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 accd503f9..a729f19ad 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 9a15b8660..d7d181c84 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 490f50725..faec1d6d0 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 80c1c5e53..6bf214f23 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 cd73b7f9b..cbb6322f2 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 5cd61b671..000000000 --- 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 2a09e95c8..90555ceb0 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 7fc17c2a9..91fb9df2e 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 c65812c01..95e716654 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 84352b088..0ea1a49be 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 59f7d3f95..c7d8624e4 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 4dd27dbf4..000000000 --- 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 a4a3b0fe3..b0e8b9bae 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 16a9a000c..000000000 --- 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 639c6af3f..48ed22d56 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 e59e3c623..1d01c2357 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 af7cb85c2..000000000 --- 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 dca0fb415..dba8e14f2 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 425b19fe2..000000000 --- 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 be9ce1130..d78a9822b 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 c1c3964b7..35aa69594 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 cf636923a..afaa0817f 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 4bb322ad7..000000000 --- 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 723374014..0110af961 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 c7f57eba2..278c4f65a 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 d63c5f2ed..000000000 --- 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 c7c1e01f4..000000000 --- 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 f5104739c..9c3e29132 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 3549aff6e..000000000 --- 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 55ed704a5..cc7ee223c 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 a4c95d8cd..8078d109a 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() } - override suspend fun favoriteNode(nodeNum: Int) { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef)) + // ── 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 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 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) { - actionHandler.handleSetRemoteConfig(packetId, destNum, config.encode()) + commandSender.sendAdmin(destNum, packetId) { AdminMessage(set_config = config) } + if (destNum == myNodeNum) { + 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) { - actionHandler.handleSetModuleConfig(packetId, destNum, config.encode()) + 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 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) { - actionHandler.handleSetRemoteChannel(packetId, destNum, channel.encode()) + 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) { + 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 setRingtone(destNum: Int, ringtone: String) { - actionHandler.handleSetRingtone(destNum, ringtone) + 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) } - override suspend fun setCannedMessages(destNum: Int, messages: String) { - actionHandler.handleSetCannedMessages(destNum, messages) - } - - override suspend fun getOwner(destNum: Int, packetId: Int) { - actionHandler.handleGetRemoteOwner(packetId, destNum) - } - - override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { - actionHandler.handleGetRemoteConfig(packetId, destNum, configType) - } - - override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) { - actionHandler.handleGetModuleConfig(packetId, destNum, moduleConfigType) - } - - override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) { - actionHandler.handleGetRemoteChannel(packetId, destNum, index) - } - - override suspend fun getRingtone(destNum: Int, packetId: Int) { - actionHandler.handleGetRingtone(packetId, destNum) - } - - override suspend fun getCannedMessages(destNum: Int, packetId: Int) { - actionHandler.handleGetCannedMessages(packetId, destNum) - } + // ── Device Status & Lifecycle ─────────────────────────────────────────── override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { - actionHandler.handleGetDeviceConnectionStatus(packetId, destNum) - } - - override suspend fun reboot(destNum: Int, packetId: Int) { - actionHandler.handleRequestReboot(packetId, destNum) - } - - override suspend fun rebootToDfu(nodeNum: Int) { - actionHandler.handleRebootToDfu(nodeNum) - } - - override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { - actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) - } - - override suspend fun shutdown(destNum: Int, packetId: Int) { - actionHandler.handleRequestShutdown(packetId, destNum) - } - - override suspend fun factoryReset(destNum: Int, packetId: Int) { - actionHandler.handleRequestFactoryReset(packetId, destNum) - } - - override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) { - actionHandler.handleRequestNodedbReset(packetId, destNum, preserveFavorites) - } - - override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) { - val myNode = nodeManager.myNodeNum.value - if (myNode != null) { - actionHandler.handleRemoveByNodenum(nodeNum, packetId, myNode) - } else { - nodeManager.removeByNodenum(nodeNum) + commandSender.sendAdmin(destNum, packetId, wantResponse = true) { + AdminMessage(get_device_connection_status_request = true) } } - override suspend fun requestPosition(destNum: Int, currentPosition: Position) { - actionHandler.handleRequestPosition(destNum, currentPosition, myNodeNum) + 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 beginEditSettings(destNum: Int) { + commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) } + } + + 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 ab107e18b..da2e78e01 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 5ad5c2d00..235d3349a 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 b93aac1a9..ecd4a9750 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 { actionHandler.handleUpdateLastAddress("tcp:192.168.1.1") } + // Verify ordering: switchDevice completes before transport reconfiguration + verifySuspend { meshPrefs.setDeviceAddress("tcp:192.168.1.1") } + verifySuspend { databaseManager.switchActiveDatabase("tcp:192.168.1.1") } verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } } + + @Test + fun setDeviceAddressSkipsSwitchWhenAddressUnchanged() = runTest { + val controller = createController() + every { meshPrefs.deviceAddress } returns MutableStateFlow("tcp:192.168.1.1") + + controller.setDeviceAddress("tcp:192.168.1.1") + testScope.advanceUntilIdle() + + // switchDevice should skip when addresses match, but transport still reconfigures + verify { radioInterfaceService.setDeviceAddress("tcp:192.168.1.1") } + verifySuspend(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 31178449c..8d64a9cbf 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 bcf3819ed..0ba6b4029 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 ce14df9cd..01e5fe877 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 b422a8a12..877f72942 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 f12c817a6..614f54865 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 1e7531058..5693f112f 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 4f0a4b153..98d391524 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 cfdc64f4f..40ce54d3f 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 4c5092080..fcd864915 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 494586e08..192d4728d 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 d00ab5f3c..337f4609a 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 2ce3077c7..77dbe36a7 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 261abeeae..e8631ad83 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 4cda00251..092358032 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 081735e25..f8b96faba 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 fdfd3f05a..bc066bda3 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 336de2a44..bc935be25 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 89ac0ef45..394533fcf 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 3f92f3cbf..0afd24b1c 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 ca29b3842..9656d0987 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 4d89b342c..8fd585d37 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 d11fc1ae9..e1adf4ea1 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 d846ba260..4332a0ea9 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 80877834b..0fd2eda0b 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 75dac0f6a..7641103fc 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 1ea463685..0742c11c5 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 fe6e2b57c..f7be9901d 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 3535511ff..000000000 --- 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 1b64f2555..d984eaf98 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 17046f3a7..0fabdd025 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 3c396d8a9..051811ce1 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 a6b091426..7953eace9 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 96bdf82c8..16e81f1f6 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 62de4973c..d23674078 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 c7504dfe4..c3fc677a3 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 fe15acfe4..6760c9182 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 4e65cf290..fcfb7ce58 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 fa54c8992..f2423819d 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 4b9fdcdd1..796e0cc8f 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 1f010b438..e41e3e412 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 8990246bf..5371ed5e3 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 0375d390f..47e214f1b 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 b3935efcb..a261188b3 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 5ae98fe86..564c1b7f0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -115,7 +115,6 @@ include( ":feature:wifi-provision", ":desktopApp", ":androidApp", - ":core:api", ":core:barcode", ":feature:widget", ":screenshot-tests",