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

@@ -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())