refactor: narrow ViewModel injections, add ConnectionAware, delete dead code, integration tests

ViewModel Narrowing:
- V1: Created ConnectionAware interface; MessageSender, DeviceAdmin, DeviceControl extend it
- V2: Narrowed 6 ViewModels/actions to focused sub-interfaces (DeviceAdmin, MessageSender, DataRequester, DeviceControl)

Cleanup:
- C1: Deleted dead MeshDataMapper and its DI registration

Integration Tests:
- T2: SdkStateBridgeTest verifying WentOffline/CameOnline presence handling
- Verified Koin resolution, full test suite passes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-05 15:15:45 -05:00
parent e9cb439849
commit 27b2c19e69
18 changed files with 252 additions and 104 deletions

View File

@@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -37,7 +37,7 @@ class MapViewModel(
mapPrefs: MapPrefs,
packetRepository: PacketRepository,
nodeRepository: NodeRepository,
radioController: RadioController,
radioController: MessageSender,
radioConfigRepository: RadioConfigRepository,
buildConfigProvider: BuildConfigProvider,
savedStateHandle: SavedStateHandle,

View File

@@ -50,7 +50,7 @@ import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -89,7 +89,7 @@ class MapViewModel(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
radioController: RadioController,
radioController: MessageSender,
private val customTileProviderRepository: CustomTileProviderRepository,
uiPrefs: UiPrefs,
savedStateHandle: SavedStateHandle,

View File

@@ -19,14 +19,10 @@ package org.meshtastic.core.data.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.NodeIdLookup
import kotlin.time.Clock
@Module
@ComponentScan("org.meshtastic.core.data")
class CoreDataModule {
@Single fun provideMeshDataMapper(nodeIdLookup: NodeIdLookup): MeshDataMapper = MeshDataMapper(nodeIdLookup)
@Single fun provideClock(): Clock = Clock.System
}

View File

@@ -0,0 +1,189 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.radio
import dev.mokkery.MockMode
import dev.mokkery.mock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.core.testing.FakeUiPrefs
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import org.meshtastic.sdk.DeviceStorage
import org.meshtastic.sdk.NodeId
import org.meshtastic.sdk.RadioClient
import org.meshtastic.sdk.StorageProvider
import org.meshtastic.sdk.TransportIdentity
import org.meshtastic.sdk.testing.FakeRadioTransport
import org.meshtastic.sdk.testing.InMemoryStorage
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class SdkStateBridgeTest {
@Test
fun `went offline marks node offline in repository`() = runTest {
val remoteNode = NodeId(0x22222222)
val staleHeartbeatMs = Clock.System.now().toEpochMilliseconds() - 5.seconds.inWholeMilliseconds
val nodeRepository =
FakeNodeRepository().apply {
setNodes(
listOf(
Node(
num = remoteNode.raw,
user = User(id = "!22222222", long_name = "Test Node"),
lastHeard = (Clock.System.now().toEpochMilliseconds() / 1000).toInt(),
),
),
)
}
val (_, client) = connectedClient(SeededHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs)))
buildBridge(client, nodeRepository)
client.connect()
runCurrent()
advanceTimeBy(30.seconds)
runCurrent()
val updated = nodeRepository.nodeDBbyNum.value.getValue(remoteNode.raw)
assertTrue(updated.lastHeard <= (staleHeartbeatMs / 1000).toInt())
assertFalse(updated.isOnline)
client.disconnect()
}
@Test
fun `came online marks node online in repository`() = runTest {
val remoteNode = NodeId(0x33333333)
val staleHeartbeatMs = Clock.System.now().toEpochMilliseconds() - 5.seconds.inWholeMilliseconds
val nodeRepository =
FakeNodeRepository().apply {
setNodes(
listOf(
Node(
num = remoteNode.raw,
user = User(id = "!33333333", long_name = "Test Node"),
lastHeard = (staleHeartbeatMs / 1000).toInt(),
),
),
)
}
val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs)))
buildBridge(client, nodeRepository)
client.connect()
runCurrent()
advanceTimeBy(30.seconds)
runCurrent()
transport.injectPacket(
MeshPacket(
from = remoteNode.raw,
to = 0,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP),
),
)
runCurrent()
val updated = nodeRepository.nodeDBbyNum.value.getValue(remoteNode.raw)
assertTrue(updated.lastHeard > (staleHeartbeatMs / 1000).toInt())
assertTrue(updated.isOnline)
client.disconnect()
}
private fun TestScope.connectedClient(
storage: StorageProvider,
myNodeNum: Int = 0x11111111,
presenceTimeout: Duration = 1.seconds,
): Pair<FakeRadioTransport, RadioClient> {
val transport = FakeRadioTransport(identity = TransportIdentity("fake:state-bridge"), autoHandshake = true, nodeNum = myNodeNum)
val client =
RadioClient.Builder()
.transport(transport)
.storage(storage)
.coroutineContext(backgroundScope.coroutineContext)
.autoSyncTimeOnConnect(false)
.presenceTimeout(presenceTimeout)
.build()
return transport to client
}
private fun TestScope.buildBridge(
client: RadioClient,
nodeRepository: FakeNodeRepository,
): SdkStateBridge =
SdkStateBridge(
accessor = TestRadioClientAccessor(client),
serviceRepository = FakeServiceRepository(),
nodeRepository = nodeRepository,
packetRepository = lazyOf(mock<PacketRepository>(MockMode.autofill)),
locationManager = NoOpLocationManager,
uiPrefs = FakeUiPrefs(),
radioController = FakeRadioController(),
dispatchers = CoroutineDispatchers(
io = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher,
main = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher,
default = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher,
),
)
private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor {
override val client = MutableStateFlow<RadioClient?>(client)
override fun rebuildAndConnectAsync() = Unit
override fun disconnect() = Unit
}
private object NoOpLocationManager : MeshLocationManager {
override fun start(scope: CoroutineScope, sendPositionFn: (Position) -> Unit) = Unit
override fun stop() = Unit
}
}
private class SeededHeartbeatStorageProvider(
private val heartbeats: Map<NodeId, Long>,
) : StorageProvider {
override suspend fun activate(identity: TransportIdentity): DeviceStorage =
InMemoryStorage().also { storage ->
heartbeats.forEach { (nodeId, heartbeatMs) ->
storage.saveHeartbeat(nodeId, heartbeatMs)
}
}
}

