From 74ba959b24871a20e129155fe0c253d9b5aa967c Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 06:41:37 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20DRY=20SDK=20integration=20=E2=80=94=20s?= =?UTF-8?q?hared=20bridge,=20Desktop=20cutover,=20dead=20infra=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major architectural changes: 1. SHARED KMP BRIDGE (core/data/radio/): - RadioClientAccessor: Platform-agnostic interface for SDK RadioClient - SdkRadioController: Shared RadioController impl (replaces per-platform copies) - SdkStateBridge: Shared SDK→repository bridge (event dispatch, node sync) - SdkPacketHandler: Thin SDK-backed PacketHandler for MQTT/XModem/History 2. DESKTOP SDK CUTOVER: - DesktopRadioClientProvider: TCP + Serial transport support - Removed DirectRadioControllerImpl (old desktop radio path) - Desktop now shares the same SDK bridge code as Android 3. DEAD INFRASTRUCTURE DELETION (~5,100 LOC removed): - PacketHandlerImpl, MeshDataHandlerImpl, MeshRouterImpl - CommandSenderImpl, MeshActionHandlerImpl, MeshConfigFlowManagerImpl - MeshConnectionManagerImpl, AdminPacketHandlerImpl - ServiceBroadcasts (Android intent-based pub/sub) - NodeRepositoryImpl (Room-backed, replaced by SdkNodeRepositoryImpl) - 8 trivial UseCases (SetLocale, SetTheme, ToggleAnalytics, etc.) - All associated test files for deleted impls - Deleted interfaces: AdminPacketHandler, CommandSender, MeshActionHandler, MeshConfigFlowManager, MeshConnectionManager, MeshRouter, ServiceBroadcasts 4. NEW FEATURES: - NodeMetadataEntity + Room migration 38→39 (persistent favorites/notes) - AppMetadataRepository (clean access to node metadata) - MessagePersistenceHandler (focused rememberDataPacket for StoreForward) All three targets compile clean: :app:compileGoogleDebugKotlin, :desktop:compileKotlin, :core:data:jvmTest passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../app/radio/RadioClientProvider.kt | 9 +- .../app/radio/TestRadioClientProvider.kt | 55 + .../app/radio/TestRadioClientProviderTest.kt | 115 ++ core/data/build.gradle.kts | 4 + .../datasource/NodeInfoWriteDataSource.kt | 43 - .../SwitchingNodeInfoWriteDataSource.kt | 72 -- .../data/manager/AdminPacketHandlerImpl.kt | 85 -- .../core/data/manager/CommandSenderImpl.kt | 466 ------- .../data/manager/DataLayerHeartbeatSender.kt | 54 - .../data/manager/MeshActionHandlerImpl.kt | 401 ------ .../data/manager/MeshConfigFlowManagerImpl.kt | 308 ----- .../data/manager/MeshConnectionManagerImpl.kt | 438 ------- .../core/data/manager/MeshDataHandlerImpl.kt | 531 -------- .../core/data/manager/MeshRouterImpl.kt | 66 - .../data/manager/MessagePersistenceHandler.kt | 195 +++ .../data/manager/NeighborInfoHandlerImpl.kt | 4 +- .../core/data/manager/NodeManagerImpl.kt | 6 +- .../core/data/manager/PacketHandlerImpl.kt | 295 ----- .../manager/StoreForwardPacketHandlerImpl.kt | 3 - .../manager/TelemetryPacketHandlerImpl.kt | 7 +- .../core/data/radio/RadioClientAccessor.kt | 39 + .../core/data/radio/SdkPacketHandler.kt | 89 ++ .../core/data/radio/SdkRadioController.kt | 50 +- .../core/data}/radio/SdkStateBridge.kt | 66 +- .../repository/AppMetadataRepositoryImpl.kt | 82 ++ .../data/repository/NodeRepositoryImpl.kt | 290 ----- .../data/repository/SdkNodeRepositoryImpl.kt | 48 +- .../manager/AdminPacketHandlerImplTest.kt | 224 ---- .../data/manager/MeshActionHandlerImplTest.kt | 583 --------- .../manager/MeshConfigFlowManagerImplTest.kt | 471 ------- .../manager/MeshConnectionManagerImplTest.kt | 430 ------- .../core/data/manager/MeshDataHandlerTest.kt | 706 ----------- .../core/data/manager/NodeManagerImplTest.kt | 4 +- .../data/manager/PacketHandlerImplTest.kt | 143 --- .../StoreForwardPacketHandlerImplTest.kt | 4 - .../manager/TelemetryPacketHandlerImplTest.kt | 8 +- .../repository/CommonNodeRepositoryTest.kt | 123 -- .../39.json | 1105 +++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 24 +- .../core/database/dao/NodeMetadataDao.kt | 57 + .../database/entity/NodeMetadataEntity.kt | 35 + .../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 - .../SetDatabaseCacheLimitUseCaseTest.kt | 49 - .../SetNotificationSettingsUseCaseTest.kt | 58 - .../settings/ToggleAnalyticsUseCaseTest.kt | 48 - .../ToggleHomoglyphEncodingUseCaseTest.kt | 48 - .../core/repository/AdminPacketHandler.kt | 30 - .../core/repository/AppMetadataRepository.kt | 49 + .../core/repository/CommandSender.kt | 86 -- .../core/repository/MeshActionHandler.kt | 119 -- .../core/repository/MeshConfigFlowManager.kt | 51 - .../core/repository/MeshConnectionManager.kt | 40 - .../meshtastic/core/repository/MeshRouter.kt | 44 - .../core/repository/ServiceBroadcasts.kt | 39 - .../core/service/ServiceBroadcastsTest.kt | 135 -- .../org/meshtastic/core/service/Constants.kt | 1 - .../meshtastic/core/service/MeshService.kt | 8 +- .../core/service/ServiceBroadcasts.kt | 164 --- .../core/service/DirectRadioControllerImpl.kt | 237 ---- .../core/service/MeshServiceOrchestrator.kt | 26 +- .../service/MeshServiceOrchestratorTest.kt | 9 +- desktop/build.gradle.kts | 7 + .../desktop/di/DesktopKoinModule.kt | 23 +- .../desktop/radio/DesktopMessageQueue.kt | 4 +- .../radio/DesktopRadioClientProvider.kt | 158 +++ .../org/meshtastic/desktop/stub/NoopStubs.kt | 16 - .../feature/settings/SettingsViewModel.kt | 29 +- .../settings/radio/RadioConfigViewModel.kt | 8 +- .../feature/settings/SettingsViewModelTest.kt | 18 - .../radio/RadioConfigViewModelTest.kt | 18 +- 77 files changed, 2190 insertions(+), 7294 deletions(-) create mode 100644 app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.kt create mode 100644 app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProviderTest.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/RadioClientAccessor.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketHandler.kt rename app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt => core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt (91%) rename {app/src/main/kotlin/org/meshtastic/app => core/data/src/commonMain/kotlin/org/meshtastic/core/data}/radio/SdkStateBridge.kt (82%) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonNodeRepositoryTest.kt create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json create mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt create mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeMetadataEntity.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/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 delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppMetadataRepository.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt 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/ServiceBroadcastsTest.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt delete mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt create mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioClientProvider.kt diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt index 85f548e65..620a55e3f 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.Single +import org.meshtastic.core.data.radio.RadioClientAccessor import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.service.SdkClientLifecycle @@ -45,15 +46,15 @@ import org.meshtastic.sdk.transport.tcp.TcpTransport * This is the SDK integration point for the POC. The [RadioClient] is exposed as a [StateFlow] so ViewModels and the * service can react to connection changes with `flatMapLatest`. */ -@Single(binds = [SdkClientLifecycle::class]) +@Single(binds = [SdkClientLifecycle::class, RadioClientAccessor::class]) class RadioClientProvider( private val context: Context, private val radioPrefs: RadioPrefs, -) : SdkClientLifecycle { +) : SdkClientLifecycle, RadioClientAccessor { private val _client = MutableStateFlow(null) /** Active [RadioClient], or `null` when disconnected or between connections. */ - val client: StateFlow = _client.asStateFlow() + override val client: StateFlow = _client.asStateFlow() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val mutex = Mutex() @@ -121,7 +122,7 @@ class RadioClientProvider( } /** Fire-and-forget version of [rebuildAndConnect] for non-suspending call sites. */ - fun rebuildAndConnectAsync() { + override fun rebuildAndConnectAsync() { scope.launch { runCatching { rebuildAndConnect() }.onFailure { e -> Logger.e(e) { "RadioClientProvider: connect failed" } } } diff --git a/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.kt b/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.kt new file mode 100644 index 000000000..ced0b2921 --- /dev/null +++ b/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.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.app.radio + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.coroutines.CoroutineContext + +/** + * Test-only RadioClient setup using FakeRadioTransport. + * Provides deterministic handshake and packet injection for integration tests. + */ +class TestRadioClientProvider( + val nodeNum: Int = 1, + coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default, +) { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:test-radio-provider"), + autoHandshake = true, + nodeNum = nodeNum, + ) + + val client: RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(coroutineContext) + .build() + + suspend fun connect() { + client.connect() + } + + suspend fun disconnect() { + client.disconnect() + } +} diff --git a/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProviderTest.kt b/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProviderTest.kt new file mode 100644 index 000000000..5216ec2e7 --- /dev/null +++ b/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProviderTest.kt @@ -0,0 +1,115 @@ +/* + * 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.app.radio + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.io.bytestring.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.User +import org.meshtastic.sdk.ConnectionState +import org.meshtastic.sdk.Frame +import org.meshtastic.sdk.NodeChange +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.TransportState +import org.meshtastic.sdk.decodeAsNodeInfo +import org.meshtastic.sdk.testing.FakeRadioTransport +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class TestRadioClientProviderTest { + + @Test + fun connectInjectNodeAndDisconnect() = runTest { + val provider = TestRadioClientProvider(coroutineContext = backgroundScope.coroutineContext) + + provider.connect() + assertEquals(ConnectionState.Connected, provider.client.connection.value) + + val nodeInfo = NodeInfo( + num = 0x1234, + user = User( + id = "!00001234", + long_name = "Test Node", + short_name = "TN", + ), + ) + + val packetAwaiter = backgroundScope.async { + provider.client.packets.first { packet -> + packet.from == nodeInfo.num && packet.decodeAsNodeInfo()?.num == nodeInfo.num + } + } + runCurrent() + + provider.transport.injectPacket( + MeshPacket( + from = nodeInfo.num, + to = provider.nodeNum, + decoded = Data( + portnum = PortNum.NODEINFO_APP, + payload = NodeInfo.ADAPTER.encode(nodeInfo).toByteString(), + ), + ), + ) + runCurrent() + runCurrent() + + assertEquals(nodeInfo.num, packetAwaiter.await().decodeAsNodeInfo()?.num) + + val nodeAwaiter = backgroundScope.async { + provider.client.nodes.first { change -> + change is NodeChange.Added && change.node.num == nodeInfo.num + } + } + runCurrent() + + provider.transport.injectNodeInfo(nodeInfo) + runCurrent() + runCurrent() + + val added = assertIs(nodeAwaiter.await()) + assertEquals(nodeInfo.num, added.node.num) + assertEquals(nodeInfo.user?.long_name, provider.client.nodeSnapshot()[NodeId(nodeInfo.num)]?.user?.long_name) + + provider.disconnect() + assertEquals(ConnectionState.Disconnected, provider.client.connection.value) + assertEquals(TransportState.Disconnected, provider.transport.state.value) + } + + private fun FakeRadioTransport.injectNodeInfo(nodeInfo: NodeInfo) { + val proto = FromRadio.ADAPTER.encode(FromRadio(node_info = nodeInfo)) + val frame = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + injectFrame(Frame(ByteString(frame))) + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index d167e1ffe..4dbd51f07 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -44,6 +44,9 @@ kotlin { implementation(projects.core.proto) implementation(projects.core.takserver) + // Meshtastic SDK — shared RadioController and StateBridge implementations + api(libs.sdk.core) + implementation(libs.jetbrains.lifecycle.runtime) implementation(libs.androidx.paging.common) implementation(libs.kotlinx.serialization.json) @@ -69,6 +72,7 @@ kotlin { commonTest.dependencies { implementation(projects.core.testing) implementation(libs.kotlinx.coroutines.test) + implementation(libs.sdk.testing) } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt deleted file mode 100644 index 12ca7154b..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt +++ /dev/null @@ -1,43 +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.datasource - -import org.meshtastic.core.database.entity.MetadataEntity -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.entity.NodeEntity - -interface NodeInfoWriteDataSource { - suspend fun upsert(node: NodeEntity) - - suspend fun installConfig(mi: MyNodeEntity, nodes: List) - - suspend fun clearNodeDB(preserveFavorites: Boolean) - - suspend fun clearMyNodeInfo() - - suspend fun deleteNode(num: Int) - - suspend fun deleteNodes(nodeNums: List) - - suspend fun deleteMetadata(num: Int) - - suspend fun upsert(metadata: MetadataEntity) - - suspend fun setNodeNotes(num: Int, notes: String) - - suspend fun backfillDenormalizedNames() -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt deleted file mode 100644 index 858b0578d..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt +++ /dev/null @@ -1,72 +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.datasource - -import kotlinx.coroutines.withContext -import org.koin.core.annotation.Single -import org.meshtastic.core.database.DatabaseProvider -import org.meshtastic.core.database.entity.MetadataEntity -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.di.CoroutineDispatchers - -@Single -class SwitchingNodeInfoWriteDataSource( - private val dbManager: DatabaseProvider, - private val dispatchers: CoroutineDispatchers, -) : NodeInfoWriteDataSource { - - override suspend fun upsert(node: NodeEntity) { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } } - } - - override suspend fun installConfig(mi: MyNodeEntity, nodes: List) { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } } - } - - override suspend fun clearNodeDB(preserveFavorites: Boolean) { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } } - } - - override suspend fun clearMyNodeInfo() { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearMyNodeInfo() } } - } - - override suspend fun deleteNode(num: Int) { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNode(num) } } - } - - override suspend fun deleteNodes(nodeNums: List) { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNodes(nodeNums) } } - } - - override suspend fun deleteMetadata(num: Int) { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteMetadata(num) } } - } - - override suspend fun upsert(metadata: MetadataEntity) { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(metadata) } } - } - - override suspend fun setNodeNotes(num: Int, notes: String) { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } } - } - - override suspend fun backfillDenormalizedNames() { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().backfillDenormalizedNames() } } - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt deleted file mode 100644 index 5f1f42c4f..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.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.data.manager - -import co.touchlab.kermit.Logger -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.AdminPacketHandler -import org.meshtastic.core.repository.MeshConfigFlowManager -import org.meshtastic.core.repository.MeshConfigHandler -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.SessionManager -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.MeshPacket - -/** - * Implementation of [AdminPacketHandler] that processes admin messages, including session passkeys, device/module - * configuration, and metadata. - */ -@Single -class AdminPacketHandlerImpl( - private val nodeManager: NodeManager, - private val configHandler: Lazy, - private val configFlowManager: Lazy, - private val sessionManager: SessionManager, -) : AdminPacketHandler { - - override fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) { - val payload = packet.decoded?.payload ?: return - val u = AdminMessage.ADAPTER.decode(payload) - Logger.d { "Admin message from=${packet.from} fields=${u.summarize()}" } - // Firmware embeds the session_passkey in every admin response. A missing (default-empty) - // field must not reset stored state, so only record refreshes when bytes arrived. - val incomingPasskey = u.session_passkey - if (incomingPasskey.size > 0) { - sessionManager.recordSession(packet.from, incomingPasskey) - } - - val fromNum = packet.from - u.get_module_config_response?.let { - if (fromNum == myNodeNum) { - configHandler.value.handleModuleConfig(it) - } else { - it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) } - } - } - - if (fromNum == myNodeNum) { - u.get_config_response?.let { configHandler.value.handleDeviceConfig(it) } - u.get_channel_response?.let { configHandler.value.handleChannel(it) } - } - - u.get_device_metadata_response?.let { - if (fromNum == myNodeNum) { - configFlowManager.value.handleLocalMetadata(it) - } else { - nodeManager.insertMetadata(fromNum, it) - } - } - } -} - -/** Returns a short summary of the non-null admin message fields for logging. */ -private fun AdminMessage.summarize(): String = buildList { - get_config_response?.let { add("get_config_response") } - get_module_config_response?.let { add("get_module_config_response") } - get_channel_response?.let { add("get_channel_response") } - get_device_metadata_response?.let { add("get_device_metadata_response") } - if (session_passkey.size > 0) add("session_passkey") -} - .joinToString() - .ifEmpty { "empty" } 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 deleted file mode 100644 index 24ababf14..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt +++ /dev/null @@ -1,466 +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.atomicfu.atomic -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -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.util.nowMillis -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.model.util.isWithinSizeLimit -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.NeighborInfoHandler -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.PacketHandler -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.SessionManager -import org.meshtastic.core.repository.TracerouteHandler -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.AirQualityMetrics -import org.meshtastic.proto.ChannelSet -import org.meshtastic.proto.Constants -import org.meshtastic.proto.Data -import org.meshtastic.proto.DeviceMetrics -import org.meshtastic.proto.EnvironmentMetrics -import org.meshtastic.proto.HostMetrics -import org.meshtastic.proto.LocalConfig -import org.meshtastic.proto.LocalStats -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.Neighbor -import org.meshtastic.proto.NeighborInfo -import org.meshtastic.proto.Paxcount -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.PowerMetrics -import org.meshtastic.proto.Telemetry -import kotlin.math.absoluteValue -import kotlin.random.Random -import kotlin.time.Duration.Companion.hours -import org.meshtastic.proto.Position as ProtoPosition - -@Suppress("TooManyFunctions", "CyclomaticComplexMethod", "LongParameterList") -@Single -class CommandSenderImpl( - private val packetHandler: PacketHandler, - private val nodeManager: NodeManager, - private val radioConfigRepository: RadioConfigRepository, - private val tracerouteHandler: TracerouteHandler, - private val neighborInfoHandler: NeighborInfoHandler, - private val sessionManager: SessionManager, - @Named("ServiceScope") private val scope: CoroutineScope, -) : CommandSender { - private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue) - - private val localConfig = MutableStateFlow(LocalConfig()) - private val channelSet = MutableStateFlow(ChannelSet()) - - init { - radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope) - radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope) - } - - override fun getCachedLocalConfig(): LocalConfig = localConfig.value - - override fun getCachedChannelSet(): ChannelSet = channelSet.value - - override fun getCurrentPacketId(): Long = currentPacketId.value - - override fun generatePacketId(): Int { - val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1) - val next = currentPacketId.incrementAndGet() and PACKET_ID_MASK - return ((next % numPacketIds) + 1L).toInt() - } - - private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT - - /** - * 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 - * 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. - */ - private fun getAdminChannelIndex(toNum: Int): Int { - val myNum = nodeManager.myNodeNum.value ?: return 0 - val myNode = nodeManager.nodeDBbyNodeNum[myNum] - val destNode = nodeManager.nodeDBbyNodeNum[toNum] - - return when { - myNum == toNum -> 0 - - myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX - - else -> - channelSet.value.settings - .indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) } - .coerceAtLeast(0) - } - } - - /** - * Returns the heard-on channel for a non-admin request to [toNum]. Does NOT use PKI — protocol-level requests need - * clear inner payloads. - */ - private fun getChannelIndex(toNum: Int): Int = nodeManager.nodeDBbyNodeNum[toNum]?.channel ?: 0 - - override 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!" } - - // Use Wire extension for accurate size validation - val data = - Data( - portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP, - payload = bytes, - reply_id = p.replyId ?: 0, - emoji = p.emoji, - ) - - if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) { - val actualSize = Data.ADAPTER.encodedSize(data) - p.status = MessageStatus.ERROR - error("Message too long: $actualSize bytes") - } else { - p.status = MessageStatus.QUEUED - } - - sendNow(p) - } - - private fun sendNow(p: DataPacket) { - val meshPacket = - buildMeshPacket( - to = resolveNodeNum(p.to ?: DataPacket.ID_BROADCAST), - id = p.id, - wantAck = p.wantAck, - hopLimit = if (p.hopLimit > 0) p.hopLimit else computeHopLimit(), - channel = p.channel, - decoded = - Data( - portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP, - payload = p.bytes ?: ByteString.EMPTY, - reply_id = p.replyId ?: 0, - emoji = p.emoji, - ), - ) - p.time = nowMillis - packetHandler.sendToRadio(meshPacket) - } - - override 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) - packetHandler.sendToRadio(packet) - } - - override suspend fun sendAdminAwait( - destNum: Int, - requestId: Int, - wantResponse: Boolean, - initFn: () -> AdminMessage, - ): Boolean { - val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum)) - val packet = - buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg) - return packetHandler.sendToRadioAndAwait(packet) - } - - override 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" } - - if (localConfig.value.position?.fixed_position != true) { - nodeManager.handleReceivedPosition(myNum, myNum, pos, nowMillis) - } - - packetHandler.sendToRadio( - buildMeshPacket( - to = idNum, - channel = if (destNum == null) 0 else getChannelIndex(destNum), - priority = MeshPacket.Priority.BACKGROUND, - decoded = - Data( - portnum = PortNum.POSITION_APP, - payload = pos.encode().toByteString(), - want_response = wantResponse, - ), - ), - ) - } - - override fun requestPosition(destNum: Int, currentPosition: Position) { - val meshPosition = - ProtoPosition( - latitude_i = Position.degI(currentPosition.latitude), - longitude_i = Position.degI(currentPosition.longitude), - altitude = currentPosition.altitude, - time = (nowMillis / 1000L).toInt(), - ) - packetHandler.sendToRadio( - buildMeshPacket( - to = destNum, - channel = getChannelIndex(destNum), - priority = MeshPacket.Priority.BACKGROUND, - decoded = - Data( - portnum = PortNum.POSITION_APP, - payload = meshPosition.encode().toByteString(), - want_response = true, - ), - ), - ) - } - - override fun setFixedPosition(destNum: Int, pos: Position) { - val meshPos = - ProtoPosition( - latitude_i = Position.degI(pos.latitude), - longitude_i = Position.degI(pos.longitude), - altitude = pos.altitude, - ) - sendAdmin(destNum) { - if (pos != Position(0.0, 0.0, 0)) { - AdminMessage(set_fixed_position = meshPos) - } else { - AdminMessage(remove_fixed_position = true) - } - } - nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum.value ?: 0, meshPos, nowMillis) - } - - override fun requestUserInfo(destNum: Int) { - val myNum = nodeManager.myNodeNum.value ?: return - val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return - packetHandler.sendToRadio( - buildMeshPacket( - to = destNum, - channel = getChannelIndex(destNum), - decoded = - Data( - portnum = PortNum.NODEINFO_APP, - want_response = true, - payload = myNode.user.encode().toByteString(), - ), - ), - ) - } - - override fun requestTraceroute(requestId: Int, destNum: Int) { - tracerouteHandler.recordStartTime(requestId) - packetHandler.sendToRadio( - buildMeshPacket( - to = destNum, - wantAck = true, - id = requestId, - channel = getChannelIndex(destNum), - decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true, dest = destNum), - ), - ) - } - - override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { - val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE - - val portNum: PortNum - val payloadBytes: ByteString - - if (type == TelemetryType.PAX) { - portNum = PortNum.PAXCOUNTER_APP - payloadBytes = Paxcount().encode().toByteString() - } else { - portNum = PortNum.TELEMETRY_APP - payloadBytes = - Telemetry( - device_metrics = if (type == TelemetryType.DEVICE) DeviceMetrics() else null, - environment_metrics = if (type == TelemetryType.ENVIRONMENT) EnvironmentMetrics() else null, - air_quality_metrics = if (type == TelemetryType.AIR_QUALITY) AirQualityMetrics() else null, - power_metrics = if (type == TelemetryType.POWER) PowerMetrics() else null, - local_stats = if (type == TelemetryType.LOCAL_STATS) LocalStats() else null, - host_metrics = if (type == TelemetryType.HOST) HostMetrics() else null, - ) - .encode() - .toByteString() - } - - packetHandler.sendToRadio( - buildMeshPacket( - to = destNum, - id = requestId, - channel = getChannelIndex(destNum), - decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true, dest = destNum), - ), - ) - } - - override fun requestNeighborInfo(requestId: Int, destNum: Int) { - neighborInfoHandler.recordStartTime(requestId) - val myNum = nodeManager.myNodeNum.value ?: 0 - if (destNum == myNum) { - val neighborInfoToSend = - neighborInfoHandler.lastNeighborInfo - ?: run { - val oneHour = 1.hours.inWholeMinutes.toInt() - Logger.d { "No stored neighbor info from connected radio, sending dummy data" } - NeighborInfo( - node_id = myNum, - last_sent_by_id = myNum, - node_broadcast_interval_secs = oneHour, - neighbors = - listOf( - Neighbor( - node_id = 0, // Dummy node ID that can be intercepted - snr = 0f, - last_rx_time = (nowMillis / 1000L).toInt(), - node_broadcast_interval_secs = oneHour, - ), - ), - ) - } - - // Send the neighbor info from our connected radio to ourselves (simulated) - packetHandler.sendToRadio( - buildMeshPacket( - to = destNum, - wantAck = true, - id = requestId, - channel = getChannelIndex(destNum), - decoded = - Data( - portnum = PortNum.NEIGHBORINFO_APP, - payload = neighborInfoToSend.encode().toByteString(), - want_response = true, - ), - ), - ) - } else { - // Send request to remote - packetHandler.sendToRadio( - buildMeshPacket( - to = destNum, - wantAck = true, - id = requestId, - channel = getChannelIndex(destNum), - decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true, dest = destNum), - ), - ) - } - } - - fun resolveNodeNum(toId: String): Int = when (toId) { - DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST - - else -> { - val numericNum = - if (toId.startsWith(NODE_ID_PREFIX)) { - toId.substring(NODE_ID_START_INDEX).toLongOrNull(HEX_RADIX)?.toInt() - } else { - null - } - numericNum - ?: nodeManager.nodeDBbyID[toId]?.num - ?: throw IllegalArgumentException("Unknown node ID $toId") - } - } - - private fun buildMeshPacket( - to: Int, - wantAck: Boolean = false, - id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one - hopLimit: Int = 0, - channel: Int = 0, - priority: MeshPacket.Priority = MeshPacket.Priority.UNSET, - decoded: Data, - ): MeshPacket { - val actualHopLimit = if (hopLimit > 0) hopLimit else computeHopLimit() - - var pkiEncrypted = false - var publicKey: ByteString = ByteString.EMPTY - var actualChannel = channel - - if (channel == DataPacket.PKC_CHANNEL_INDEX) { - pkiEncrypted = true - val destNode = nodeManager.nodeDBbyNodeNum[to] - // Resolve the public key using the same fallback as Node.hasPKC: - // standalone publicKey (populated after Room round-trip) first, then - // the embedded user.public_key (always available in-memory). - publicKey = destNode?.let { it.publicKey ?: it.user.public_key } ?: ByteString.EMPTY - if (publicKey.size == 0) { - Logger.w { "buildMeshPacket: no public key for node ${to.toUInt()}, PKI encryption will fail" } - } - actualChannel = 0 - } - - return MeshPacket( - from = nodeManager.myNodeNum.value ?: 0, - to = to, - id = id, - want_ack = wantAck, - hop_limit = actualHopLimit, - hop_start = actualHopLimit, - priority = priority, - pki_encrypted = pkiEncrypted, - public_key = publicKey, - channel = actualChannel, - decoded = decoded, - ) - } - - private fun buildAdminPacket( - to: Int, - id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one - wantResponse: Boolean = false, - adminMessage: AdminMessage, - ): MeshPacket = - buildMeshPacket( - to = to, - id = id, - wantAck = true, - channel = getAdminChannelIndex(to), - priority = MeshPacket.Priority.RELIABLE, - decoded = - Data( - want_response = wantResponse, - portnum = PortNum.ADMIN_APP, - payload = adminMessage.encode().toByteString(), - ), - ) - - companion object { - private const val PACKET_ID_MASK = 0xffffffffL - 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/DataLayerHeartbeatSender.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt deleted file mode 100644 index 6ca10df26..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt +++ /dev/null @@ -1,54 +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.atomicfu.atomic -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.PacketHandler -import org.meshtastic.proto.Heartbeat -import org.meshtastic.proto.ToRadio - -/** - * Centralized heartbeat sender for the data layer. - * - * Consolidates heartbeat nonce management into a single monotonically increasing counter, preventing the firmware's - * per-connection duplicate-write filter (byte-level memcmp) from silently dropping consecutive heartbeats. - * - * This is distinct from [org.meshtastic.core.network.transport.HeartbeatSender], which operates at the transport layer - * with raw byte encoding. This class works at the protobuf/data layer through [PacketHandler]. - */ -@Single -class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) { - private val nonce = atomic(0) - - /** - * Enqueues a heartbeat with a unique nonce. - * - * @param tag descriptive label for log messages (e.g. "pre-handshake", "inter-stage") - */ - @Suppress("TooGenericExceptionCaught") - fun sendHeartbeat(tag: String = "handshake") { - try { - val n = nonce.incrementAndGet() - packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n))) - Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" } - } catch (e: Exception) { - Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" } - } - } -} 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 4595a6de4..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ /dev/null @@ -1,401 +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.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 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() - 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 deleted file mode 100644 index 5c39dedfa..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ /dev/null @@ -1,308 +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.atomicfu.atomic -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DeviceVersion -import org.meshtastic.core.repository.HandshakeConstants -import org.meshtastic.core.repository.MeshConfigFlowManager -import org.meshtastic.core.repository.MeshConnectionManager -import org.meshtastic.core.repository.NodeManager -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 -import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.NodeInfo -import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo -import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo - -@Suppress("LongParameterList", "TooManyFunctions") -@Single -class MeshConfigFlowManagerImpl( - private val nodeManager: NodeManager, - private val connectionManager: Lazy, - private val nodeRepository: NodeRepository, - private val radioConfigRepository: RadioConfigRepository, - private val serviceRepository: ServiceRepository, - private val serviceBroadcasts: ServiceBroadcasts, - private val analytics: PlatformAnalytics, - private val heartbeatSender: DataLayerHeartbeatSender, - private val notificationPrefs: NotificationPrefs, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshConfigFlowManager { - private val wantConfigDelay = 100L - - /** Monotonically increasing generation so async clears from a stale handshake are discarded. */ - private val handshakeGeneration = atomic(0L) - - /** - * Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase, - * eliminating the possibility of accessing stale or uninitialized fields. - * - * Guards [handleConfigComplete] so that duplicate or out-of-order `config_complete_id` signals from the firmware - * cannot trigger the wrong stage handler or drive the state machine backward. - */ - private sealed class HandshakeState { - /** No handshake in progress. */ - data object Idle : HandshakeState() - - /** - * Stage 1: receiving device config, module config, channels, and metadata. - * - * [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed - * together by [buildMyNodeInfo] at Stage 1 completion. - */ - data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null) : - HandshakeState() - - /** - * Stage 2: receiving node-info packets from the firmware. - * - * [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until - * `config_complete_id` arrives. - */ - data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List = emptyList()) : - HandshakeState() - - /** Both stages finished. The app is fully connected. */ - data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState() - } - - private var handshakeState: HandshakeState = HandshakeState.Idle - - override val newNodeCount: Int - get() = (handshakeState as? HandshakeState.ReceivingNodeInfo)?.nodes?.size ?: 0 - - override fun handleConfigComplete(configCompleteId: Int) { - val state = handshakeState - when (configCompleteId) { - HandshakeConstants.CONFIG_NONCE -> { - if (state !is HandshakeState.ReceivingConfig) { - Logger.w { "Ignoring Stage 1 config_complete in state=$state" } - return - } - handleConfigOnlyComplete(state) - } - - HandshakeConstants.NODE_INFO_NONCE -> { - if (state !is HandshakeState.ReceivingNodeInfo) { - Logger.w { "Ignoring Stage 2 config_complete in state=$state" } - return - } - handleNodeInfoComplete(state) - } - - else -> Logger.w { "Config complete id mismatch: $configCompleteId" } - } - } - - private fun handleConfigOnlyComplete(state: HandshakeState.ReceivingConfig) { - Logger.i { "Config-only complete (Stage 1)" } - - val finalizedInfo = buildMyNodeInfo(state.rawMyNodeInfo, state.metadata) - if (finalizedInfo == null) { - Logger.w { "Stage 1 failed: could not build MyNodeInfo, retrying Stage 1" } - handshakeState = HandshakeState.Idle - scope.handledLaunch { - delay(wantConfigDelay) - connectionManager.value.startConfigOnly() - } - return - } - - // Warn if firmware is below the absolute minimum supported version. - // The UI layer already enforces this via FirmwareVersionCheck, so we just log here - // for diagnostics rather than hard-disconnecting. - finalizedInfo.firmwareVersion?.let { fwVersion -> - if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) { - Logger.w { - "Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION} — " + - "protocol incompatibilities may occur" - } - } - } - - handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo) - Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" } - connectionManager.value.onRadioConfigLoaded() - - scope.handledLaunch { - delay(wantConfigDelay) - heartbeatSender.sendHeartbeat("inter-stage") - delay(wantConfigDelay) - Logger.i { "Requesting NodeInfo (Stage 2)" } - connectionManager.value.startNodeInfoOnly() - } - } - - private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) { - Logger.i { "NodeInfo complete (Stage 2)" } - - val info = state.myNodeInfo - - // Transition state immediately (synchronously) to prevent duplicate handling. - // The async work below (DB writes, broadcasts) proceeds without the guard. - // Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot. - // Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored. - handshakeState = HandshakeState.Complete(myNodeInfo = info) - - val entities = - state.nodes.mapNotNull { nodeInfo -> - nodeManager.installNodeInfo(nodeInfo, withBroadcast = false) - nodeManager.nodeDBbyNodeNum[nodeInfo.num] - ?: run { - Logger.w { "Node ${nodeInfo.num} missing from DB after installNodeInfo; skipping" } - null - } - } - - scope.handledLaunch { - nodeRepository.installConfig(info, entities) - analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown") - nodeManager.setNodeDbReady(true) - nodeManager.setAllowNodeDbWrites(true) - serviceRepository.setConnectionState(ConnectionState.Connected) - serviceBroadcasts.broadcastConnection() - connectionManager.value.onNodeDbReady() - } - } - - override fun handleMyInfo(myInfo: ProtoMyNodeInfo) { - Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" } - - // Transition to Stage 1, discarding any stale data from a prior interrupted handshake. - handshakeState = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo) - nodeManager.setMyNodeNum(myInfo.my_node_num) - nodeManager.setFirmwareEdition(myInfo.firmware_edition) - applyEventFirmwareNotificationDefaults(myInfo.firmware_edition) - - // Bump the generation so that a pending clear from a prior (interrupted) handshake - // will see a stale snapshot and skip its writes, preventing it from wiping config - // that was saved by this (newer) handshake's incoming packets. - val gen = handshakeGeneration.incrementAndGet() - - // Clear persisted radio config so the new handshake starts from a clean slate. - // DataStore serializes its own writes, so the clear will precede subsequent - // setLocalConfig / updateChannelSettings calls dispatched by later packets in this - // session (handleFromRadio processes packets sequentially, so later dispatches always - // occur after this one returns). - scope.handledLaunch { - if (handshakeGeneration.value != gen) return@handledLaunch // Stale handshake; skip. - radioConfigRepository.clearChannelSet() - radioConfigRepository.clearLocalConfig() - radioConfigRepository.clearLocalModuleConfig() - radioConfigRepository.clearDeviceUIConfig() - radioConfigRepository.clearFileManifest() - } - } - - override fun handleLocalMetadata(metadata: DeviceMetadata) { - Logger.i { "Local Metadata received: ${metadata.firmware_version}" } - val state = handshakeState - if (state is HandshakeState.ReceivingConfig) { - handshakeState = state.copy(metadata = metadata) - // Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete, - // but the DB write does not need to wait until then. - if (metadata != DeviceMetadata()) { - scope.handledLaunch { nodeRepository.insertMetadata(state.rawMyNodeInfo.my_node_num, metadata) } - } - } else { - Logger.w { "Ignoring metadata outside Stage 1 (state=$state)" } - } - } - - override fun handleNodeInfo(info: NodeInfo) { - val state = handshakeState - if (state is HandshakeState.ReceivingNodeInfo) { - handshakeState = state.copy(nodes = state.nodes + info) - } else { - Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" } - } - } - - override fun handleFileInfo(info: FileInfo) { - Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" } - scope.handledLaunch { radioConfigRepository.addFileInfo(info) } - } - - override fun triggerWantConfig() { - connectionManager.value.startConfigOnly() - } - - /** - * Builds a [SharedMyNodeInfo] from the raw proto and optional firmware metadata. Pure function — no side effects. - * Returns null only if construction throws. - */ - private fun buildMyNodeInfo(raw: ProtoMyNodeInfo, metadata: DeviceMetadata?): SharedMyNodeInfo? = try { - with(raw) { - SharedMyNodeInfo( - myNodeNum = my_node_num, - hasGPS = false, - model = - when (val hwModel = metadata?.hw_model) { - null, - HardwareModel.UNSET, - -> null - - else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() - }, - firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() }, - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 0L, - messageTimeoutMsec = 300000, - minAppVersion = min_app_version, - maxChannels = 8, - hasWifi = metadata?.hasWifi == true, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = device_id.utf8(), - pioEnv = pio_env.ifEmpty { null }, - ) - } - } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) { - Logger.e(ex) { "Failed to build MyNodeInfo" } - null - } - - private fun applyEventFirmwareNotificationDefaults(edition: FirmwareEdition) { - if (edition != FirmwareEdition.VANILLA) { - if (!notificationPrefs.nodeEventsAutoDisabledForEvent.value) { - notificationPrefs.setNodeEventsEnabled(false) - notificationPrefs.setNodeEventsAutoDisabledForEvent(true) - } - } else { - if (notificationPrefs.nodeEventsAutoDisabledForEvent.value) { - notificationPrefs.setNodeEventsEnabled(true) - notificationPrefs.setNodeEventsAutoDisabledForEvent(false) - } - } - } -} 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 deleted file mode 100644 index dc1f12918..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ /dev/null @@ -1,438 +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.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.repository.AppWidgetUpdater -import org.meshtastic.core.repository.DataPair -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.MeshWorkerManager -import org.meshtastic.core.repository.MqttManager -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -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.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 -import org.meshtastic.proto.Config -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.ToRadio -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.time.DurationUnit - -@Suppress("LongParameterList", "TooManyFunctions") -@Single -class MeshConnectionManagerImpl( - private val radioInterfaceService: RadioInterfaceService, - private val serviceRepository: ServiceRepository, - private val serviceBroadcasts: ServiceBroadcasts, - private val serviceNotifications: MeshServiceNotifications, - private val uiPrefs: UiPrefs, - private val packetHandler: PacketHandler, - private val nodeRepository: NodeRepository, - private val locationManager: MeshLocationManager, - private val mqttManager: MqttManager, - private val historyManager: HistoryManager, - private val radioConfigRepository: RadioConfigRepository, - private val radioController: RadioController, - private val sessionManager: SessionManager, - private val nodeManager: NodeManager, - private val analytics: PlatformAnalytics, - private val packetRepository: PacketRepository, - private val workerManager: MeshWorkerManager, - private val appWidgetUpdater: AppWidgetUpdater, - private val heartbeatSender: DataLayerHeartbeatSender, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshConnectionManager { - /** - * Serializes [onConnectionChanged] to prevent TOCTOU races when multiple coroutines emit state transitions - * concurrently (e.g. flow collector vs. sleep-timeout coroutine). - */ - private val connectionMutex = Mutex() - - private var preHandshakeJob: Job? = null - private var sleepTimeout: Job? = null - private var locationRequestsJob: Job? = null - private var handshakeTimeout: Job? = null - private var connectTimeMsec = 0L - private var connectionRestored = false - - init { - // Bridge transport-level state into the canonical app-level state. - // This is the ONLY consumer of RadioInterfaceService.connectionState — it applies - // light-sleep policy and handshake awareness before writing to ServiceRepository. - radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope) - - // Ensure notification title and content stay in sync with state changes - serviceRepository.connectionState.onEach { updateStatusNotification() }.launchIn(scope) - - scope.launch { - try { - appWidgetUpdater.updateAll() - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.e(e) { "Failed to kickstart LocalStatsWidget" } - } - } - - nodeRepository.myNodeInfo - .onEach { myNodeEntity -> - locationRequestsJob?.cancel() - if (myNodeEntity != null) { - locationRequestsJob = - uiPrefs - .shouldProvideNodeLocation(myNodeEntity.myNodeNum) - .onEach { shouldProvide -> - if (shouldProvide) { - locationManager.start(scope) { pos -> - scope.handledLaunch { - val packet = DataPacket( - bytes = okio.ByteString.of(*org.meshtastic.proto.Position.ADAPTER.encode(pos)), - dataType = org.meshtastic.proto.PortNum.POSITION_APP.value, - ) - radioController.sendMessage(packet) - } - } - } else { - locationManager.stop() - } - } - .launchIn(scope) - } - } - .launchIn(scope) - } - - /** - * Bridges a transport-level [ConnectionState] into the canonical app-level state. - * - * Applies light-sleep policy (power-saving / router role) to decide whether a [ConnectionState.DeviceSleep] event - * should be surfaced as sleep or as a full disconnect, then delegates to [onConnectionChanged] for the actual state - * transition. - */ - private suspend fun onRadioConnectionState(newState: ConnectionState) { - val localConfig = radioConfigRepository.localConfigFlow.first() - val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER - val lsEnabled = localConfig.power?.is_power_saving == true || isRouter - - val effectiveState = - when (newState) { - is ConnectionState.Connected -> ConnectionState.Connected - - is ConnectionState.DeviceSleep -> - if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected - - is ConnectionState.Connecting -> ConnectionState.Connecting - - is ConnectionState.Disconnected -> ConnectionState.Disconnected - } - onConnectionChanged(effectiveState) - } - - private suspend fun onConnectionChanged(c: ConnectionState) = connectionMutex.withLock { - val current = serviceRepository.connectionState.value - if (current == c) return@withLock - - // If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting) - if (c is ConnectionState.Connected && current is ConnectionState.Connecting) { - Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" } - return@withLock - } - - Logger.i { "onConnectionChanged: $current -> $c" } - - sleepTimeout?.cancel() - sleepTimeout = null - preHandshakeJob?.cancel() - preHandshakeJob = null - handshakeTimeout?.cancel() - handshakeTimeout = null - - when (c) { - is ConnectionState.Connecting -> serviceRepository.setConnectionState(ConnectionState.Connecting) - is ConnectionState.Connected -> handleConnected() - is ConnectionState.DeviceSleep -> handleDeviceSleep() - is ConnectionState.Disconnected -> handleDisconnected() - } - } - - private fun handleConnected() { - // Track whether this connection was restored from device sleep (vs. a fresh connect), - // matching Apple's "connectionRestored" attribute for cross-platform DataDog parity. - connectionRestored = serviceRepository.connectionState.value is ConnectionState.DeviceSleep - // The service state remains 'Connecting' until config is fully loaded - 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 - // power-saving state where the NimBLE callback context needs warming up. The 100ms - // delay ensures the heartbeat BLE write is enqueued before the want_config_id - // (sendToRadio is fire-and-forget through async coroutine launches). - preHandshakeJob = - scope.handledLaunch { - heartbeatSender.sendHeartbeat("pre-handshake") - delay(PRE_HANDSHAKE_SETTLE_MS) - Logger.i { "Starting mesh handshake (Stage 1)" } - startConfigOnly() - } - } - - private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) { - handshakeTimeout?.cancel() - handshakeTimeout = - scope.handledLaunch { - delay(timeout) - if (serviceRepository.connectionState.value is ConnectionState.Connecting) { - // Attempt one retry. Note: the firmware silently drops identical consecutive - // writes (per-connection dedup). If the first want_config_id was received and - // the stall is on our side, the retry will be dropped and the reconnect below - // will trigger instead — which is the right recovery in that case. - Logger.w { - "Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled" - } - action() - delay(HANDSHAKE_RETRY_TIMEOUT) - if (serviceRepository.connectionState.value is ConnectionState.Connecting) { - Logger.e { "Handshake still stalled after retry, forcing reconnect" } - onConnectionChanged(ConnectionState.Disconnected) - } - } - } - } - - private fun tearDownConnection() { - packetHandler.stopPacketQueue() - sessionManager.clearAll() // Prevent stale per-node passkeys on reconnect. - locationManager.stop() - mqttManager.stop() - } - - private fun handleDeviceSleep() { - serviceRepository.setConnectionState(ConnectionState.DeviceSleep) - tearDownConnection() - - if (connectTimeMsec != 0L) { - val now = nowMillis - val duration = now - connectTimeMsec - connectTimeMsec = 0L - analytics.track( - EVENT_CONNECTED_SECONDS, - DataPair(EVENT_CONNECTED_SECONDS, duration.milliseconds.toDouble(DurationUnit.SECONDS)), - ) - } - - sleepTimeout = - scope.handledLaunch { - try { - val localConfig = radioConfigRepository.localConfigFlow.first() - val rawTimeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS - // Cap the timeout so routers or power-saving configs (ls_secs=3600) don't - // leave the UI stuck in DeviceSleep for over an hour. - val timeout = rawTimeout.coerceAtMost(MAX_SLEEP_TIMEOUT_SECONDS) - Logger.d { "Waiting for sleeping device, timeout=$timeout secs (raw=$rawTimeout)" } - delay(timeout.seconds) - Logger.w { "Device timed out, setting disconnected" } - onConnectionChanged(ConnectionState.Disconnected) - } catch (_: CancellationException) { - Logger.d { "device sleep timeout cancelled" } - } - } - - serviceBroadcasts.broadcastConnection() - } - - private fun handleDisconnected() { - serviceRepository.setConnectionState(ConnectionState.Disconnected) - tearDownConnection() - - analytics.track( - EVENT_MESH_DISCONNECT, - DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size), - 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() { - val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) } - startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action) - action() - } - - override fun startNodeInfoOnly() { - val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) } - startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action) - action() - } - - override fun onRadioConfigLoaded() { - scope.handledLaunch { - val queuedPackets = packetRepository.getQueuedPackets() - queuedPackets.forEach { packet -> - try { - workerManager.enqueueSendMessage(packet.id) - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.e(e) { "Failed to enqueue queued packet worker" } - } - } - } - } - - override fun onNodeDbReady() { - handshakeTimeout?.cancel() - handshakeTimeout = null - - val myNodeNum = nodeManager.myNodeNum.value ?: 0 - - // NOTE: Time sync and session passkey seeding are handled by the SDK's RadioClient - // during its own handshake — no need to send set_time_only or get_owner_request here. - - // Start MQTT if enabled - scope.handledLaunch { - val moduleConfig = radioConfigRepository.moduleConfigFlow.first() - mqttManager.startProxy( - moduleConfig.mqtt?.enabled == true, - moduleConfig.mqtt?.proxy_to_client_enabled == true, - ) - } - - reportConnection() - - // Request history - scope.handledLaunch { - val moduleConfig = radioConfigRepository.moduleConfigFlow.first() - moduleConfig.store_forward?.let { - historyManager.requestHistoryReplay("onNodeDbReady", myNodeNum, it, "Unknown") - } - } - - // Request immediate LocalStats and DeviceMetrics update on connection - scope.handledLaunch { - radioController.requestTelemetry(myNodeNum.hashCode(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal) - radioController.requestTelemetry(myNodeNum.hashCode() + 1, myNodeNum, TelemetryType.DEVICE.ordinal) - } - } - - private fun reportConnection() { - val myNode = nodeManager.getMyNodeInfo() - val radioModel = DataPair(KEY_RADIO_MODEL, myNode?.model ?: "unknown") - analytics.track( - EVENT_MESH_CONNECT, - DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size), - DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }), - radioModel, - ) - - // DataDog RUM custom action matching Apple's "connect" event for cross-platform analytics. - val transportType = radioInterfaceService.getDeviceAddress()?.let { DeviceType.fromAddress(it)?.name } - analytics.trackConnect( - firmwareVersion = myNode?.firmwareVersion, - transportType = transportType, - hardwareModel = myNode?.model, - nodes = nodeManager.nodeDBbyNodeNum.size, - connectionRestored = connectionRestored, - ) - } - - override fun updateTelemetry(t: Telemetry) { - t.local_stats?.let { nodeRepository.updateLocalStats(it) } - updateStatusNotification(t) - } - - override fun updateStatusNotification(telemetry: Telemetry?) { - serviceNotifications.updateServiceStateNotification( - serviceRepository.connectionState.value, - telemetry = telemetry, - ) - } - - companion object { - private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30 - - // Maximum time (in seconds) to wait for a sleeping device before declaring it - // disconnected, regardless of the device's ls_secs configuration. Without this - // cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour. - private const val MAX_SLEEP_TIMEOUT_SECONDS = 300 - - /** - * Delay between the pre-handshake heartbeat and the want_config_id send. - * - * Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the - * config request arrives. 100ms is well above observed ESP32 task scheduling latency (~10–50ms) while adding - * negligible connection latency. - */ - private const val PRE_HANDSHAKE_SETTLE_MS = 100L - - private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds - - /** - * Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes. - * 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+ - * nodes. - */ - private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds - - // Shorter window for the retry attempt: if the device genuinely didn't receive the - // first want_config_id the retry completes within a few seconds. Waiting another 30s - // before reconnecting just delays recovery unnecessarily. - private val HANDSHAKE_RETRY_TIMEOUT = 15.seconds - - private const val EVENT_CONNECTED_SECONDS = "connected_seconds" - private const val EVENT_MESH_DISCONNECT = "mesh_disconnect" - private const val EVENT_NUM_NODES = "num_nodes" - private const val EVENT_MESH_CONNECT = "mesh_connect" - - private const val KEY_NUM_NODES = "num_nodes" - private const val KEY_NUM_ONLINE = "num_online" - private const val KEY_RADIO_MODEL = "radio_model" - } -} 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 deleted file mode 100644 index fa935473a..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ /dev/null @@ -1,531 +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 co.touchlab.kermit.Severity -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import okio.ByteString -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -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.MessageStatus -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.Reaction -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.MessageFilter -import org.meshtastic.core.repository.NeighborInfoHandler -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.Notification -import org.meshtastic.core.repository.NotificationManager -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 -import org.meshtastic.core.repository.TracerouteHandler -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.critical_alert -import org.meshtastic.core.resources.error_duty_cycle -import org.meshtastic.core.resources.getStringSuspend -import org.meshtastic.core.resources.unknown_username -import org.meshtastic.core.resources.waypoint_received -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.Paxcount -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.Position -import org.meshtastic.proto.Routing -import org.meshtastic.proto.StatusMessage -import org.meshtastic.proto.User -import org.meshtastic.proto.Waypoint - -/** - * Implementation of [MeshDataHandler] that decodes and routes incoming mesh data packets. - * - * This class handles the complexity of: - * 1. Mapping raw [MeshPacket] objects to domain-friendly [DataPacket] objects. - * 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, Telemetry, Admin, SFPP). - * 3. Managing message history and persistence. - * 4. Triggering notifications for various packet types (Text, Waypoints). - */ -@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") -@Single -class MeshDataHandlerImpl( - private val nodeManager: NodeManager, - 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 analytics: PlatformAnalytics, - private val dataMapper: MeshDataMapper, - private val tracerouteHandler: TracerouteHandler, - private val neighborInfoHandler: NeighborInfoHandler, - private val radioConfigRepository: RadioConfigRepository, - private val messageFilter: MessageFilter, - private val storeForwardHandler: StoreForwardPacketHandler, - private val telemetryHandler: TelemetryPacketHandler, - private val adminPacketHandler: AdminPacketHandler, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshDataHandler { - - private val rememberDataType = - setOf( - PortNum.TEXT_MESSAGE_APP.value, - PortNum.ALERT_APP.value, - PortNum.WAYPOINT_APP.value, - PortNum.NODE_STATUS_APP.value, - ) - - override fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String?, logInsertJob: Job?) { - val dataPacket = dataMapper.toDataPacket(packet) ?: return - val fromUs = myNodeNum == packet.from - dataPacket.status = MessageStatus.RECEIVED - - val shouldBroadcast = handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob) - - if (shouldBroadcast) { - serviceBroadcasts.broadcastReceivedData(dataPacket) - } - analytics.track("num_data_receive", DataPair("num_data_receive", 1)) - } - - private fun handleDataPacket( - packet: MeshPacket, - dataPacket: DataPacket, - myNodeNum: Int, - fromUs: Boolean, - logUuid: String?, - logInsertJob: Job?, - ): Boolean { - var shouldBroadcast = !fromUs - val decoded = packet.decoded ?: return shouldBroadcast - 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) - } - 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 - when (decoded.portnum) { - PortNum.TRACEROUTE_APP -> { - tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob) - shouldBroadcast = false - } - - PortNum.ROUTING_APP -> { - handleRouting(packet, dataPacket) - shouldBroadcast = true - } - - PortNum.PAXCOUNTER_APP -> { - handlePaxCounter(packet) - } - - PortNum.STORE_FORWARD_APP -> { - storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum) - } - - PortNum.STORE_FORWARD_PLUSPLUS_APP -> { - storeForwardHandler.handleStoreForwardPlusPlus(packet) - } - - PortNum.ADMIN_APP -> { - adminPacketHandler.handleAdminMessage(packet, myNodeNum) - } - - PortNum.NEIGHBORINFO_APP -> { - neighborInfoHandler.handleNeighborInfo(packet) - shouldBroadcast = true - } - - PortNum.ATAK_PLUGIN, - PortNum.ATAK_FORWARDER, - 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 - } - } - return shouldBroadcast - } - - private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) { - val u = dataPacket.copy(dataType = PortNum.TEXT_MESSAGE_APP.value) - rememberDataPacket(u, myNodeNum) - } - - private fun handlePaxCounter(packet: MeshPacket) { - val payload = packet.decoded?.payload ?: return - val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return - nodeManager.handleReceivedPaxcounter(packet.from, p) - } - - private fun handlePosition(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val payload = packet.decoded?.payload ?: return - val p = Position.ADAPTER.decodeOrNull(payload, Logger) ?: return - Logger.d { "Position from ${packet.from}: ${Position.ADAPTER.toOneLiner(p)}" } - nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time) - } - - private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val payload = packet.decoded?.payload ?: return - val u = Waypoint.ADAPTER.decode(payload) - if (u.locked_to != 0 && u.locked_to != packet.from) return - val currentSecond = nowSeconds.toInt() - rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond) - } - - private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val decoded = packet.decoded ?: return - if (decoded.reply_id != 0 && decoded.emoji != 0) { - rememberReaction(packet) - } else { - rememberDataPacket(dataPacket, myNodeNum) - } - } - - private fun handleNodeInfo(packet: MeshPacket) { - val payload = packet.decoded?.payload ?: return - val u = - User.ADAPTER.decode(payload) - .let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } - .let { - if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) { - it.copy(long_name = "${it.long_name} (MQTT)") - } else { - it - } - } - nodeManager.handleReceivedUser(packet.from, u, packet.channel) - } - - private fun handleNodeStatus(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val payload = packet.decoded?.payload ?: return - val s = StatusMessage.ADAPTER.decodeOrNull(payload, Logger) ?: return - nodeManager.handleReceivedNodeStatus(packet.from, s) - rememberDataPacket(dataPacket, myNodeNum) - } - - private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) { - val payload = packet.decoded?.payload ?: return - val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return - if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) { - scope.launch { - serviceRepository.setErrorMessage(getStringSuspend(Res.string.error_duty_cycle), Severity.Warn) - } - } - handleAckNak( - packet.decoded?.request_id ?: 0, - nodeManager.toNodeID(packet.from), - r.error_reason?.value ?: 0, - dataPacket.relayNode, - ) - packet.decoded?.request_id?.let { packetHandler.removeResponse(it, complete = true) } - } - - @Suppress("CyclomaticComplexMethod", "LongMethod") - private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) { - scope.handledLaunch { - val isAck = routingError == Routing.Error.NONE.value - val p = packetRepository.value.getPacketByPacketId(requestId) - val reaction = packetRepository.value.getReactionByPacketId(requestId) - - @Suppress("MaxLineLength") - Logger.d { - val statusInfo = "status=${p?.status ?: reaction?.status}" - "[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " + - "packetId=${p?.id ?: reaction?.packetId} dataId=${p?.id} $statusInfo" - } - - val m = - when { - isAck && (fromId == p?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED - isAck -> MessageStatus.DELIVERED - else -> MessageStatus.ERROR - } - if (p != null && p.status != MessageStatus.RECEIVED) { - val updatedPacket = - p.copy(status = m, relays = if (isAck) p.relays + 1 else p.relays, relayNode = relayNode) - packetRepository.value.update(updatedPacket, routingError = routingError) - } - - reaction?.let { r -> - if (r.status != MessageStatus.RECEIVED) { - var updated = r.copy(status = m, routingError = routingError, relayNode = relayNode) - if (isAck) { - updated = updated.copy(relays = updated.relays + 1) - } - 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 contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from - - // contactKey: unique contact key filter (channel)+(nodeId) - val contactKey = "${dataPacket.channel}$contactId" - - scope.handledLaunch { - packetRepository.value.apply { - // Check for duplicates before inserting - val existingPackets = findPacketsWithId(dataPacket.id) - if (existingPackets.isNotEmpty()) { - Logger.d { - "Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " + - "to=${dataPacket.to} contactKey=$contactKey" + - " (already have ${existingPackets.size} packet(s))" - } - return@handledLaunch - } - - // Check if message should be filtered - val isFiltered = shouldFilterMessage(dataPacket, contactKey) - - insert( - dataPacket, - myNodeNum, - contactKey, - nowMillis, - read = fromLocal || isFiltered, - filtered = isFiltered, - ) - if (!isFiltered) { - handlePacketNotification(dataPacket, contactKey, updateNotification) - } - } - } - } - - @Suppress("ReturnCount") - private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean { - val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true - if (isIgnored) return true - - if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false - val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled - return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled) - } - - private suspend fun handlePacketNotification( - dataPacket: DataPacket, - contactKey: String, - updateNotification: Boolean, - ) { - val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted - val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true - val isSilent = conversationMuted || nodeMuted - if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) { - scope.launch { - notificationManager.dispatch( - Notification( - title = getSenderName(dataPacket), - message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert), - category = Notification.Category.Alert, - contactKey = contactKey, - ), - ) - } - } else if (updateNotification && !isSilent) { - scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) } - } - } - - private suspend fun getSenderName(packet: DataPacket): String { - if (packet.from == DataPacket.ID_LOCAL) { - val myId = nodeManager.getMyId() - return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) - } - return nodeManager.nodeDBbyID[packet.from]?.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 channelName = - if (dataPacket.to == DataPacket.ID_BROADCAST) { - radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name - } else { - null - } - serviceNotifications.updateMessageNotification( - contactKey, - getSenderName(dataPacket), - message, - dataPacket.to == DataPacket.ID_BROADCAST, - channelName, - isSilent, - ) - } - - PortNum.WAYPOINT_APP.value -> { - val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name) - notificationManager.dispatch( - Notification( - title = getSenderName(dataPacket), - message = message, - category = Notification.Category.Message, - contactKey = contactKey, - isSilent = isSilent, - ), - ) - } - - else -> return - } - } - - @Suppress("LongMethod", "KotlinConstantConditions") - private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch { - val decoded = packet.decoded ?: return@handledLaunch - val emoji = decoded.payload.toByteArray().decodeToString() - val fromId = nodeManager.toNodeID(packet.from) - - val fromNode = nodeManager.nodeDBbyNodeNum[packet.from] ?: Node(num = packet.from) - val toNode = nodeManager.nodeDBbyNodeNum[packet.to] ?: Node(num = packet.to) - - val reaction = - Reaction( - replyId = decoded.reply_id, - user = fromNode.user, - emoji = emoji, - timestamp = nowMillis, - snr = packet.rx_snr, - rssi = packet.rx_rssi, - hopsAway = - if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) { - HOPS_AWAY_UNAVAILABLE - } else { - packet.hop_start - packet.hop_limit - }, - packetId = packet.id, - status = MessageStatus.RECEIVED, - to = toNode.user.id, - channel = packet.channel, - ) - - // Check for duplicates before inserting - val existingReactions = packetRepository.value.findReactionsWithId(packet.id) - if (existingReactions.isNotEmpty()) { - Logger.d { - "Skipping duplicate reaction: packetId=${packet.id} replyId=${decoded.reply_id} " + - "from=$fromId emoji=$emoji (already have ${existingReactions.size} reaction(s))" - } - return@handledLaunch - } - - packetRepository.value.insertReaction(reaction, nodeManager.myNodeNum.value ?: 0) - - // Find the original packet to get the contactKey - 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 - val contactKey = "${originalPacket.channel}$targetId" - val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted - val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true - val isSilent = conversationMuted || nodeMuted - - if (!isSilent) { - val channelName = - if (originalPacket.to == DataPacket.ID_BROADCAST) { - radioConfigRepository.channelSetFlow - .first() - .settings - .getOrNull(originalPacket.channel) - ?.name - } else { - null - } - serviceNotifications.updateReactionNotification( - contactKey, - getSenderName(dataMapper.toDataPacket(packet)!!), - emoji, - originalPacket.to == DataPacket.ID_BROADCAST, - channelName, - isSilent, - ) - } - } - } - - companion object { - private const val HOPS_AWAY_UNAVAILABLE = -1 - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt deleted file mode 100644 index fe58735da..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.MeshActionHandler -import org.meshtastic.core.repository.MeshConfigFlowManager -import org.meshtastic.core.repository.MeshConfigHandler -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.MqttManager -import org.meshtastic.core.repository.NeighborInfoHandler -import org.meshtastic.core.repository.TracerouteHandler -import org.meshtastic.core.repository.XModemManager - -/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */ -@Suppress("LongParameterList") -@Single -class MeshRouterImpl( - private val dataHandlerLazy: Lazy, - private val configHandlerLazy: Lazy, - private val tracerouteHandlerLazy: Lazy, - private val neighborInfoHandlerLazy: Lazy, - private val configFlowManagerLazy: Lazy, - private val mqttManagerLazy: Lazy, - private val actionHandlerLazy: Lazy, - private val xmodemManagerLazy: Lazy, -) : MeshRouter { - override val dataHandler: MeshDataHandler - get() = dataHandlerLazy.value - - override val configHandler: MeshConfigHandler - get() = configHandlerLazy.value - - override val tracerouteHandler: TracerouteHandler - get() = tracerouteHandlerLazy.value - - override val neighborInfoHandler: NeighborInfoHandler - get() = neighborInfoHandlerLazy.value - - override val configFlowManager: MeshConfigFlowManager - get() = configFlowManagerLazy.value - - override val mqttManager: MqttManager - get() = mqttManagerLazy.value - - override val actionHandler: MeshActionHandler - get() = actionHandlerLazy.value - - override val xmodemManager: XModemManager - get() = xmodemManagerLazy.value -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt new file mode 100644 index 000000000..85d576004 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt @@ -0,0 +1,195 @@ +/* + * 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 kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MessageFilter +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.critical_alert +import org.meshtastic.core.resources.getStringSuspend +import org.meshtastic.core.resources.unknown_username +import org.meshtastic.core.resources.waypoint_received +import org.meshtastic.proto.PortNum + +/** + * SDK-era implementation of [MeshDataHandler] focused on message persistence and notifications. + * + * The full packet-routing logic (handleReceivedData) is no longer needed — the SDK's packet flow + * is consumed directly by VMs and SdkStateBridge. This class retains only [rememberDataPacket] + * which is called by [StoreForwardPacketHandlerImpl] to persist forwarded messages. + */ +@Single +class MessagePersistenceHandler( + private val nodeManager: NodeManager, + private val packetRepository: Lazy, + private val notificationManager: NotificationManager, + private val serviceNotifications: MeshServiceNotifications, + private val radioConfigRepository: RadioConfigRepository, + private val messageFilter: MessageFilter, + @Named("ServiceScope") private val scope: CoroutineScope, +) : MeshDataHandler { + + private val rememberDataType = + setOf( + PortNum.TEXT_MESSAGE_APP.value, + PortNum.ALERT_APP.value, + PortNum.WAYPOINT_APP.value, + PortNum.NODE_STATUS_APP.value, + ) + + override fun handleReceivedData( + packet: org.meshtastic.proto.MeshPacket, + myNodeNum: Int, + logUuid: String?, + logInsertJob: kotlinx.coroutines.Job?, + ) { + // No-op: Incoming packet routing is handled by SdkStateBridge / VM packet observers. + // This method exists only to satisfy the MeshDataHandler interface contract. + } + + 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 contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from + + val contactKey = "${dataPacket.channel}$contactId" + + scope.handledLaunch { + packetRepository.value.apply { + val existingPackets = findPacketsWithId(dataPacket.id) + if (existingPackets.isNotEmpty()) { + Logger.d { + "Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " + + "to=${dataPacket.to} contactKey=$contactKey" + + " (already have ${existingPackets.size} packet(s))" + } + return@handledLaunch + } + + val isFiltered = shouldFilterMessage(dataPacket, contactKey) + + insert( + dataPacket, + myNodeNum, + contactKey, + nowMillis, + read = fromLocal || isFiltered, + filtered = isFiltered, + ) + if (!isFiltered) { + handlePacketNotification(dataPacket, contactKey, updateNotification) + } + } + } + } + + @Suppress("ReturnCount") + private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean { + val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true + if (isIgnored) return true + + if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false + val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled + return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled) + } + + private suspend fun handlePacketNotification( + dataPacket: DataPacket, + contactKey: String, + updateNotification: Boolean, + ) { + val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted + val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true + val isSilent = conversationMuted || nodeMuted + if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) { + scope.launch { + notificationManager.dispatch( + Notification( + title = getSenderName(dataPacket), + message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert), + category = Notification.Category.Alert, + contactKey = contactKey, + ), + ) + } + } else if (updateNotification && !isSilent) { + scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) } + } + } + + private suspend fun getSenderName(packet: DataPacket): String { + if (packet.from == DataPacket.ID_LOCAL) { + val myId = nodeManager.getMyId() + return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) + } + return nodeManager.nodeDBbyID[packet.from]?.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 channelName = + if (dataPacket.to == DataPacket.ID_BROADCAST) { + radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name + } else { + null + } + serviceNotifications.updateMessageNotification( + contactKey, + getSenderName(dataPacket), + message, + dataPacket.to == DataPacket.ID_BROADCAST, + channelName, + isSilent, + ) + } + + PortNum.WAYPOINT_APP.value -> { + val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name) + notificationManager.dispatch( + Notification( + title = getSenderName(dataPacket), + message = message, + category = Notification.Category.Message, + contactKey = contactKey, + isSilent = isSilent, + ), + ) + } + + else -> return + } + } +} 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..51221f9ef 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 { @@ -59,7 +57,7 @@ class NeighborInfoHandlerImpl( } // Update Node DB - nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) } + nodeManager.nodeDBbyNodeNum[from]?.let { /* SDK client.nodes is canonical source */ } // Format for UI response val requestId = packet.decoded?.request_id ?: 0 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 58b0cad91..0d845b71c 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 @@ -41,7 +41,6 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.data.repository.SdkNodeRepositoryImpl 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,7 +59,6 @@ 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 { @@ -197,9 +195,7 @@ class NodeManagerImpl( scope.handledLaunch { nodeRepository.upsert(result) } } - if (withBroadcast) { - serviceBroadcasts.broadcastNodeChange(result) - } + } override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) { 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 deleted file mode 100644 index aa62b76b9..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt +++ /dev/null @@ -1,295 +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.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -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 -import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withTimeoutOrNull -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MeshLog -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.RadioNotConnectedException -import org.meshtastic.core.model.util.toOneLineString -import org.meshtastic.core.model.util.toPIIString -import org.meshtastic.core.repository.MeshLogRepository -import org.meshtastic.core.repository.PacketHandler -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.FromRadio -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.QueueStatus -import org.meshtastic.proto.ToRadio -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.uuid.Uuid - -@Suppress("TooManyFunctions") -@Single -class PacketHandlerImpl( - private val packetRepository: Lazy, - private val serviceBroadcasts: ServiceBroadcasts, - private val radioInterfaceService: RadioInterfaceService, - private val meshLogRepository: Lazy, - private val serviceRepository: ServiceRepository, - @Named("ServiceScope") private val scope: CoroutineScope, -) : PacketHandler { - - companion object { - private val TIMEOUT = 5.seconds - } - - private var queueJob: Job? = null - - 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 - - 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() - - radioInterfaceService.sendToRadio(b) - p.packet?.id?.let { changeStatus(it, MessageStatus.ENROUTE) } - - val packet = p.packet - if (packet?.decoded != null) { - val packetToSave = - MeshLog( - uuid = Uuid.random().toString(), - message_type = "Packet", - received_date = nowMillis, - raw_message = packet.toString(), - fromNum = MeshLog.NODE_NUM_LOCAL, - portNum = packet.decoded?.portnum?.value ?: 0, - fromRadio = FromRadio(packet = packet), - ) - insertMeshLog(packetToSave) - } - } - - 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) - } - - @Suppress("TooGenericExceptionCaught", "SwallowedException") - override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean { - // Pre-register the deferred so the queue processor and QueueStatus handler - // can find it immediately — no polling required. - val deferred = CompletableDeferred() - responseMutex.withLock { queueResponse[packet.id] = deferred } - queueMutex.withLock { - queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle. - queuedPackets.add(packet) - startPacketQueueLocked() - } - return try { - withTimeout(TIMEOUT) { deferred.await() } - } catch (e: TimeoutCancellationException) { - Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} timeout" } - false - } catch (e: CancellationException) { - throw e // Preserve structured concurrency cancellation propagation. - } catch (e: Exception) { - Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} failed: ${e.message}" } - false - } finally { - responseMutex.withLock { queueResponse.remove(packet.id) } - } - } - - override fun stopPacketQueue() { - // Run async so callers (non-suspend) don't block, but all mutations are - // serialized under the same mutexes used by the queue processor and senders. - scope.launch { - Logger.i { "Stopping packet queueJob" } - queueMutex.withLock { - queueStopped = true - queueJob?.cancel() - queueJob = null - queuedPackets.clear() - } - responseMutex.withLock { - queueResponse.values.forEach { if (!it.isCompleted) it.complete(false) } - queueResponse.clear() - } - } - } - - override fun handleQueueStatus(queueStatus: QueueStatus) { - Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" } - val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) } - if (success && isFull) return - - scope.launch { - responseMutex.withLock { - if (requestId != 0) { - queueResponse.remove(requestId)?.complete(success) - } else { - queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success) - } - } - } - } - - override fun removeResponse(dataRequestId: Int, complete: Boolean) { - scope.launch { responseMutex.withLock { queueResponse.remove(dataRequestId)?.complete(complete) } } - } - - /** - * Starts the packet queue processor. Must be called while holding [queueMutex] to ensure the check-then-start is - * atomic — preventing two concurrent callers from launching duplicate processors. - */ - private fun startPacketQueueLocked() { - if (queueStopped) return - if (queueJob?.isActive == true) return - queueJob = - scope.handledLaunch { - try { - while (serviceRepository.connectionState.value == ConnectionState.Connected) { - val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break - @Suppress("TooGenericExceptionCaught", "SwallowedException") - try { - val response = sendPacket(packet) - Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" } - val success = withTimeout(TIMEOUT) { response.await() } - Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" } - } catch (e: TimeoutCancellationException) { - Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" } - // Clean up the deferred for this packet. sendToRadioAndAwait callers - // also clean up in their own finally block (idempotent remove). - responseMutex.withLock { queueResponse.remove(packet.id) } - } catch (e: CancellationException) { - throw e // Preserve structured concurrency cancellation propagation. - } catch (e: Exception) { - Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" } - responseMutex.withLock { queueResponse.remove(packet.id) } - } - // Deferred cleanup is now handled in the catch blocks above. - // handleQueueStatus (normal success) and stopPacketQueue (bulk cleanup) - // also remove entries, and these removals are idempotent. - } - } finally { - // Hold queueMutex so that clearing queueJob and the restart decision are - // atomic with respect to new senders calling startPacketQueueLocked(). - queueMutex.withLock { - queueJob = null - if (!queueStopped && queuedPackets.isNotEmpty()) { - startPacketQueueLocked() - } - } - } - } - } - - private fun changeStatus(packetId: Int, m: MessageStatus) = scope.handledLaunch { - if (packetId != 0) { - getDataPacketById(packetId)?.let { p -> - if (p.status == m) return@handledLaunch - packetRepository.value.updateMessageStatus(p, m) - serviceBroadcasts.broadcastMessageStatus(packetId, m) - } - } - } - - private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1.seconds) { - var dataPacket: DataPacket? = null - while (dataPacket == null) { - dataPacket = packetRepository.value.getPacketById(packetId) - if (dataPacket == null) delay(100.milliseconds) - } - dataPacket - } - - @Suppress("TooGenericExceptionCaught") - private suspend fun sendPacket(packet: MeshPacket): Deferred { - // Reuse a deferred pre-registered by sendToRadioAndAwait, or create a new one. - val deferred = responseMutex.withLock { queueResponse.getOrPut(packet.id) { CompletableDeferred() } } - try { - if (serviceRepository.connectionState.value != ConnectionState.Connected) { - throw RadioNotConnectedException() - } - sendToRadio(ToRadio(packet = packet)) - } catch (ex: RadioNotConnectedException) { - Logger.w(ex) { "sendToRadio skipped: Not connected to radio" } - deferred.complete(false) - } catch (ex: Exception) { - Logger.e(ex) { "sendToRadio error: ${ex.message}" } - deferred.complete(false) - } - // Return a read-only Deferred view (kotlinx.coroutines 1.11+) so callers can await it - // without being able to complete the underlying CompletableDeferred; cancellation is - // still exposed via Deferred/Job. - return deferred.asDeferred() - } - - private fun insertMeshLog(packetToSave: MeshLog) { - scope.handledLaunch { - Logger.d { - "insert: ${packetToSave.message_type} = " + - "${packetToSave.raw_message.toOneLineString()} from=${packetToSave.fromNum}" - } - meshLogRepository.value.insert(packetToSave) - } - } -} 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..cd2a31a57 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 @@ -30,7 +30,6 @@ 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 +42,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, @@ -125,7 +123,6 @@ class StoreForwardPacketHandlerImpl( rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, myNodeNum = nodeManager.myNodeNum.value ?: 0, ) - serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt index 078089396..8c044af72 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt @@ -28,7 +28,6 @@ import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.decodeOrNull import org.meshtastic.core.model.util.toOneLiner -import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager @@ -48,7 +47,6 @@ import kotlin.time.Duration.Companion.milliseconds @Single class TelemetryPacketHandlerImpl( private val nodeManager: NodeManager, - private val connectionManager: Lazy, private val notificationManager: NotificationManager, @Named("ServiceScope") private val scope: CoroutineScope, ) : TelemetryPacketHandler { @@ -66,9 +64,8 @@ class TelemetryPacketHandlerImpl( Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" } val fromNum = packet.from val isRemote = (fromNum != myNodeNum) - if (!isRemote) { - connectionManager.value.updateTelemetry(t) - } + // Note: Local telemetry notification update was previously handled by + // MeshConnectionManager.updateTelemetry(), now managed via SDK flows. nodeManager.updateNode(fromNum) { node: Node -> val metrics = t.device_metrics diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/RadioClientAccessor.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/RadioClientAccessor.kt new file mode 100644 index 000000000..71467725a --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/RadioClientAccessor.kt @@ -0,0 +1,39 @@ +/* + * 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.radio + +import kotlinx.coroutines.flow.StateFlow +import org.meshtastic.sdk.RadioClient + +/** + * Platform-agnostic accessor for the active [RadioClient] instance. + * + * Implemented by platform-specific providers (Android's `RadioClientProvider`, Desktop's + * `DesktopRadioClientProvider`) that handle transport creation and lifecycle. The shared + * [SdkRadioController] and [SdkStateBridge] depend on this interface rather than any + * concrete provider. + */ +interface RadioClientAccessor { + /** Active [RadioClient], or `null` when disconnected or between connections. */ + val client: StateFlow + + /** Tear down the existing client and rebuild + connect using the current saved address. */ + fun rebuildAndConnectAsync() + + /** Gracefully disconnect and release the active SDK radio client. */ + fun disconnect() +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketHandler.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketHandler.kt new file mode 100644 index 000000000..8ded4976c --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketHandler.kt @@ -0,0 +1,89 @@ +/* + * 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.radio + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.QueueStatus +import org.meshtastic.proto.ToRadio + +/** + * SDK-backed [PacketHandler] that sends packets through the active [RadioClient]. + * + * Replaces the monolithic [PacketHandlerImpl] which routed through the old + * `RadioInterfaceService.sendToRadio()` pipeline. This thin implementation only supports the + * `sendToRadio` surface needed by MQTT, XModem, and History managers. + * + * Queue management (QueueStatus, packet ordering) is handled internally by the SDK engine. + */ +@Single(binds = [PacketHandler::class]) +class SdkPacketHandler( + private val accessor: RadioClientAccessor, + private val dispatchers: CoroutineDispatchers, +) : PacketHandler { + + private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) + + override fun sendToRadio(p: ToRadio) { + val packet = p.packet + if (packet != null) { + // Regular MeshPacket — route through the tracked send path. + sendToRadio(packet) + return + } + // Non-packet ToRadio (mqttClientProxyMessage, xmodemPacket) — send as raw frame. + val client = accessor.client.value ?: run { + Logger.w { "SdkPacketHandler: no client, dropping non-packet ToRadio" } + return + } + scope.launch { + runCatching { client.sendRaw(p) } + .onFailure { e -> Logger.w(e) { "SdkPacketHandler: sendRaw(ToRadio) failed" } } + } + } + + override fun sendToRadio(packet: MeshPacket) { + val client = accessor.client.value ?: run { + Logger.w { "SdkPacketHandler: no client, dropping packet id=${packet.id}" } + return + } + client.send(packet) + } + + override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean { + val client = accessor.client.value ?: return false + return runCatching { client.send(packet) }.isSuccess + } + + override fun handleQueueStatus(queueStatus: QueueStatus) { + // Queue management is internal to the SDK engine; no-op. + } + + override fun removeResponse(dataRequestId: Int, complete: Boolean) { + // Response tracking is internal to the SDK engine; no-op. + } + + override fun stopPacketQueue() { + // Queue management is internal to the SDK engine; no-op. + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt similarity index 91% rename from app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt rename to core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 549155de5..b1e9c5efa 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -14,11 +14,11 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.app.radio +package org.meshtastic.core.data.radio import co.touchlab.kermit.Logger +import kotlinx.atomicfu.atomic import kotlinx.coroutines.flow.StateFlow -import okio.ByteString import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState @@ -42,37 +42,35 @@ import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.ChannelIndex import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.RadioClient -import java.util.concurrent.atomic.AtomicInteger /** - * [RadioController] implementation that delegates all operations through the meshtastic-sdk. + * Shared KMP [RadioController] implementation that delegates all operations through the meshtastic-sdk. * - * This replaces [org.meshtastic.core.service.AndroidRadioControllerImpl] in the hard-cutover POC. Feature modules - * continue injecting [RadioController] and get SDK-backed behavior without code changes. + * Feature modules inject [RadioController] and get SDK-backed behavior without needing platform-specific code. * * **Command dispatch:** All admin, telemetry, and routing operations go through [RadioClient.admin], * [RadioClient.telemetry], and [RadioClient.routing] respectively. * - * **State distribution:** Handled separately by [SdkStateBridge], which feeds SDK flows back into + * **State distribution:** Handled by [SdkStateBridge], which feeds SDK flows into * [ServiceRepository] and [org.meshtastic.core.repository.NodeManager]. */ @Single(binds = [RadioController::class]) @Suppress("TooManyFunctions", "LongParameterList") -class SdkRadioControllerImpl( - private val provider: RadioClientProvider, +class SdkRadioController( + private val accessor: RadioClientAccessor, private val serviceRepository: ServiceRepository, private val nodeRepository: NodeRepository, private val locationManager: MeshLocationManager, ) : RadioController { - private val packetIdCounter = AtomicInteger(1) + private val packetIdCounter = atomic(1) private val client: RadioClient? - get() = provider.client.value + get() = accessor.client.value private fun requireClient(): RadioClient { return client ?: run { - Logger.w { "SdkRadioControllerImpl: no active RadioClient" } + Logger.w { "SdkRadioController: no active RadioClient" } throw IllegalStateException("RadioClient not connected") } } @@ -105,7 +103,7 @@ class SdkRadioControllerImpl( channel = packet.channel, decoded = Data( portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP, - payload = packet.bytes ?: ByteString.EMPTY, + payload = packet.bytes ?: okio.ByteString.EMPTY, want_response = false, ), ) @@ -272,7 +270,12 @@ class SdkRadioControllerImpl( if (isLocalNode(destNum)) { c.admin.getCannedMessages() } else { - sendRemoteAdmin(c, destNum, AdminMessage(get_canned_message_module_messages_request = true), wantResponse = true) + sendRemoteAdmin( + c, + destNum, + AdminMessage(get_canned_message_module_messages_request = true), + wantResponse = true, + ) } } @@ -281,7 +284,12 @@ class SdkRadioControllerImpl( if (isLocalNode(destNum)) { c.admin.getDeviceConnectionStatus() } else { - sendRemoteAdmin(c, destNum, AdminMessage(get_device_connection_status_request = true), wantResponse = true) + sendRemoteAdmin( + c, + destNum, + AdminMessage(get_device_connection_status_request = true), + wantResponse = true, + ) } } @@ -366,7 +374,6 @@ class SdkRadioControllerImpl( override suspend fun requestUserInfo(destNum: Int) { val c = client ?: return - // Send an empty NODEINFO_APP packet with want_response to request user info c.send( MeshPacket( to = destNum, @@ -388,7 +395,6 @@ class SdkRadioControllerImpl( override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { val c = requireClient() val node = NodeId(destNum) - // TelemetryType enum values: 0=DEVICE, 1=ENVIRONMENT, 2=AIR_QUALITY, 3=POWER, 4=LOCAL_STATS, 5=HEALTH when (typeValue) { 0 -> c.telemetry.requestDevice(node) 1 -> c.telemetry.requestEnvironment(node) @@ -411,7 +417,6 @@ class SdkRadioControllerImpl( override suspend fun beginEditSettings(destNum: Int) { val c = client ?: return - // Send raw begin_edit_settings admin message for compatibility with the split begin/commit pattern val msg = AdminMessage(begin_edit_settings = true) val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum) sendRemoteAdmin(c, target.raw, msg) @@ -429,7 +434,7 @@ class SdkRadioControllerImpl( override fun getPacketId(): Int = packetIdCounter.getAndIncrement() override fun startProvideLocation() { - // Location provision is managed at the app level; no-op until bridge wires it + // Location provision is managed at the app level; no-op here } override fun stopProvideLocation() { @@ -437,8 +442,7 @@ class SdkRadioControllerImpl( } override fun setDeviceAddress(address: String) { - // Changing device address requires rebuilding the SDK client connection - provider.rebuildAndConnectAsync() + accessor.rebuildAndConnectAsync() } // ── Private helpers ───────────────────────────────────────────────────── @@ -449,10 +453,6 @@ class SdkRadioControllerImpl( return destNum == ownNum } - /** - * Sends a raw admin message to a remote node via the SDK's send path. - * Used for remote-admin operations where destNum != local node. - */ private suspend fun sendRemoteAdmin( c: RadioClient, destNum: Int, diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt similarity index 82% rename from app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt rename to core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index d9395ab7a..39825cfac 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -14,24 +14,29 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.app.radio +package org.meshtastic.core.data.radio import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState as AppConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Data @@ -52,28 +57,23 @@ import org.meshtastic.sdk.NodeId * and [NodeManager] so that existing feature-module UI code (which observes those repositories) * continues to work without modification. * - * **Node state:** The SDK's [NodeChange] flow provides fully-updated [NodeInfo] instances that - * already include position, telemetry, and user changes. No manual packet decoding is needed. - * - * **Packets:** Raw [MeshPacket]s are forwarded to [ServiceRepository.emitMeshPacket] for - * consumers that need them (RadioConfigViewModel admin responses, TAK integration). - * - * **ServiceActions:** Handled inline via SDK [AdminApi] — eliminates the old - * MeshServiceOrchestrator → MeshActionHandler → CommandSender dispatch chain. - * - * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientProvider.client] + * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientAccessor.client] * and starts/stops collection as clients come and go. */ @Single @Suppress("TooManyFunctions") class SdkStateBridge( - private val provider: RadioClientProvider, + private val accessor: RadioClientAccessor, private val serviceRepository: ServiceRepository, private val nodeManager: NodeManager, private val packetRepository: Lazy, + private val locationManager: MeshLocationManager, + private val uiPrefs: UiPrefs, + private val radioController: RadioController, private val dispatchers: CoroutineDispatchers, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) + private var locationJob: Job? = null init { startBridge() @@ -82,13 +82,13 @@ class SdkStateBridge( private fun startBridge() { // ── Connection state ──────────────────────────────────────────────── - provider.client + accessor.client .flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) } .onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) } .launchIn(scope) // ── Node updates (position, telemetry, user all included in NodeInfo) ─ - provider.client + accessor.client .flatMapLatest { client -> client?.nodes ?: flowOf() } .onEach { change -> when (change) { @@ -107,19 +107,19 @@ class SdkStateBridge( .launchIn(scope) // ── Own node identity ─────────────────────────────────────────────── - provider.client + accessor.client .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } .onEach { ownNode -> if (ownNode != null) nodeManager.setMyNodeNum(ownNode.num) } .launchIn(scope) // ── Raw packet forward (for RadioConfigViewModel + TAK) ───────────── - provider.client + accessor.client .flatMapLatest { client -> client?.packets ?: flowOf() } .onEach { packet -> serviceRepository.emitMeshPacket(packet) } .launchIn(scope) // ── Events (notifications, security, backpressure) ────────────────── - provider.client + accessor.client .flatMapLatest { client -> client?.events ?: flowOf() } .onEach { event -> when (event) { @@ -141,13 +141,43 @@ class SdkStateBridge( .onEach { action -> handleServiceAction(action) } .launchIn(scope) + // ── Location publishing ───────────────────────────────────────────── + accessor.client + .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } + .onEach { ownNode -> + locationJob?.cancel() + locationJob = null + if (ownNode != null) { + locationJob = uiPrefs.shouldProvideNodeLocation(ownNode.num) + .onEach { shouldProvide -> + if (shouldProvide) { + locationManager.start(scope) { pos -> + scope.launch { + val packet = DataPacket( + bytes = okio.ByteString.of( + *org.meshtastic.proto.Position.ADAPTER.encode(pos), + ), + dataType = PortNum.POSITION_APP.value, + ) + radioController.sendMessage(packet) + } + } + } else { + locationManager.stop() + } + } + .launchIn(scope) + } + } + .launchIn(scope) + Logger.i { "SdkStateBridge started — SDK owns transport + ServiceAction dispatch" } } // ── ServiceAction handling ─────────────────────────────────────────────── private suspend fun handleServiceAction(action: ServiceAction) { - val client = provider.client.value + val client = accessor.client.value if (client == null) { Logger.w { "[SdkBridge] ServiceAction ${action::class.simpleName} dropped — no client" } if (action is ServiceAction.SendContact) action.result.complete(false) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt new file mode 100644 index 000000000..b69d04db0 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt @@ -0,0 +1,82 @@ +/* + * 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.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single +import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.entity.NodeMetadataEntity +import org.meshtastic.core.repository.AppMetadataRepository +import org.meshtastic.core.repository.NodeMetadata + +@Single(binds = [AppMetadataRepository::class]) +class AppMetadataRepositoryImpl( + private val dbManager: DatabaseProvider, +) : AppMetadataRepository { + + override val metadataByNum: Flow> = + dbManager.currentDb.flatMapLatest { db -> db.nodeMetadataDao().getAllFlow() } + .map { list -> list.associate { it.num to it.toModel() } } + + override suspend fun setFavorite(nodeNum: Int, isFavorite: Boolean) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setFavorite(nodeNum, isFavorite) } + } + + override suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setIgnored(nodeNum, isIgnored) } + } + + override suspend fun setMuted(nodeNum: Int, isMuted: Boolean) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setMuted(nodeNum, isMuted) } + } + + override suspend fun setNotes(nodeNum: Int, notes: String) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setNotes(nodeNum, notes) } + } + + override suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setManuallyVerified(nodeNum, verified) } + } + + override suspend fun delete(nodeNum: Int) { + dbManager.withDb { it.nodeMetadataDao().delete(nodeNum) } + } + + private suspend fun ensureExists(nodeNum: Int) { + dbManager.withDb { db -> + if (db.nodeMetadataDao().getByNum(nodeNum) == null) { + db.nodeMetadataDao().upsert(NodeMetadataEntity(num = nodeNum)) + } + } + } +} + +private fun NodeMetadataEntity.toModel() = NodeMetadata( + num = num, + isFavorite = isFavorite, + isIgnored = isIgnored, + isMuted = isMuted, + notes = notes, + manuallyVerified = manuallyVerified, +) 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 deleted file mode 100644 index 09dc86fcc..000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt +++ /dev/null @@ -1,290 +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.repository - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope -import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.conflate -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.data.datasource.NodeInfoReadDataSource -import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource -import org.meshtastic.core.database.entity.MetadataEntity -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.NodeSortOption -import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.LocalStats -import org.meshtastic.proto.User - -/** Repository for managing node-related data, including hardware info, node database, and identity. */ -// @Single — Replaced by SdkNodeRepositoryImpl in SDK mode. Kept for reference/desktop fallback. -@Suppress("TooManyFunctions") -class NodeRepositoryImpl( - @Named("ProcessLifecycle") private val processLifecycle: Lifecycle, - private val nodeInfoReadDataSource: NodeInfoReadDataSource, - private val nodeInfoWriteDataSource: NodeInfoWriteDataSource, - private val dispatchers: CoroutineDispatchers, - private val localStatsDataSource: LocalStatsDataSource, -) : NodeRepository { - /** Hardware info about our local device (can be null if not connected). */ - override val myNodeInfo: StateFlow = - nodeInfoReadDataSource - .myNodeInfoFlow() - .map { it?.toMyNodeInfo() } - .flowOn(dispatchers.io) - .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null) - - private val _ourNodeInfo = MutableStateFlow(null) - - /** Information about the locally connected node, as seen from the mesh. */ - override val ourNodeInfo: StateFlow - get() = _ourNodeInfo - - private val _myId = MutableStateFlow(null) - - /** The unique userId (hex string) of our local node. */ - override val myId: StateFlow - get() = _myId - - /** The latest local stats telemetry received from the locally connected node. */ - override val localStats: StateFlow = - localStatsDataSource.localStatsFlow.stateIn( - processLifecycle.coroutineScope, - SharingStarted.Eagerly, - LocalStats(), - ) - - /** Update the cached local stats telemetry. */ - override fun updateLocalStats(stats: LocalStats) { - processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) } - } - - /** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */ - override val nodeDBbyNum: StateFlow> = - nodeInfoReadDataSource - .nodeDBbyNumFlow() - .mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } } - .flowOn(dispatchers.io) - .conflate() - .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) - - init { - // Backfill denormalized name columns for existing nodes on startup - processLifecycle.coroutineScope.launch { - processLifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) { - withContext(dispatchers.io) { nodeInfoWriteDataSource.backfillDenormalizedNames() } - } - } - - // Keep ourNodeInfo and myId correctly updated based on current connection and node DB - combine(nodeDBbyNum, nodeInfoReadDataSource.myNodeInfoFlow()) { db, info -> info?.myNodeNum?.let { db[it] } } - .onEach { node -> - _ourNodeInfo.value = node - _myId.value = node?.user?.id - } - .launchIn(processLifecycle.coroutineScope) - } - - /** - * Returns the node number used for log queries. Maps [nodeNum] to [MeshLog.NODE_NUM_LOCAL] (0) if it is the locally - * connected node. - */ - override fun effectiveLogNodeId(nodeNum: Int): Flow = nodeInfoReadDataSource - .myNodeInfoFlow() - .map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum } - .distinctUntilChanged() - - fun getNodeEntityDBbyNumFlow() = - nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } } - - /** 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)) - - /** Returns the [User] info for a given [nodeNum]. */ - override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) - - private val last4 = 4 - - /** Returns the [User] info for a given [userId]. Falls back to a generic user if not found. */ - override fun getUser(userId: String): User { - val found = nodeDBbyNum.value.values.find { it.user.id == userId }?.user - if (found != null && found.long_name.isNotBlank() && found.short_name.isNotBlank()) { - return found - } - - val fallbackId = userId.takeLast(last4) - val defaultLong = - if (userId == DataPacket.ID_LOCAL) { - ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local" - } else { - "Meshtastic $fallbackId" - } - val defaultShort = - if (userId == DataPacket.ID_LOCAL) { - ourNodeInfo.value?.user?.short_name?.takeIf { it.isNotBlank() } ?: "Local" - } else { - fallbackId - } - - return found?.copy( - long_name = found.long_name.takeIf { it.isNotBlank() } ?: defaultLong, - short_name = found.short_name.takeIf { it.isNotBlank() } ?: defaultShort, - ) ?: User(id = userId, long_name = defaultLong, short_name = defaultShort) - } - - /** Returns a flow of nodes filtered and sorted according to the parameters. */ - override fun getNodes( - sort: NodeSortOption, - filter: String, - includeUnknown: Boolean, - onlyOnline: Boolean, - onlyDirect: Boolean, - ): Flow> = nodeInfoReadDataSource - .getNodesFlow( - sort = sort.sqlValue, - filter = filter, - includeUnknown = includeUnknown, - hopsAwayMax = if (onlyDirect) 0 else -1, - lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1, - ) - .mapLatest { list -> list.map { it.toModel() } } - .flowOn(dispatchers.io) - .conflate() - - /** Upserts a [Node] to the database. */ - override suspend fun upsert(node: Node) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node.toEntity()) } - - /** Installs initial configuration data (local info and remote nodes) into the database. */ - override suspend fun installConfig(mi: MyNodeInfo, nodes: List) = withContext(dispatchers.io) { - nodeInfoWriteDataSource.installConfig(mi.toEntity(), nodes.map { it.toEntity() }) - } - - /** Deletes all nodes from the database, optionally preserving favorites. */ - override suspend fun clearNodeDB(preserveFavorites: Boolean) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.clearNodeDB(preserveFavorites) } - - /** Clears the local node's connection info. */ - override suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() } - - /** Deletes a node and its metadata by [num]. */ - override suspend fun deleteNode(num: Int) = withContext(dispatchers.io) { - nodeInfoWriteDataSource.deleteNode(num) - nodeInfoWriteDataSource.deleteMetadata(num) - } - - /** Deletes multiple nodes and their metadata. */ - override suspend fun deleteNodes(nodeNums: List) = withContext(dispatchers.io) { - nodeInfoWriteDataSource.deleteNodes(nodeNums) - nodeNums.forEach { nodeInfoWriteDataSource.deleteMetadata(it) } - } - - override suspend fun getNodesOlderThan(lastHeard: Int): List = - withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard).map { it.toModel() } } - - override suspend fun getUnknownNodes(): List = - withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes().map { it.toModel() } } - - /** Persists hardware metadata for a node. */ - override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(MetadataEntity(nodeNum, metadata)) } - - /** Flow emitting the count of nodes currently considered "online". */ - override val onlineNodeCount: Flow = - nodeInfoReadDataSource - .nodeDBbyNumFlow() - .mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } } - .flowOn(dispatchers.io) - .conflate() - - /** Flow emitting the total number of nodes in the database. */ - override val totalNodeCount: Flow = - nodeInfoReadDataSource - .nodeDBbyNumFlow() - .mapLatest { map -> map.values.count() } - .flowOn(dispatchers.io) - .conflate() - - override suspend fun setNodeNotes(num: Int, notes: String) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) } - - private fun MyNodeInfo.toEntity() = MyNodeEntity( - myNodeNum = myNodeNum, - model = model, - firmwareVersion = firmwareVersion, - couldUpdate = couldUpdate, - shouldUpdate = shouldUpdate, - currentPacketId = currentPacketId, - messageTimeoutMsec = messageTimeoutMsec, - minAppVersion = minAppVersion, - maxChannels = maxChannels, - hasWifi = hasWifi, - deviceId = deviceId, - pioEnv = pioEnv, - ) - - private fun Node.toEntity() = NodeEntity( - num = num, - user = user, - position = position, - latitude = latitude, - longitude = longitude, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceTelemetry = org.meshtastic.proto.Telemetry(device_metrics = deviceMetrics), - channel = channel, - viaMqtt = viaMqtt, - hopsAway = hopsAway, - isFavorite = isFavorite, - isIgnored = isIgnored, - isMuted = isMuted, - environmentTelemetry = org.meshtastic.proto.Telemetry(environment_metrics = environmentMetrics), - powerTelemetry = org.meshtastic.proto.Telemetry(power_metrics = powerMetrics), - paxcounter = paxcounter, - publicKey = publicKey, - notes = notes, - manuallyVerified = manuallyVerified, - nodeStatus = nodeStatus, - lastTransport = lastTransport, - ) -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt index f75b65e47..83c6d528d 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -23,12 +23,15 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.entity.NodeMetadataEntity import org.meshtastic.core.datastore.LocalStatsDataSource import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MeshLog @@ -50,12 +53,13 @@ import org.meshtastic.proto.User * SDK's NodeChange flow (bridged through SdkStateBridge). * * Cold start: nodes are empty until the SDK emits its snapshot from storage (<1s). - * Node notes: stored in-memory for this POC (will not survive process death). + * Node metadata (favorites, notes, ignored, muted) persists via Room's node_metadata table. */ @Single(binds = [NodeRepository::class]) @Suppress("TooManyFunctions") class SdkNodeRepositoryImpl( private val localStatsDataSource: LocalStatsDataSource, + private val dbManager: DatabaseProvider, @Named("ServiceScope") private val scope: CoroutineScope, ) : NodeRepository { @@ -63,8 +67,15 @@ class SdkNodeRepositoryImpl( private val _myNodeInfo = MutableStateFlow(null) private val _myNodeNum = MutableStateFlow(null) - // Local-only notes storage (in-memory for POC; does not survive process death) - private val nodeNotes = MutableStateFlow>(emptyMap()) + // Cached metadata from Room (loaded on init, updated on writes) + private val _metadataCache = MutableStateFlow>(emptyMap()) + + init { + scope.launch { + dbManager.currentDb.flatMapLatest { db -> db.nodeMetadataDao().getAllFlow() } + .collect { list -> _metadataCache.value = list.associateBy { it.num } } + } + } override val nodeDBbyNum: StateFlow> = _nodeDBbyNum @@ -151,11 +162,20 @@ class SdkNodeRepositoryImpl( } override suspend fun upsert(node: Node) { - _nodeDBbyNum.update { map -> map + (node.num to node) } - // Also keep _myNodeNum consistent - if (node.num == _myNodeNum.value) { - // ourNodeInfo will auto-update via combine + // Merge persisted metadata with incoming node data + val meta = _metadataCache.value[node.num] + val enriched = if (meta != null) { + node.copy( + isFavorite = meta.isFavorite, + isIgnored = meta.isIgnored, + isMuted = meta.isMuted, + notes = meta.notes, + manuallyVerified = meta.manuallyVerified, + ) + } else { + node } + _nodeDBbyNum.update { map -> map + (enriched.num to enriched) } } override suspend fun installConfig(mi: MyNodeInfo, nodes: List) { @@ -178,12 +198,12 @@ class SdkNodeRepositoryImpl( override suspend fun deleteNode(num: Int) { _nodeDBbyNum.update { it - num } - nodeNotes.update { it - num } + dbManager.withDb { it.nodeMetadataDao().delete(num) } } override suspend fun deleteNodes(nodeNums: List) { _nodeDBbyNum.update { map -> map - nodeNums.toSet() } - nodeNotes.update { notes -> notes - nodeNums.toSet() } + dbManager.withDb { db -> nodeNums.forEach { db.nodeMetadataDao().delete(it) } } } override suspend fun getNodesOlderThan(lastHeard: Int): List = @@ -193,7 +213,8 @@ class SdkNodeRepositoryImpl( _nodeDBbyNum.value.values.filter { it.user.hw_model == HardwareModel.UNSET } override suspend fun setNodeNotes(num: Int, notes: String) { - nodeNotes.update { it + (num to notes) } + ensureMetadataExists(num) + dbManager.withDb { it.nodeMetadataDao().setNotes(num, notes) } _nodeDBbyNum.update { map -> val node = map[num] ?: return@update map map + (num to node.copy(notes = notes)) @@ -212,6 +233,13 @@ class SdkNodeRepositoryImpl( _myNodeNum.value = num } + /** Ensures a metadata row exists for the given node, creating a default if needed. */ + private suspend fun ensureMetadataExists(num: Int) { + if (_metadataCache.value[num] == null) { + dbManager.withDb { it.nodeMetadataDao().upsert(NodeMetadataEntity(num = num)) } + } + } + private fun sortComparator(sort: NodeSortOption): Comparator = when (sort) { NodeSortOption.LAST_HEARD -> compareByDescending { it.lastHeard } NodeSortOption.ALPHABETICAL -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.user.long_name } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt deleted file mode 100644 index e6c2841f1..000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import dev.mokkery.MockMode -import dev.mokkery.mock -import dev.mokkery.verify -import okio.ByteString -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.repository.MeshConfigFlowManager -import org.meshtastic.core.repository.MeshConfigHandler -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.SessionManager -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.Channel -import org.meshtastic.proto.Config -import org.meshtastic.proto.Data -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.PortNum -import kotlin.test.BeforeTest -import kotlin.test.Test - -class AdminPacketHandlerImplTest { - - private val nodeManager = mock(MockMode.autofill) - private val configHandler = mock(MockMode.autofill) - private val configFlowManager = mock(MockMode.autofill) - private val sessionManager = mock(MockMode.autofill) - - private lateinit var handler: AdminPacketHandlerImpl - - private val myNodeNum = 12345 - - @BeforeTest - fun setUp() { - handler = - AdminPacketHandlerImpl( - nodeManager = nodeManager, - configHandler = lazy { configHandler }, - configFlowManager = lazy { configFlowManager }, - sessionManager = sessionManager, - ) - } - - private fun makePacket(from: Int, adminMessage: AdminMessage): MeshPacket { - val payload = AdminMessage.ADAPTER.encode(adminMessage).toByteString() - return MeshPacket(from = from, decoded = Data(portnum = PortNum.ADMIN_APP, payload = payload)) - } - - // ---------- Session passkey ---------- - - @Test - fun `session passkey is updated when present`() { - val passkey = ByteString.of(1, 2, 3, 4) - val adminMsg = AdminMessage(session_passkey = passkey) - val packet = makePacket(myNodeNum, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - - verify { sessionManager.recordSession(myNodeNum, passkey) } - } - - @Test - fun `empty session passkey does not record refresh`() { - val adminMsg = AdminMessage(session_passkey = ByteString.EMPTY) - val packet = makePacket(myNodeNum, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - // recordSession should NOT be called for empty passkey - } - - // ---------- get_config_response ---------- - - @Test - fun `get_config_response from own node delegates to configHandler`() { - val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) - val adminMsg = AdminMessage(get_config_response = config) - val packet = makePacket(myNodeNum, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - - verify { configHandler.handleDeviceConfig(config) } - } - - @Test - fun `get_config_response from remote node is ignored`() { - val config = Config(device = Config.DeviceConfig()) - val adminMsg = AdminMessage(get_config_response = config) - val packet = makePacket(99999, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - // configHandler.handleDeviceConfig should NOT be called - } - - // ---------- get_module_config_response ---------- - - @Test - fun `get_module_config_response from own node delegates to configHandler`() { - val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) - val adminMsg = AdminMessage(get_module_config_response = moduleConfig) - val packet = makePacket(myNodeNum, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - - verify { configHandler.handleModuleConfig(moduleConfig) } - } - - @Test - fun `get_module_config_response from remote node updates node status`() { - val moduleConfig = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Battery Low")) - val adminMsg = AdminMessage(get_module_config_response = moduleConfig) - val remoteNode = 99999 - val packet = makePacket(remoteNode, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - - verify { nodeManager.updateNodeStatus(remoteNode, "Battery Low") } - } - - @Test - fun `get_module_config_response from remote without status message does not crash`() { - val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) - val adminMsg = AdminMessage(get_module_config_response = moduleConfig) - val packet = makePacket(99999, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - // No crash, no updateNodeStatus call - } - - // ---------- get_channel_response ---------- - - @Test - fun `get_channel_response from own node delegates to configHandler`() { - val channel = Channel(index = 0) - val adminMsg = AdminMessage(get_channel_response = channel) - val packet = makePacket(myNodeNum, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - - verify { configHandler.handleChannel(channel) } - } - - @Test - fun `get_channel_response from remote node is ignored`() { - val channel = Channel(index = 0) - val adminMsg = AdminMessage(get_channel_response = channel) - val packet = makePacket(99999, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - // configHandler.handleChannel should NOT be called - } - - // ---------- get_device_metadata_response ---------- - - @Test - fun `device metadata from own node delegates to configFlowManager`() { - val metadata = DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3) - val adminMsg = AdminMessage(get_device_metadata_response = metadata) - val packet = makePacket(myNodeNum, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - - verify { configFlowManager.handleLocalMetadata(metadata) } - } - - @Test - fun `device metadata from remote node delegates to nodeManager`() { - val metadata = DeviceMetadata(firmware_version = "2.5.0", hw_model = HardwareModel.TBEAM) - val adminMsg = AdminMessage(get_device_metadata_response = metadata) - val remoteNode = 99999 - val packet = makePacket(remoteNode, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - - verify { nodeManager.insertMetadata(remoteNode, metadata) } - } - - // ---------- Edge cases ---------- - - @Test - fun `packet with null decoded payload is ignored`() { - val packet = MeshPacket(from = myNodeNum, decoded = null) - handler.handleAdminMessage(packet, myNodeNum) - // No crash - } - - @Test - fun `packet with empty payload bytes is ignored`() { - val packet = - MeshPacket(from = myNodeNum, decoded = Data(portnum = PortNum.ADMIN_APP, payload = ByteString.EMPTY)) - handler.handleAdminMessage(packet, myNodeNum) - // No crash — decodes as default AdminMessage with no fields set - } - - @Test - fun `combined admin message with passkey and config response`() { - val passkey = ByteString.of(5, 6, 7, 8) - val config = Config(lora = Config.LoRaConfig()) - val adminMsg = AdminMessage(session_passkey = passkey, get_config_response = config) - val packet = makePacket(myNodeNum, adminMsg) - - handler.handleAdminMessage(packet, myNodeNum) - - verify { sessionManager.recordSession(myNodeNum, passkey) } - verify { configHandler.handleDeviceConfig(config) } - } -} 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 9e28e7481..000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ /dev/null @@ -1,583 +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.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 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, - 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 { 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.ADAPTER.encode(config) - - 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.ADAPTER.encode(moduleConfig) - - 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.ADAPTER.encode(moduleConfig) - - 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.ADAPTER.encode(channel) - - 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.ADAPTER.encode(user) - - 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.ADAPTER.encode(channel) - - 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 deleted file mode 100644 index 5a9672284..000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ /dev/null @@ -1,471 +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.calls -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import dev.mokkery.verify.VerifyMode -import dev.mokkery.verifySuspend -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import okio.ByteString.Companion.encodeUtf8 -import org.meshtastic.core.repository.HandshakeConstants -import org.meshtastic.core.repository.MeshConnectionManager -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -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 -import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.NodeInfo -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo - -@OptIn(ExperimentalCoroutinesApi::class) -class MeshConfigFlowManagerImplTest { - - private val nodeManager = mock(MockMode.autofill) - private val connectionManager = mock(MockMode.autofill) - 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 packetHandler = mock(MockMode.autofill) - private val notificationPrefs = mock(MockMode.autofill) - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private lateinit var manager: MeshConfigFlowManagerImpl - - private val myNodeNum = 12345 - - private val protoMyNodeInfo = - ProtoMyNodeInfo( - my_node_num = myNodeNum, - min_app_version = 30000, - device_id = "test-device".encodeUtf8(), - pio_env = "", - ) - - private val metadata = - DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false) - - @BeforeTest - fun setUp() { - every { packetHandler.sendToRadio(any()) } returns Unit - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - every { nodeManager.myNodeNum } returns MutableStateFlow(null) - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) - every { notificationPrefs.nodeEventsEnabled } returns MutableStateFlow(true) - - manager = - MeshConfigFlowManagerImpl( - nodeManager = nodeManager, - connectionManager = lazy { connectionManager }, - nodeRepository = nodeRepository, - radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, - serviceBroadcasts = serviceBroadcasts, - analytics = analytics, - heartbeatSender = DataLayerHeartbeatSender(packetHandler), - notificationPrefs = notificationPrefs, - scope = testScope, - ) - } - - // ---------- handleMyInfo ---------- - - @Test - fun `handleMyInfo transitions to ReceivingConfig and sets myNodeNum`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - verify { nodeManager.setMyNodeNum(myNodeNum) } - } - - @Test - fun `handleMyInfo clears persisted radio config`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - verifySuspend { radioConfigRepository.clearChannelSet() } - verifySuspend { radioConfigRepository.clearLocalConfig() } - verifySuspend { radioConfigRepository.clearLocalModuleConfig() } - verifySuspend { radioConfigRepository.clearDeviceUIConfig() } - verifySuspend { radioConfigRepository.clearFileManifest() } - } - - // ---------- handleLocalMetadata ---------- - - @Test - fun `handleLocalMetadata persists metadata when in ReceivingConfig state`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - verifySuspend { nodeRepository.insertMetadata(myNodeNum, metadata) } - } - - @Test - fun `handleLocalMetadata skips empty metadata`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - // Default/empty DeviceMetadata should not trigger insertMetadata - manager.handleLocalMetadata(DeviceMetadata()) - advanceUntilIdle() - - // insertMetadata should only have been called zero times for default metadata - // (we just verify no crash occurs) - } - - @Test - fun `handleLocalMetadata ignored outside ReceivingConfig state`() = testScope.runTest { - // State is Idle — handleLocalMetadata should be a no-op - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - // No crash, no insertMetadata call - } - - // ---------- handleConfigComplete Stage 1 ---------- - - @Test - fun `Stage 1 complete builds MyNodeInfo and transitions to ReceivingNodeInfo`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - verify { connectionManager.onRadioConfigLoaded() } - verify { connectionManager.startNodeInfoOnly() } - } - - @Test - fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest { - val sentPackets = mutableListOf() - every { packetHandler.sendToRadio(any()) } calls - { call -> - sentPackets.add(call.arg(0)) - } - - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - sentPackets.clear() // Clear any packets from prior phases - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - val heartbeats = sentPackets.filter { it.heartbeat != null } - assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat") - assertEquals( - true, - heartbeats[0].heartbeat!!.nonce != 0, - "Inter-stage heartbeat should have a non-zero nonce", - ) - } - - @Test - fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest { - val oldMetadata = - DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false) - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(oldMetadata) - advanceUntilIdle() - - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - // Handshake should still progress despite old firmware - verify { connectionManager.onRadioConfigLoaded() } - verify { connectionManager.startNodeInfoOnly() } - } - - @Test - fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - // No metadata provided - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - verify { connectionManager.onRadioConfigLoaded() } - } - - @Test - fun `Stage 1 complete id ignored when not in ReceivingConfig state`() = testScope.runTest { - // State is Idle — should be a no-op - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - // No crash, no onRadioConfigLoaded - } - - @Test - fun `Duplicate Stage 1 config_complete does not re-trigger`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - // Now in ReceivingNodeInfo — a second Stage 1 complete should be ignored - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - } - - // ---------- handleNodeInfo ---------- - - @Test - fun `handleNodeInfo accumulates nodes during Stage 2`() = testScope.runTest { - // Transition to Stage 2 - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - // Now in ReceivingNodeInfo - manager.handleNodeInfo(NodeInfo(num = 100)) - manager.handleNodeInfo(NodeInfo(num = 200)) - - assertEquals(2, manager.newNodeCount) - } - - @Test - fun `handleNodeInfo ignored outside Stage 2`() = testScope.runTest { - // State is Idle - manager.handleNodeInfo(NodeInfo(num = 999)) - - assertEquals(0, manager.newNodeCount) - } - - // ---------- handleConfigComplete Stage 2 ---------- - - @Test - fun `Stage 2 complete processes nodes and sets Connected state`() = testScope.runTest { - val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100) - every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode) - - // Full handshake: MyInfo -> metadata -> Stage 1 complete -> nodes -> Stage 2 complete - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - manager.handleNodeInfo(NodeInfo(num = 100)) - manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) - advanceUntilIdle() - - verify { nodeManager.installNodeInfo(any(), withBroadcast = false) } - verify { nodeManager.setNodeDbReady(true) } - verify { nodeManager.setAllowNodeDbWrites(true) } - verify { serviceBroadcasts.broadcastConnection() } - verify { connectionManager.onNodeDbReady() } - } - - @Test - fun `Stage 2 complete id ignored when not in ReceivingNodeInfo state`() = testScope.runTest { - manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) - advanceUntilIdle() - // No crash - } - - @Test - fun `Stage 2 complete with no nodes still transitions to Connected`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - // No handleNodeInfo calls — empty node list - manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) - advanceUntilIdle() - - verify { nodeManager.setNodeDbReady(true) } - verify { connectionManager.onNodeDbReady() } - } - - // ---------- Unknown config_complete_id ---------- - - @Test - fun `Unknown config_complete_id is ignored`() = testScope.runTest { - manager.handleConfigComplete(99999) - advanceUntilIdle() - // No crash - } - - // ---------- newNodeCount ---------- - - @Test - fun `newNodeCount returns 0 when not in ReceivingNodeInfo state`() { - assertEquals(0, manager.newNodeCount) - } - - // ---------- handleFileInfo ---------- - - @Test - fun `handleFileInfo delegates to radioConfigRepository`() = testScope.runTest { - val fileInfo = FileInfo(file_name = "firmware.bin", size_bytes = 1024) - manager.handleFileInfo(fileInfo) - advanceUntilIdle() - - verifySuspend { radioConfigRepository.addFileInfo(fileInfo) } - } - - // ---------- triggerWantConfig ---------- - - @Test - fun `triggerWantConfig delegates to connectionManager startConfigOnly`() { - manager.triggerWantConfig() - verify { connectionManager.startConfigOnly() } - } - - // ---------- Full handshake flow ---------- - - @Test - fun `Full handshake from Idle to Complete`() = testScope.runTest { - val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100) - every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode) - - // Stage 0: Idle -> handleMyInfo - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - verify { nodeManager.setMyNodeNum(myNodeNum) } - - // Receive metadata during Stage 1 - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - // Stage 1 complete - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - verify { connectionManager.onRadioConfigLoaded() } - - // Receive NodeInfo during Stage 2 - manager.handleNodeInfo(NodeInfo(num = 100)) - assertEquals(1, manager.newNodeCount) - - // Stage 2 complete - manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) - advanceUntilIdle() - - verify { nodeManager.setNodeDbReady(true) } - verify { connectionManager.onNodeDbReady() } - - // After complete, newNodeCount should be 0 (state is Complete) - assertEquals(0, manager.newNodeCount) - } - - // ---------- Interrupted handshake ---------- - - @Test - fun `handleMyInfo resets stale handshake state`() = testScope.runTest { - // Start first handshake - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - // Before Stage 1 completes, a new handleMyInfo arrives (device rebooted) - val newMyInfo = protoMyNodeInfo.copy(my_node_num = 99999) - manager.handleMyInfo(newMyInfo) - advanceUntilIdle() - - verify { nodeManager.setMyNodeNum(99999) } - } - - // ---------- Event firmware notification defaults ---------- - - @Test - fun `handleMyInfo disables node notifications for event firmware`() = testScope.runTest { - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) - - val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON) - manager.handleMyInfo(eventMyInfo) - advanceUntilIdle() - - verify { notificationPrefs.setNodeEventsEnabled(false) } - verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(true) } - } - - @Test - fun `handleMyInfo does not re-disable if already auto-disabled`() = testScope.runTest { - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true) - - val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON) - manager.handleMyInfo(eventMyInfo) - advanceUntilIdle() - - verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) } - } - - @Test - fun `handleMyInfo re-enables node notifications when vanilla firmware reconnects`() = testScope.runTest { - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true) - - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - verify { notificationPrefs.setNodeEventsEnabled(true) } - verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(false) } - } - - @Test - fun `handleMyInfo does not touch prefs for vanilla when not previously auto-disabled`() = testScope.runTest { - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) - - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) } - verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsAutoDisabledForEvent(any()) } - } -} 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 deleted file mode 100644 index e36efda4f..000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ /dev/null @@ -1,430 +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.calls -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.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.AppWidgetUpdater -import org.meshtastic.core.repository.HistoryManager -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.MeshWorkerManager -import org.meshtastic.core.repository.MqttManager -import org.meshtastic.core.repository.NodeManager -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.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 -import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.proto.Config -import org.meshtastic.proto.LocalConfig -import org.meshtastic.proto.LocalModuleConfig -import org.meshtastic.proto.ModuleConfig -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -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 uiPrefs = mock(MockMode.autofill) - private val packetHandler = mock(MockMode.autofill) - private val nodeRepository = FakeNodeRepository() - private val locationManager = mock(MockMode.autofill) - private val mqttManager = mock(MockMode.autofill) - private val historyManager = mock(MockMode.autofill) - private val radioConfigRepository = mock(MockMode.autofill) - private val radioController = mock(MockMode.autofill) - private val sessionManager = mock(MockMode.autofill) - private val nodeManager = mock(MockMode.autofill) - private val analytics = mock(MockMode.autofill) - private val packetRepository = mock(MockMode.autofill) - private val workerManager = mock(MockMode.autofill) - private val appWidgetUpdater = mock(MockMode.autofill) - - private val dataPacket = DataPacket(id = 456, time = 0L, to = "0", from = "0", bytes = null, dataType = 0) - - private val radioConnectionState = MutableStateFlow(ConnectionState.Disconnected) - private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected) - private val localConfigFlow = MutableStateFlow(LocalConfig()) - private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig()) - - private val testDispatcher = UnconfinedTestDispatcher() - - private lateinit var manager: MeshConnectionManagerImpl - - @BeforeTest - fun setUp() { - every { radioInterfaceService.connectionState } returns radioConnectionState - every { radioConfigRepository.localConfigFlow } returns localConfigFlow - every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow - every { serviceRepository.connectionState } returns connectionStateFlow - every { serviceRepository.setConnectionState(any()) } calls - { call -> - connectionStateFlow.value = call.arg(0) - } - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - every { packetHandler.stopPacketQueue() } returns Unit - every { locationManager.stop() } returns Unit - every { mqttManager.stop() } returns Unit - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - every { packetHandler.sendToRadio(any()) } returns Unit - } - - private fun createManager(scope: CoroutineScope): MeshConnectionManagerImpl = MeshConnectionManagerImpl( - radioInterfaceService, - serviceRepository, - serviceBroadcasts, - serviceNotifications, - uiPrefs, - packetHandler, - nodeRepository, - locationManager, - mqttManager, - historyManager, - radioConfigRepository, - radioController, - sessionManager, - nodeManager, - analytics, - packetRepository, - workerManager, - appWidgetUpdater, - DataLayerHeartbeatSender(packetHandler), - scope, - ) - - @AfterTest fun tearDown() = Unit - - @Test - fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) { - manager = createManager(backgroundScope) - radioConnectionState.value = ConnectionState.Connected - advanceUntilIdle() - - assertEquals( - ConnectionState.Connecting, - serviceRepository.connectionState.value, - "State should be Connecting after radio Connected", - ) - verify { serviceBroadcasts.broadcastConnection() } - } - - @Test - fun `Connected state sends pre-handshake heartbeat before config request`() = runTest(testDispatcher) { - val sentPackets = mutableListOf() - every { packetHandler.sendToRadio(any()) } calls - { call -> - sentPackets.add(call.arg(0)) - } - - manager = createManager(backgroundScope) - radioConnectionState.value = ConnectionState.Connected - // Advance past PRE_HANDSHAKE_SETTLE_MS (100ms) but NOT the 30s stall guard timeout - advanceTimeBy(200) - - // First ToRadio should be a heartbeat, second should be want_config_id - assertEquals(2, sentPackets.size, "Expected heartbeat + want_config_id, got ${sentPackets.size} packets") - val heartbeat = sentPackets[0] - val wantConfig = sentPackets[1] - - assertEquals(true, heartbeat.heartbeat != null, "First packet should be a heartbeat") - assertEquals(true, heartbeat.heartbeat!!.nonce != 0, "Heartbeat should have a non-zero nonce") - assertEquals( - org.meshtastic.core.repository.HandshakeConstants.CONFIG_NONCE, - wantConfig.want_config_id, - "Second packet should be want_config_id with CONFIG_NONCE", - ) - } - - @Test - fun `Disconnect during pre-handshake settle cancels config start`() = runTest(testDispatcher) { - val sentPackets = mutableListOf() - every { packetHandler.sendToRadio(any()) } calls - { call -> - sentPackets.add(call.arg(0)) - } - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - manager = createManager(backgroundScope) - radioConnectionState.value = ConnectionState.Connected - // Advance only 50ms — within the 100ms settle window - advanceTimeBy(50) - - // Should have sent only the heartbeat so far, not want_config_id - assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes") - - // Disconnect before the settle delay completes — should cancel the pending config start - radioConnectionState.value = ConnectionState.Disconnected - advanceTimeBy(200) - - // The want_config_id should NOT have been sent because the job was cancelled - val configPackets = sentPackets.filter { it.want_config_id != null } - assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect") - } - - @Test - fun `Disconnected state stops services`() = runTest(testDispatcher) { - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - manager = createManager(backgroundScope) - // Transition to Connected first so that Disconnected actually does something - radioConnectionState.value = ConnectionState.Connected - advanceUntilIdle() - - radioConnectionState.value = ConnectionState.Disconnected - advanceUntilIdle() - - assertEquals( - ConnectionState.Disconnected, - serviceRepository.connectionState.value, - "State should be Disconnected after radio Disconnected", - ) - verify { packetHandler.stopPacketQueue() } - verify { locationManager.stop() } - verify { mqttManager.stop() } - } - - @Test - fun `DeviceSleep behavior when power saving is off maps to Disconnected`() = runTest(testDispatcher) { - // Power saving disabled + Role CLIENT - val config = - LocalConfig( - power = Config.PowerConfig(is_power_saving = false), - device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT), - ) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - manager = createManager(backgroundScope) - advanceUntilIdle() - - radioConnectionState.value = ConnectionState.DeviceSleep - advanceUntilIdle() - - assertEquals( - ConnectionState.Disconnected, - serviceRepository.connectionState.value, - "State should be Disconnected when power saving is off", - ) - } - - @Test - fun `DeviceSleep behavior when power saving is on stays in DeviceSleep`() = runTest(testDispatcher) { - // Power saving enabled - val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - - manager = createManager(backgroundScope) - advanceUntilIdle() - - radioConnectionState.value = ConnectionState.DeviceSleep - advanceUntilIdle() - - assertEquals( - ConnectionState.DeviceSleep, - serviceRepository.connectionState.value, - "State should stay in DeviceSleep when power saving is on", - ) - } - - @Test - fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) { - manager = createManager(backgroundScope) - val packetId = 456 - everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket) - every { workerManager.enqueueSendMessage(any()) } returns Unit - - manager.onRadioConfigLoaded() - advanceUntilIdle() - - verify { workerManager.enqueueSendMessage(packetId) } - } - - @Test - fun `onNodeDbReady starts MQTT and requests history`() = runTest(testDispatcher) { - val moduleConfig = - LocalModuleConfig( - mqtt = ModuleConfig.MQTTConfig(enabled = true, proxy_to_client_enabled = true), - store_forward = ModuleConfig.StoreForwardConfig(enabled = true), - ) - moduleConfigFlow.value = moduleConfig - every { nodeManager.myNodeNum } returns MutableStateFlow(123) - everySuspend { radioController.requestTelemetry(any(), any(), any()) } returns Unit - every { mqttManager.startProxy(any(), any()) } returns Unit - every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit - every { nodeManager.getMyNodeInfo() } returns null - - manager = createManager(backgroundScope) - manager.onNodeDbReady() - advanceUntilIdle() - - verify { mqttManager.startProxy(true, true) } - verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) } - } - - @Test - fun `DeviceSleep timeout is capped at MAX_SLEEP_TIMEOUT_SECONDS for high ls_secs`() = runTest(testDispatcher) { - // Router with ls_secs=3600 — previously this created a 3630s timeout. - // With the cap, it should be clamped to 300s. - val config = - LocalConfig( - power = Config.PowerConfig(is_power_saving = true, ls_secs = 3600), - device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER), - ) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - manager = createManager(backgroundScope) - advanceUntilIdle() - - // Transition to Connected then DeviceSleep - radioConnectionState.value = ConnectionState.Connected - advanceUntilIdle() - radioConnectionState.value = ConnectionState.DeviceSleep - advanceUntilIdle() - - assertEquals( - ConnectionState.DeviceSleep, - serviceRepository.connectionState.value, - "Should be in DeviceSleep initially", - ) - - // Advance 300 seconds (the cap) + 1 second to trigger the timeout. - advanceTimeBy(301_000L) - - assertEquals( - ConnectionState.Disconnected, - serviceRepository.connectionState.value, - "Should transition to Disconnected after capped timeout (300s), not the raw 3630s", - ) - } - - @Test - fun `rapid state transitions are serialized by connectionMutex`() = runTest(testDispatcher) { - // Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected) - val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - // Record every state transition so we can verify ordering - val observed = mutableListOf() - every { serviceRepository.setConnectionState(any()) } calls - { call -> - val state = call.arg(0) - observed.add(state) - connectionStateFlow.value = state - } - - manager = createManager(backgroundScope) - advanceUntilIdle() - - // Rapid-fire: Connected -> DeviceSleep -> Disconnected without yielding between them. - // Without the Mutex, the intermediate DeviceSleep could be missed or applied out of order. - radioConnectionState.value = ConnectionState.Connected - radioConnectionState.value = ConnectionState.DeviceSleep - radioConnectionState.value = ConnectionState.Disconnected - advanceUntilIdle() - - // Verify final state - assertEquals( - ConnectionState.Disconnected, - serviceRepository.connectionState.value, - "Final state should be Disconnected after rapid transitions", - ) - - // Verify that all intermediate states were observed in correct order. - // Connected triggers handleConnected() which sets Connecting (handshake start), - // then DeviceSleep, then Disconnected. - assertEquals( - listOf(ConnectionState.Connecting, ConnectionState.DeviceSleep, ConnectionState.Disconnected), - observed, - "State transitions should be serialized in order: Connecting -> DeviceSleep -> Disconnected", - ) - } - - @Test - fun `concurrent sleep-timeout and radio state change are serialized`() { - val standardDispatcher = StandardTestDispatcher() - runTest(standardDispatcher) { - // Power saving enabled with a short ls_secs so the sleep timeout fires quickly - val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1)) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - val observed = mutableListOf() - every { serviceRepository.setConnectionState(any()) } calls - { call -> - val state = call.arg(0) - observed.add(state) - connectionStateFlow.value = state - } - - manager = createManager(backgroundScope) - advanceUntilIdle() - - // Transition to Connected -> DeviceSleep to start the sleep timer - radioConnectionState.value = ConnectionState.Connected - advanceUntilIdle() - radioConnectionState.value = ConnectionState.DeviceSleep - advanceUntilIdle() - - observed.clear() - - // Before the sleep timeout fires, emit Connected from the radio (simulating device - // waking up). Then let the timeout fire. The mutex ensures they don't race. - radioConnectionState.value = ConnectionState.Connected - // Advance past the sleep timeout (ls_secs=1 + 30s base = 31s) - advanceTimeBy(32_000L) - advanceUntilIdle() - - // The Connected transition should have cancelled the sleep timeout, so we should - // end up in Connecting (from handleConnected), NOT Disconnected (from timeout). - assertEquals( - ConnectionState.Connecting, - serviceRepository.connectionState.value, - "Connected should cancel the sleep timeout; final state should be Connecting", - ) - } - } -} 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 deleted file mode 100644 index 5327449e9..000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt +++ /dev/null @@ -1,706 +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.verifySuspend -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.model.ContactSettings -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.util.MeshDataMapper -import org.meshtastic.core.repository.AdminPacketHandler -import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.MessageFilter -import org.meshtastic.core.repository.NeighborInfoHandler -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NotificationManager -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 -import org.meshtastic.core.repository.TracerouteHandler -import org.meshtastic.proto.ChannelSet -import org.meshtastic.proto.Data -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo -import org.meshtastic.proto.Paxcount -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.Position -import org.meshtastic.proto.Routing -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.User -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertNotNull - -@OptIn(ExperimentalCoroutinesApi::class) -class MeshDataHandlerTest { - - private lateinit var handler: MeshDataHandlerImpl - private val nodeManager: NodeManager = mock(MockMode.autofill) - 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 analytics: PlatformAnalytics = mock(MockMode.autofill) - private val dataMapper: MeshDataMapper = mock(MockMode.autofill) - private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill) - private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill) - private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) - private val messageFilter: MessageFilter = mock(MockMode.autofill) - private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill) - private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill) - private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill) - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - @BeforeTest - fun setUp() { - handler = - MeshDataHandlerImpl( - nodeManager = nodeManager, - packetHandler = packetHandler, - serviceRepository = serviceRepository, - packetRepository = lazy { packetRepository }, - serviceBroadcasts = serviceBroadcasts, - notificationManager = notificationManager, - serviceNotifications = serviceNotifications, - analytics = analytics, - dataMapper = dataMapper, - tracerouteHandler = tracerouteHandler, - neighborInfoHandler = neighborInfoHandler, - radioConfigRepository = radioConfigRepository, - messageFilter = messageFilter, - storeForwardHandler = storeForwardHandler, - telemetryHandler = telemetryHandler, - adminPacketHandler = adminPacketHandler, - scope = testScope, - ) - - // Default: mapper returns null for empty packets, which is the safe default - every { dataMapper.toDataPacket(any()) } returns null - // Stub commonly accessed properties to avoid NPE from autofill - every { nodeManager.nodeDBbyID } returns emptyMap() - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet()) - } - - @Test - fun testInitialization() { - assertNotNull(handler) - } - - @Test - fun `handleReceivedData returns early when dataMapper returns null`() { - val packet = MeshPacket() - every { dataMapper.toDataPacket(packet) } returns null - - handler.handleReceivedData(packet, 123) - - // Should not broadcast if dataMapper returns null - verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) } - } - - @Test - fun `handleReceivedData does not broadcast for position from local node`() { - val myNodeNum = 123 - val position = Position(latitude_i = 450000000, longitude_i = 900000000) - val packet = - MeshPacket( - from = myNodeNum, - decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()), - ) - val dataPacket = - DataPacket( - from = DataPacket.nodeNumToDefaultId(myNodeNum), - to = DataPacket.ID_BROADCAST, - bytes = position.encode().toByteString(), - dataType = PortNum.POSITION_APP.value, - time = 1000L, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, myNodeNum) - - // Position from local node: shouldBroadcast stays as !fromUs = false - verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) } - } - - @Test - fun `handleReceivedData broadcasts for remote packets`() { - val myNodeNum = 123 - val remoteNum = 456 - val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP)) - val dataPacket = - DataPacket( - from = DataPacket.nodeNumToDefaultId(remoteNum), - to = DataPacket.ID_BROADCAST, - bytes = null, - dataType = PortNum.PRIVATE_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, myNodeNum) - - verify { serviceBroadcasts.broadcastReceivedData(any()) } - } - - @Test - fun `handleReceivedData tracks analytics`() { - val packet = MeshPacket(from = 456, decoded = Data(portnum = PortNum.PRIVATE_APP)) - val dataPacket = - DataPacket( - from = "!other", - to = DataPacket.ID_BROADCAST, - bytes = null, - dataType = PortNum.PRIVATE_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, 123) - - verify { analytics.track("num_data_receive", any()) } - } - - // --- Position handling --- - - @Test - fun `position packet delegates to nodeManager`() { - val myNodeNum = 123 - val remoteNum = 456 - val position = Position(latitude_i = 450000000, longitude_i = 900000000) - val packet = - MeshPacket( - from = remoteNum, - decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()), - ) - val dataPacket = - DataPacket( - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = position.encode().toByteString(), - dataType = PortNum.POSITION_APP.value, - time = 1000L, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, myNodeNum) - - verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L) } - } - - // --- NodeInfo handling --- - - @Test - fun `nodeinfo packet from remote delegates to handleReceivedUser`() { - val myNodeNum = 123 - val remoteNum = 456 - val user = User(id = "!remote", long_name = "Remote", short_name = "R") - val packet = - MeshPacket( - from = remoteNum, - decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()), - ) - val dataPacket = - DataPacket( - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = user.encode().toByteString(), - dataType = PortNum.NODEINFO_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, myNodeNum) - - verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any()) } - } - - @Test - fun `nodeinfo packet from local node is ignored`() { - val myNodeNum = 123 - val user = User(id = "!local", long_name = "Local", short_name = "L") - val packet = - MeshPacket( - from = myNodeNum, - decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()), - ) - val dataPacket = - DataPacket( - from = "!local", - to = DataPacket.ID_BROADCAST, - bytes = user.encode().toByteString(), - dataType = PortNum.NODEINFO_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, myNodeNum) - - verify(mode = dev.mokkery.verify.VerifyMode.not) { nodeManager.handleReceivedUser(any(), any(), any(), any()) } - } - - // --- Paxcounter handling --- - - @Test - fun `paxcounter packet delegates to nodeManager`() { - val remoteNum = 456 - val pax = Paxcount(wifi = 10, ble = 5, uptime = 1000) - val packet = - MeshPacket( - from = remoteNum, - decoded = Data(portnum = PortNum.PAXCOUNTER_APP, payload = pax.encode().toByteString()), - ) - val dataPacket = - DataPacket( - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = pax.encode().toByteString(), - dataType = PortNum.PAXCOUNTER_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, 123) - - verify { nodeManager.handleReceivedPaxcounter(remoteNum, any()) } - } - - // --- Traceroute handling --- - - @Test - fun `traceroute packet delegates to tracerouteHandler and suppresses broadcast`() { - val packet = - MeshPacket( - from = 456, - decoded = Data(portnum = PortNum.TRACEROUTE_APP, payload = byteArrayOf().toByteString()), - ) - val dataPacket = - DataPacket( - from = "!remote", - to = "!local", - bytes = byteArrayOf().toByteString(), - dataType = PortNum.TRACEROUTE_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, 123) - - verify { tracerouteHandler.handleTraceroute(packet, any(), any()) } - verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) } - } - - // --- NeighborInfo handling --- - - @Test - fun `neighborinfo packet delegates to neighborInfoHandler and broadcasts`() { - val ni = NeighborInfo(node_id = 456) - val packet = - MeshPacket( - from = 456, - decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, payload = ni.encode().toByteString()), - ) - val dataPacket = - DataPacket( - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = ni.encode().toByteString(), - dataType = PortNum.NEIGHBORINFO_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, 123) - - verify { neighborInfoHandler.handleNeighborInfo(packet) } - verify { serviceBroadcasts.broadcastReceivedData(any()) } - } - - // --- Store-and-Forward handling --- - - @Test - fun `store forward packet delegates to storeForwardHandler`() { - val packet = - MeshPacket( - from = 456, - decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = byteArrayOf().toByteString()), - ) - val dataPacket = - DataPacket( - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = byteArrayOf().toByteString(), - dataType = PortNum.STORE_FORWARD_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, 123) - - verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123) } - } - - // --- Routing/ACK-NAK handling --- - - @Test - fun `routing packet with successful ack broadcasts and removes response`() { - val routing = Routing(error_reason = Routing.Error.NONE) - val packet = - MeshPacket( - from = 456, - decoded = - Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99), - ) - val dataPacket = - DataPacket( - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = routing.encode().toByteString(), - dataType = PortNum.ROUTING_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - every { nodeManager.toNodeID(456) } returns "!remote" - - handler.handleReceivedData(packet, 123) - - verify { packetHandler.removeResponse(99, complete = true) } - } - - @Test - fun `routing packet always broadcasts`() { - val routing = Routing(error_reason = Routing.Error.NONE) - val packet = - MeshPacket( - from = 456, - decoded = - Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99), - ) - val dataPacket = - DataPacket( - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = routing.encode().toByteString(), - dataType = PortNum.ROUTING_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - every { nodeManager.toNodeID(456) } returns "!remote" - - handler.handleReceivedData(packet, 123) - - verify { serviceBroadcasts.broadcastReceivedData(any()) } - } - - // --- Telemetry handling --- - - @Test - fun `telemetry packet delegates to telemetryHandler`() { - val telemetry = - Telemetry( - time = 2000, - device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f), - ) - val packet = - MeshPacket( - from = 456, - decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()), - ) - val dataPacket = - DataPacket( - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = telemetry.encode().toByteString(), - dataType = PortNum.TELEMETRY_APP.value, - time = 2000000L, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, 123) - - verify { telemetryHandler.handleTelemetry(packet, any(), 123) } - } - - @Test - fun `telemetry from local node delegates to telemetryHandler`() { - val myNodeNum = 123 - val telemetry = - Telemetry( - time = 2000, - device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f), - ) - val packet = - MeshPacket( - from = myNodeNum, - decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()), - ) - val dataPacket = - DataPacket( - from = "!local", - to = DataPacket.ID_BROADCAST, - bytes = telemetry.encode().toByteString(), - dataType = PortNum.TELEMETRY_APP.value, - time = 2000000L, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, myNodeNum) - - verify { telemetryHandler.handleTelemetry(packet, any(), myNodeNum) } - } - - // --- Text message handling --- - - @Test - fun `text message is persisted via rememberDataPacket`() = testScope.runTest { - val packet = - MeshPacket( - id = 42, - from = 456, - decoded = - Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()), - ) - val dataPacket = - DataPacket( - id = 42, - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = "hello".encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - everySuspend { packetRepository.findPacketsWithId(42) } returns emptyList() - everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test") - every { messageFilter.shouldFilter(any(), any()) } returns false - // Provide sender node so getSenderName() doesn't fall back to getString (requires Skiko) - every { nodeManager.nodeDBbyID } returns - mapOf( - "!remote" to - Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")), - ) - - handler.handleReceivedData(packet, 123) - advanceUntilIdle() - - verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) } - } - - @Test - fun `duplicate text message is not inserted again`() = testScope.runTest { - val packet = - MeshPacket( - id = 42, - from = 456, - decoded = - Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()), - ) - val dataPacket = - DataPacket( - id = 42, - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = "hello".encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - // Return existing packet on duplicate check - everySuspend { packetRepository.findPacketsWithId(42) } returns listOf(dataPacket) - - handler.handleReceivedData(packet, 123) - advanceUntilIdle() - - verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) { - packetRepository.insert(any(), any(), any(), any(), any(), any()) - } - } - - // --- Reaction handling --- - - @Test - fun `text with reply_id and emoji is treated as reaction`() = testScope.runTest { - val emojiBytes = "👍".encodeToByteArray() - val packet = - MeshPacket( - id = 99, - from = 456, - to = 123, - decoded = - Data( - portnum = PortNum.TEXT_MESSAGE_APP, - payload = emojiBytes.toByteString(), - reply_id = 42, - emoji = 1, - ), - ) - val dataPacket = - DataPacket( - id = 99, - from = "!remote", - to = "!local", - bytes = emojiBytes.toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - every { nodeManager.nodeDBbyNodeNum } returns - mapOf( - 456 to Node(num = 456, user = User(id = "!remote")), - 123 to Node(num = 123, user = User(id = "!local")), - ) - everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList() - every { nodeManager.myNodeNum } returns MutableStateFlow(123) - everySuspend { packetRepository.getPacketByPacketId(42) } returns null - - handler.handleReceivedData(packet, 123) - advanceUntilIdle() - - verifySuspend { packetRepository.insertReaction(any(), 123) } - } - - // --- Range test / detection sensor handling --- - - @Test - fun `range test packet is remembered as text message type`() = testScope.runTest { - val packet = - MeshPacket( - id = 55, - from = 456, - decoded = - Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()), - ) - val dataPacket = - DataPacket( - id = 55, - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = "test".encodeToByteArray().toByteString(), - dataType = PortNum.RANGE_TEST_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - everySuspend { packetRepository.findPacketsWithId(55) } returns emptyList() - everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test") - every { messageFilter.shouldFilter(any(), any()) } returns false - every { nodeManager.nodeDBbyID } returns - mapOf( - "!remote" to - Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")), - ) - - handler.handleReceivedData(packet, 123) - advanceUntilIdle() - - // Range test should be remembered with TEXT_MESSAGE_APP dataType - verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) } - } - - // --- Admin message handling --- - - @Test - fun `admin message delegates to adminPacketHandler`() { - val admin = org.meshtastic.proto.AdminMessage(session_passkey = okio.ByteString.of(1, 2, 3)) - val packet = - MeshPacket(from = 123, decoded = Data(portnum = PortNum.ADMIN_APP, payload = admin.encode().toByteString())) - val dataPacket = - DataPacket( - from = "!local", - to = DataPacket.ID_BROADCAST, - bytes = admin.encode().toByteString(), - dataType = PortNum.ADMIN_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - - handler.handleReceivedData(packet, 123) - - verify { adminPacketHandler.handleAdminMessage(packet, 123) } - } - - // --- Message filtering --- - - @Test - fun `filtered message is inserted with filtered flag`() = testScope.runTest { - val packet = - MeshPacket( - id = 77, - from = 456, - decoded = - Data( - portnum = PortNum.TEXT_MESSAGE_APP, - payload = "spam content".encodeToByteArray().toByteString(), - ), - ) - val dataPacket = - DataPacket( - id = 77, - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = "spam content".encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - everySuspend { packetRepository.findPacketsWithId(77) } returns emptyList() - every { nodeManager.nodeDBbyID } returns emptyMap() - everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test") - every { messageFilter.shouldFilter("spam content", false) } returns true - - handler.handleReceivedData(packet, 123) - advanceUntilIdle() - - // Verify insert was called with filtered = true (6th param) - verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) } - } - - @Test - fun `message from ignored node is filtered`() = testScope.runTest { - val packet = - MeshPacket( - id = 88, - from = 456, - decoded = - Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()), - ) - val dataPacket = - DataPacket( - id = 88, - from = "!remote", - to = DataPacket.ID_BROADCAST, - bytes = "hello".encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ) - every { dataMapper.toDataPacket(packet) } returns dataPacket - everySuspend { packetRepository.findPacketsWithId(88) } returns emptyList() - every { nodeManager.nodeDBbyID } returns - mapOf("!remote" to Node(num = 456, user = User(id = "!remote"), isIgnored = true)) - everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test") - - handler.handleReceivedData(packet, 123) - advanceUntilIdle() - - verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) } - } -} 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..2fd61e67b 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 @@ -25,7 +25,6 @@ import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.HardwareModel @@ -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 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 deleted file mode 100644 index e0bda6075..000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import dev.mokkery.MockMode -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import dev.mokkery.verifySuspend -import io.kotest.property.Arb -import io.kotest.property.arbitrary.int -import io.kotest.property.checkAll -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -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 -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.QueueStatus -import org.meshtastic.proto.ToRadio -import kotlin.test.BeforeTest -import kotlin.test.Test -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) - - private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected) - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private lateinit var handler: PacketHandlerImpl - - @BeforeTest - fun setUp() { - every { serviceRepository.connectionState } returns connectionStateFlow - - handler = - PacketHandlerImpl( - lazy { packetRepository }, - serviceBroadcasts, - radioInterfaceService, - lazy { meshLogRepository }, - serviceRepository, - testScope, - ) - } - - @Test - fun testInitialization() { - assertNotNull(handler) - } - - @Test - fun `sendToRadio with ToRadio sends immediately`() { - val toRadio = ToRadio(packet = MeshPacket(id = 123)) - - handler.sendToRadio(toRadio) - - verify { radioInterfaceService.sendToRadio(any()) } - } - - @Test - fun `sendToRadio with MeshPacket queues and sends when connected`() = runTest(testDispatcher) { - val packet = MeshPacket(id = 456) - connectionStateFlow.value = ConnectionState.Connected - - handler.sendToRadio(packet) - testScheduler.runCurrent() - - verify { radioInterfaceService.sendToRadio(any()) } - } - - @Test - fun `handleQueueStatus completes deferred`() = runTest(testDispatcher) { - val packet = MeshPacket(id = 789) - connectionStateFlow.value = ConnectionState.Connected - - handler.sendToRadio(packet) - testScheduler.runCurrent() - - val status = - QueueStatus( - mesh_packet_id = 789, - res = 0, // Success - free = 1, - ) - - handler.handleQueueStatus(status) - testScheduler.runCurrent() - } - - @Test - fun `handleQueueStatus property test`() = runTest(testDispatcher) { - checkAll(Arb.int(0, 10), Arb.int(0, 32), Arb.int(0, 100000)) { res, free, packetId -> - val status = QueueStatus(res = res, free = free, mesh_packet_id = packetId) - - // Ensure it doesn't crash on any input - handler.handleQueueStatus(status) - testScheduler.runCurrent() - } - } - - @Test - fun `outgoing packets are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) { - val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) - val toRadio = ToRadio(packet = packet) - - handler.sendToRadio(toRadio) - testScheduler.runCurrent() - - verifySuspend { meshLogRepository.insert(any()) } - } -} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index 900245332..c87790d9a 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -36,7 +36,6 @@ 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 +49,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 +67,6 @@ class StoreForwardPacketHandlerImplTest { StoreForwardPacketHandlerImpl( nodeManager = nodeManager, packetRepository = lazy { packetRepository }, - serviceBroadcasts = serviceBroadcasts, historyManager = historyManager, dataHandler = lazy { dataHandler }, scope = testScope, @@ -222,7 +219,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/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 28bf22fdc..71fa60157 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,7 +27,6 @@ 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.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NotificationManager import org.meshtastic.proto.Data @@ -44,7 +43,6 @@ import kotlin.test.Test class TelemetryPacketHandlerImplTest { private val nodeManager = mock(MockMode.autofill) - private val connectionManager = mock(MockMode.autofill) private val notificationManager = mock(MockMode.autofill) private val testDispatcher = StandardTestDispatcher() @@ -60,7 +58,6 @@ class TelemetryPacketHandlerImplTest { handler = TelemetryPacketHandlerImpl( nodeManager = nodeManager, - connectionManager = lazy { connectionManager }, notificationManager = notificationManager, scope = testScope, ) @@ -87,7 +84,7 @@ class TelemetryPacketHandlerImplTest { // ---------- Device metrics from local node ---------- @Test - fun `local device metrics updates telemetry on connectionManager`() = testScope.runTest { + fun `local device metrics updates node`() = testScope.runTest { val telemetry = Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.1f)) val packet = makeTelemetryPacket(myNodeNum, telemetry) @@ -96,14 +93,13 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { connectionManager.updateTelemetry(any()) } verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) } } // ---------- Device metrics from remote node ---------- @Test - fun `remote device metrics updates node but not connectionManager`() = testScope.runTest { + fun `remote device metrics updates node`() = testScope.runTest { val telemetry = Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 90, voltage = 4.2f)) val packet = makeTelemetryPacket(remoteNodeNum, telemetry) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonNodeRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonNodeRepositoryTest.kt deleted file mode 100644 index 05ffae23d..000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonNodeRepositoryTest.kt +++ /dev/null @@ -1,123 +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.repository - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import dev.mokkery.MockMode -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.mock -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.meshtastic.core.data.datasource.NodeInfoReadDataSource -import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.entity.NodeWithRelations -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.MeshLog -import org.meshtastic.core.testing.FakeLocalStatsDataSource -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals - -abstract class CommonNodeRepositoryTest { - - protected lateinit var lifecycleOwner: LifecycleOwner - protected lateinit var readDataSource: NodeInfoReadDataSource - protected lateinit var writeDataSource: NodeInfoWriteDataSource - protected lateinit var localStatsDataSource: FakeLocalStatsDataSource - private val testDispatcher = UnconfinedTestDispatcher() - private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) - - private val myNodeInfoFlow = MutableStateFlow(null) - - protected lateinit var repository: NodeRepositoryImpl - - fun setupRepo() { - Dispatchers.setMain(testDispatcher) - lifecycleOwner = - object : LifecycleOwner { - override val lifecycle = LifecycleRegistry(this) - } - (lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_RESUME) - - readDataSource = mock(MockMode.autofill) - writeDataSource = mock(MockMode.autofill) - localStatsDataSource = FakeLocalStatsDataSource() - - every { readDataSource.myNodeInfoFlow() } returns myNodeInfoFlow - every { readDataSource.nodeDBbyNumFlow() } returns MutableStateFlow>(emptyMap()) - - repository = - NodeRepositoryImpl( - lifecycleOwner.lifecycle, - readDataSource, - writeDataSource, - dispatchers, - localStatsDataSource, - ) - } - - @AfterTest - fun tearDown() { - // Essential to stop background jobs in NodeRepositoryImpl - (lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - Dispatchers.resetMain() - } - - private fun createMyNodeEntity(nodeNum: Int) = MyNodeEntity( - myNodeNum = nodeNum, - model = "model", - firmwareVersion = "1.0", - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 0L, - messageTimeoutMsec = 0, - minAppVersion = 0, - maxChannels = 0, - hasWifi = false, - ) - - @Test - fun `effectiveLogNodeId maps local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) { - val myNodeNum = 12345 - myNodeInfoFlow.value = createMyNodeEntity(myNodeNum) - - val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first() - - assertEquals(MeshLog.NODE_NUM_LOCAL, result) - } - - @Test - fun `effectiveLogNodeId preserves remote node numbers`() = runTest(testDispatcher) { - val myNodeNum = 12345 - val remoteNodeNum = 67890 - myNodeInfoFlow.value = createMyNodeEntity(myNodeNum) - - val result = repository.effectiveLogNodeId(remoteNodeNum).first() - - assertEquals(remoteNodeNum, result) - } -} diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json new file mode 100644 index 000000000..887e36c1b --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json @@ -0,0 +1,1105 @@ +{ + "formatVersion": 1, + "database": { + "version": 39, + "identityHash": "8be391f22cbdff88309ae7230a2a8b10", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "node_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL DEFAULT 0, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + } + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8be391f22cbdff88309ae7230a2a8b10')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index d329d184c..72f4e9209 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -23,11 +23,14 @@ import androidx.room3.DeleteTable import androidx.room3.RoomDatabase import androidx.room3.TypeConverters import androidx.room3.migration.AutoMigrationSpec +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.dao.DeviceHardwareDao import org.meshtastic.core.database.dao.FirmwareReleaseDao import org.meshtastic.core.database.dao.MeshLogDao import org.meshtastic.core.database.dao.NodeInfoDao +import org.meshtastic.core.database.dao.NodeMetadataDao import org.meshtastic.core.database.dao.PacketDao import org.meshtastic.core.database.dao.QuickChatActionDao import org.meshtastic.core.database.dao.TracerouteNodePositionDao @@ -38,6 +41,7 @@ import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.NodeEntity +import org.meshtastic.core.database.entity.NodeMetadataEntity import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.database.entity.ReactionEntity @@ -48,6 +52,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity [ MyNodeEntity::class, NodeEntity::class, + NodeMetadataEntity::class, Packet::class, ContactSettings::class, MeshLog::class, @@ -95,8 +100,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 35, to = 36), AutoMigration(from = 36, to = 37), AutoMigration(from = 37, to = 38), + AutoMigration(from = 38, to = 39, spec = AutoMigration38to39::class), ], - version = 38, + version = 39, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) @@ -105,6 +111,8 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity abstract class MeshtasticDatabase : RoomDatabase() { abstract fun nodeInfoDao(): NodeInfoDao + abstract fun nodeMetadataDao(): NodeMetadataDao + abstract fun packetDao(): PacketDao abstract fun meshLogDao(): MeshLogDao @@ -138,3 +146,17 @@ class AutoMigration33to34 : AutoMigrationSpec @DeleteColumn(tableName = "packet", columnName = "retry_count") @DeleteColumn(tableName = "reactions", columnName = "retry_count") class AutoMigration34to35 : AutoMigrationSpec + +/** Copies favorites, notes, ignored, muted, and manuallyVerified from nodes → node_metadata. */ +class AutoMigration38to39 : AutoMigrationSpec { + override suspend fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL( + """ + INSERT OR IGNORE INTO node_metadata (num, is_favorite, is_ignored, is_muted, notes, manually_verified) + SELECT num, is_favorite, is_ignored, is_muted, notes, manually_verified + FROM nodes + WHERE is_favorite = 1 OR is_ignored = 1 OR is_muted = 1 OR notes != '' OR manually_verified = 1 + """.trimIndent() + ) + } +} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt new file mode 100644 index 000000000..35f1f06e6 --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt @@ -0,0 +1,57 @@ +/* + * 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.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.NodeMetadataEntity + +@Dao +interface NodeMetadataDao { + + @Upsert + suspend fun upsert(metadata: NodeMetadataEntity) + + @Query("SELECT * FROM node_metadata") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM node_metadata WHERE num = :num") + suspend fun getByNum(num: Int): NodeMetadataEntity? + + @Query("UPDATE node_metadata SET is_favorite = :isFavorite WHERE num = :num") + suspend fun setFavorite(num: Int, isFavorite: Boolean) + + @Query("UPDATE node_metadata SET is_ignored = :isIgnored WHERE num = :num") + suspend fun setIgnored(num: Int, isIgnored: Boolean) + + @Query("UPDATE node_metadata SET is_muted = :isMuted WHERE num = :num") + suspend fun setMuted(num: Int, isMuted: Boolean) + + @Query("UPDATE node_metadata SET notes = :notes WHERE num = :num") + suspend fun setNotes(num: Int, notes: String) + + @Query("UPDATE node_metadata SET manually_verified = :verified WHERE num = :num") + suspend fun setManuallyVerified(num: Int, verified: Boolean) + + @Query("DELETE FROM node_metadata WHERE num = :num") + suspend fun delete(num: Int) + + @Query("DELETE FROM node_metadata") + suspend fun deleteAll() +} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeMetadataEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeMetadataEntity.kt new file mode 100644 index 000000000..5ddd5c4de --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeMetadataEntity.kt @@ -0,0 +1,35 @@ +/* + * 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.database.entity + +import androidx.room3.ColumnInfo +import androidx.room3.Entity +import androidx.room3.PrimaryKey + +/** + * Persists app-local node metadata that survives process death. + * These fields are user preferences/annotations that the SDK does not manage. + */ +@Entity(tableName = "node_metadata") +data class NodeMetadataEntity( + @PrimaryKey val num: Int, + @ColumnInfo(name = "is_favorite", defaultValue = "0") val isFavorite: Boolean = false, + @ColumnInfo(name = "is_ignored", defaultValue = "0") val isIgnored: Boolean = false, + @ColumnInfo(name = "is_muted", defaultValue = "0") val isMuted: Boolean = false, + @ColumnInfo(name = "notes", defaultValue = "") val notes: String = "", + @ColumnInfo(name = "manually_verified", defaultValue = "0") val manuallyVerified: Boolean = false, +) 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/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/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt deleted file mode 100644 index 8b8249adf..000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.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.repository - -import org.meshtastic.proto.MeshPacket - -/** Interface for handling admin messages from the mesh (config, metadata, session passkey). */ -interface AdminPacketHandler { - /** - * Processes an admin message packet. - * - * @param packet The received mesh packet. - * @param myNodeNum The local node number. - */ - fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppMetadataRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppMetadataRepository.kt new file mode 100644 index 000000000..e053a2ebc --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppMetadataRepository.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import kotlinx.coroutines.flow.Flow + +/** + * App-local node metadata that persists independently of the SDK's node database. + * + * This covers user annotations (favorites, notes, mute, ignore) that are NOT synced to the radio. + * VMs and feature modules inject this instead of the full [NodeRepository] when they only need + * metadata operations. + */ +interface AppMetadataRepository { + + /** Flow of all node metadata, keyed by node number. */ + val metadataByNum: Flow> + + suspend fun setFavorite(nodeNum: Int, isFavorite: Boolean) + suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean) + suspend fun setMuted(nodeNum: Int, isMuted: Boolean) + suspend fun setNotes(nodeNum: Int, notes: String) + suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean) + suspend fun delete(nodeNum: Int) +} + +/** Lightweight metadata value object exposed to feature modules. */ +data class NodeMetadata( + val num: Int, + val isFavorite: Boolean = false, + val isIgnored: Boolean = false, + val isMuted: Boolean = false, + val notes: String? = null, + val manuallyVerified: Boolean = false, +) 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 deleted file mode 100644 index a6b58bb48..000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt +++ /dev/null @@ -1,86 +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.Position -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.ChannelSet -import org.meshtastic.proto.LocalConfig - -/** Interface for sending commands and packets to the mesh network. */ -@Suppress("TooManyFunctions") -interface CommandSender { - /** Returns the current packet ID. */ - fun getCurrentPacketId(): Long - - /** Returns the cached local configuration. */ - fun getCachedLocalConfig(): LocalConfig - - /** Returns the cached channel set. */ - fun getCachedChannelSet(): ChannelSet - - /** Generates a new unique packet ID. */ - fun generatePacketId(): Int - - /** Sends a data packet to the mesh. */ - fun sendData(p: DataPacket) - - /** Sends an admin message to a specific node. */ - fun sendAdmin( - destNum: Int, - requestId: Int = generatePacketId(), - wantResponse: Boolean = false, - initFn: () -> AdminMessage, - ) - - /** - * Sends an admin message and suspends until the radio acknowledges it. - * - * This is used when the caller needs to guarantee a packet has been accepted by the radio before proceeding, such - * as sending a shared contact before the first DM to a node. - * - * @return `true` if the radio accepted the packet, `false` on timeout or failure. - */ - suspend fun sendAdminAwait( - destNum: Int, - requestId: Int = generatePacketId(), - wantResponse: Boolean = false, - initFn: () -> AdminMessage, - ): Boolean - - /** Sends our current position to the mesh. */ - 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) - - /** Sets a fixed position for a node. */ - fun setFixedPosition(destNum: Int, pos: Position) - - /** Requests user info from a specific node. */ - fun requestUserInfo(destNum: Int) - - /** Requests a traceroute to a specific node. */ - fun requestTraceroute(requestId: Int, destNum: Int) - - /** Requests telemetry from a specific node. */ - fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) - - /** Requests neighbor info from a specific node. */ - fun requestNeighborInfo(requestId: Int, destNum: Int) -} 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/MeshConfigFlowManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt deleted file mode 100644 index bd2f8c612..000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt +++ /dev/null @@ -1,51 +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.proto.DeviceMetadata -import org.meshtastic.proto.FileInfo -import org.meshtastic.proto.MyNodeInfo -import org.meshtastic.proto.NodeInfo - -/** Interface for managing the configuration flow, including local node info and metadata. */ -interface MeshConfigFlowManager { - /** Handles received local node information. */ - fun handleMyInfo(myInfo: MyNodeInfo) - - /** Handles received local device metadata. */ - fun handleLocalMetadata(metadata: DeviceMetadata) - - /** Handles received node information. */ - fun handleNodeInfo(info: NodeInfo) - - /** - * Handles a [FileInfo] packet received during STATE_SEND_FILEMANIFEST. - * - * Each packet describes one file available on the device. Accumulated into [RadioConfigRepository.fileManifestFlow] - * and cleared at the start of each new handshake. - */ - fun handleFileInfo(info: FileInfo) - - /** Returns the number of nodes received in the current stage. */ - val newNodeCount: Int - - /** Handles the completion of a configuration stage. */ - fun handleConfigComplete(configCompleteId: Int) - - /** Triggers a request for the full device configuration. */ - fun triggerWantConfig() -} 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 deleted file mode 100644 index 9d898a333..000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt +++ /dev/null @@ -1,40 +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.proto.Telemetry - -/** Interface for managing the connection lifecycle and status with the mesh radio. */ -interface MeshConnectionManager { - /** Called when the radio configuration has been fully loaded. */ - fun onRadioConfigLoaded() - - /** Initiates the configuration synchronization stage. */ - fun startConfigOnly() - - /** Initiates the node information synchronization stage. */ - fun startNodeInfoOnly() - - /** Called when the node database is ready and fully populated. */ - fun onNodeDbReady() - - /** Updates the telemetry information for the local node. */ - fun updateTelemetry(t: Telemetry) - - /** Updates the current status notification. */ - fun updateStatusNotification(telemetry: Telemetry? = null) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt deleted file mode 100644 index 490f50725..000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.repository - -/** Interface for the central router that orchestrates specialized mesh packet handlers. */ -interface MeshRouter { - /** Access to the data handler. */ - val dataHandler: MeshDataHandler - - /** Access to the configuration handler. */ - val configHandler: MeshConfigHandler - - /** Access to the traceroute handler. */ - val tracerouteHandler: TracerouteHandler - - /** Access to the neighbor info handler. */ - val neighborInfoHandler: NeighborInfoHandler - - /** Access to the configuration flow manager. */ - val configFlowManager: MeshConfigFlowManager - - /** Access to the MQTT manager. */ - val mqttManager: MqttManager - - /** Access to the action handler. */ - val actionHandler: MeshActionHandler - - /** Access to the XModem file-transfer manager. */ - val xmodemManager: XModemManager -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/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/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/Constants.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt index 425b19fe2..b5648d7d3 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt @@ -24,7 +24,6 @@ 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 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 307afbc7f..3a401a0c3 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 @@ -30,10 +30,10 @@ import org.koin.android.ext.android.inject import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceVersion -import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.SERVICE_NOTIFY_ID +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.PortNum /** @@ -47,10 +47,10 @@ class MeshService : Service() { private val radioInterfaceService: RadioInterfaceService by inject() - private val connectionManager: MeshConnectionManager by inject() - private val notifications: MeshServiceNotifications by inject() + private val serviceRepository: ServiceRepository by inject() + /** Android-typed accessor for the foreground service notification. */ private val androidNotifications: MeshServiceNotificationsImpl get() = notifications as MeshServiceNotificationsImpl @@ -112,7 +112,7 @@ class MeshService : Service() { val a = radioInterfaceService.getDeviceAddress() val wantForeground = a != null && a != "n" - connectionManager.updateStatusNotification() + notifications.updateServiceStateNotification(serviceRepository.connectionState.value, null) val notification = androidNotifications.getServiceNotification() val foregroundServiceType = 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/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt deleted file mode 100644 index a4c95d8cd..000000000 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.service - -import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.Channel -import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.SharedContact -import org.meshtastic.proto.User - -/** - * Platform-agnostic [RadioController] implementation that delegates directly to service-layer handlers. - * - * Unlike [AndroidRadioControllerImpl], which routes every call through the AIDL [IMeshService] binder, this - * implementation talks directly to [CommandSender], [MeshRouter.actionHandler], [ServiceRepository], and [NodeManager]. - * This is the correct implementation for any target where the service runs in-process (Desktop, iOS, or Android in - * single-process mode). - * - * This eliminates the need for [NoopRadioController] on non-Android targets. - */ -@Suppress("TooManyFunctions", "LongParameterList") -class DirectRadioControllerImpl( - private val serviceRepository: ServiceRepository, - private val nodeRepository: NodeRepository, - private val commandSender: CommandSender, - private val router: MeshRouter, - private val nodeManager: NodeManager, - private val radioInterfaceService: RadioInterfaceService, - private val locationManager: MeshLocationManager, -) : RadioController { - - private val actionHandler - get() = router.actionHandler - - private val myNodeNum: Int - get() = nodeManager.myNodeNum.value ?: 0 - - /** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */ - override val connectionState: StateFlow - get() = serviceRepository.connectionState - - override val clientNotification: StateFlow - get() = serviceRepository.clientNotification - - override suspend fun sendMessage(packet: DataPacket) { - actionHandler.handleSend(packet, myNodeNum) - } - - override fun clearClientNotification() { - serviceRepository.clearClientNotification() - } - - override suspend fun favoriteNode(nodeNum: Int) { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef)) - } - - override suspend fun sendSharedContact(nodeNum: Int): Boolean { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - val contact = - SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified) - val action = ServiceAction.SendContact(contact) - serviceRepository.onServiceAction(action) - return action.result.await() - } - - override suspend fun setLocalConfig(config: Config) { - actionHandler.handleSetConfig(config.encode(), myNodeNum) - } - - override suspend fun setLocalChannel(channel: Channel) { - actionHandler.handleSetChannel(channel.encode(), myNodeNum) - } - - override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { - actionHandler.handleSetRemoteOwner(packetId, destNum, user.encode()) - } - - override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { - actionHandler.handleSetRemoteConfig(packetId, destNum, config.encode()) - } - - override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { - actionHandler.handleSetModuleConfig(packetId, destNum, config.encode()) - } - - override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { - actionHandler.handleSetRemoteChannel(packetId, destNum, channel.encode()) - } - - override suspend fun setFixedPosition(destNum: Int, position: Position) { - commandSender.setFixedPosition(destNum, position) - } - - override suspend fun setRingtone(destNum: Int, ringtone: String) { - actionHandler.handleSetRingtone(destNum, ringtone) - } - - override suspend fun setCannedMessages(destNum: Int, messages: String) { - actionHandler.handleSetCannedMessages(destNum, messages) - } - - override suspend fun getOwner(destNum: Int, packetId: Int) { - actionHandler.handleGetRemoteOwner(packetId, destNum) - } - - override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { - actionHandler.handleGetRemoteConfig(packetId, destNum, configType) - } - - override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) { - actionHandler.handleGetModuleConfig(packetId, destNum, moduleConfigType) - } - - override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) { - actionHandler.handleGetRemoteChannel(packetId, destNum, index) - } - - override suspend fun getRingtone(destNum: Int, packetId: Int) { - actionHandler.handleGetRingtone(packetId, destNum) - } - - override suspend fun getCannedMessages(destNum: Int, packetId: Int) { - actionHandler.handleGetCannedMessages(packetId, destNum) - } - - override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { - actionHandler.handleGetDeviceConnectionStatus(packetId, destNum) - } - - override suspend fun reboot(destNum: Int, packetId: Int) { - actionHandler.handleRequestReboot(packetId, destNum) - } - - override suspend fun rebootToDfu(nodeNum: Int) { - actionHandler.handleRebootToDfu(nodeNum) - } - - override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { - actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) - } - - override suspend fun shutdown(destNum: Int, packetId: Int) { - actionHandler.handleRequestShutdown(packetId, destNum) - } - - override suspend fun factoryReset(destNum: Int, packetId: Int) { - actionHandler.handleRequestFactoryReset(packetId, destNum) - } - - override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) { - actionHandler.handleRequestNodedbReset(packetId, destNum, preserveFavorites) - } - - override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) { - val myNode = nodeManager.myNodeNum.value - if (myNode != null) { - actionHandler.handleRemoveByNodenum(nodeNum, packetId, myNode) - } else { - nodeManager.removeByNodenum(nodeNum) - } - } - - override suspend fun requestPosition(destNum: Int, currentPosition: Position) { - actionHandler.handleRequestPosition(destNum, currentPosition, myNodeNum) - } - - override suspend fun requestUserInfo(destNum: Int) { - if (destNum != myNodeNum) { - commandSender.requestUserInfo(destNum) - } - } - - override suspend fun requestTraceroute(requestId: Int, destNum: Int) { - commandSender.requestTraceroute(requestId, destNum) - } - - override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { - actionHandler.handleRequestTelemetry(requestId, destNum, typeValue) - } - - override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) { - actionHandler.handleRequestNeighborInfo(requestId, destNum) - } - - override suspend fun beginEditSettings(destNum: Int) { - actionHandler.handleBeginEditSettings(destNum) - } - - override suspend fun commitEditSettings(destNum: Int) { - actionHandler.handleCommitEditSettings(destNum) - } - - override fun getPacketId(): Int = commandSender.generatePacketId() - - override fun startProvideLocation() { - // Location provision requires a scope — typically managed by the orchestrator. - // On platforms without GPS hardware (desktop), this is a no-op via the injected locationManager. - } - - override fun stopProvideLocation() { - locationManager.stop() - } - - override fun setDeviceAddress(address: String) { - actionHandler.handleUpdateLastAddress(address) - radioInterfaceService.setDeviceAddress(address) - } -} diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index bc46f452c..a6644e444 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 @@ -27,10 +27,11 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServerManager @@ -53,7 +54,8 @@ class MeshServiceOrchestrator( private val takMeshIntegration: TAKMeshIntegration, private val takPrefs: TakPrefs, private val databaseManager: DatabaseManager, - private val connectionManager: MeshConnectionManager, + private val serviceRepository: ServiceRepository, + private val appWidgetUpdater: AppWidgetUpdater, private val dispatchers: CoroutineDispatchers, ) { // Per-start coroutine scope. A fresh scope is created on each start() and cancelled on stop(), so all collectors @@ -86,7 +88,21 @@ class MeshServiceOrchestrator( scope = newScope serviceNotifications.initChannels() - connectionManager.updateStatusNotification() + serviceNotifications.updateServiceStateNotification(serviceRepository.connectionState.value, null) + + // Keep notification in sync with connection state changes + serviceRepository.connectionState + .onEach { state -> serviceNotifications.updateServiceStateNotification(state, null) } + .launchIn(newScope) + + // Kickstart app widget + newScope.handledLaunch { + try { + appWidgetUpdater.updateAll() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "Failed to kickstart LocalStatsWidget" } + } + } // Observe TAK server pref to start/stop takPrefs.isTakServerEnabled @@ -107,10 +123,6 @@ class MeshServiceOrchestrator( Logger.i { "Per-device database initialized" } } - // NOTE: Radio connection, packet routing, and ServiceAction dispatch are now handled - // by RadioClientProvider + SdkStateBridge. The old radioInterfaceService.connect() / - // receivedData / serviceAction subscription paths are no longer needed. - nodeManager.loadCachedNodeDB() } diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index ec30d024c..baeaf6a4f 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 @@ -30,9 +30,10 @@ 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.ConnectionState import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.MeshConfigHandler -import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository @@ -55,7 +56,7 @@ class MeshServiceOrchestratorTest { private val takServerManager: TAKServerManager = mock(MockMode.autofill) private val takPrefs: TakPrefs = mock(MockMode.autofill) private val databaseManager: DatabaseManager = mock(MockMode.autofill) - private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) + private val appWidgetUpdater: AppWidgetUpdater = mock(MockMode.autofill) // TAKMeshIntegration deps (final class — constructed directly) private val radioController: RadioController = mock(MockMode.autofill) @@ -80,6 +81,7 @@ class MeshServiceOrchestratorTest { every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig()) every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() + every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) val takMeshIntegration = TAKMeshIntegration( takServerManager = takServerManager, @@ -98,7 +100,8 @@ class MeshServiceOrchestratorTest { takMeshIntegration = takMeshIntegration, takPrefs = takPrefs, databaseManager = databaseManager, - connectionManager = connectionManager, + serviceRepository = serviceRepository, + appWidgetUpdater = appWidgetUpdater, dispatchers = dispatchers, ) } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 4fa09179f..e3d4dd089 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -99,6 +99,7 @@ kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) freeCompilerArgs.add("-jvm-default=no-compatibility") + freeCompilerArgs.add("-Xskip-prerelease-check") } } @@ -262,6 +263,12 @@ dependencies { implementation(projects.core.proto) implementation(projects.core.ble) + // Meshtastic SDK (composite build — TCP, Serial transports + storage) + implementation(libs.sdk.core) + implementation(libs.sdk.transport.tcp) + implementation(libs.sdk.transport.serial) + implementation(libs.sdk.storage.sqldelight) + // Feature modules (JVM variants for real composable wiring) implementation(projects.feature.settings) implementation(projects.feature.node) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 59f468f82..e3f83bea1 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -39,7 +39,7 @@ import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases -import org.meshtastic.core.model.RadioController +import org.meshtastic.core.data.radio.RadioClientAccessor import org.meshtastic.core.network.HttpClientDefaults import org.meshtastic.core.network.KermitHttpLogger import org.meshtastic.core.network.repository.MQTTRepository @@ -54,9 +54,8 @@ 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.SdkClientLifecycle import org.meshtastic.core.service.ServiceRepositoryImpl import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.desktop.DesktopNotificationManager @@ -67,6 +66,7 @@ import org.meshtastic.desktop.notification.MacOSNotificationSender import org.meshtastic.desktop.notification.NativeNotificationSender import org.meshtastic.desktop.notification.WindowsNotificationSender import org.meshtastic.desktop.radio.DesktopMessageQueue +import org.meshtastic.desktop.radio.DesktopRadioClientProvider import org.meshtastic.desktop.radio.DesktopRadioTransportFactory import org.meshtastic.desktop.stub.NoopAppWidgetUpdater import org.meshtastic.desktop.stub.NoopCompassHeadingProvider @@ -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.node.compass.CompassHeadingProvider import org.meshtastic.feature.node.compass.MagneticFieldProvider import org.meshtastic.feature.node.compass.PhoneLocationProvider @@ -159,17 +158,10 @@ private fun desktopPlatformStubsModule() = module { connectionFactory = get(), ) } - single { - DirectRadioControllerImpl( - serviceRepository = get(), - nodeRepository = get(), - commandSender = get(), - router = get(), - nodeManager = get(), - radioInterfaceService = get(), - locationManager = get(), - ) - } + // SDK-backed RadioClient lifecycle — replaces DirectRadioControllerImpl + single { DesktopRadioClientProvider(radioPrefs = get()) } + single { get() } + single { get() } single { when (DesktopOS.current()) { DesktopOS.Linux -> LinuxNotificationSender() @@ -181,7 +173,6 @@ private fun desktopPlatformStubsModule() = module { single { get() } single { DesktopMeshServiceNotifications(notificationManager = get()) } single { NoopPlatformAnalytics() } - single { NoopServiceBroadcasts() } single { NoopAppWidgetUpdater() } single { NoopMeshWorkerManager() } single { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt index 3888b0af3..a7e58f265 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt @@ -46,9 +46,7 @@ class DesktopMessageQueue( // Verify we are connected before attempting to send to avoid unnecessary Exception bubbling if (radioController.connectionState.value != ConnectionState.Connected) { - // In a real desktop environment, we might want a background loop to retry queued messages. - // For now, it will retry when connection is re-established (handled by - // MeshConnectionManager.onRadioConfigLoaded). + // Queued messages will be retried when connection is re-established. return@launch } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioClientProvider.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioClientProvider.kt new file mode 100644 index 000000000..0b9027547 --- /dev/null +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioClientProvider.kt @@ -0,0 +1,158 @@ +/* + * 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.desktop.radio + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.meshtastic.core.data.radio.RadioClientAccessor +import org.meshtastic.core.model.InterfaceId +import org.meshtastic.core.repository.RadioPrefs +import org.meshtastic.core.service.SdkClientLifecycle +import org.meshtastic.sdk.AutoReconnectConfig +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.RadioTransport +import org.meshtastic.sdk.storage.sqldelight.SqlDelightStorageProvider +import org.meshtastic.sdk.transport.serial.JvmSerialPorts +import org.meshtastic.sdk.transport.tcp.TcpTransport + +/** + * Desktop (JVM) implementation of [RadioClientAccessor]. + * + * Supports BLE (Kable JVM — macOS/Windows/Linux), TCP, and Serial (jSerialComm) transports. + * Storage uses file-system backed SqlDelightStorageProvider with a platform-appropriate data dir. + * + * Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid + * double-registration with the @ComponentScan in DesktopDiModule. + */ +class DesktopRadioClientProvider( + private val radioPrefs: RadioPrefs, +) : RadioClientAccessor, SdkClientLifecycle { + + private val _client = MutableStateFlow(null) + override val client: StateFlow = _client.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val mutex = Mutex() + + /** + * Tear down the existing client (if any) and build + connect a new one using the current + * saved radio address from [RadioPrefs]. + * + * Supports BLE (`x` prefix), TCP (`t` prefix, format `tHOST:PORT`), and Serial (`s` prefix). + */ + suspend fun rebuildAndConnect() = mutex.withLock { + val rawAddress = radioPrefs.devAddr.value + ?: run { + Logger.w { "DesktopRadioClientProvider: no saved device address — skipping connect" } + return@withLock + } + + val interfaceChar = rawAddress.firstOrNull() ?: run { + Logger.w { "DesktopRadioClientProvider: empty address — skipping connect" } + return@withLock + } + val addressPayload = rawAddress.substring(1) + + val transport: RadioTransport = when (InterfaceId.forIdChar(interfaceChar)) { + InterfaceId.BLUETOOTH -> { + // BLE on Desktop requires a Kable Peripheral (obtained via Scanner in the connections UI). + // Direct MAC-address construction is Android-only. Desktop BLE is handled by the + // connections feature via DesktopRadioTransportFactory; skip SDK client for BLE for now. + Logger.w { "DesktopRadioClientProvider: BLE not yet supported via SDK — use connections UI" } + return@withLock + } + + InterfaceId.TCP -> { + val (host, port) = parseTcpAddress(addressPayload) + Logger.i { "DesktopRadioClientProvider: building TCP transport for $host:$port" } + TcpTransport(host, port) + } + + InterfaceId.SERIAL -> { + Logger.i { "DesktopRadioClientProvider: building Serial transport for $addressPayload" } + JvmSerialPorts.open(addressPayload) + } + + InterfaceId.MOCK, InterfaceId.NOP, null -> { + Logger.w { "DesktopRadioClientProvider: unsupported transport '$interfaceChar' ($rawAddress)" } + return@withLock + } + } + + val old = _client.value + _client.value = null + old?.let { runCatching { it.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect old" } } } + + val newClient = RadioClient.Builder() + .transport(transport) + .storage(SqlDelightStorageProvider(baseDir = storageDir())) + .autoReconnect(AutoReconnectConfig()) + .build() + + _client.value = newClient + newClient.connect() + + Logger.i { "DesktopRadioClientProvider: connected via ${InterfaceId.forIdChar(interfaceChar)}" } + } + + override fun rebuildAndConnectAsync() { + scope.launch { + runCatching { rebuildAndConnect() } + .onFailure { e -> Logger.e(e) { "DesktopRadioClientProvider: connect failed" } } + } + } + + override fun disconnect() { + scope.launch { + mutex.withLock { + val c = _client.value ?: return@withLock + _client.value = null + runCatching { c.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect" } } + } + } + } + + companion object { + private const val DEFAULT_TCP_PORT = 4403 + + private fun parseTcpAddress(payload: String): Pair { + val parts = payload.split(":") + val host = parts[0] + val port = parts.getOrNull(1)?.toIntOrNull() ?: DEFAULT_TCP_PORT + return host to port + } + + /** Platform-appropriate storage directory for SDK state (channels, nodeDB, etc.). */ + private fun storageDir(): String { + val os = System.getProperty("os.name", "").lowercase() + val home = System.getProperty("user.home", ".") + return when { + os.contains("mac") -> "$home/Library/Application Support/Meshtastic/sdk" + os.contains("win") -> "${System.getenv("APPDATA") ?: home}/Meshtastic/sdk" + else -> "${System.getenv("XDG_DATA_HOME") ?: "$home/.local/share"}/meshtastic/sdk" + } + } + } +} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index 081735e25..dbfc5b477 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/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() {} } 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 be5ca8c79..7dd0387ad 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 @@ -29,16 +29,11 @@ import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.BuildConfigProvider import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.database.DatabaseConstants 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 @@ -64,13 +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, @@ -123,7 +112,7 @@ class SettingsViewModel( val dbCacheLimit: StateFlow = databaseManager.cacheLimit fun setDbCacheLimit(limit: Int) { - setDatabaseCacheLimitUseCase(limit) + databaseManager.setCacheLimit(limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)) } // Notifications @@ -131,11 +120,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) @@ -155,20 +144,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 fd923a133..f9258deb1 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 @@ -44,8 +44,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 @@ -121,8 +119,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, @@ -137,13 +133,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 95e02f05b..10433e118 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 @@ -39,13 +39,7 @@ import org.meshtastic.core.common.BuildConfigProvider 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.repository.FileService import org.meshtastic.core.repository.RadioConfigRepository @@ -95,13 +89,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) @@ -115,13 +103,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, 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 c1b7d8a9e..affa6edf3 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 @@ -44,8 +44,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.Node import org.meshtastic.core.repository.AnalyticsPrefs import org.meshtastic.core.repository.FileService @@ -89,8 +87,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) @@ -146,8 +142,6 @@ class RadioConfigViewModelTest { mapConsentPrefs = mapConsentPrefs, analyticsPrefs = analyticsPrefs, homoglyphEncodingPrefs = homoglyphEncodingPrefs, - toggleAnalyticsUseCase = toggleAnalyticsUseCase, - toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase, importProfileUseCase = importProfileUseCase, exportProfileUseCase = exportProfileUseCase, exportSecurityConfigUseCase = exportSecurityConfigUseCase, @@ -181,21 +175,21 @@ class RadioConfigViewModelTest { } @Test - fun `toggleAnalyticsAllowed calls useCase`() { - every { toggleAnalyticsUseCase() } returns Unit + fun `toggleAnalyticsAllowed updates prefs`() { + every { analyticsPrefs.setAnalyticsAllowed(any()) } returns Unit viewModel.toggleAnalyticsAllowed() - verify { toggleAnalyticsUseCase() } + verify { analyticsPrefs.setAnalyticsAllowed(true) } } @Test - fun `toggleHomoglyphCharactersEncodingEnabled calls useCase`() { - every { toggleHomoglyphEncodingUseCase() } returns Unit + fun `toggleHomoglyphCharactersEncodingEnabled updates prefs`() { + every { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(any()) } returns Unit viewModel.toggleHomoglyphCharactersEncodingEnabled() - verify { toggleHomoglyphEncodingUseCase() } + verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(true) } } @Test