From 296f27dc73cf8921bfd28e028876c7d0ab675c22 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 11:44:40 -0500 Subject: [PATCH] refactor: merge NodeManager into NodeRepository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate the vestigial NodeManager/NodeRepository interface split. All runtime node state management methods (handleReceivedUser, handleReceivedPosition, handleReceivedTelemetry, updateNode, etc.) now live directly on NodeRepository alongside the query surface. - Delete NodeManager.kt (82 LOC) - Extend NodeRepository with NodeIdLookup and add all manager methods - Update 8 consumers to inject NodeRepository instead of NodeManager - Remove dead nodeManager param from MeshServiceOrchestrator - Add NodeManager methods to FakeNodeRepository test double - Update all tests (mocks, constructor params, verifications) - SdkNodeRepositoryImpl now binds [NodeRepository, NodeIdLookup] Build: assembleDebug ✅, desktop:compileKotlin ✅, all jvmTests ✅ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/manager/MessagePersistenceHandler.kt | 14 ++-- .../data/manager/NeighborInfoHandlerImpl.kt | 8 +- .../manager/StoreForwardPacketHandlerImpl.kt | 8 +- .../manager/TelemetryPacketHandlerImpl.kt | 6 +- .../core/data/radio/SdkRadioController.kt | 2 +- .../core/data/radio/SdkStateBridge.kt | 30 +++---- .../data/repository/SdkNodeRepositoryImpl.kt | 9 +- .../StoreForwardPacketHandlerImplTest.kt | 8 +- .../manager/TelemetryPacketHandlerImplTest.kt | 16 ++-- .../meshtastic/core/repository/NodeManager.kt | 82 ------------------- .../core/repository/NodeRepository.kt | 63 +++++++++++++- .../core/service/MeshServiceOrchestrator.kt | 2 - .../service/MeshServiceOrchestratorTest.kt | 3 - .../core/testing/FakeNodeRepository.kt | 71 ++++++++++++++++ .../feature/widget/RefreshLocalStatsAction.kt | 6 +- 15 files changed, 184 insertions(+), 144 deletions(-) delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt 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 index 85d576004..22793bf0e 100644 --- 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 @@ -28,7 +28,7 @@ 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.NodeRepository import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PacketRepository @@ -49,7 +49,7 @@ import org.meshtastic.proto.PortNum */ @Single class MessagePersistenceHandler( - private val nodeManager: NodeManager, + private val nodeRepository: NodeRepository, private val packetRepository: Lazy, private val notificationManager: NotificationManager, private val serviceNotifications: MeshServiceNotifications, @@ -116,7 +116,7 @@ class MessagePersistenceHandler( @Suppress("ReturnCount") private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean { - val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true + val isIgnored = nodeRepository.nodeDBbyID[dataPacket.from]?.isIgnored == true if (isIgnored) return true if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false @@ -130,7 +130,7 @@ class MessagePersistenceHandler( updateNotification: Boolean, ) { val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted - val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true + val nodeMuted = nodeRepository.nodeDBbyID[dataPacket.from]?.isMuted == true val isSilent = conversationMuted || nodeMuted if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) { scope.launch { @@ -150,10 +150,10 @@ class MessagePersistenceHandler( 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) + val myId = nodeRepository.getMyId() + return nodeRepository.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) } - return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) + return nodeRepository.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) } private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) { 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 51221f9ef..91c68b1aa 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 @@ -24,7 +24,6 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.repository.NeighborInfoHandler -import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.MeshPacket @@ -32,9 +31,8 @@ import org.meshtastic.proto.NeighborInfo @Single class NeighborInfoHandlerImpl( - private val nodeManager: NodeManager, - private val serviceRepository: ServiceRepository, private val nodeRepository: NodeRepository, + private val serviceRepository: ServiceRepository, ) : NeighborInfoHandler { private val startTimes = atomic(persistentMapOf()) @@ -51,13 +49,13 @@ class NeighborInfoHandlerImpl( // Store the last neighbor info from our connected radio val from = packet.from - if (from == nodeManager.myNodeNum.value) { + if (from == nodeRepository.myNodeNum.value) { lastNeighborInfo = ni Logger.d { "Stored last neighbor info from connected radio" } } // Update Node DB - nodeManager.nodeDBbyNodeNum[from]?.let { /* SDK client.nodes is canonical source */ } + nodeRepository.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/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index cd2a31a57..6de3fee62 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 @@ -28,7 +28,7 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.util.SfppHasher import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.proto.MeshPacket @@ -40,7 +40,7 @@ import kotlin.time.Duration.Companion.milliseconds /** Implementation of [StoreForwardPacketHandler] that handles both legacy S&F and SF++ packets. */ @Single class StoreForwardPacketHandlerImpl( - private val nodeManager: NodeManager, + private val nodeRepository: NodeRepository, private val packetRepository: Lazy, private val historyManager: HistoryManager, private val dataHandler: Lazy, @@ -111,7 +111,7 @@ class StoreForwardPacketHandlerImpl( Logger.d { "SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " + - "to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum.value} status=$status" + "to=${sfpp.encapsulated_to} myNodeNum=${nodeRepository.myNodeNum.value} status=$status" } scope.handledLaunch { packetRepository.value.updateSFPPStatus( @@ -121,7 +121,7 @@ class StoreForwardPacketHandlerImpl( hash = hash, status = status, rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, - myNodeNum = nodeManager.myNodeNum.value ?: 0, + myNodeNum = nodeRepository.myNodeNum.value ?: 0, ) } } 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 8c044af72..57be6f731 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,7 @@ 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.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.TelemetryPacketHandler @@ -46,7 +46,7 @@ import kotlin.time.Duration.Companion.milliseconds */ @Single class TelemetryPacketHandlerImpl( - private val nodeManager: NodeManager, + private val nodeRepository: NodeRepository, private val notificationManager: NotificationManager, @Named("ServiceScope") private val scope: CoroutineScope, ) : TelemetryPacketHandler { @@ -67,7 +67,7 @@ class TelemetryPacketHandlerImpl( // Note: Local telemetry notification update was previously handled by // MeshConnectionManager.updateTelemetry(), now managed via SDK flows. - nodeManager.updateNode(fromNum) { node: Node -> + nodeRepository.updateNode(fromNum) { node: Node -> val metrics = t.device_metrics val environment = t.environment_metrics val power = t.power_metrics diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index c0b96f910..40ef57663 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -53,7 +53,7 @@ import org.meshtastic.sdk.RadioClient * [RadioClient.telemetry], and [RadioClient.routing] respectively. * * **State distribution:** Handled by [SdkStateBridge], which feeds SDK flows into - * [ServiceRepository] and [org.meshtastic.core.repository.NodeManager]. + * [ServiceRepository] and [org.meshtastic.core.repository.NodeRepository]. */ @Single(binds = [RadioController::class]) @Suppress("TooManyFunctions", "LongParameterList") diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 39825cfac..176cc76f5 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -33,7 +33,7 @@ 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.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs @@ -50,11 +50,11 @@ import org.meshtastic.sdk.NodeChange import org.meshtastic.sdk.NodeId /** - * Bridges SDK reactive flows into the legacy repository layer and routes [ServiceAction]s + * Bridges SDK reactive flows into the repository layer and routes [ServiceAction]s * directly through the SDK, bypassing the old CommandSender/MeshActionHandler pipeline. * * The SDK owns the transport and all state; this bridge maps SDK emissions into [ServiceRepository] - * and [NodeManager] so that existing feature-module UI code (which observes those repositories) + * and [NodeRepository] so that existing feature-module UI code (which observes those repositories) * continues to work without modification. * * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientAccessor.client] @@ -65,7 +65,7 @@ import org.meshtastic.sdk.NodeId class SdkStateBridge( private val accessor: RadioClientAccessor, private val serviceRepository: ServiceRepository, - private val nodeManager: NodeManager, + private val nodeRepository: NodeRepository, private val packetRepository: Lazy, private val locationManager: MeshLocationManager, private val uiPrefs: UiPrefs, @@ -93,15 +93,15 @@ class SdkStateBridge( .onEach { change -> when (change) { is NodeChange.Snapshot -> { - nodeManager.clear() + nodeRepository.clear() change.nodes.forEach { (_, nodeInfo) -> - nodeManager.installNodeInfo(nodeInfo, withBroadcast = false) + nodeRepository.installNodeInfo(nodeInfo, withBroadcast = false) } - nodeManager.setNodeDbReady(true) + nodeRepository.setNodeDbReady(true) } - is NodeChange.Added -> nodeManager.installNodeInfo(change.node, withBroadcast = true) - is NodeChange.Updated -> nodeManager.installNodeInfo(change.node, withBroadcast = true) - is NodeChange.Removed -> nodeManager.removeByNodenum(change.nodeId.raw) + is NodeChange.Added -> nodeRepository.installNodeInfo(change.node, withBroadcast = true) + is NodeChange.Updated -> nodeRepository.installNodeInfo(change.node, withBroadcast = true) + is NodeChange.Removed -> nodeRepository.removeByNodenum(change.nodeId.raw) } } .launchIn(scope) @@ -109,7 +109,7 @@ class SdkStateBridge( // ── Own node identity ─────────────────────────────────────────────── accessor.client .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } - .onEach { ownNode -> if (ownNode != null) nodeManager.setMyNodeNum(ownNode.num) } + .onEach { ownNode -> if (ownNode != null) nodeRepository.setMyNodeNum(ownNode.num) } .launchIn(scope) // ── Raw packet forward (for RadioConfigViewModel + TAK) ───────────── @@ -188,21 +188,21 @@ class SdkStateBridge( is ServiceAction.Favorite -> { val node = action.node client.admin.setFavorite(NodeId(node.num), !node.isFavorite) - nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } + nodeRepository.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } } is ServiceAction.Ignore -> { val node = action.node val newIgnored = !node.isIgnored client.admin.setIgnored(NodeId(node.num), newIgnored) - nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnored) } + nodeRepository.updateNode(node.num) { it.copy(isIgnored = newIgnored) } packetRepository.value.updateFilteredBySender(node.user.id, newIgnored) } is ServiceAction.Mute -> { val node = action.node client.admin.toggleMuted(NodeId(node.num)) - nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } + nodeRepository.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } } is ServiceAction.Reaction -> { @@ -228,7 +228,7 @@ class SdkStateBridge( is ServiceAction.ImportContact -> { val verified = action.contact.copy(manually_verified = true) client.admin.addContact(verified) - nodeManager.handleReceivedUser( + nodeRepository.handleReceivedUser( verified.node_num, verified.user ?: User(), manuallyVerified = true, 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 a0c9bc876..6f0a5c680 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 @@ -43,7 +43,6 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.util.NodeIdLookup import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager @@ -61,7 +60,7 @@ import org.meshtastic.proto.Position as ProtoPosition /** * Unified node repository and manager — single source of truth for all mesh node state. * - * Replaces the previous split between `NodeManagerImpl` (write operations, in-memory atomicfu maps) + * Replaces the previous split between a write-operation layer (in-memory atomicfu maps) * and `SdkNodeRepositoryImpl` (repository interface, StateFlows). Now uses a single StateFlow * with metadata enrichment on every write. * @@ -69,14 +68,14 @@ import org.meshtastic.proto.Position as ProtoPosition * database in-memory, populated by SdkStateBridge from the SDK's NodeChange flow. * Node metadata (favorites, notes, ignored, muted) persists via Room's node_metadata table. */ -@Single(binds = [NodeRepository::class, NodeManager::class, NodeIdLookup::class]) +@Single(binds = [NodeRepository::class, NodeIdLookup::class]) @Suppress("TooManyFunctions", "LongParameterList") class SdkNodeRepositoryImpl( private val localStatsDataSource: LocalStatsDataSource, private val dbManager: DatabaseProvider, private val notificationManager: NotificationManager, @Named("ServiceScope") private val scope: CoroutineScope, -) : NodeRepository, NodeManager { +) : NodeRepository { private val _nodeDBbyNum = MutableStateFlow>(emptyMap()) private val _myNodeInfo = MutableStateFlow(null) @@ -227,7 +226,7 @@ class SdkNodeRepositoryImpl( } } - // ── NodeManager surface ───────────────────────────────────────────────── + // ── Runtime node state management ──────────────────────────────────────── override val nodeDBbyNodeNum: Map get() = _nodeDBbyNum.value 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 c87790d9a..11916f60c 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 @@ -34,7 +34,7 @@ import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket @@ -47,7 +47,7 @@ import kotlin.test.Test @OptIn(ExperimentalCoroutinesApi::class) class StoreForwardPacketHandlerImplTest { - private val nodeManager = mock(MockMode.autofill) + private val nodeRepository = mock(MockMode.autofill) private val packetRepository = mock(MockMode.autofill) private val historyManager = mock(MockMode.autofill) private val dataHandler = mock(MockMode.autofill) @@ -61,11 +61,11 @@ class StoreForwardPacketHandlerImplTest { @BeforeTest fun setUp() { - every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) + every { nodeRepository.myNodeNum } returns MutableStateFlow(myNodeNum) handler = StoreForwardPacketHandlerImpl( - nodeManager = nodeManager, + nodeRepository = nodeRepository, packetRepository = lazy { packetRepository }, historyManager = historyManager, dataHandler = lazy { dataHandler }, 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 71fa60157..49d583f94 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,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager import org.meshtastic.proto.Data import org.meshtastic.proto.DeviceMetrics @@ -42,7 +42,7 @@ import kotlin.test.Test @OptIn(ExperimentalCoroutinesApi::class) class TelemetryPacketHandlerImplTest { - private val nodeManager = mock(MockMode.autofill) + private val nodeRepository = mock(MockMode.autofill) private val notificationManager = mock(MockMode.autofill) private val testDispatcher = StandardTestDispatcher() @@ -57,7 +57,7 @@ class TelemetryPacketHandlerImplTest { fun setUp() { handler = TelemetryPacketHandlerImpl( - nodeManager = nodeManager, + nodeRepository = nodeRepository, notificationManager = notificationManager, scope = testScope, ) @@ -93,7 +93,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(myNodeNum, any(), any(), any()) } } // ---------- Device metrics from remote node ---------- @@ -108,7 +108,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(remoteNodeNum, any(), any(), any()) } } // ---------- Environment metrics ---------- @@ -126,7 +126,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(remoteNodeNum, any(), any(), any()) } } // ---------- Power metrics ---------- @@ -140,7 +140,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(remoteNodeNum, any(), any(), any()) } } // ---------- Telemetry time handling ---------- @@ -154,7 +154,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(myNodeNum, any(), any(), any()) } } // ---------- Null payload ---------- diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt deleted file mode 100644 index 8c2d192c1..000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ /dev/null @@ -1,82 +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 kotlinx.coroutines.flow.StateFlow -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.util.NodeIdLookup -import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.User -import org.meshtastic.proto.NodeInfo as ProtoNodeInfo -import org.meshtastic.proto.Position as ProtoPosition - -/** Interface for managing the in-memory node database and processing received node information. */ -@Suppress("TooManyFunctions") -interface NodeManager : NodeIdLookup { - /** Reactive map of all nodes by their number. */ - val nodeDBbyNodeNum: Map - - /** Reactive map of all nodes by their ID string. */ - val nodeDBbyID: Map - - /** Whether the node database is ready. */ - val isNodeDbReady: StateFlow - - /** Sets whether the node database is ready. */ - fun setNodeDbReady(ready: Boolean) - - /** The local node number as a thread-safe [StateFlow]. */ - val myNodeNum: StateFlow - - /** Sets the local node number. */ - fun setMyNodeNum(num: Int?) - - /** The firmware edition reported by the connected device. */ - val firmwareEdition: StateFlow - - /** Sets the firmware edition of the connected device. */ - fun setFirmwareEdition(edition: FirmwareEdition?) - - /** Clears the in-memory node database. */ - fun clear() - - /** Returns information about the local node. */ - fun getMyNodeInfo(): MyNodeInfo? - - /** Returns the local node ID. */ - fun getMyId(): String - - /** Processes a received user packet. */ - fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) - - /** Processes a received position packet. */ - fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) - - /** Processes a received telemetry packet. */ - fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) - - /** Updates a node using a transformation function. */ - fun updateNode(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, transform: (Node) -> Node) - - /** Removes a node from the in-memory database by its number. */ - fun removeByNodenum(nodeNum: Int) - - /** Installs node information from a ProtoNodeInfo object. */ - fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt index 095f5d8c9..2fc11ccae 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt @@ -21,19 +21,25 @@ import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.model.util.NodeIdLookup +import org.meshtastic.proto.FirmwareEdition import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +import org.meshtastic.proto.NodeInfo as ProtoNodeInfo +import org.meshtastic.proto.Position as ProtoPosition /** * Repository interface for managing node-related data. * * This component provides access to the mesh's node database, local device information, and mesh-wide statistics. It - * supports reactive queries for node lists, counts, and filtered/sorted views. + * supports reactive queries for node lists, counts, and filtered/sorted views, as well as runtime in-memory state + * management for processing incoming node packets from the radio. * * This interface is shared across platforms via Kotlin Multiplatform (KMP). */ @Suppress("TooManyFunctions") -interface NodeRepository { +interface NodeRepository : NodeIdLookup { /** Reactive flow of hardware info about our local radio device. */ val myNodeInfo: StateFlow @@ -165,4 +171,57 @@ interface NodeRepository { * Used during the initial connection handshake. */ suspend fun installConfig(mi: MyNodeInfo, nodes: List) + + // ── Runtime node state management ─────────────────────────────────────── + + /** Reactive map of all nodes by their number (snapshot access). */ + val nodeDBbyNodeNum: Map + + /** Reactive map of all nodes by their ID string. */ + val nodeDBbyID: Map + + /** Whether the node database is ready. */ + val isNodeDbReady: StateFlow + + /** Sets whether the node database is ready. */ + fun setNodeDbReady(ready: Boolean) + + /** The local node number as a thread-safe [StateFlow]. */ + val myNodeNum: StateFlow + + /** Sets the local node number. */ + fun setMyNodeNum(num: Int?) + + /** The firmware edition reported by the connected device. */ + val firmwareEdition: StateFlow + + /** Sets the firmware edition of the connected device. */ + fun setFirmwareEdition(edition: FirmwareEdition?) + + /** Clears the in-memory node database. */ + fun clear() + + /** Returns information about the local node. */ + fun getMyNodeInfo(): MyNodeInfo? + + /** Returns the local node ID. */ + fun getMyId(): String + + /** Processes a received user packet. */ + fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) + + /** Processes a received position packet. */ + fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) + + /** Processes a received telemetry packet. */ + fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) + + /** Updates a node using a transformation function. */ + fun updateNode(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, transform: (Node) -> Node) + + /** Removes a node from the in-memory database by its number. */ + fun removeByNodenum(nodeNum: Int) + + /** Installs node information from a ProtoNodeInfo object. */ + fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) } 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 75cf1ec05..c4f877d02 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 @@ -29,7 +29,6 @@ import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs @@ -48,7 +47,6 @@ import org.meshtastic.core.takserver.TAKServerManager @Single class MeshServiceOrchestrator( private val radioPrefs: RadioPrefs, - private val nodeManager: NodeManager, private val serviceNotifications: MeshServiceNotifications, private val takServerManager: TAKServerManager, private val takMeshIntegration: TAKMeshIntegration, 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 804c54487..e83b367ff 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 @@ -34,7 +34,6 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioPrefs @@ -51,7 +50,6 @@ import kotlin.test.assertTrue class MeshServiceOrchestratorTest { private val radioPrefs: RadioPrefs = mock(MockMode.autofill) - private val nodeManager: NodeManager = mock(MockMode.autofill) private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill) private val takServerManager: TAKServerManager = mock(MockMode.autofill) private val takPrefs: TakPrefs = mock(MockMode.autofill) @@ -94,7 +92,6 @@ class MeshServiceOrchestratorTest { return MeshServiceOrchestrator( radioPrefs = radioPrefs, - nodeManager = nodeManager, serviceNotifications = serviceNotifications, takServerManager = takServerManager, takMeshIntegration = takMeshIntegration, diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt index c83fb7d14..e9220ed8a 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt @@ -24,8 +24,12 @@ import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.proto.FirmwareEdition import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +import org.meshtastic.proto.NodeInfo as ProtoNodeInfo +import org.meshtastic.proto.Position as ProtoPosition /** * A test double for [NodeRepository] that provides an in-memory implementation. @@ -163,6 +167,73 @@ class FakeNodeRepository : _nodeDBbyNum.value = nodes.associateBy { it.num } } + // ── Runtime node state management (from NodeManager merge) ────────────── + + override val nodeDBbyNodeNum: Map + get() = _nodeDBbyNum.value + + override val nodeDBbyID: Map + get() = _nodeDBbyNum.value.values.associateBy { it.user.id } + + private val _isNodeDbReady = MutableStateFlow(false) + override val isNodeDbReady: StateFlow = _isNodeDbReady + + override fun setNodeDbReady(ready: Boolean) { + _isNodeDbReady.value = ready + } + + private val _myNodeNum = MutableStateFlow(null) + override val myNodeNum: StateFlow = _myNodeNum + + override fun setMyNodeNum(num: Int?) { + _myNodeNum.value = num + } + + private val _firmwareEdition = MutableStateFlow(null) + override val firmwareEdition: StateFlow = _firmwareEdition + + override fun setFirmwareEdition(edition: FirmwareEdition?) { + _firmwareEdition.value = edition + } + + override fun clear() { + _nodeDBbyNum.value = emptyMap() + } + + override fun getMyNodeInfo(): MyNodeInfo? = _myNodeInfo.value + + override fun getMyId(): String = _myId.value ?: "" + + override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) { + // no-op for tests + } + + override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) { + // no-op for tests + } + + override fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) { + // no-op for tests + } + + override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) { + val current = _nodeDBbyNum.value[nodeNum] ?: return + _nodeDBbyNum.value = _nodeDBbyNum.value + (nodeNum to transform(current)) + } + + override fun removeByNodenum(nodeNum: Int) { + _nodeDBbyNum.value = _nodeDBbyNum.value - nodeNum + } + + override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) { + // Simplified: just store the node number + val num = info.num + val existing = _nodeDBbyNum.value[num] ?: Node(num = num, user = User()) + _nodeDBbyNum.value = _nodeDBbyNum.value + (num to existing) + } + + override fun toNodeID(nodeNum: Int): String = "!%08x".format(nodeNum) + // --- Helper methods for testing --- fun setNodes(nodes: List) { diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt index e0e276875..b382ba4ab 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt @@ -25,17 +25,17 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository class RefreshLocalStatsAction : ActionCallback, KoinComponent { private val radioController: RadioController by inject() - private val nodeManager: NodeManager by inject() + private val nodeRepository: NodeRepository by inject() override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - val myNodeNum = nodeManager.myNodeNum.value + val myNodeNum = nodeRepository.myNodeNum.value if (myNodeNum == null) { Logger.w { "RefreshLocalStatsAction: myNodeNum is null, skipping telemetry request" } return