View File

@@ -0,0 +1,24 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model
import kotlinx.coroutines.flow.StateFlow
/** Provides read-only access to the app's connection state. */
interface ConnectionAware {
val connectionState: StateFlow<ConnectionState>
}

View File

@@ -20,7 +20,7 @@ import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
/** Focused interface for local device configuration and edit sessions. */
interface DeviceAdmin {
interface DeviceAdmin : ConnectionAware {
suspend fun setLocalConfig(config: Config)
suspend fun setLocalChannel(channel: Channel)
suspend fun beginEditSettings(destNum: Int)

View File

@@ -17,7 +17,7 @@
package org.meshtastic.core.model
/** Focused interface for device lifecycle control. */
interface DeviceControl {
interface DeviceControl : ConnectionAware {
suspend fun reboot(destNum: Int, packetId: Int)
suspend fun rebootToDfu(nodeNum: Int)
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)

View File

@@ -17,7 +17,7 @@
package org.meshtastic.core.model
/** Focused interface for sending messages over the mesh. */
interface MessageSender {
interface MessageSender : ConnectionAware {
suspend fun sendMessage(packet: DataPacket)
fun getPacketId(): Int
}

View File

@@ -26,18 +26,6 @@ import org.meshtastic.proto.ClientNotification
* This super-interface remains for backward compatibility with existing injections.
*/
interface RadioController : MessageSender, DeviceAdmin, RemoteAdmin, DeviceControl, DataRequester {
/**
* Canonical app-level connection state, delegated from [ServiceRepository][connectionState].
*
* This exposes the same single source of truth as `ServiceRepository.connectionState`, surfaced through the
* controller interface for convenience in feature modules and ViewModels that depend on [RadioController] rather
* than [ServiceRepository] directly.
*
* This is **not** the transport-level state — it reflects the fully reconciled app-level state including handshake
* progress and device sleep policy.
*/
val connectionState: StateFlow<ConnectionState>
/**
* Flow of notifications from the radio client.
*

View File

@@ -1,55 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.core.model.util
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshPacket
/**
* Utility class to map [MeshPacket] protobufs to [DataPacket] domain models.
*
* This class is platform-agnostic and can be used in shared logic.
*/
open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
/** Maps a [MeshPacket] to a [DataPacket], or returns null if the packet has no decoded data. */
open fun toDataPacket(packet: MeshPacket): DataPacket? {
val decoded = packet.decoded ?: return null
return DataPacket(
from = packet.from,
to = packet.to,
time = packet.rx_time * 1000L,
id = packet.id,
dataType = decoded.portnum.value,
bytes = decoded.payload.toByteArray().toByteString(),
hopLimit = packet.hop_limit,
channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel,
wantAck = packet.want_ack == true,
hopStart = packet.hop_start,
snr = packet.rx_snr,
rssi = packet.rx_rssi,
replyId = decoded.reply_id,
relayNode = packet.relay_node,
viaMqtt = packet.via_mqtt == true,
emoji = decoded.emoji,
transportMechanism = packet.transport_mechanism.value,
)
}
}

View File

@@ -18,7 +18,7 @@ package org.meshtastic.core.ui.qr
import androidx.lifecycle.ViewModel
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.DeviceAdmin
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.core.ui.viewmodel.safeLaunch
@@ -31,7 +31,7 @@ import org.meshtastic.proto.LocalConfig
@KoinViewModel
class ScannedQrCodeViewModel(
private val radioConfigRepository: RadioConfigRepository,
private val radioController: RadioController,
private val radioController: DeviceAdmin,
) : ViewModel() {
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet())

View File

@@ -28,7 +28,7 @@ import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@@ -55,7 +55,7 @@ open class BaseMapViewModel(
protected val mapPrefs: MapPrefs,
protected val nodeRepository: NodeRepository,
private val packetRepository: PacketRepository,
private val radioController: RadioController,
private val radioController: MessageSender,
) : ViewModel() {
val myNodeInfo = nodeRepository.myNodeInfo

View File

@@ -17,7 +17,7 @@
package org.meshtastic.feature.map
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -27,5 +27,5 @@ class SharedMapViewModel(
mapPrefs: MapPrefs,
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
radioController: MessageSender,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController)

View File

@@ -26,8 +26,9 @@ import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataRequester
import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
@@ -48,7 +49,8 @@ import org.meshtastic.core.ui.util.SnackbarManager
@Single(binds = [NodeRequestActions::class])
class CommonNodeRequestActions
constructor(
private val radioController: RadioController,
private val dataRequester: DataRequester,
private val messageSender: MessageSender,
private val snackbarManager: SnackbarManager,
) : NodeRequestActions {
@@ -65,7 +67,7 @@ constructor(
override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
dataRequester.requestUserInfo(destNum)
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName))
}
}
@@ -73,8 +75,8 @@ constructor(
override fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestNeighborInfo(packetId, destNum)
val packetId = messageSender.getPacketId()
dataRequester.requestNeighborInfo(packetId, destNum)
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName))
}
@@ -83,7 +85,7 @@ constructor(
override fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
dataRequester.requestPosition(destNum, position)
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName))
}
}
@@ -91,8 +93,8 @@ constructor(
override fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
val packetId = messageSender.getPacketId()
dataRequester.requestTelemetry(packetId, destNum, type.ordinal)
val typeRes =
when (type) {
@@ -112,8 +114,8 @@ constructor(
override fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTraceroute(packetId, destNum)
val packetId = messageSender.getPacketId()
dataRequester.requestTraceroute(packetId, destNum)
_lastTracerouteTime.value = nowMillis
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName))
}

View File

@@ -22,8 +22,9 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.DeviceControl
import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
@@ -47,7 +48,8 @@ open class NodeManagementActions
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val radioController: RadioController,
private val deviceControl: DeviceControl,
private val messageSender: MessageSender,
private val alertManager: AlertManager,
) {
open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) {
@@ -64,8 +66,8 @@ constructor(
open fun removeNode(scope: CoroutineScope, nodeNum: Int) {
scope.launch(ioDispatcher) {
Logger.i { "Removing node '$nodeNum'" }
val packetId = radioController.getPacketId()
radioController.removeByNodenum(packetId, nodeNum)
val packetId = messageSender.getPacketId()
deviceControl.removeByNodenum(packetId, nodeNum)
nodeRepository.deleteNode(nodeNum)
}
}

View File

@@ -29,7 +29,7 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.DeviceAdmin
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioPrefs
@@ -47,7 +47,7 @@ class NodeListViewModel(
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val radioController: RadioController,
private val radioController: DeviceAdmin,
private val radioPrefs: RadioPrefs,
val nodeManagementActions: NodeManagementActions,
private val getFilteredNodesUseCase: GetFilteredNodesUseCase,

View File

@@ -46,7 +46,8 @@ class NodeManagementActionsTest {
NodeManagementActions(
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
radioController = radioController,
deviceControl = radioController,
messageSender = radioController,
alertManager = alertManager,
)
@@ -78,7 +79,8 @@ class NodeManagementActionsTest {
NodeManagementActions(
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
radioController = radioController,
deviceControl = radioController,
messageSender = radioController,
alertManager = realAlertManager,
)
val node = Node(num = 123, user = User(long_name = "Test Node"))

View File

@@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.DeviceAdmin
import org.meshtastic.core.model.util.toChannelSet
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.PlatformAnalytics
@@ -37,7 +37,7 @@ import org.meshtastic.proto.LocalConfig
@KoinViewModel
class ChannelViewModel(
private val radioController: RadioController,
private val radioController: DeviceAdmin,
private val radioConfigRepository: RadioConfigRepository,
private val analytics: PlatformAnalytics,
) : ViewModel() {