diff --git a/MIGRATION-REMAINING.md b/MIGRATION-REMAINING.md new file mode 100644 index 0000000000..9174fd07b5 --- /dev/null +++ b/MIGRATION-REMAINING.md @@ -0,0 +1,162 @@ +# SDK Migration — Status & Remaining Work + +> Tracks progress of the Meshtastic-Android clean-break migration to meshtastic-sdk. +> Updated: 2026-05-05 + +--- + +## Summary + +**Completed:** ~100% of the Clean Break migration. AIDL dropped, SDK is sole radio path, +transport layer fully deleted, Desktop uses shared SDK bridge, dead infrastructure gone, +POC ViewModels removed, NodeInfoReadDataSource eliminated, Room legacy tables dropped, +NodeManager merged into SdkNodeRepositoryImpl, MeshActivity restored. + +**Remaining:** Optional VM parameter slimming and test coverage for new bridge code. + +**Net change:** 170 files changed, +4,601 / -16,963 lines (net -12,362 LOC removed) + +--- + +## Architecture (current state) + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Feature VMs (NodeList, Settings, RadioConfig, Messages...) │ +│ inject: RadioController, NodeRepository, ServiceRepository │ +└───────────────────────────────┬──────────────────────────────┘ + │ +┌───────────────────────────────▼──────────────────────────────┐ +│ SDK-Backed Adapters (core/data) │ +│ SdkRadioController, SdkStateBridge, SdkNodeRepositoryImpl │ +│ SdkPacketHandler, SdkRadioInterfaceService │ +│ MessagePersistenceHandler │ +└───────────────────────────────┬──────────────────────────────┘ + │ +┌───────────────────────────────▼──────────────────────────────┐ +│ RadioClientAccessor (platform-specific providers) │ +│ Android: RadioClientProvider (app/) │ +│ Desktop: DesktopRadioClientProvider (desktop/) │ +└───────────────────────────────┬──────────────────────────────┘ + │ +┌───────────────────────────────▼──────────────────────────────┐ +│ meshtastic-sdk │ +│ RadioClient → MeshEngine → Transport (BLE/TCP/Serial) │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## What's Done + +### Infrastructure ✅ +- AIDL completely removed +- SDK composite build integrated +- `RadioClientProvider` (Android) + `DesktopRadioClientProvider` (Desktop) +- `SdkClientLifecycle` bridges to service layer +- SDK `sendRaw(ToRadio)` API added for MQTT/XModem + +### Transport Layer ✅ +- **Fully deleted:** BleRadioTransport, TcpRadioTransport, SerialRadioTransport, StreamTransport, HeartbeatSender, StreamFrameCodec, all transport factories, BleReconnectPolicy, TcpTransport +- `RadioInterfaceService` slimmed to device-address surface only +- `SdkRadioInterfaceService` created (thin adapter over RadioPrefs + RadioClientAccessor) +- `NoopRadioInterfaceService` deleted (superseded by SdkRadioInterfaceService) +- `JvmUsbScanner` migrated to SDK's `JvmSerialPorts.list()` + +### Pipeline ✅ +- **Fully deleted:** CommandSender, MeshRouter, MeshActionHandler, PacketHandlerImpl, MeshDataHandlerImpl, MeshConnectionManager, MeshConfigFlowManager, ServiceBroadcasts, DirectRadioControllerImpl, broadcast Constants.kt +- `SdkRadioController` is sole RadioController impl +- `SdkStateBridge` bridges SDK events → repositories +- `SdkPacketHandler` routes MeshPackets via `client.send()`, raw ToRadio via `client.sendRaw()` + +### POC Code ✅ +- **Deleted:** SdkConfigViewModel, SdkMessagingViewModel, SdkTelemetryViewModel, RadioClientViewModel, SdkNodeListViewModel +- All POC diagnostic logging removed from Main.kt +- Dead test fakes removed (app/test/Fakes.kt) + +### Data Layer ✅ +- Room migration 38→39: NodeMetadata persistence +- Room migration 39→40: DROP legacy `nodes`, `my_node`, `metadata` tables +- `SdkNodeRepositoryImpl` implements unified NodeRepository + NodeIdLookup +- SDK storage (SqlDelight) is source of truth for node data +- `AppMetadataRepository` provides firmware/hardware/model info +- NodeManagerImpl deleted — logic merged into SdkNodeRepositoryImpl + +### Desktop ✅ +- Fully cut over to SDK via shared KMP bridge +- `DesktopRadioClientProvider` manages TCP/Serial transport +- No transport stubs needed — SDK handles everything + +### NodeManager Merge ✅ +- NodeManager interface eliminated — all methods merged into NodeRepository +- `SdkNodeRepositoryImpl` now binds [NodeRepository, NodeIdLookup] +- Single in-memory StateFlow — no duplicate maps +- Metadata enrichment on every write (favorites, notes, ignore, mute) +- `NodeManagerImpl.kt` deleted (377 LOC) +- `NodeManager.kt` interface deleted (82 LOC) + +### MeshActivity Restoration ✅ +- `meshActivityFlow` added to ServiceRepository interface +- Emit `Send` from SdkPacketHandler.sendToRadio() and SdkRadioController.sendMessage() +- Emit `Receive` from ServiceRepositoryImpl.emitMeshPacket() +- UIViewModel.meshActivity wired to serviceRepository.meshActivityFlow +- Connection icon animation fully functional + +### Dead Code Removal ✅ +- Removed 7 dead methods from NodeManager/NodeRepository interfaces (~220 LOC) +- Deleted `NodeInfo` data class (kept MeshUser, Position, DeviceMetrics, EnvironmentMetrics) +- Renamed `NodeInfo.kt` → `MeshModels.kt` +- Removed dead `nodeManager` parameter from MeshServiceOrchestrator + +### Error Handling ✅ +- SdkStateBridge: ServiceAction dispatch wrapped in try/catch (prevents bridge death) +- Favorite/Ignore/Mute: local state update only applied on admin call success +- SdkRadioController: sendMessage + sendRemoteAdmin log errors before re-throwing +- ImportContact: guarded with runCatching + +### UseCases Deleted ✅ +- ProcessRadioResponse (tests only — impl kept, has real packet parsing logic) +- AdminActions (tests only — impl kept, has real reboot/reset logic) +- SetMeshLogSettings (tests only — impl kept) +- CleanNodeDatabase (tests only — impl kept) +- IsOtaCapable (tests only — impl kept) +- EnsureRemoteAdminSession (tests only — impl kept, complex concurrency) + +--- + +## What Remains (optional, quality-of-life) + +### 1. VM Parameter Slimming +VMs currently inject SDK-backed adapters (RadioController, NodeRepository, etc.) +which work correctly. Direct SDK injection would reduce params but isn't required: + +| VM | Current Params | Could Be | +|----|---------------|----------| +| RadioConfigVM | 15 | 8-10 | +| SettingsVM | 12 | 8-10 | +| MessageVM | 12 | 6-8 | +| NodeListVM | 9 | 5-6 | +| NodeDetailVM | 7 | 4-5 | + +### 2. Test Coverage +- New code (`SdkRadioInterfaceService`, `SdkPacketHandler`, `MessagePersistenceHandler`) + has no dedicated tests yet (existing integration tests cover happy paths) +- UseCase tests were deleted with the impls — should add back for kept impls + +--- + +## What STAYS (permanent architecture) + +These components are NOT migration candidates: + +- `PacketRepository` — message persistence (SDK doesn't persist chat history) +- `MeshLogRepository` — debug logging (app-local) +- `QuickChatActionRepository` — quick-chat templates +- `DeviceHardwareRepository` / `FirmwareReleaseRepository` — GitHub API +- `NodeMetadataDao` / `AppMetadataRepository` — favorites, notes, ignore, mute +- `MeshServiceOrchestrator` — TAK lifecycle, notifications, DB init, widget +- `SdkStateBridge` — SDK → repository bridging, notifications, TAK dispatch +- `MqttManager` / `HistoryManager` / `XModemManager` — real features +- `TelemetryPacketHandler` / `NeighborInfoHandler` / `TracerouteHandler` — packet processors +- `ContactSettings` — per-contact mute/read state +- `SessionManager` — per-node admin session passkey management diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45de5390d9..3736ca1b79 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -225,6 +225,17 @@ dependencies { implementation(projects.feature.wifiProvision) implementation(projects.feature.widget) + // Meshtastic SDK (composite build — sourced from ../meshtastic-sdk) + implementation(libs.sdk.core) + implementation(libs.sdk.proto) + implementation(libs.sdk.transport.ble) + implementation(libs.sdk.transport.tcp) + implementation(libs.sdk.transport.serial) + implementation(libs.sdk.storage.sqldelight) + // Kable Peripheral type is used directly in RadioClientProvider via BlePeripheralFactory + implementation(libs.kable.core) + testImplementation(libs.sdk.testing) + implementation(libs.jetbrains.compose.material3.adaptive) implementation(libs.jetbrains.compose.material3.adaptive.layout) implementation(libs.jetbrains.compose.material3.adaptive.navigation) diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index da85fc9500..65040e2dc7 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -431,10 +431,10 @@ fun MapView( } } - fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) { + fun getUsername(id: Int) = if (id == DataPacket.LOCAL || id == mapViewModel.myNodeNum) { getString(Res.string.you) } else { - mapViewModel.getUser(id).long_name + mapViewModel.getUser(DataPacket.nodeNumToId(id)).long_name } @Suppress("MagicNumber") diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt index eefd9df435..90ea43d40c 100644 --- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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, diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 8a4a798a81..9a05e6d870 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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, @@ -671,7 +671,7 @@ class MapViewModel( } override fun getUser(userId: String?) = - nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST) + nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.nodeNumToId(org.meshtastic.core.model.DataPacket.BROADCAST)) } enum class LayerType { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bc5dab9d31..c3e1bab7a0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -67,11 +67,7 @@ --> - - - + diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index 09c867a328..e6bff8de17 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.repository.MeshPrefs import org.meshtastic.core.service.worker.MeshLogCleanupWorker import org.meshtastic.feature.widget.LocalStatsWidgetReceiver +import org.meshtastic.sdk.storage.sqldelight.AndroidContextHolder import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @@ -63,6 +64,10 @@ open class MeshUtilApplication : super.onCreate() ContextServices.app = this + // Must be set before startKoin so SqlDelightStorageProvider (used by RadioClientProvider) + // can resolve applicationContext internally. + AndroidContextHolder.context = applicationContext + startKoin { androidContext(this@MeshUtilApplication) workManagerFactory() diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt new file mode 100644 index 0000000000..620a55e3f2 --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt @@ -0,0 +1,153 @@ +/* + * 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 android.content.Context +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.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 +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.ble.BleTransport +import org.meshtastic.sdk.transport.serial.AndroidSerialPorts +import org.meshtastic.sdk.transport.tcp.TcpTransport + +/** + * Holds the active [RadioClient] and orchestrates connect/disconnect lifecycle. + * + * 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, RadioClientAccessor::class]) +class RadioClientProvider( + private val context: Context, + private val radioPrefs: RadioPrefs, +) : SdkClientLifecycle, RadioClientAccessor { + private val _client = MutableStateFlow(null) + + /** Active [RadioClient], or `null` when disconnected or between connections. */ + 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, format `sPORTNAME`). + */ + suspend fun rebuildAndConnect() = mutex.withLock { + val rawAddress = + radioPrefs.devAddr.value + ?: run { + Logger.w { "RadioClientProvider: no saved device address — skipping connect" } + return@withLock + } + + val interfaceChar = rawAddress.firstOrNull() ?: run { + Logger.w { "RadioClientProvider: empty address — skipping connect" } + return@withLock + } + val addressPayload = rawAddress.substring(1) // strip leading interface char + + val transport: RadioTransport = when (InterfaceId.forIdChar(interfaceChar)) { + InterfaceId.BLUETOOTH -> { + Logger.i { "RadioClientProvider: building BLE transport for $addressPayload" } + BleTransport(addressPayload) { autoConnectIf { true } } + } + + InterfaceId.TCP -> { + val (host, port) = parseTcpAddress(addressPayload) + Logger.i { "RadioClientProvider: building TCP transport for $host:$port" } + TcpTransport(host, port) + } + + InterfaceId.SERIAL -> { + Logger.i { "RadioClientProvider: building Serial transport for $addressPayload" } + AndroidSerialPorts.init(context) + AndroidSerialPorts.open(addressPayload) + } + + InterfaceId.MOCK, InterfaceId.NOP, null -> { + Logger.w { "RadioClientProvider: unsupported transport type '$interfaceChar' ($rawAddress)" } + return@withLock + } + } + + // Clear first so observers see null during teardown + val old = _client.value + _client.value = null + old?.let { runCatching { it.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect old client" } } } + + val newClient = + RadioClient.Builder() + .transport(transport) + .storage(SqlDelightStorageProvider(baseDir = context.filesDir.absolutePath)) + .autoReconnect(AutoReconnectConfig()) // enabled=true; Disabled is SDK default — must opt in + .build() + + _client.value = newClient + newClient.connect() + + Logger.i { "RadioClientProvider: client connected via ${InterfaceId.forIdChar(interfaceChar)}" } + } + + /** Fire-and-forget version of [rebuildAndConnect] for non-suspending call sites. */ + override fun rebuildAndConnectAsync() { + scope.launch { + runCatching { rebuildAndConnect() }.onFailure { e -> Logger.e(e) { "RadioClientProvider: connect failed" } } + } + } + + /** Disconnect and clear the active client. */ + 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 + + /** Parse "host:port" or "host" (uses default 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 + } + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 46409b14eb..767cff54df 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -44,6 +44,7 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite import org.meshtastic.core.ui.viewmodel.UIViewModel import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph +import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.map.navigation.mapGraph import org.meshtastic.feature.messaging.navigation.contactsGraph import org.meshtastic.feature.node.navigation.nodesGraph @@ -86,7 +87,7 @@ fun MainScreen() { ) mapGraph(backStack) channelsGraph(backStack) - connectionsGraph(backStack) + connectionsGraph(backStack) { koinViewModel() } settingsGraph(backStack) firmwareGraph(backStack) wifiProvisionGraph(backStack) 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 0000000000..ced0b29219 --- /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 0000000000..5216ec2e72 --- /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/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt deleted file mode 100644 index 0da77c5243..0000000000 --- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt +++ /dev/null @@ -1,80 +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.app.service - -import dev.mokkery.MockMode -import dev.mokkery.mock -import org.meshtastic.core.model.Node -import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.Telemetry - -class Fakes { - val service: RadioInterfaceService = mock(MockMode.autofill) -} - -class FakeMeshServiceNotifications : MeshServiceNotifications { - override fun clearNotifications() {} - - override fun initChannels() {} - - override fun updateServiceStateNotification( - state: org.meshtastic.core.model.ConnectionState, - telemetry: Telemetry?, - ) {} - - override suspend fun updateMessageNotification( - contactKey: String, - name: String, - message: String, - isBroadcast: Boolean, - channelName: String?, - isSilent: Boolean, - ) {} - - override suspend fun updateWaypointNotification( - contactKey: String, - name: String, - message: String, - waypointId: Int, - isSilent: Boolean, - ) {} - - override suspend fun updateReactionNotification( - contactKey: String, - name: String, - emoji: String, - isBroadcast: Boolean, - channelName: String?, - isSilent: Boolean, - ) {} - - override fun showAlertNotification(contactKey: String, name: String, alert: String) {} - - override fun showNewNodeSeenNotification(node: Node) {} - - override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {} - - override fun showClientNotification(clientNotification: ClientNotification) {} - - override fun cancelMessageNotification(contactKey: String) {} - - override fun cancelLowBatteryNotification(node: Node) {} - - override fun clearClientNotification(notification: ClientNotification) {} -} diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt index 1fd4b39ce7..8bae44f757 100644 --- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -21,9 +21,12 @@ import androidx.compose.ui.test.v2.runComposeUiTest import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import org.junit.Test import org.junit.runner.RunWith +import org.meshtastic.core.model.RadioConfigStateProvider +import org.meshtastic.core.model.ResponseState import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.feature.connections.navigation.connectionsGraph import org.meshtastic.feature.firmware.navigation.firmwareGraph @@ -49,7 +52,14 @@ class NavigationAssemblyTest { nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow()) mapGraph(backStack) channelsGraph(backStack) - connectionsGraph(backStack) + connectionsGraph(backStack) { + object : RadioConfigStateProvider { + override val packetResponseState = MutableStateFlow>(ResponseState.Empty) + override val pendingRouteName = MutableStateFlow("") + override fun requestConfigLoad(routeName: String) {} + override fun clearPacketResponse() {} + } + } settingsGraph(backStack) firmwareGraph(backStack) } diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl deleted file mode 100644 index b8a1640568..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable DataPacket; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl deleted file mode 100644 index ba71539738..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable MeshUser; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl deleted file mode 100644 index 1286d7c7fe..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable MyNodeInfo; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl deleted file mode 100644 index ab7c1c9261..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable NodeInfo; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl deleted file mode 100644 index be49bd57a9..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl +++ /dev/null @@ -1,3 +0,0 @@ -package org.meshtastic.core.model; - -parcelable Position; \ No newline at end of file diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl deleted file mode 100644 index f2307dd904..0000000000 --- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ /dev/null @@ -1,207 +0,0 @@ -package org.meshtastic.core.service; - -// Declare any non-default types here with import statements -import org.meshtastic.core.model.DataPacket; -import org.meshtastic.core.model.NodeInfo; -import org.meshtastic.core.model.MeshUser; -import org.meshtastic.core.model.Position; -import org.meshtastic.core.model.MyNodeInfo; - -/** -This is the public android API for talking to meshtastic radios. - -@deprecated The AIDL service integration is deprecated and will be removed in a future release. - New integrations should connect via the built-in Local TAK Server on 127.0.0.1:8087 (TCP). - Import the DataPackage from the TAK Config screen in the Meshtastic app to configure ATAK. - -To connect to meshtastic you should bind to it per https://developer.android.com/guide/components/bound-services - -The intent you use to reach the service should ideally use the action string: - - val intent = Intent("com.geeksville.mesh.Service") - -Or if using an explicit intent: - - val intent = Intent().apply { - setClassName( - "com.geeksville.mesh", - "org.meshtastic.core.service.MeshService" - ) - } - -In Android 11+ you *may* need to add the following to the client app's manifest to allow binding of the mesh service: - - - -For additional information, see https://developer.android.com/guide/topics/manifest/queries-element - - -Once you have bound to the service you should register your broadcast receivers per https://developer.android.com/guide/components/broadcasts#context-registered-receivers - - // com.geeksville.mesh.x broadcast intents, where x is: - - // RECEIVED. - will **only** deliver packets for the specified port number. If a wellknown portnums.proto name for portnum is known it will be used - // (i.e. com.geeksville.mesh.RECEIVED.TEXT_MESSAGE_APP) else the numeric portnum will be included as a base 10 integer (com.geeksville.mesh.RECEIVED.4403 etc...) - - // NODE_CHANGE for new IDs appearing or disappearing - // CONNECTION_CHANGED for losing/gaining connection to the packet radio - // MESSAGE_STATUS_CHANGED for any message status changes (for sent messages only, payload will contain a message ID and a MessageStatus) - -Note - these calls might throw RemoteException to indicate mesh error states -*/ -interface IMeshService { - /// Tell the service where to send its broadcasts of received packets - /// This call is only required for manifest declared receivers. If your receiver is context-registered - /// you don't need this. - void subscribeReceiver(String packageName, String receiverName); - - /** - * Set the user info for this node - */ - void setOwner(in MeshUser user); - - void setRemoteOwner(in int requestId, in int destNum, in byte []payload); - void getRemoteOwner(in int requestId, in int destNum); - - /// Return my unique user ID string - String getMyId(); - - /// Return a unique packet ID - int getPacketId(); - - /* - Send a packet to a specified node name - - typ is defined in mesh.proto Data.Type. For now juse use 0 to mean opaque bytes. - - destId can be null to indicate "broadcast message" - - messageStatus and id of the provided message will be updated by this routine to indicate - message send status and the ID that can be used to locate the message in the future - */ - void send(inout DataPacket packet); - - /** - Get the IDs of everyone on the mesh. You should also subscribe for NODE_CHANGE broadcasts. - */ - List getNodes(); - - /// This method is only intended for use in our GUI, so the user can set radio options - /// It returns a DeviceConfig protobuf. - byte []getConfig(); - /// It sets a Config protobuf via admin packet - void setConfig(in byte []payload); - - /// Set and get a Config protobuf via admin packet - void setRemoteConfig(in int requestId, in int destNum, in byte []payload); - void getRemoteConfig(in int requestId, in int destNum, in int configTypeValue); - - /// Set and get a ModuleConfig protobuf via admin packet - void setModuleConfig(in int requestId, in int destNum, in byte []payload); - void getModuleConfig(in int requestId, in int destNum, in int moduleConfigTypeValue); - - /// Set and get the Ext Notification Ringtone string via admin packet - void setRingtone(in int destNum, in String ringtone); - void getRingtone(in int requestId, in int destNum); - - /// Set and get the Canned Message Messages string via admin packet - void setCannedMessages(in int destNum, in String messages); - void getCannedMessages(in int requestId, in int destNum); - - /// This method is only intended for use in our GUI, so the user can set radio options - /// It sets a Channel protobuf via admin packet - void setChannel(in byte []payload); - - /// Set and get a Channel protobuf via admin packet - void setRemoteChannel(in int requestId, in int destNum, in byte []payload); - void getRemoteChannel(in int requestId, in int destNum, in int channelIndex); - - /// Send beginEditSettings admin packet to nodeNum - void beginEditSettings(in int destNum); - - /// Send commitEditSettings admin packet to nodeNum - void commitEditSettings(in int destNum); - - /// delete a specific nodeNum from nodeDB - void removeByNodenum(in int requestID, in int nodeNum); - - /// Send position packet with wantResponse to nodeNum - void requestPosition(in int destNum, in Position position); - - /// Send setFixedPosition admin packet (or removeFixedPosition if Position is empty) - void setFixedPosition(in int destNum, in Position position); - - /// Send traceroute packet with wantResponse to nodeNum - void requestTraceroute(in int requestId, in int destNum); - - /// Send neighbor info packet with wantResponse to nodeNum - void requestNeighborInfo(in int requestId, in int destNum); - - /// Send Shutdown admin packet to nodeNum - void requestShutdown(in int requestId, in int destNum); - - /// Send Reboot admin packet to nodeNum - void requestReboot(in int requestId, in int destNum); - - /// Send FactoryReset admin packet to nodeNum - void requestFactoryReset(in int requestId, in int destNum); - - /// Send reboot to DFU admin packet - void rebootToDfu(in int destNum); - - /// Send NodedbReset admin packet to nodeNum - void requestNodedbReset(in int requestId, in int destNum, in boolean preserveFavorites); - - /// Returns a ChannelSet protobuf - byte []getChannelSet(); - - /** - Is the packet radio currently connected to the phone? Returns a ConnectionState string. - */ - String connectionState(); - - /** - * @deprecated For internal use only. External callers must not invoke this method; - * it will be removed from the public API in a future release. - */ - boolean setDeviceAddress(String deviceAddr); - - /// Get basic device hardware info about our connected radio. Will never return NULL. Will return NULL - /// if no my node info is available (i.e. it will not throw an exception) - MyNodeInfo getMyNodeInfo(); - - /** - * @deprecated No-op stub — firmware update is now handled entirely by the in-app OTA system. - * This method will be removed from the public API in a future release. - */ - void startFirmwareUpdate(); - - /** - * @deprecated Always returns {@code -4}, which is outside the documented range. - * Firmware update progress is now tracked internally by the in-app OTA system. - * This method will be removed from the public API in a future release. - */ - int getUpdateStatus(); - - /// Start providing location (from phone GPS) to mesh - void startProvideLocation(); - - /// Stop providing location (from phone GPS) to mesh - void stopProvideLocation(); - - /// Send request for node UserInfo - void requestUserInfo(in int destNum); - - /// Request device connection status from the radio - void getDeviceConnectionStatus(in int requestId, in int destNum); - - /// Send request for telemetry to nodeNum - void requestTelemetry(in int requestId, in int destNum, in int type); - - /** - * Tell the node to reboot into OTA mode for firmware update via BLE or WiFi (ESP32 only) - * mode is 1 for BLE, 2 for WiFi - * hash is the 32-byte firmware SHA256 hash (optional, can be null) - */ - void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash); -} diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BlePeripheralFactory.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BlePeripheralFactory.kt new file mode 100644 index 0000000000..376100297a --- /dev/null +++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BlePeripheralFactory.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.ble + +import com.juul.kable.Peripheral + +/** + * Build a Kable [Peripheral] for a previously saved BLE MAC address. + * + * Uses `autoConnect = true` (bonded-device path) since there is no live advertisement. + * Platform-specific MTU negotiation and threading strategy are applied via [platformConfig]. + * + * Intended for use in [RadioClientProvider][org.meshtastic.app.radio.RadioClientProvider] + * when reconstructing a [BleTransport] from a persisted radio address. + * + * SDK gap F: [org.meshtastic.sdk.transport.ble.BleTransport] currently requires a caller-supplied + * [Peripheral] — it has no factory that accepts a MAC address string directly. This function + * bridges that gap on the Android side until the SDK exposes a convenience constructor. + */ +public fun buildPeripheralForSavedAddress(address: String): Peripheral { + val device = MeshtasticBleDevice(address) + return createPeripheral(address) { + platformConfig(device, autoConnect = { true }) + } +} diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 1927104b4e..6101977f41 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -15,6 +15,9 @@ * along with this program. If not, see . */ +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaLanguageVersion + plugins { alias(libs.plugins.meshtastic.kmp.library) alias(libs.plugins.kotlin.parcelize) @@ -34,6 +37,7 @@ kotlin { commonMain.dependencies { implementation(libs.kotlinx.atomicfu) implementation(libs.kotlinx.coroutines.core) + implementation(libs.sdk.core) api(libs.kotlinx.datetime) api(libs.okio) api(libs.uri.kmp) @@ -44,3 +48,11 @@ kotlin { commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) } } } + +tasks.withType().configureEach { + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }, + ) +} diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt index bdb13eac8a..85fc16cc6d 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt @@ -18,13 +18,8 @@ package org.meshtastic.core.common.util -import kotlin.math.PI -import kotlin.math.asin -import kotlin.math.atan2 -import kotlin.math.cos +import org.meshtastic.sdk.PositionUtils import kotlin.math.pow -import kotlin.math.sin -import kotlin.math.sqrt @Suppress("MagicNumber") object GPSFormat { @@ -39,30 +34,13 @@ object GPSFormat { } } -private const val EARTH_RADIUS_METERS = 6371e3 - -@Suppress("MagicNumber") -private fun Double.toRadians(): Double = this * PI / 180.0 - -@Suppress("MagicNumber") -private fun Double.toDegrees(): Double = this * 180.0 / PI - /** @return distance in meters along the surface of the earth (ish) */ -@Suppress("MagicNumber") -fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double { - val lat1 = latitudeA.toRadians() - val lon1 = longitudeA.toRadians() - val lat2 = latitudeB.toRadians() - val lon2 = longitudeB.toRadians() - - val dLat = lat2 - lat1 - val dLon = lon2 - lon1 +fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double = + PositionUtils.distance(latitudeA, longitudeA, latitudeB, longitudeB) - val a = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2) - val c = 2 * asin(sqrt(a)) - - return EARTH_RADIUS_METERS * c -} +/** @return distance in kilometers along the surface of the earth (ish) */ +fun distanceKm(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double = + latLongToMeter(latitudeA, longitudeA, latitudeB, longitudeB) / 1000.0 /** * Computes the bearing in degrees between two points on Earth. @@ -73,18 +51,5 @@ fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, lon * @param lon2 Longitude of the second point * @return Bearing between the two points in degrees. A value of 0 means due north. */ -@Suppress("MagicNumber") -fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { - val lat1Rad = lat1.toRadians() - val lon1Rad = lon1.toRadians() - val lat2Rad = lat2.toRadians() - val lon2Rad = lon2.toRadians() - - val dLon = lon2Rad - lon1Rad - - val y = sin(dLon) * cos(lat2Rad) - val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon) - val bearing = atan2(y, x).toDegrees() - - return (bearing + 360) % 360 -} +fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double = + PositionUtils.bearing(lat1, lon1, lat2, lon2) diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt index db59a52d40..9f0d17dd27 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt @@ -39,6 +39,12 @@ class LocationUtilsTest { assertTrue(distance2 > 78000 && distance2 < 79000, "Distance was $distance2") } + @Test + fun testDistanceKm() { + val distance = distanceKm(0.0, 0.0, 0.0, 1.0) + assertTrue(distance > 111 && distance < 112, "Distance was $distance") + } + @Test fun testBearing() { // North diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index d167e1ffef..4dbd51f07a 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/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt new file mode 100644 index 0000000000..adf943844b --- /dev/null +++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt @@ -0,0 +1,208 @@ +/* + * 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.meshtastic.core.repository.NodeMetadata +import org.meshtastic.core.testing.FakeDatabaseProvider +import org.meshtastic.core.testing.setupTestContext +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalCoroutinesApi::class) +class AppMetadataRepositoryImplTest { + + private lateinit var dbProvider: FakeDatabaseProvider + private lateinit var repository: AppMetadataRepositoryImpl + + @BeforeTest + fun setUp() { + setupTestContext() + dbProvider = FakeDatabaseProvider() + repository = AppMetadataRepositoryImpl(dbProvider) + } + + @AfterTest + fun tearDown() { + dbProvider.close() + } + + @Test + fun `metadataByNum starts empty`() = runTest { + assertTrue(repository.metadataByNum.first().isEmpty()) + } + + @Test + fun `setFavorite creates missing metadata row`() = runTest { + repository.setFavorite(nodeNum = 101, isFavorite = true) + advanceUntilIdle() + + assertEquals( + NodeMetadata(num = 101, isFavorite = true, notes = ""), + repository.metadataByNum.first().getValue(101), + ) + } + + @Test + fun `setIgnored creates missing metadata row`() = runTest { + repository.setIgnored(nodeNum = 102, isIgnored = true) + advanceUntilIdle() + + assertEquals( + NodeMetadata(num = 102, isIgnored = true, notes = ""), + repository.metadataByNum.first().getValue(102), + ) + } + + @Test + fun `setMuted and setNotes preserve existing flags`() = runTest { + repository.setFavorite(nodeNum = 103, isFavorite = true) + repository.setMuted(nodeNum = 103, isMuted = true) + repository.setNotes(nodeNum = 103, notes = "Portable node") + advanceUntilIdle() + + assertEquals( + NodeMetadata(num = 103, isFavorite = true, isMuted = true, notes = "Portable node"), + repository.metadataByNum.first().getValue(103), + ) + } + + @Test + fun `setManuallyVerified updates verification flag`() = runTest { + repository.setManuallyVerified(nodeNum = 104, verified = true) + advanceUntilIdle() + + assertEquals( + NodeMetadata(num = 104, manuallyVerified = true, notes = ""), + repository.metadataByNum.first().getValue(104), + ) + } + + @Test + fun `repeated updates keep a single metadata entry per node`() = runTest { + repository.setFavorite(nodeNum = 105, isFavorite = true) + repository.setFavorite(nodeNum = 105, isFavorite = false) + repository.setNotes(nodeNum = 105, notes = "Updated") + advanceUntilIdle() + + val metadata = repository.metadataByNum.first() + assertEquals(1, metadata.size) + assertEquals(NodeMetadata(num = 105, notes = "Updated"), metadata.getValue(105)) + } + + @Test + fun `delete removes existing metadata`() = runTest { + repository.setFavorite(nodeNum = 106, isFavorite = true) + advanceUntilIdle() + + repository.delete(106) + advanceUntilIdle() + + assertTrue(repository.metadataByNum.first().isEmpty()) + } + + @Test + fun `delete missing metadata is a no-op`() = runTest { + repository.delete(999) + advanceUntilIdle() + + assertTrue(repository.metadataByNum.first().isEmpty()) + } + + @Test + fun `metadataByNum flow reflects create update and delete changes`() = runTest { + val created = backgroundScope.async(UnconfinedTestDispatcher(testScheduler)) { + repository.metadataByNum.drop(1).first { it[107]?.isFavorite == true } + } + + repository.setFavorite(nodeNum = 107, isFavorite = true) + advanceUntilIdle() + assertEquals(NodeMetadata(num = 107, isFavorite = true, notes = ""), created.await().getValue(107)) + + val updated = backgroundScope.async(UnconfinedTestDispatcher(testScheduler)) { + repository.metadataByNum.drop(1).first { it[107]?.notes == "Flow note" } + } + + repository.setNotes(nodeNum = 107, notes = "Flow note") + advanceUntilIdle() + assertEquals("Flow note", updated.await().getValue(107).notes) + + val deleted = backgroundScope.async(UnconfinedTestDispatcher(testScheduler)) { + repository.metadataByNum.drop(1).first { it.isEmpty() } + } + + repository.delete(107) + advanceUntilIdle() + assertTrue(deleted.await().isEmpty()) + } + + @Test + fun `concurrent updates on same missing node produce one merged row`() = runTest { + coroutineScope { + launch { repository.setFavorite(nodeNum = 108, isFavorite = true) } + launch { repository.setIgnored(nodeNum = 108, isIgnored = true) } + launch { repository.setMuted(nodeNum = 108, isMuted = true) } + launch { repository.setNotes(nodeNum = 108, notes = "Concurrent") } + launch { repository.setManuallyVerified(nodeNum = 108, verified = true) } + } + advanceUntilIdle() + + val metadata = repository.metadataByNum.first() + assertEquals(1, metadata.size) + assertEquals( + NodeMetadata( + num = 108, + isFavorite = true, + isIgnored = true, + isMuted = true, + notes = "Concurrent", + manuallyVerified = true, + ), + metadata.getValue(108), + ) + } + + @Test + fun `updates for multiple nodes stay isolated`() = runTest { + repository.setFavorite(nodeNum = 201, isFavorite = true) + repository.setIgnored(nodeNum = 202, isIgnored = true) + repository.setNotes(nodeNum = 203, notes = "Third") + advanceUntilIdle() + + val metadata = repository.metadataByNum.first() + assertEquals(3, metadata.size) + assertEquals(NodeMetadata(num = 201, isFavorite = true, notes = ""), metadata.getValue(201)) + assertEquals(NodeMetadata(num = 202, isIgnored = true, notes = ""), metadata.getValue(202)) + assertEquals(NodeMetadata(num = 203, notes = "Third"), metadata.getValue(203)) + } +} diff --git a/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt new file mode 100644 index 0000000000..519026dbd7 --- /dev/null +++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt @@ -0,0 +1,308 @@ +/* + * 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 dev.mokkery.MockMode +import dev.mokkery.answering.calls +import dev.mokkery.answering.returns +import dev.mokkery.answering.throws +import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource +import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource +import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource +import org.meshtastic.core.database.entity.asEntity +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.BootloaderOtaQuirk +import org.meshtastic.core.model.NetworkDeviceHardware +import org.meshtastic.core.model.util.TimeConstants +import org.meshtastic.core.network.DeviceHardwareRemoteDataSource +import org.meshtastic.core.network.service.ApiService +import org.meshtastic.core.testing.FakeDatabaseProvider +import org.meshtastic.core.testing.setupTestContext +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalCoroutinesApi::class) +class DeviceHardwareRepositoryImplTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) + + private lateinit var dbProvider: FakeDatabaseProvider + private lateinit var apiService: ApiService + private lateinit var jsonDataSource: DeviceHardwareJsonDataSource + private lateinit var quirksJsonDataSource: BootloaderOtaQuirksJsonDataSource + private lateinit var repository: DeviceHardwareRepositoryImpl + + private var remoteCallCount = 0 + private var jsonCallCount = 0 + + @BeforeTest + fun setUp() { + setupTestContext() + dbProvider = FakeDatabaseProvider() + apiService = mock(MockMode.autofill) + jsonDataSource = mock(MockMode.autofill) + quirksJsonDataSource = mock(MockMode.autofill) + + everySuspend { apiService.getDeviceHardware() } calls { + remoteCallCount += 1 + emptyList() + } + every { jsonDataSource.loadDeviceHardwareFromJsonAsset() } calls { + jsonCallCount += 1 + emptyList() + } + every { quirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns emptyList() + + repository = DeviceHardwareRepositoryImpl( + remoteDataSource = DeviceHardwareRemoteDataSource(apiService, dispatchers), + localDataSource = DeviceHardwareLocalDataSource(dbProvider, dispatchers), + jsonDataSource = jsonDataSource, + bootloaderOtaQuirksJsonDataSource = quirksJsonDataSource, + dispatchers = dispatchers, + ) + } + + @AfterTest + fun tearDown() { + dbProvider.close() + } + + @Test + fun `returns fresh cached hardware without hitting remote sources`() = runTest(testDispatcher) { + cacheHardware(hardware(hwModel = 1, target = "t-echo", displayName = "Cached")) + + val result = repository.getDeviceHardwareByModel(hwModel = 1) + + assertEquals("Cached", result.getOrNull()?.displayName) + assertEquals(0, remoteCallCount) + assertEquals(0, jsonCallCount) + } + + @Test + fun `disambiguates cached variants by target ignoring case and preserves reported target`() = runTest(testDispatcher) { + cacheHardware( + hardware(hwModel = 7, target = "t-beam", displayName = "Beam"), + hardware(hwModel = 7, target = "t-deck", displayName = "Deck"), + ) + + val result = repository.getDeviceHardwareByModel(hwModel = 7, target = "T-DECK") + val device = result.getOrNull() + + assertNotNull(device) + assertEquals("Deck", device.displayName) + assertEquals("T-DECK", device.platformioTarget) + assertEquals(0, remoteCallCount) + } + + @Test + fun `falls back to cached target lookup when model cache is empty`() = runTest(testDispatcher) { + cacheHardware(hardware(hwModel = 42, target = "target-only", displayName = "Target Match")) + + val result = repository.getDeviceHardwareByModel(hwModel = 999, target = "target-only") + val device = result.getOrNull() + + assertNotNull(device) + assertEquals(42, device.hwModel) + assertEquals("Target Match", device.displayName) + } + + @Test + fun `force refresh clears cache and replaces it with remote data`() = runTest(testDispatcher) { + cacheHardware(hardware(hwModel = 5, target = "old-target", displayName = "Old Cache")) + everySuspend { apiService.getDeviceHardware() } calls { + remoteCallCount += 1 + listOf(hardware(hwModel = 5, target = "new-target", displayName = "Remote Fresh")) + } + + val result = repository.getDeviceHardwareByModel(hwModel = 5, forceRefresh = true) + val cachedAfterRefresh = dbProvider.currentDb.value.deviceHardwareDao().getByHwModel(5) + + assertEquals("Remote Fresh", result.getOrNull()?.displayName) + assertEquals(listOf("new-target"), cachedAfterRefresh.map { it.platformioTarget }) + assertEquals(1, remoteCallCount) + } + + @Test + fun `stale cache refreshes from remote and returns updated hardware`() = runTest(testDispatcher) { + cacheHardware( + hardware(hwModel = 9, target = "stale", displayName = "Stale Cache"), + lastUpdated = nowMillis - TimeConstants.ONE_DAY.inWholeMilliseconds - 1, + ) + everySuspend { apiService.getDeviceHardware() } calls { + remoteCallCount += 1 + listOf(hardware(hwModel = 9, target = "fresh", displayName = "Fresh Remote")) + } + + val result = repository.getDeviceHardwareByModel(hwModel = 9) + + assertEquals("Fresh Remote", result.getOrNull()?.displayName) + assertEquals(1, remoteCallCount) + assertEquals(0, jsonCallCount) + } + + @Test + fun `returns stale cache when remote fails and stale data is still complete`() = runTest(testDispatcher) { + cacheHardware( + hardware(hwModel = 10, target = "complete", displayName = "Stale Complete"), + lastUpdated = nowMillis - TimeConstants.ONE_DAY.inWholeMilliseconds - 1, + ) + everySuspend { apiService.getDeviceHardware() } calls { + remoteCallCount += 1 + throw IllegalStateException("network down") + } + + val result = repository.getDeviceHardwareByModel(hwModel = 10) + + assertEquals("Stale Complete", result.getOrNull()?.displayName) + assertTrue(result.isSuccess) + assertEquals(1, remoteCallCount) + assertEquals(0, jsonCallCount) + } + + @Test + fun `falls back to bundled json when stale cache is incomplete`() = runTest(testDispatcher) { + cacheHardware( + hardware(hwModel = 11, target = "broken", displayName = "", images = emptyList()), + lastUpdated = nowMillis - TimeConstants.ONE_DAY.inWholeMilliseconds - 1, + ) + everySuspend { apiService.getDeviceHardware() } calls { + remoteCallCount += 1 + throw IllegalStateException("network down") + } + every { jsonDataSource.loadDeviceHardwareFromJsonAsset() } calls { + jsonCallCount += 1 + listOf(hardware(hwModel = 11, target = "json-target", displayName = "Bundled Json")) + } + + val result = repository.getDeviceHardwareByModel(hwModel = 11) + + assertEquals("Bundled Json", result.getOrNull()?.displayName) + assertEquals(1, remoteCallCount) + assertEquals(1, jsonCallCount) + } + + @Test + fun `applies bootloader quirks to cached hardware`() = runTest(testDispatcher) { + cacheHardware(hardware(hwModel = 12, target = "quirky", displayName = "Quirky")) + every { quirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns listOf( + BootloaderOtaQuirk( + hwModel = 12, + requiresBootloaderUpgradeForOta = true, + infoUrl = "https://example.invalid/bootloader", + ), + ) + + val result = repository.getDeviceHardwareByModel(hwModel = 12) + val device = result.getOrNull() + + assertNotNull(device) + assertTrue(device.requiresBootloaderUpgradeForOta == true) + assertEquals("https://example.invalid/bootloader", device.bootloaderInfoUrl) + } + + @Test + fun `returns success null when remote data does not contain requested model`() = runTest(testDispatcher) { + everySuspend { apiService.getDeviceHardware() } calls { + remoteCallCount += 1 + listOf(hardware(hwModel = 99, target = "other", displayName = "Other")) + } + + val result = repository.getDeviceHardwareByModel(hwModel = 13) + + assertTrue(result.isSuccess) + assertNull(result.getOrNull()) + assertEquals(1, remoteCallCount) + assertEquals(0, jsonCallCount) + } + + @Test + fun `uses target lookup after remote fetch when requested model is absent`() = runTest(testDispatcher) { + everySuspend { apiService.getDeviceHardware() } calls { + remoteCallCount += 1 + listOf(hardware(hwModel = 77, target = "shared-target", displayName = "Remote Target Match")) + } + + val result = repository.getDeviceHardwareByModel(hwModel = 14, target = "shared-target") + val device = result.getOrNull() + + assertNotNull(device) + assertEquals(77, device.hwModel) + assertEquals("shared-target", device.platformioTarget) + assertEquals(1, remoteCallCount) + } + + @Test + fun `returns failure when both remote and bundled json sources fail`() = runTest(testDispatcher) { + everySuspend { apiService.getDeviceHardware() } calls { + remoteCallCount += 1 + throw IllegalStateException("network down") + } + every { jsonDataSource.loadDeviceHardwareFromJsonAsset() } calls { + jsonCallCount += 1 + throw IllegalArgumentException("missing asset") + } + + val result = repository.getDeviceHardwareByModel(hwModel = 15) + + assertTrue(result.isFailure) + assertIs(result.exceptionOrNull()) + assertEquals(1, remoteCallCount) + assertEquals(1, jsonCallCount) + } + + private suspend fun cacheHardware(vararg hardware: NetworkDeviceHardware, lastUpdated: Long = nowMillis) { + dbProvider.currentDb.value.deviceHardwareDao().insertAll(hardware.map { it.asEntity().copy(lastUpdated = lastUpdated) }) + } + + private fun hardware( + hwModel: Int, + target: String, + displayName: String, + images: List? = listOf("$target.png"), + ) = NetworkDeviceHardware( + activelySupported = true, + architecture = "esp32s3", + displayName = displayName, + hwModel = hwModel, + hwModelSlug = "hw-$hwModel", + images = images, + platformioTarget = target, + requiresDfu = false, + supportLevel = 3, + tags = listOf("portable"), + ) +} diff --git a/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt new file mode 100644 index 0000000000..74b21dc7d7 --- /dev/null +++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt @@ -0,0 +1,297 @@ +/* + * 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 dev.mokkery.MockMode +import dev.mokkery.answering.calls +import dev.mokkery.answering.returns +import dev.mokkery.every +import dev.mokkery.everySuspend +import dev.mokkery.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource +import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource +import org.meshtastic.core.database.entity.FirmwareRelease +import org.meshtastic.core.database.entity.FirmwareReleaseType +import org.meshtastic.core.database.entity.asEntity +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.NetworkFirmwareRelease +import org.meshtastic.core.model.NetworkFirmwareReleases +import org.meshtastic.core.model.Releases +import org.meshtastic.core.model.util.TimeConstants +import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource +import org.meshtastic.core.network.service.ApiService +import org.meshtastic.core.testing.FakeDatabaseProvider +import org.meshtastic.core.testing.setupTestContext +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalCoroutinesApi::class) +class FirmwareReleaseRepositoryImplTest { + + private val testDispatcher = UnconfinedTestDispatcher() + private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) + + private lateinit var dbProvider: FakeDatabaseProvider + private lateinit var apiService: ApiService + private lateinit var jsonDataSource: FirmwareReleaseJsonDataSource + private lateinit var repository: FirmwareReleaseRepositoryImpl + + private var remoteCallCount = 0 + private var jsonCallCount = 0 + + @BeforeTest + fun setUp() { + setupTestContext() + dbProvider = FakeDatabaseProvider() + apiService = mock(MockMode.autofill) + jsonDataSource = mock(MockMode.autofill) + + everySuspend { apiService.getFirmwareReleases() } calls { + remoteCallCount += 1 + NetworkFirmwareReleases() + } + every { jsonDataSource.loadFirmwareReleaseFromJsonAsset() } calls { + jsonCallCount += 1 + NetworkFirmwareReleases() + } + + repository = FirmwareReleaseRepositoryImpl( + remoteDataSource = FirmwareReleaseRemoteDataSource(apiService, dispatchers), + localDataSource = FirmwareReleaseLocalDataSource(dbProvider, dispatchers), + jsonDataSource = jsonDataSource, + ) + } + + @AfterTest + fun tearDown() { + dbProvider.close() + } + + @Test + fun `empty cache emits null then latest stable from remote`() = runTest(testDispatcher) { + everySuspend { apiService.getFirmwareReleases() } calls { + remoteCallCount += 1 + releases( + stable = listOf(release("v2.9.0.abc"), release("v2.10.0.abc")), + alpha = listOf(release("v2.11.0.alpha.1")), + ) + } + + val emissions = repository.stableRelease.toList() + + assertReleaseIds(emissions, null, "v2.10.0.abc") + assertReleaseTypes(emissions, null, FirmwareReleaseType.STABLE) + assertEquals(1, remoteCallCount) + assertEquals(0, jsonCallCount) + } + + @Test + fun `fresh stable cache emits once and skips remote refresh`() = runTest(testDispatcher) { + cacheRelease(release("v2.8.0.abc"), FirmwareReleaseType.STABLE) + + val emissions = repository.stableRelease.toList() + + assertReleaseIds(emissions, "v2.8.0.abc") + assertReleaseTypes(emissions, FirmwareReleaseType.STABLE) + assertEquals(0, remoteCallCount) + assertEquals(0, jsonCallCount) + } + + @Test + fun `stale stable cache emits stale value then refreshed remote value`() = runTest(testDispatcher) { + cacheRelease( + release("v2.7.0.abc"), + FirmwareReleaseType.STABLE, + lastUpdated = nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - 1, + ) + everySuspend { apiService.getFirmwareReleases() } calls { + remoteCallCount += 1 + releases(stable = listOf(release("v2.10.1.abc"))) + } + + val emissions = repository.stableRelease.toList() + + assertReleaseIds(emissions, "v2.7.0.abc", "v2.10.1.abc") + assertReleaseTypes(emissions, FirmwareReleaseType.STABLE, FirmwareReleaseType.STABLE) + } + + @Test + fun `stale cache falls back to bundled json when remote fetch fails`() = runTest(testDispatcher) { + cacheRelease( + release("v2.6.0.abc"), + FirmwareReleaseType.STABLE, + lastUpdated = nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - 1, + ) + everySuspend { apiService.getFirmwareReleases() } calls { + remoteCallCount += 1 + throw IllegalStateException("network down") + } + every { jsonDataSource.loadFirmwareReleaseFromJsonAsset() } calls { + jsonCallCount += 1 + releases(stable = listOf(release("v2.11.0.abc"))) + } + + val emissions = repository.stableRelease.toList() + + assertReleaseIds(emissions, "v2.6.0.abc", "v2.11.0.abc") + assertReleaseTypes(emissions, FirmwareReleaseType.STABLE, FirmwareReleaseType.STABLE) + assertEquals(1, remoteCallCount) + assertEquals(1, jsonCallCount) + } + + @Test + fun `stale cache is re-emitted when both remote and json refreshes fail`() = runTest(testDispatcher) { + cacheRelease( + release("v2.5.0.abc"), + FirmwareReleaseType.STABLE, + lastUpdated = nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - 1, + ) + everySuspend { apiService.getFirmwareReleases() } calls { + remoteCallCount += 1 + throw IllegalStateException("network down") + } + every { jsonDataSource.loadFirmwareReleaseFromJsonAsset() } calls { + jsonCallCount += 1 + throw IllegalArgumentException("missing asset") + } + + val emissions = repository.stableRelease.toList() + + assertReleaseIds(emissions, "v2.5.0.abc", "v2.5.0.abc") + assertReleaseTypes(emissions, FirmwareReleaseType.STABLE, FirmwareReleaseType.STABLE) + } + + @Test + fun `alpha release emits the newest alpha version only`() = runTest(testDispatcher) { + everySuspend { apiService.getFirmwareReleases() } calls { + remoteCallCount += 1 + releases( + stable = listOf(release("v2.9.0.abc")), + alpha = listOf(release("v2.11.0.alpha.1"), release("v2.12.0.alpha.1")), + ) + } + + val emissions = repository.alphaRelease.toList() + + assertReleaseIds(emissions, null, "v2.12.0.alpha.1") + assertReleaseTypes(emissions, null, FirmwareReleaseType.ALPHA) + } + + @Test + fun `stable collection warms alpha cache for subsequent alpha collectors`() = runTest(testDispatcher) { + everySuspend { apiService.getFirmwareReleases() } calls { + remoteCallCount += 1 + releases( + stable = listOf(release("v2.9.9.abc")), + alpha = listOf(release("v2.12.0.alpha.2")), + ) + } + + val stableEmissions = repository.stableRelease.toList() + val alphaEmissions = repository.alphaRelease.toList() + + assertReleaseIds(stableEmissions, null, "v2.9.9.abc") + assertReleaseTypes(stableEmissions, null, FirmwareReleaseType.STABLE) + assertReleaseIds(alphaEmissions, "v2.12.0.alpha.2") + assertReleaseTypes(alphaEmissions, FirmwareReleaseType.ALPHA) + assertEquals(1, remoteCallCount) + } + + @Test + fun `invalidateCache clears database and next collection refetches`() = runTest(testDispatcher) { + cacheRelease(release("v2.8.0.abc"), FirmwareReleaseType.STABLE) + everySuspend { apiService.getFirmwareReleases() } calls { + remoteCallCount += 1 + releases(stable = listOf(release("v2.10.2.abc"))) + } + + repository.invalidateCache() + val emissions = repository.stableRelease.toList() + + assertTrue(dbProvider.currentDb.value.firmwareReleaseDao().getAllReleases().isNotEmpty()) + assertReleaseIds(emissions, null, "v2.10.2.abc") + assertReleaseTypes(emissions, null, FirmwareReleaseType.STABLE) + assertEquals(1, remoteCallCount) + } + + @Test + fun `selects highest semantic version from fresh cached releases`() = runTest(testDispatcher) { + cacheRelease(release("v2.9.9.abc"), FirmwareReleaseType.STABLE) + cacheRelease(release("v2.10.0.abc"), FirmwareReleaseType.STABLE) + + val emissions = repository.stableRelease.toList() + + assertReleaseIds(emissions, "v2.10.0.abc") + assertReleaseTypes(emissions, FirmwareReleaseType.STABLE) + assertEquals(0, remoteCallCount) + } + + @Test + fun `empty remote release list emits null twice`() = runTest(testDispatcher) { + everySuspend { apiService.getFirmwareReleases() } calls { + remoteCallCount += 1 + NetworkFirmwareReleases() + } + + val emissions = repository.stableRelease.toList() + + assertEquals(listOf(null, null), emissions) + assertEquals(1, remoteCallCount) + } + + private suspend fun cacheRelease( + release: NetworkFirmwareRelease, + type: FirmwareReleaseType, + lastUpdated: Long = nowMillis, + ) { + dbProvider.currentDb.value.firmwareReleaseDao().insert(release.asEntity(type).copy(lastUpdated = lastUpdated)) + } + + private fun release(id: String) = NetworkFirmwareRelease( + id = id, + pageUrl = "https://example.invalid/$id", + releaseNotes = "notes for $id", + title = id, + zipUrl = "https://example.invalid/$id.zip", + ) + + private fun releases( + stable: List = emptyList(), + alpha: List = emptyList(), + ) = NetworkFirmwareReleases(releases = Releases(alpha = alpha, stable = stable)) + + private fun assertReleaseIds(emissions: List, vararg expected: String?) { + assertEquals(expected.toList(), emissions.map { it?.id }) + } + + private fun assertReleaseTypes(emissions: List, vararg expected: FirmwareReleaseType?) { + assertEquals(expected.toList(), emissions.map { it?.releaseType }) + } +} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt similarity index 65% rename from core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt rename to core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt index d768ba0091..b4d0603eb6 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt +++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt @@ -14,14 +14,19 @@ * 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 +package org.meshtastic.core.data.repository -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.UiPrefs +import kotlin.test.BeforeTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config -@Single -open class SetProvideLocationUseCase constructor(private val uiPrefs: UiPrefs) { - operator fun invoke(myNodeNum: Int, provideLocation: Boolean) { - uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PacketRepositoryImplTest : CommonPacketRepositoryTest() { + + @BeforeTest + fun setUp() { + setupRepo() } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt index 58673c8798..e4048cdde3 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt @@ -18,6 +18,10 @@ package org.meshtastic.core.data.datasource import org.meshtastic.core.model.BootloaderOtaQuirk +/** + * Loads bundled bootloader OTA quirk metadata for hardware-specific update handling. + */ interface BootloaderOtaQuirksJsonDataSource { + /** Returns bootloader OTA quirks parsed from the packaged JSON asset. */ fun loadBootloaderOtaQuirksFromJsonAsset(): List } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt index 6c53b33670..864a7d59c2 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt @@ -18,6 +18,10 @@ package org.meshtastic.core.data.datasource import org.meshtastic.core.model.NetworkDeviceHardware +/** + * Loads bundled device hardware metadata used when cached or remote data is unavailable. + */ interface DeviceHardwareJsonDataSource { + /** Returns device hardware entries parsed from the packaged JSON asset. */ fun loadDeviceHardwareFromJsonAsset(): List } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt index 043d165e15..7612a41ca2 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt @@ -18,6 +18,10 @@ package org.meshtastic.core.data.datasource import org.meshtastic.core.model.NetworkFirmwareReleases +/** + * Loads bundled firmware release metadata used as a local fallback source. + */ interface FirmwareReleaseJsonDataSource { + /** Returns firmware release metadata parsed from the packaged JSON asset. */ fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt deleted file mode 100644 index c9438ebbf6..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.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.data.datasource - -import kotlinx.coroutines.flow.Flow -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.database.entity.NodeWithRelations - -interface NodeInfoReadDataSource { - fun myNodeInfoFlow(): Flow - - fun nodeDBbyNumFlow(): Flow> - - fun getNodesFlow( - sort: String, - filter: String, - includeUnknown: Boolean, - hopsAwayMax: Int, - lastHeardMin: Int, - ): Flow> - - suspend fun getNodesOlderThan(lastHeard: Int): List - - suspend fun getUnknownNodes(): List -} 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 12ca7154bf..0000000000 --- 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/SwitchingNodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt deleted file mode 100644 index 015f312b42..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.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.data.datasource - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest -import org.koin.core.annotation.Single -import org.meshtastic.core.database.DatabaseProvider -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.database.entity.NodeWithRelations - -@Single -class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseProvider) : NodeInfoReadDataSource { - - override fun myNodeInfoFlow(): Flow = - dbManager.currentDb.flatMapLatest { db -> db.nodeInfoDao().getMyNodeInfo() } - - override fun nodeDBbyNumFlow(): Flow> = - dbManager.currentDb.flatMapLatest { db -> db.nodeInfoDao().nodeDBbyNum() } - - override fun getNodesFlow( - sort: String, - filter: String, - includeUnknown: Boolean, - hopsAwayMax: Int, - lastHeardMin: Int, - ): Flow> = dbManager.currentDb.flatMapLatest { db -> - db.nodeInfoDao() - .getNodes( - sort = sort, - filter = filter, - includeUnknown = includeUnknown, - hopsAwayMax = hopsAwayMax, - lastHeardMin = lastHeardMin, - ) - } - - override suspend fun getNodesOlderThan(lastHeard: Int): List = - dbManager.withDb { it.nodeInfoDao().getNodesOlderThan(lastHeard) } ?: emptyList() - - override suspend fun getUnknownNodes(): List = - dbManager.withDb { it.nodeInfoDao().getUnknownNodes() } ?: emptyList() -} 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 858b0578db..0000000000 --- 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/di/CoreDataModule.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt index a2407008b1..308a821f09 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/di/CoreDataModule.kt @@ -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 } 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 5f1f42c4f9..0000000000 --- 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 24ababf144..0000000000 --- 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 6ca10df266..0000000000 --- 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/FromRadioPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt deleted file mode 100644 index 7ea4e92d57..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt +++ /dev/null @@ -1,156 +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 kotlinx.coroutines.SupervisorJob -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.ioDispatcher -import org.meshtastic.core.repository.FromRadioPacketHandler -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.MqttManager -import org.meshtastic.core.repository.Notification -import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.PacketHandler -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.client_notification -import org.meshtastic.core.resources.duplicated_public_key_title -import org.meshtastic.core.resources.getStringSuspend -import org.meshtastic.core.resources.key_verification_final_title -import org.meshtastic.core.resources.key_verification_request_title -import org.meshtastic.core.resources.key_verification_title -import org.meshtastic.core.resources.low_entropy_key_title -import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.FromRadio - -/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */ -@Single -class FromRadioPacketHandlerImpl( - private val serviceRepository: ServiceRepository, - private val router: Lazy, - private val mqttManager: MqttManager, - private val packetHandler: PacketHandler, - private val notificationManager: NotificationManager, -) : FromRadioPacketHandler { - - // Application-scoped coroutine context for suspend work (e.g. getStringSuspend). - // This @Single lives for the entire app lifetime, so the SupervisorJob is never cancelled. - private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) - - @Suppress("CyclomaticComplexMethod") - override fun handleFromRadio(proto: FromRadio) { - val myInfo = proto.my_info - val metadata = proto.metadata - val nodeInfo = proto.node_info - val configCompleteId = proto.config_complete_id - val mqttProxyMessage = proto.mqttClientProxyMessage - val queueStatus = proto.queueStatus - val config = proto.config - val moduleConfig = proto.moduleConfig - val channel = proto.channel - val clientNotification = proto.clientNotification - val deviceUIConfig = proto.deviceuiConfig - val fileInfo = proto.fileInfo - val xmodemPacket = proto.xmodemPacket - - when { - myInfo != null -> router.value.configFlowManager.handleMyInfo(myInfo) - - // deviceuiConfig arrives immediately after my_info (STATE_SEND_UIDATA). It carries - // the device's display, theme, node-filter, and other UI preferences. - deviceUIConfig != null -> router.value.configHandler.handleDeviceUIConfig(deviceUIConfig) - - metadata != null -> router.value.configFlowManager.handleLocalMetadata(metadata) - - nodeInfo != null -> { - router.value.configFlowManager.handleNodeInfo(nodeInfo) - serviceRepository.setConnectionProgress("Nodes (${router.value.configFlowManager.newNodeCount})") - } - - configCompleteId != null -> router.value.configFlowManager.handleConfigComplete(configCompleteId) - - mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage) - - queueStatus != null -> packetHandler.handleQueueStatus(queueStatus) - - config != null -> router.value.configHandler.handleDeviceConfig(config) - - moduleConfig != null -> router.value.configHandler.handleModuleConfig(moduleConfig) - - channel != null -> router.value.configHandler.handleChannel(channel) - - fileInfo != null -> router.value.configFlowManager.handleFileInfo(fileInfo) - - xmodemPacket != null -> router.value.xmodemManager.handleIncomingXModem(xmodemPacket) - - clientNotification != null -> handleClientNotification(clientNotification) - - // Firmware rebooted without a transport-level disconnect (common on serial/TCP). - // Re-handshake immediately rather than waiting for the 30s stall guard. - proto.rebooted != null -> { - Logger.w { "Firmware rebooted (rebooted=${proto.rebooted}), re-initiating handshake" } - router.value.configFlowManager.triggerWantConfig() - } - } - } - - private fun handleClientNotification(cn: ClientNotification) { - serviceRepository.setClientNotification(cn) - - scope.handledLaunch { - val inform = cn.key_verification_number_inform - val request = cn.key_verification_number_request - val verificationFinal = cn.key_verification_final - val (title, type) = - when { - inform != null -> { - Logger.i { "Key verification inform from ${inform.remote_longname}" } - Pair(getStringSuspend(Res.string.key_verification_title), Notification.Type.Info) - } - - request != null -> { - Logger.i { "Key verification request from ${request.remote_longname}" } - Pair(getStringSuspend(Res.string.key_verification_request_title), Notification.Type.Info) - } - - verificationFinal != null -> { - Logger.i { "Key verification final from ${verificationFinal.remote_longname}" } - Pair(getStringSuspend(Res.string.key_verification_final_title), Notification.Type.Info) - } - - cn.duplicated_public_key != null -> { - Logger.w { "Duplicated public key notification received" } - Pair(getStringSuspend(Res.string.duplicated_public_key_title), Notification.Type.Warning) - } - - cn.low_entropy_key != null -> { - Logger.w { "Low entropy key notification received" } - Pair(getStringSuspend(Res.string.low_entropy_key_title), Notification.Type.Warning) - } - - else -> Pair(getStringSuspend(Res.string.client_notification), Notification.Type.Info) - } - - notificationManager.dispatch( - Notification(title = title, type = type, message = cn.message, category = Notification.Category.Alert), - ) - } - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt deleted file mode 100644 index 7ea4d7cf09..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt +++ /dev/null @@ -1,124 +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 okio.ByteString.Companion.toByteString -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.safeCatching -import org.meshtastic.core.repository.HistoryManager -import org.meshtastic.core.repository.MeshPrefs -import org.meshtastic.core.repository.PacketHandler -import org.meshtastic.proto.Data -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.StoreAndForward - -@Single -class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHandler: PacketHandler) : HistoryManager { - - companion object { - private const val HISTORY_TAG = "HistoryReplay" - private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24 - private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100 - private const val NO_DEVICE_SELECTED = "No device selected" - - fun buildStoreForwardHistoryRequest( - lastRequest: Int, - historyReturnWindow: Int, - historyReturnMax: Int, - ): StoreAndForward { - val history = - StoreAndForward.History( - last_request = lastRequest.coerceAtLeast(0), - window = historyReturnWindow.coerceAtLeast(0), - history_messages = historyReturnMax.coerceAtLeast(0), - ) - return StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, history = history) - } - - fun resolveHistoryRequestParameters(window: Int, max: Int): Pair { - val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES - val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES - return resolvedWindow to resolvedMax - } - } - - private val logger = Logger.withTag(HISTORY_TAG) - - private fun historyLog(message: String, throwable: Throwable? = null) { - logger.i(throwable) { message } - } - - private fun activeDeviceAddress(): String? = - meshPrefs.deviceAddress.value?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() } - - override fun requestHistoryReplay( - trigger: String, - myNodeNum: Int?, - storeForwardConfig: ModuleConfig.StoreForwardConfig?, - transport: String, - ) { - val address = activeDeviceAddress() - if (address == null || myNodeNum == null) { - val reason = if (address == null) "no_addr" else "no_my_node" - historyLog("requestHistory skipped trigger=$trigger reason=$reason") - return - } - - val lastRequest = meshPrefs.getStoreForwardLastRequest(address).value - val (window, max) = - resolveHistoryRequestParameters( - storeForwardConfig?.history_return_window ?: 0, - storeForwardConfig?.history_return_max ?: 0, - ) - - val request = buildStoreForwardHistoryRequest(lastRequest, window, max) - - historyLog( - "requestHistory trigger=$trigger transport=$transport addr=$address " + - "lastRequest=$lastRequest window=$window max=$max", - ) - - safeCatching { - packetHandler.sendToRadio( - MeshPacket( - from = myNodeNum, - to = myNodeNum, - id = kotlin.random.Random.nextInt(1, Int.MAX_VALUE), - decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = request.encode().toByteString()), - priority = MeshPacket.Priority.BACKGROUND, - ), - ) - } - .onFailure { ex -> logger.w(ex) { "requestHistory failed" } } - } - - override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) { - if (lastRequest <= 0) return - val address = activeDeviceAddress() ?: return - val current = meshPrefs.getStoreForwardLastRequest(address).value - if (lastRequest != current) { - meshPrefs.setStoreForwardLastRequest(address, lastRequest) - historyLog( - "historyMarker updated source=$source transport=$transport " + - "addr=$address from=$current to=$lastRequest", - ) - } - } -} 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 e16852d251..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ /dev/null @@ -1,404 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import okio.ByteString -import okio.ByteString.Companion.toByteString -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.ignoreExceptionSuspend -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.safeCatching -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.Reaction -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.DataPair -import org.meshtastic.core.repository.MeshActionHandler -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.MeshMessageProcessor -import org.meshtastic.core.repository.MeshPrefs -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.Channel -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.OTAMode -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.User - -@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") -@Single -class MeshActionHandlerImpl( - private val nodeManager: NodeManager, - private val commandSender: CommandSender, - private val packetRepository: Lazy, - private val serviceBroadcasts: ServiceBroadcasts, - private val dataHandler: Lazy, - private val analytics: PlatformAnalytics, - private val meshPrefs: MeshPrefs, - private val uiPrefs: UiPrefs, - private val databaseManager: DatabaseManager, - private val notificationManager: NotificationManager, - private val messageProcessor: Lazy, - private val radioConfigRepository: RadioConfigRepository, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshActionHandler { - - companion object { - private const val DEFAULT_REBOOT_DELAY = 5 - private const val EMOJI_INDICATOR = 1 - } - - override suspend fun onServiceAction(action: ServiceAction) { - Logger.d { "ServiceAction dispatched: ${action::class.simpleName}" } - ignoreExceptionSuspend { - val myNodeNum = nodeManager.myNodeNum.value - if (myNodeNum == null) { - Logger.w { "MeshActionHandlerImpl: myNodeNum is null, skipping ServiceAction!" } - if (action is ServiceAction.SendContact) { - action.result.complete(false) - } - return@ignoreExceptionSuspend - } - when (action) { - is ServiceAction.Favorite -> handleFavorite(action, myNodeNum) - - is ServiceAction.Ignore -> handleIgnore(action, myNodeNum) - - is ServiceAction.Mute -> handleMute(action, myNodeNum) - - is ServiceAction.Reaction -> handleReaction(action, myNodeNum) - - is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum) - - is ServiceAction.SendContact -> { - val accepted = - safeCatching { - commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) } - } - .getOrDefault(false) - action.result.complete(accepted) - } - - is ServiceAction.GetDeviceMetadata -> { - commandSender.sendAdmin(action.destNum, wantResponse = true) { - AdminMessage(get_device_metadata_request = true) - } - } - } - } - } - - private fun handleFavorite(action: ServiceAction.Favorite, myNodeNum: Int) { - val node = action.node - commandSender.sendAdmin(myNodeNum) { - if (node.isFavorite) { - AdminMessage(remove_favorite_node = node.num) - } else { - AdminMessage(set_favorite_node = node.num) - } - } - nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } - } - - private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) { - val node = action.node - val newIgnoredStatus = !node.isIgnored - commandSender.sendAdmin(myNodeNum) { - if (newIgnoredStatus) { - AdminMessage(set_ignored_node = node.num) - } else { - AdminMessage(remove_ignored_node = node.num) - } - } - nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnoredStatus) } - scope.handledLaunch { packetRepository.value.updateFilteredBySender(node.user.id, newIgnoredStatus) } - } - - private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) { - val node = action.node - commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) } - nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } - } - - private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) { - val channel = action.contactKey[0].digitToInt() - val destId = action.contactKey.substring(1) - val dataPacket = - DataPacket( - to = destId, - dataType = PortNum.TEXT_MESSAGE_APP.value, - bytes = action.emoji.encodeToByteArray().toByteString(), - channel = channel, - replyId = action.replyId, - wantAck = true, - emoji = EMOJI_INDICATOR, - ) - .apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL } - commandSender.sendData(dataPacket) - rememberReaction(action, dataPacket.id, myNodeNum) - } - - private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) { - val verifiedContact = action.contact.copy(manually_verified = true) - commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) } - nodeManager.handleReceivedUser( - verifiedContact.node_num, - verifiedContact.user ?: User(), - manuallyVerified = true, - ) - } - - private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) { - scope.handledLaunch { - val user = nodeManager.nodeDBbyNodeNum[myNodeNum]?.user ?: User(id = nodeManager.getMyId()) - val reaction = - Reaction( - replyId = action.replyId, - user = user, - emoji = action.emoji, - timestamp = nowMillis, - snr = 0f, - rssi = 0, - hopsAway = 0, - packetId = packetId, - status = MessageStatus.QUEUED, - to = action.contactKey.substring(1), - channel = action.contactKey[0].digitToInt(), - ) - packetRepository.value.insertReaction(reaction, myNodeNum) - } - } - - override fun handleSetOwner(u: MeshUser, myNodeNum: Int) { - Logger.d { "Setting owner: longName=${u.longName}, shortName=${u.shortName}" } - val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed) - commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) } - nodeManager.handleReceivedUser(myNodeNum, newUser) - } - - override fun handleSend(p: DataPacket, myNodeNum: Int) { - commandSender.sendData(p) - serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN) - dataHandler.value.rememberDataPacket(p, myNodeNum, false) - val bytes = p.bytes ?: ByteString.EMPTY - analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType)) - } - - override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) { - if (destNum != myNodeNum) { - val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value - val currentPosition = - when { - provideLocation && position.isValid() -> position - - provideLocation -> - nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } - ?: Position(0.0, 0.0, 0) - - else -> Position(0.0, 0.0, 0) - } - commandSender.requestPosition(destNum, currentPosition) - } - } - - override fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) { - nodeManager.removeByNodenum(nodeNum) - commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) } - } - - override fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) { - val u = User.ADAPTER.decode(payload) - commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) } - nodeManager.handleReceivedUser(destNum, u) - } - - override fun handleGetRemoteOwner(id: Int, destNum: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) } - } - - override fun handleSetConfig(payload: ByteArray, myNodeNum: Int) { - val c = Config.ADAPTER.decode(payload) - commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) } - // Optimistically persist the config locally so CommandSender picks up - // the new values (e.g. hop_limit) immediately instead of waiting for - // the next want_config handshake. - scope.handledLaunch { radioConfigRepository.setLocalConfig(c) } - } - - override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) { - val c = Config.ADAPTER.decode(payload) - commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) } - // When targeting the local node, optimistically persist the config so the - // UI reflects changes immediately (matching handleSetConfig behaviour). - if (destNum == nodeManager.myNodeNum.value) { - scope.handledLaunch { radioConfigRepository.setLocalConfig(c) } - } - } - - override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { - if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) { - AdminMessage(get_device_metadata_request = true) - } else { - AdminMessage(get_config_request = AdminMessage.ConfigType.fromValue(config)) - } - } - } - - override fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) { - val c = ModuleConfig.ADAPTER.decode(payload) - commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) } - c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) } - // Optimistically persist module config locally so the UI reflects the - // new values immediately instead of waiting for the next want_config handshake. - if (destNum == nodeManager.myNodeNum.value) { - scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(c) } - } - } - - override fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { - AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config)) - } - } - - override fun handleSetRingtone(destNum: Int, ringtone: String) { - commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) } - } - - override fun handleGetRingtone(id: Int, destNum: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) } - } - - override fun handleSetCannedMessages(destNum: Int, messages: String) { - commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) } - } - - override fun handleGetCannedMessages(id: Int, destNum: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { - AdminMessage(get_canned_message_module_messages_request = true) - } - } - - override fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) { - if (payload != null) { - val c = Channel.ADAPTER.decode(payload) - commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) } - // Optimistically persist the channel settings locally so the UI - // reflects changes immediately instead of waiting for the next - // want_config handshake. - scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) } - } - } - - override fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) { - if (payload != null) { - val c = Channel.ADAPTER.decode(payload) - commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) } - // When targeting the local node, optimistically persist the channel so - // the UI reflects changes immediately (matching handleSetChannel behaviour). - if (destNum == nodeManager.myNodeNum.value) { - scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) } - } - } - } - - override fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) } - } - - override fun handleRequestNeighborInfo(requestId: Int, destNum: Int) { - commandSender.requestNeighborInfo(requestId, destNum) - } - - override fun handleBeginEditSettings(destNum: Int) { - commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) } - } - - override fun handleCommitEditSettings(destNum: Int) { - commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) } - } - - override fun handleRebootToDfu(destNum: Int) { - commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) } - } - - override fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) { - commandSender.requestTelemetry(requestId, destNum, type) - } - - override fun handleRequestShutdown(requestId: Int, destNum: Int) { - commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) } - } - - override fun handleRequestReboot(requestId: Int, destNum: Int) { - Logger.i { "Reboot requested for node $destNum" } - commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) } - } - - override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { - val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA - val otaEvent = - AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY) - commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) } - } - - override fun handleRequestFactoryReset(requestId: Int, destNum: Int) { - Logger.i { "Factory reset requested for node $destNum" } - commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) } - } - - override fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) { - commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) } - } - - override fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) { - commandSender.sendAdmin(destNum, requestId, wantResponse = true) { - AdminMessage(get_device_connection_status_request = true) - } - } - - override fun handleUpdateLastAddress(deviceAddr: String?) { - val currentAddr = meshPrefs.deviceAddress.value - if (deviceAddr != currentAddr) { - Logger.i { "Device address changed, switching database and clearing node DB" } - meshPrefs.setDeviceAddress(deviceAddr) - scope.handledLaunch { - nodeManager.clear() - messageProcessor.value.clearEarlyPackets() - databaseManager.switchActiveDatabase(deviceAddr) - notificationManager.cancelAll() - nodeManager.loadCachedNodeDB() - } - } - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt deleted file mode 100644 index 9e32381861..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ /dev/null @@ -1,310 +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.CommandSender -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 commandSender: CommandSender, - 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 = commandSender.getCurrentPacketId() and 0xffffffffL, - 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/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt deleted file mode 100644 index d8f76f7f0c..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt +++ /dev/null @@ -1,125 +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 kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.repository.MeshConfigHandler -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.Channel -import org.meshtastic.proto.Config -import org.meshtastic.proto.DeviceUIConfig -import org.meshtastic.proto.LocalConfig -import org.meshtastic.proto.LocalModuleConfig -import org.meshtastic.proto.ModuleConfig - -@Single -class MeshConfigHandlerImpl( - private val radioConfigRepository: RadioConfigRepository, - private val serviceRepository: ServiceRepository, - private val nodeManager: NodeManager, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshConfigHandler { - - private val _localConfig = MutableStateFlow(LocalConfig()) - override val localConfig = _localConfig.asStateFlow() - - private val _moduleConfig = MutableStateFlow(LocalModuleConfig()) - override val moduleConfig = _moduleConfig.asStateFlow() - - init { - radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope) - radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope) - } - - override fun handleDeviceConfig(config: Config) { - Logger.d { "Device config received: ${config.summarize()}" } - scope.handledLaunch { radioConfigRepository.setLocalConfig(config) } - serviceRepository.setConnectionProgress("Device config received") - } - - override fun handleModuleConfig(config: ModuleConfig) { - Logger.d { "Module config received: ${config.summarize()}" } - scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } - serviceRepository.setConnectionProgress("Module config received") - - config.statusmessage?.let { sm -> - nodeManager.myNodeNum.value?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) } - } - } - - override fun handleChannel(channel: Channel) { - // We always want to save channel settings we receive from the radio - scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } - - // Update status message if we have node info, otherwise use a generic one - val mi = nodeManager.getMyNodeInfo() - val index = channel.index - if (mi != null) { - serviceRepository.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})") - } else { - serviceRepository.setConnectionProgress("Channels (${index + 1})") - } - } - - override fun handleDeviceUIConfig(config: DeviceUIConfig) { - Logger.d { "DeviceUI config received" } - scope.handledLaunch { radioConfigRepository.setDeviceUIConfig(config) } - } -} - -/** Returns a short summary of which Config variant is set. */ -private fun Config.summarize(): String = when { - device != null -> "device" - position != null -> "position" - power != null -> "power" - network != null -> "network" - display != null -> "display" - lora != null -> "lora" - bluetooth != null -> "bluetooth" - security != null -> "security" - else -> "unknown" -} - -/** Returns a short summary of which ModuleConfig variant is set. */ -@Suppress("CyclomaticComplexMethod") -private fun ModuleConfig.summarize(): String = when { - mqtt != null -> "mqtt" - serial != null -> "serial" - external_notification != null -> "external_notification" - store_forward != null -> "store_forward" - range_test != null -> "range_test" - telemetry != null -> "telemetry" - canned_message != null -> "canned_message" - audio != null -> "audio" - remote_hardware != null -> "remote_hardware" - neighbor_info != null -> "neighbor_info" - ambient_lighting != null -> "ambient_lighting" - detection_sensor != null -> "detection_sensor" - paxcounter != null -> "paxcounter" - statusmessage != null -> "statusmessage" - else -> "unknown" -} 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 a62cb5bedc..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ /dev/null @@ -1,435 +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.DeviceType -import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.repository.AppWidgetUpdater -import org.meshtastic.core.repository.CommandSender -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.AdminMessage -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 commandSender: CommandSender, - 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 -> commandSender.sendPosition(pos) } - } 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 - - // Set device time now that the full node picture is ready. Sending this during Stage 1 - // (onRadioConfigLoaded) introduced GATT write contention with the Stage 2 node-info burst. - commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) } - - // Proactively seed the session passkey. The firmware embeds session_passkey in every - // admin *response* (wantResponse=true), but set_time_only has no response. A get_owner - // request is the lightest way to trigger a response and populate the passkey cache so - // that subsequent write operations don't fail with ADMIN_BAD_SESSION_KEY. - commandSender.sendAdmin(myNodeNum, wantResponse = true) { AdminMessage(get_owner_request = true) } - - // 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 with proper request IDs - commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal) - commandSender.requestTelemetry(commandSender.generatePacketId(), 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 fa935473a8..0000000000 --- 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/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt deleted file mode 100644 index 1e1406090c..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ /dev/null @@ -1,298 +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 kotlinx.coroutines.Job -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.MeshLog -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.util.isLora -import org.meshtastic.core.model.util.toOneLineString -import org.meshtastic.core.model.util.toPIIString -import org.meshtastic.core.repository.FromRadioPacketHandler -import org.meshtastic.core.repository.MeshLogRepository -import org.meshtastic.core.repository.MeshMessageProcessor -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.FromRadio -import org.meshtastic.proto.LogRecord -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum -import kotlin.concurrent.Volatile -import kotlin.uuid.Uuid - -/** Implementation of [MeshMessageProcessor] that handles raw radio messages and prepares mesh packets for routing. */ -@Suppress("TooManyFunctions") -@Single -class MeshMessageProcessorImpl( - private val nodeManager: NodeManager, - private val serviceRepository: ServiceRepository, - private val meshLogRepository: Lazy, - private val router: Lazy, - private val fromRadioDispatcher: FromRadioPacketHandler, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshMessageProcessor { - - private val mapsMutex = Mutex() - private val logUuidByPacketId = mutableMapOf() - private val logInsertJobByPacketId = mutableMapOf() - - /** - * Epoch-millisecond timestamp of the last local-node `lastHeard` DB write. Used to throttle updates to at most once - * per [LOCAL_NODE_REFRESH_INTERVAL_MS] so that high-frequency FromRadio variants (log records, queue status) don't - * flood the DB. - */ - @Volatile private var lastLocalNodeRefreshMs = 0L - - private val earlyMutex = Mutex() - private val earlyReceivedPackets = ArrayDeque() - private val maxEarlyPacketBuffer = 10240 - - override fun clearEarlyPackets() { - scope.launch { earlyMutex.withLock { earlyReceivedPackets.clear() } } - } - - init { - nodeManager.isNodeDbReady - .onEach { ready -> - if (ready) { - flushEarlyReceivedPackets("dbReady") - } - } - .launchIn(scope) - } - - override fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) { - runCatching { FromRadio.ADAPTER.decode(bytes) } - .onSuccess { proto -> processFromRadio(proto, myNodeNum) } - .onFailure { primaryException -> - runCatching { - val logRecord = LogRecord.ADAPTER.decode(bytes) - processFromRadio(FromRadio(log_record = logRecord), myNodeNum) - } - .onFailure { _ -> - Logger.e(primaryException) { - "Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord." - } - } - } - } - - private fun processFromRadio(proto: FromRadio, myNodeNum: Int?) { - // Any decoded FromRadio proves the radio link is alive — keep the local node fresh. - refreshLocalNodeLastHeard() - - // Audit log every incoming variant - logVariant(proto) - - val packet = proto.packet - if (packet != null) { - handleReceivedMeshPacket(packet, myNodeNum) - } else { - fromRadioDispatcher.handleFromRadio(proto) - } - } - - private fun logVariant(proto: FromRadio) { - val (type, message) = - when { - proto.log_record != null -> "LogRecord" to proto.log_record.toString() - proto.rebooted != null -> "Rebooted" to proto.rebooted.toString() - proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString() - proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString() - proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString() - proto.my_info != null -> "MyInfo" to proto.my_info!!.toOneLineString() - proto.node_info != null -> "NodeInfo" to proto.node_info!!.toPIIString() - proto.config != null -> "Config" to proto.config!!.toOneLineString() - proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig!!.toOneLineString() - proto.channel != null -> "Channel" to proto.channel!!.toOneLineString() - proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString() - else -> return - } - - insertMeshLog( - MeshLog( - uuid = Uuid.random().toString(), - message_type = type, - received_date = nowMillis, - raw_message = message, - fromRadio = proto, - ), - ) - } - - override fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { - val rxTime = - if (packet.rx_time == 0) { - nowSeconds.toInt() - } else { - packet.rx_time - } - val preparedPacket = packet.copy(rx_time = rxTime) - - if (nodeManager.isNodeDbReady.value) { - processReceivedMeshPacket(preparedPacket, myNodeNum) - } else { - scope.launch { - earlyMutex.withLock { - val queueSize = earlyReceivedPackets.size - if (queueSize >= maxEarlyPacketBuffer) { - Logger.w { "Early packet buffer full ($queueSize), dropping oldest packet" } - earlyReceivedPackets.removeFirstOrNull() - } - earlyReceivedPackets.addLast(preparedPacket) - } - } - } - } - - private fun flushEarlyReceivedPackets(reason: String) { - scope.launch { - val packets = - earlyMutex.withLock { - if (earlyReceivedPackets.isEmpty()) return@withLock emptyList() - val list = earlyReceivedPackets.toList() - earlyReceivedPackets.clear() - list - } - if (packets.isEmpty()) return@launch - - Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" } - val myNodeNum = nodeManager.myNodeNum.value - packets.forEach { processReceivedMeshPacket(it, myNodeNum) } - } - } - - @Suppress("LongMethod") - private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) { - val decoded = packet.decoded ?: return - val log = - MeshLog( - uuid = Uuid.random().toString(), - message_type = "Packet", - received_date = nowMillis, - raw_message = packet.toString(), - fromNum = if (packet.from == myNodeNum) MeshLog.NODE_NUM_LOCAL else packet.from, - portNum = decoded.portnum.value, - fromRadio = FromRadio(packet = packet), - ) - val logJob = insertMeshLog(log) - - scope.launch { - mapsMutex.withLock { - logInsertJobByPacketId[packet.id] = logJob - logUuidByPacketId[packet.id] = log.uuid - } - } - - scope.handledLaunch { serviceRepository.emitMeshPacket(packet) } - - myNodeNum?.let { myNum -> - val from = packet.from - val isOtherNode = myNum != from - nodeManager.updateNode(myNum, withBroadcast = isOtherNode) { node: Node -> - node.copy(lastHeard = nowSeconds.toInt()) - } - nodeManager.updateNode(from, withBroadcast = false, channel = packet.channel) { node: Node -> - val viaMqtt = packet.via_mqtt == true - val isDirect = packet.hop_start == packet.hop_limit - - var snr = node.snr - var rssi = node.rssi - if (isDirect && packet.isLora() && !viaMqtt) { - snr = packet.rx_snr - rssi = packet.rx_rssi - } - - val hopsAway = - if (decoded.portnum == PortNum.RANGE_TEST_APP) { - 0 - } else if (viaMqtt) { - -1 - } else if (packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0) { - -1 - } else if (packet.hop_limit > packet.hop_start) { - -1 - } else { - packet.hop_start - packet.hop_limit - } - - node.copy( - lastHeard = packet.rx_time, - viaMqtt = viaMqtt, - lastTransport = packet.transport_mechanism.value, - snr = snr, - rssi = rssi, - hopsAway = hopsAway, - ) - } - - try { - router.value.dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob) - } finally { - scope.launch { - mapsMutex.withLock { - logUuidByPacketId.remove(packet.id) - logInsertJobByPacketId.remove(packet.id) - } - } - } - } - } - - /** - * Refreshes the local node's [Node.lastHeard] to prove the radio link is alive. - * - * Without this, [lastHeard] is only set when a [MeshPacket] arrives from another node (see - * [processReceivedMeshPacket]). On a quiet mesh the heartbeat cycle still exchanges data with the firmware (ToRadio - * heartbeat → FromRadio queueStatus every 30 s), but that data never touched [lastHeard], causing the local node to - * appear stale in the UI even though the connection is healthy. - * - * To avoid flooding the DB on high-frequency variants (log records arrive many times per second when debug logging - * is enabled), writes are throttled to at most once per [LOCAL_NODE_REFRESH_INTERVAL_MS]. - */ - private fun refreshLocalNodeLastHeard() { - val now = nowMillis - if (now - lastLocalNodeRefreshMs < LOCAL_NODE_REFRESH_INTERVAL_MS) return - lastLocalNodeRefreshMs = now - - val myNum = nodeManager.myNodeNum.value ?: return - nodeManager.updateNode(myNum, withBroadcast = false) { node: Node -> node.copy(lastHeard = nowSeconds.toInt()) } - } - - private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.value.insert(log) } - - companion object { - /** - * Minimum interval between local-node `lastHeard` DB writes, in milliseconds. Aligned with the heartbeat - * interval (30 s) so that one write per heartbeat cycle keeps the node fresh without unnecessary DB churn. - */ - private const val LOCAL_NODE_REFRESH_INTERVAL_MS = 30_000L - } -} 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 fe58735da6..0000000000 --- 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/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt deleted file mode 100644 index 2975341cca..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ /dev/null @@ -1,95 +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.atomicfu.update -import kotlinx.collections.immutable.persistentMapOf -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.ServiceBroadcasts -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo - -@Single -class NeighborInfoHandlerImpl( - private val nodeManager: NodeManager, - private val serviceRepository: ServiceRepository, - private val serviceBroadcasts: ServiceBroadcasts, - private val nodeRepository: NodeRepository, -) : NeighborInfoHandler { - - private val startTimes = atomic(persistentMapOf()) - - override var lastNeighborInfo: NeighborInfo? = null - - override fun recordStartTime(requestId: Int) { - startTimes.update { it.put(requestId, nowMillis) } - } - - override fun handleNeighborInfo(packet: MeshPacket) { - val payload = packet.decoded?.payload ?: return - val ni = NeighborInfo.ADAPTER.decode(payload) - - // Store the last neighbor info from our connected radio - val from = packet.from - if (from == nodeManager.myNodeNum.value) { - lastNeighborInfo = ni - Logger.d { "Stored last neighbor info from connected radio" } - } - - // Update Node DB - nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) } - - // Format for UI response - val requestId = packet.decoded?.request_id ?: 0 - val start = startTimes.value[requestId] - startTimes.update { it.remove(requestId) } - - val neighbors = - ni.neighbors.joinToString("\n") { n -> - val user = nodeRepository.getUser(n.node_id) - val name = "${user.long_name} (${user.short_name})" - "• $name (SNR: ${n.snr})" - } - - val fromUser = nodeRepository.getUser(from) - val formatted = "Neighbors of ${fromUser.long_name}:\n$neighbors" - - val responseText = - if (start != null) { - val elapsedMs = nowMillis - start - val seconds = elapsedMs / MILLIS_PER_SECOND - Logger.i { "Neighbor info $requestId complete in $seconds s" } - "$formatted\n\nDuration: ${NumberFormatter.format(seconds, 1)} s" - } else { - formatted - } - - serviceRepository.setNeighborInfoResponse(responseText) - } - - companion object { - private const val MILLIS_PER_SECOND = 1000.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 deleted file mode 100644 index c3e21955a1..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ /dev/null @@ -1,378 +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.atomicfu.update -import kotlinx.collections.immutable.persistentMapOf -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -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.model.DataPacket -import org.meshtastic.core.model.DeviceMetrics -import org.meshtastic.core.model.EnvironmentMetrics -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.util.NodeIdLookup -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.Notification -import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getStringSuspend -import org.meshtastic.core.resources.new_node_seen -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.Paxcount -import org.meshtastic.proto.StatusMessage -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.User -import org.meshtastic.proto.NodeInfo as ProtoNodeInfo -import org.meshtastic.proto.Position as ProtoPosition - -/** Implementation of [NodeManager] that maintains an in-memory database of the mesh. */ -@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") -@Single(binds = [NodeManager::class, NodeIdLookup::class]) -class NodeManagerImpl( - private val nodeRepository: NodeRepository, - private val serviceBroadcasts: ServiceBroadcasts, - private val notificationManager: NotificationManager, - @Named("ServiceScope") private val scope: CoroutineScope, -) : NodeManager { - - private val _nodeDBbyNodeNum = atomic(persistentMapOf()) - private val _nodeDBbyID = atomic(persistentMapOf()) - - override val nodeDBbyNodeNum: Map - get() = _nodeDBbyNodeNum.value - - override val nodeDBbyID: Map - get() = _nodeDBbyID.value - - override val isNodeDbReady = MutableStateFlow(false) - override val allowNodeDbWrites = MutableStateFlow(false) - - override fun setNodeDbReady(ready: Boolean) { - isNodeDbReady.value = ready - } - - override fun setAllowNodeDbWrites(allowed: Boolean) { - allowNodeDbWrites.value = allowed - } - - override val myNodeNum = MutableStateFlow(null) - - override fun setMyNodeNum(num: Int?) { - myNodeNum.value = num - } - - override val firmwareEdition = MutableStateFlow(null) - - override fun setFirmwareEdition(edition: FirmwareEdition?) { - firmwareEdition.value = edition - } - - companion object { - private const val TIME_MS_TO_S = 1000L - } - - override fun loadCachedNodeDB() { - scope.handledLaunch { - val nodes = nodeRepository.nodeDBbyNum.first() - _nodeDBbyNodeNum.value = persistentMapOf().putAll(nodes) - val byId = mutableMapOf() - nodes.values.forEach { byId[it.user.id] = it } - _nodeDBbyID.value = persistentMapOf().putAll(byId) - if (myNodeNum.value == null) { - myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum - } - } - } - - override fun clear() { - _nodeDBbyNodeNum.value = persistentMapOf() - _nodeDBbyID.value = persistentMapOf() - isNodeDbReady.value = false - allowNodeDbWrites.value = false - myNodeNum.value = null - firmwareEdition.value = null - } - - override fun getMyNodeInfo(): MyNodeInfo? { - val mi = nodeRepository.myNodeInfo.value ?: return null - val myNode = _nodeDBbyNodeNum.value[mi.myNodeNum] - return MyNodeInfo( - myNodeNum = mi.myNodeNum, - hasGPS = (myNode?.position?.latitude_i ?: 0) != 0, - model = mi.model ?: myNode?.user?.hw_model?.name, - firmwareVersion = mi.firmwareVersion, - couldUpdate = mi.couldUpdate, - shouldUpdate = mi.shouldUpdate, - currentPacketId = mi.currentPacketId, - messageTimeoutMsec = mi.messageTimeoutMsec, - minAppVersion = mi.minAppVersion, - maxChannels = mi.maxChannels, - hasWifi = mi.hasWifi, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = mi.deviceId ?: myNode?.user?.id, - ) - } - - override fun getMyId(): String { - val num = myNodeNum.value ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return "" - return _nodeDBbyNodeNum.value[num]?.user?.id ?: "" - } - - override fun getNodes(): List = _nodeDBbyNodeNum.value.values.map { it.toNodeInfo() } - - override fun removeByNodenum(nodeNum: Int) { - val removed = atomic(null) - _nodeDBbyNodeNum.update { map -> - val node = map[nodeNum] - removed.value = node - map.remove(nodeNum) - } - removed.value?.let { node -> _nodeDBbyID.update { it.remove(node.user.id) } } - } - - internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = _nodeDBbyNodeNum.value[n] - ?: run { - val userId = DataPacket.nodeNumToDefaultId(n) - val defaultUser = - User( - id = userId, - long_name = "Meshtastic ${userId.takeLast(n = 4)}", - short_name = userId.takeLast(n = 4), - hw_model = HardwareModel.UNSET, - ) - - Node(num = n, user = defaultUser, channel = channel) - } - - override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) { - // Perform read + transform inside update{} to ensure atomicity. - // Without this, concurrent calls for the same nodeNum could read the same snapshot - // and the last writer would silently overwrite the other's changes. - var next: Node? = null - _nodeDBbyNodeNum.update { map -> - val current = map[nodeNum] ?: getOrCreateNode(nodeNum, channel) - val transformed = transform(current) - next = transformed - map.put(nodeNum, transformed) - } - val result = next ?: return - if (result.user.id.isNotEmpty()) { - _nodeDBbyID.update { it.put(result.user.id, result) } - } - - if (result.user.id.isNotEmpty() && isNodeDbReady.value) { - scope.handledLaunch { nodeRepository.upsert(result) } - } - - if (withBroadcast) { - serviceBroadcasts.broadcastNodeChange(result) - } - } - - override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) { - updateNode(fromNum) { node -> - val newNode = (node.isUnknownUser && p.hw_model != HardwareModel.UNSET) - val shouldPreserve = shouldPreserveExistingUser(node.user, p) - - val next = - if (shouldPreserve) { - node.copy(channel = channel, manuallyVerified = manuallyVerified) - } else { - val keyMatch = !node.hasPKC || node.user.public_key == p.public_key - val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY) - node.copy( - user = newUser, - publicKey = newUser.public_key, - channel = channel, - manuallyVerified = manuallyVerified, - ) - } - if (newNode && !shouldPreserve) { - scope.handledLaunch { - notificationManager.dispatch( - Notification( - title = getStringSuspend(Res.string.new_node_seen, next.user.short_name), - message = next.user.long_name, - category = Notification.Category.NodeEvent, - ), - ) - } - } - next - } - } - - override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) { - val isZeroPos = (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0 - @Suppress("ComplexCondition") - if (myNodeNum == fromNum && isZeroPos && p.sats_in_view == 0 && p.time == 0) { - Logger.d { "Ignoring empty position update for the local node" } - return - } - - updateNode(fromNum) { node -> - val posTime = if (p.time != 0) p.time else (defaultTime / TIME_MS_TO_S).toInt() - val newLastHeard = maxOf(node.lastHeard, posTime) - - val newPos = - if (isZeroPos) { - p.copy( - time = posTime, - latitude_i = node.position.latitude_i, - longitude_i = node.position.longitude_i, - altitude = p.altitude ?: node.position.altitude, - sats_in_view = p.sats_in_view, - ) - } else { - p.copy(time = posTime) - } - - node.copy(position = newPos, lastHeard = newLastHeard) - } - } - - override fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) { - updateNode(fromNum) { node -> - var nextNode = node - telemetry.device_metrics?.let { nextNode = nextNode.copy(deviceMetrics = it) } - telemetry.environment_metrics?.let { nextNode = nextNode.copy(environmentMetrics = it) } - telemetry.power_metrics?.let { nextNode = nextNode.copy(powerMetrics = it) } - val telemetryTime = if (telemetry.time != 0) telemetry.time else node.lastHeard - val newLastHeard = maxOf(node.lastHeard, telemetryTime) - nextNode.copy(lastHeard = newLastHeard) - } - } - - override fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) { - updateNode(fromNum) { it.copy(paxcounter = p) } - } - - override fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) { - updateNodeStatus(fromNum, s.status) - } - - override fun updateNodeStatus(nodeNum: Int, status: String?) { - updateNode(nodeNum) { it.copy(nodeStatus = status?.takeIf { s -> s.isNotEmpty() }) } - } - - override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) { - updateNode(info.num, withBroadcast = withBroadcast) { node -> - var next = node - val user = info.user - if (user != null) { - if (shouldPreserveExistingUser(node.user, user)) { - // keep existing names - } else { - var newUser = - user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } - if (info.via_mqtt && !newUser.long_name.endsWith(" (MQTT)")) { - newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)") - } - next = next.copy(user = newUser, publicKey = newUser.public_key) - } - } - val position = info.position - if (position != null) { - next = next.copy(position = position) - } - next = - next.copy( - lastHeard = info.last_heard, - deviceMetrics = info.device_metrics ?: next.deviceMetrics, - channel = info.channel, - viaMqtt = info.via_mqtt, - hopsAway = info.hops_away ?: -1, - isFavorite = info.is_favorite, - isIgnored = info.is_ignored, - isMuted = info.is_muted, - ) - next - } - } - - override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { - scope.handledLaunch { nodeRepository.insertMetadata(nodeNum, metadata) } - } - - private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean { - val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) - val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET - val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET - return hasExistingUser && isDefaultName && isDefaultHwModel - } - - override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) { - DataPacket.ID_BROADCAST - } else { - _nodeDBbyNodeNum.value[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum) - } - - private fun Node.toNodeInfo(): NodeInfo = NodeInfo( - num = num, - user = - MeshUser( - id = user.id, - longName = user.long_name, - shortName = user.short_name, - hwModel = user.hw_model, - role = user.role.value, - ), - position = - Position( - latitude = latitude, - longitude = longitude, - altitude = position.altitude ?: 0, - time = position.time, - satellitesInView = position.sats_in_view, - groundSpeed = position.ground_speed ?: 0, - groundTrack = position.ground_track ?: 0, - precisionBits = position.precision_bits, - ) - .takeIf { latitude != 0.0 || longitude != 0.0 }, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceMetrics = - DeviceMetrics( - batteryLevel = deviceMetrics.battery_level ?: 0, - voltage = deviceMetrics.voltage ?: 0f, - channelUtilization = deviceMetrics.channel_utilization ?: 0f, - airUtilTx = deviceMetrics.air_util_tx ?: 0f, - uptimeSeconds = deviceMetrics.uptime_seconds ?: 0, - ), - channel = channel, - environmentMetrics = EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0), - hopsAway = hopsAway, - nodeStatus = nodeStatus, - ) -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt deleted file mode 100644 index aa62b76b97..0000000000 --- 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 deleted file mode 100644 index 6504faf80f..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ /dev/null @@ -1,189 +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.Companion.toByteString -import okio.IOException -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.util.SfppHasher -import org.meshtastic.core.repository.HistoryManager -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.StoreForwardPacketHandler -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.StoreAndForward -import org.meshtastic.proto.StoreForwardPlusPlus -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 packetRepository: Lazy, - private val serviceBroadcasts: ServiceBroadcasts, - private val historyManager: HistoryManager, - private val dataHandler: Lazy, - @Named("ServiceScope") private val scope: CoroutineScope, -) : StoreForwardPacketHandler { - - override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val payload = packet.decoded?.payload ?: return - val u = StoreAndForward.ADAPTER.decode(payload) - handleReceivedStoreAndForward(dataPacket, u, myNodeNum) - } - - @Suppress("LongMethod", "ReturnCount") - override fun handleStoreForwardPlusPlus(packet: MeshPacket) { - val payload = packet.decoded?.payload ?: return - val sfpp = - try { - StoreForwardPlusPlus.ADAPTER.decode(payload) - } catch (e: IOException) { - Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" } - return - } - Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" } - - when (sfpp.sfpp_message_type) { - StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, - StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, - StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, - -> handleLinkProvide(sfpp) - - StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp) - - StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> { - Logger.i { "SF++: Node ${packet.from} is querying chain status" } - } - - StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> { - Logger.i { "SF++: Node ${packet.from} is requesting links" } - } - } - } - - private fun handleLinkProvide(sfpp: StoreForwardPlusPlus) { - val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE - - val status = if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED - - val hash = - when { - sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray() - - !isFragment && sfpp.message.size != 0 -> { - SfppHasher.computeMessageHash( - encryptedPayload = sfpp.message.toByteArray(), - to = - if (sfpp.encapsulated_to == 0) { - DataPacket.NODENUM_BROADCAST - } else { - sfpp.encapsulated_to - }, - from = sfpp.encapsulated_from, - id = sfpp.encapsulated_id, - ) - } - - else -> null - } ?: return - - Logger.d { - "SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " + - "to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum.value} status=$status" - } - scope.handledLaunch { - packetRepository.value.updateSFPPStatus( - packetId = sfpp.encapsulated_id, - from = sfpp.encapsulated_from, - to = sfpp.encapsulated_to, - hash = hash, - status = status, - rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, - myNodeNum = nodeManager.myNodeNum.value ?: 0, - ) - serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status) - } - } - - private fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus) { - scope.handledLaunch { - sfpp.message_hash.let { - packetRepository.value.updateSFPPStatusByHash( - hash = it.toByteArray(), - status = MessageStatus.SFPP_CONFIRMED, - rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, - ) - } - } - } - - private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) { - val lastRequest = s.history?.last_request ?: 0 - Logger.d { "StoreAndForward from=${dataPacket.from} lastRequest=$lastRequest" } - when { - s.stats != null -> { - val text = s.stats.toString() - val u = - dataPacket.copy( - bytes = text.encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ) - dataHandler.value.rememberDataPacket(u, myNodeNum) - } - - s.history != null -> { - val h = s.history!! - val text = - "Total messages: ${h.history_messages}\n" + - "History window: ${h.window.milliseconds.inWholeMinutes} min\n" + - "Last request: ${h.last_request}" - val u = - dataPacket.copy( - bytes = text.encodeToByteArray().toByteString(), - dataType = PortNum.TEXT_MESSAGE_APP.value, - ) - dataHandler.value.rememberDataPacket(u, myNodeNum) - historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown") - } - - s.heartbeat != null -> { - val hb = s.heartbeat!! - Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" } - } - - s.text != null -> { - if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { - dataPacket.to = DataPacket.ID_BROADCAST - } - val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value) - dataHandler.value.rememberDataPacket(u, myNodeNum) - } - - else -> {} - } - } -} 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 deleted file mode 100644 index 0780893960..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +++ /dev/null @@ -1,170 +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 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.nowSeconds -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 -import org.meshtastic.core.repository.TelemetryPacketHandler -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getStringSuspend -import org.meshtastic.core.resources.low_battery_message -import org.meshtastic.core.resources.low_battery_title -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.Telemetry -import kotlin.time.Duration.Companion.milliseconds - -/** - * Implementation of [TelemetryPacketHandler] that processes telemetry packets and manages battery-level notifications - * with cooldown logic. - */ -@Single -class TelemetryPacketHandlerImpl( - private val nodeManager: NodeManager, - private val connectionManager: Lazy, - private val notificationManager: NotificationManager, - @Named("ServiceScope") private val scope: CoroutineScope, -) : TelemetryPacketHandler { - - private val batteryMutex = Mutex() - private val batteryPercentCooldowns = mutableMapOf() - - @Suppress("LongMethod", "CyclomaticComplexMethod") - override fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { - val payload = packet.decoded?.payload ?: return - val t = - (Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let { - if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it - } - Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" } - val fromNum = packet.from - val isRemote = (fromNum != myNodeNum) - if (!isRemote) { - connectionManager.value.updateTelemetry(t) - } - - nodeManager.updateNode(fromNum) { node: Node -> - val metrics = t.device_metrics - val environment = t.environment_metrics - val power = t.power_metrics - - var nextNode = node - when { - metrics != null -> { - nextNode = nextNode.copy(deviceMetrics = metrics) - if (fromNum == myNodeNum || (isRemote && node.isFavorite)) { - if ( - (metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED && - (metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD - ) { - scope.launch { - if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) { - notificationManager.dispatch( - Notification( - title = - getStringSuspend( - Res.string.low_battery_title, - nextNode.user.short_name, - ), - message = - getStringSuspend( - Res.string.low_battery_message, - nextNode.user.long_name, - nextNode.deviceMetrics.battery_level ?: 0, - ), - category = Notification.Category.Battery, - ), - ) - } - } - } else { - scope.launch { - batteryMutex.withLock { - if (batteryPercentCooldowns.containsKey(fromNum)) { - batteryPercentCooldowns.remove(fromNum) - } - } - notificationManager.cancel(nextNode.num) - } - } - } - } - - environment != null -> nextNode = nextNode.copy(environmentMetrics = environment) - - power != null -> nextNode = nextNode.copy(powerMetrics = power) - } - - val telemetryTime = if (t.time != 0) t.time else nextNode.lastHeard - val newLastHeard = maxOf(nextNode.lastHeard, telemetryTime) - nextNode.copy(lastHeard = newLastHeard) - } - } - - @Suppress("ReturnCount") - private suspend fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean { - val isRemote = (fromNum != myNodeNum) - var shouldDisplay = false - var forceDisplay = false - val metrics = t.device_metrics ?: return false - val batteryLevel = metrics.battery_level ?: 0 - when { - batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> { - shouldDisplay = true - forceDisplay = true - } - - batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true - - batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true - - isRemote -> shouldDisplay = true - } - if (shouldDisplay) { - val now = nowSeconds - batteryMutex.withLock { - if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L - if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) { - batteryPercentCooldowns[fromNum] = now - return true - } - } - } - return false - } - - companion object { - private const val BATTERY_PERCENT_UNSUPPORTED = 0.0 - private const val BATTERY_PERCENT_LOW_THRESHOLD = 20 - private const val BATTERY_PERCENT_LOW_DIVISOR = 5 - private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5 - private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500 - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt deleted file mode 100644 index e3668df393..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt +++ /dev/null @@ -1,118 +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.atomicfu.update -import kotlinx.collections.immutable.persistentMapOf -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.NumberFormatter -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.model.fullRouteDiscovery -import org.meshtastic.core.model.getTracerouteResponse -import org.meshtastic.core.model.service.TracerouteResponse -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.repository.TracerouteHandler -import org.meshtastic.core.repository.TracerouteSnapshotRepository -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getStringSuspend -import org.meshtastic.core.resources.traceroute_route_back_to_us -import org.meshtastic.core.resources.traceroute_route_towards_dest -import org.meshtastic.proto.MeshPacket - -@Single -class TracerouteHandlerImpl( - private val serviceRepository: ServiceRepository, - private val tracerouteSnapshotRepository: TracerouteSnapshotRepository, - private val nodeRepository: NodeRepository, - @Named("ServiceScope") private val scope: CoroutineScope, -) : TracerouteHandler { - - private val startTimes = atomic(persistentMapOf()) - - override fun recordStartTime(requestId: Int) { - startTimes.update { it.put(requestId, nowMillis) } - } - - override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) { - // Decode the route discovery once — avoids triple protobuf decode - val routeDiscovery = packet.fullRouteDiscovery ?: return - val forwardRoute = routeDiscovery.route - val returnRoute = routeDiscovery.route_back - - // Require both directions for a "full" traceroute response - if (forwardRoute.isEmpty() || returnRoute.isEmpty()) return - - scope.handledLaunch { - val full = - routeDiscovery.getTracerouteResponse( - getUser = { num -> - val user = nodeRepository.getUser(num) - "${user.long_name} (${user.short_name})" - }, - headerTowards = getStringSuspend(Res.string.traceroute_route_towards_dest), - headerBack = getStringSuspend(Res.string.traceroute_route_back_to_us), - ) - - val requestId = packet.decoded?.request_id ?: 0 - - if (logUuid != null) { - logInsertJob?.join() - val routeNodeNums = (forwardRoute + returnRoute).distinct() - val nodeDbByNum = nodeRepository.nodeDBbyNum.value - val snapshotPositions = - routeNodeNums.mapNotNull { num -> nodeDbByNum[num]?.validPosition?.let { num to it } }.toMap() - tracerouteSnapshotRepository.upsertSnapshotPositions(logUuid, requestId, snapshotPositions) - } - - val start = startTimes.value[requestId] - startTimes.update { it.remove(requestId) } - val responseText = - if (start != null) { - val elapsedMs = nowMillis - start - val seconds = elapsedMs / MILLIS_PER_SECOND - Logger.i { "Traceroute $requestId complete in $seconds s" } - "$full\n\nDuration: ${NumberFormatter.format(seconds, 1)} s" - } else { - full - } - - val destination = forwardRoute.firstOrNull() ?: returnRoute.lastOrNull() ?: 0 - - serviceRepository.setTracerouteResponse( - TracerouteResponse( - message = responseText, - destinationNodeNum = destination, - requestId = requestId, - forwardRoute = forwardRoute, - returnRoute = returnRoute, - logUuid = logUuid, - ), - ) - } - } - - companion object { - private const val MILLIS_PER_SECOND = 1000.0 - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MeshTopologyService.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MeshTopologyService.kt new file mode 100644 index 0000000000..a9d72dcda7 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MeshTopologyService.kt @@ -0,0 +1,93 @@ +/* + * 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.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Single +import org.meshtastic.sdk.MeshTopology +import org.meshtastic.sdk.NeighborInfo +import org.meshtastic.sdk.NodeId + +/** + * Thread-safe wrapper around SDK's [MeshTopology] graph utility. + * + * Fed by [SdkStateBridge] whenever a NEIGHBORINFO_APP packet arrives. Exposes reactive + * topology state for feature modules (map visualization, route analysis, neighbor lists). + * + * The graph is incrementally built: each [ingestNeighborInfo] call replaces all edges from + * the reporting node, keeping the topology fresh as nodes broadcast their neighbor tables. + */ +@Single +class MeshTopologyService { + private val topology = MeshTopology() + private val mutex = Mutex() + + private val _edges = MutableStateFlow>(emptyList()) + /** All directed edges in the mesh topology graph. */ + val edges: StateFlow> = _edges + + private val _nodeCount = MutableStateFlow(0) + /** Total number of nodes participating in the topology (reporters + reported neighbors). */ + val nodeCount: StateFlow = _nodeCount + + /** + * Ingest a [NeighborInfo] report into the topology graph. + * Replaces all prior edges from the reporting node. + */ + suspend fun ingestNeighborInfo(info: NeighborInfo) { + mutex.withLock { + topology.addNeighborInfo(info) + _edges.value = topology.allEdges() + _nodeCount.value = topology.nodes().size + } + Logger.d { "[Topology] Ingested neighbors from ${info.nodeId}: ${info.neighbors.size} edges" } + } + + /** Remove a node from the topology (e.g., when it goes permanently offline). */ + suspend fun removeNode(nodeId: NodeId) { + mutex.withLock { + topology.removeNode(nodeId) + _edges.value = topology.allEdges() + _nodeCount.value = topology.nodes().size + } + } + + /** Get all neighbors of a specific node (thread-safe snapshot). */ + suspend fun getNeighbors(nodeId: NodeId): List = + mutex.withLock { topology.getNeighbors(nodeId) } + + /** Find the shortest path between two nodes via BFS. */ + suspend fun shortestPath(from: NodeId, to: NodeId): List = + mutex.withLock { topology.shortestPath(from, to) } + + /** Check if two nodes have a direct edge in either direction. */ + suspend fun isDirectReach(a: NodeId, b: NodeId): Boolean = + mutex.withLock { topology.isDirectReach(a, b) } + + /** Clear all topology data (e.g., on disconnect). */ + suspend fun clear() { + mutex.withLock { + topology.clear() + _edges.value = emptyList() + _nodeCount.value = 0 + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTracker.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTracker.kt new file mode 100644 index 0000000000..d54a1d8578 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTracker.kt @@ -0,0 +1,111 @@ +/* + * 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.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.sdk.MessageHandle +import org.meshtastic.sdk.RetryPolicy +import org.meshtastic.sdk.SendOutcome +import org.meshtastic.sdk.SendState +import org.meshtastic.sdk.retryWith +import kotlin.time.Duration.Companion.seconds + +/** + * Tracks in-flight message delivery via SDK [MessageHandle]s. + * Maps SDK [SendState] transitions to app [MessageStatus] and persists updates. + */ +@Single +class MessageDeliveryTracker( + private val packetRepository: Lazy, + dispatchers: CoroutineDispatchers, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) + private val activeHandles = mutableMapOf() + private val activeHandlesMutex = Mutex() + private val defaultRetryPolicy = RetryPolicy.ExponentialBackoff(maxAttempts = 3, initialDelay = 2.seconds) + + /** + * Begin tracking a [MessageHandle] for the given packet ID. + * Observes intermediate state transitions and resolves the terminal status via SDK retries. + */ + fun track(packetId: Int, handle: MessageHandle, policy: RetryPolicy = defaultRetryPolicy) { + scope.launch { + activeHandlesMutex.withLock { + activeHandles[packetId] = handle + } + + val repository = packetRepository.value + val stateObserver = launch { + handle.state + .onEach { state -> + val status = mapObservedState(state) + Logger.d { "[DeliveryTracker] Packet $packetId state=$state → $status" } + repository.updateMessageStatus(packetId, status) + } + .first { state -> state.isTerminal() } + } + + try { + val outcome = handle.retryWith(policy) + stateObserver.join() + val status = outcome.toMessageStatus() + Logger.d { "[DeliveryTracker] Packet $packetId outcome=$outcome → $status" } + repository.updateMessageStatus(packetId, status) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Logger.e(e) { "[DeliveryTracker] Packet $packetId retry tracking failed" } + repository.updateMessageStatus(packetId, MessageStatus.ERROR) + } finally { + stateObserver.cancel() + activeHandlesMutex.withLock { + if (activeHandles[packetId] === handle) { + activeHandles.remove(packetId) + } + } + } + } + } + + private fun mapObservedState(state: SendState): MessageStatus = when (state) { + SendState.Queued -> MessageStatus.QUEUED + SendState.Sent -> MessageStatus.ENROUTE + SendState.Acked -> MessageStatus.DELIVERED + SendState.Delivered -> MessageStatus.DELIVERED + is SendState.Failed -> MessageStatus.ENROUTE + } + + private fun SendOutcome.toMessageStatus(): MessageStatus = when (this) { + SendOutcome.Success -> MessageStatus.DELIVERED + is SendOutcome.Failure -> MessageStatus.ERROR + } + + private fun SendState.isTerminal(): Boolean = + this is SendState.Acked || this is SendState.Delivered || this is SendState.Failed +} 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 0000000000..71467725af --- /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/SdkEventBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkEventBridge.kt new file mode 100644 index 0000000000..899ed69579 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkEventBridge.kt @@ -0,0 +1,60 @@ +/* + * 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.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.ClientNotification +import org.meshtastic.sdk.MeshEvent + +internal class SdkEventBridge( + private val serviceRepository: ServiceRepository, +) { + fun observe(accessor: RadioClientAccessor, scope: CoroutineScope) { + accessor.client + .flatMapLatest { client -> client?.events ?: emptyFlow() } + .onEach(::handleEvent) + .launchIn(scope) + } + + internal fun handleEvent(event: MeshEvent) { + when (event) { + is MeshEvent.DeviceRebooted -> { + Logger.i { "[SdkBridge] Device rebooted" } + serviceRepository.setClientNotification( + ClientNotification(message = "Device rebooted"), + ) + } + + is MeshEvent.CongestionWarning -> { + Logger.w { + "[SdkBridge] Congestion warning: level=${event.metrics.level}, airUtil=${event.metrics.airUtilTx}%, channelUtil=${event.metrics.channelUtil}%" + } + serviceRepository.setCongestionLevel(event.metrics.level) + } + + is MeshEvent.SecurityWarning -> Logger.w { "[SdkBridge] Security warning: $event" } + is MeshEvent.PacketsDropped -> Logger.w { "[SdkBridge] Packets dropped: ${event.count} from ${event.flow}" } + else -> Logger.d { "[SdkBridge] Event: $event" } + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkNodeBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkNodeBridge.kt new file mode 100644 index 0000000000..5d50f178d2 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkNodeBridge.kt @@ -0,0 +1,102 @@ +/* + * 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.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.meshtastic.core.common.util.nowSeconds +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.util.onlineTimeThreshold +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.sdk.NodeChange + +internal class SdkNodeBridge( + private val nodeRepository: NodeRepository, + private val topologyService: MeshTopologyService, +) { + fun observe(accessor: RadioClientAccessor, scope: CoroutineScope) { + accessor.client + .flatMapLatest { client -> client?.nodes ?: emptyFlow() } + .onEach(::handleNodeChange) + .launchIn(scope) + + accessor.client + .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } + .onEach { ownNode -> if (ownNode != null) nodeRepository.setMyNodeNum(ownNode.num) } + .launchIn(scope) + + accessor.client + .flatMapLatest { client -> client?.packets ?: emptyFlow() } + .filter { it.decoded?.portnum == PortNum.NODE_STATUS_APP } + .onEach(::handleNodeStatusPacket) + .launchIn(scope) + } + + internal suspend fun handleNodeChange(change: NodeChange) { + when (change) { + is NodeChange.Snapshot -> { + nodeRepository.clear() + topologyService.clear() + change.nodes.forEach { (_, nodeInfo) -> + nodeRepository.installNodeInfo(nodeInfo, withBroadcast = false) + } + nodeRepository.setNodeDbReady(true) + } + + 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) + is NodeChange.WentOffline -> handleWentOffline(change) + is NodeChange.CameOnline -> handleCameOnline(change) + } + } + + internal fun handleNodeStatusPacket(packet: MeshPacket) { + val status = packet.decoded?.payload?.utf8() ?: return + nodeRepository.updateNode(packet.from) { it.copy(nodeStatus = status) } + } + + private fun handleWentOffline(change: NodeChange.WentOffline) { + val nodeNum = change.nodeId.raw + Logger.d { + "[SdkBridge] Node ${DataPacket.nodeNumToDefaultId(nodeNum)} went offline (last heard: ${change.lastHeard})" + } + if (nodeRepository.nodeDBbyNodeNum.containsKey(nodeNum)) { + nodeRepository.updateNode(nodeNum) { node -> + node.copy(lastHeard = minOf(node.lastHeard, change.lastHeard, onlineTimeThreshold())) + } + } + } + + private fun handleCameOnline(change: NodeChange.CameOnline) { + val nodeNum = change.nodeId.raw + Logger.d { "[SdkBridge] Node ${DataPacket.nodeNumToDefaultId(nodeNum)} came online" } + if (nodeRepository.nodeDBbyNodeNum.containsKey(nodeNum)) { + nodeRepository.updateNode(nodeNum) { node -> + node.copy(lastHeard = maxOf(node.lastHeard, nowSeconds.toInt())) + } + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketBridge.kt new file mode 100644 index 0000000000..5ec74bac55 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketBridge.kt @@ -0,0 +1,118 @@ +/* + * 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.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.sdk.StoreForwardEvent + +internal class SdkPacketBridge( + private val serviceRepository: ServiceRepository, + private val packetRepository: Lazy, + private val nodeRepository: NodeRepository, +) { + fun observe(accessor: RadioClientAccessor, scope: CoroutineScope) { + accessor.client + .flatMapLatest { client -> client?.packets ?: emptyFlow() } + .onEach { packet -> serviceRepository.emitMeshPacket(packet) } + .launchIn(scope) + + accessor.client + .flatMapLatest { client -> + client?.storeForward?.servers + ?.map { servers -> servers.map { it.raw } } + ?: flowOf(emptyList()) + } + .onEach { servers -> serviceRepository.setStoreForwardServers(servers) } + .launchIn(scope) + + accessor.client + .flatMapLatest { client -> client?.storeForward?.events ?: emptyFlow() } + .onEach(::handleStoreForwardEvent) + .launchIn(scope) + } + + internal suspend fun handleStoreForwardEvent(event: StoreForwardEvent) { + when (event) { + is StoreForwardEvent.ServerDiscovered -> { + Logger.i { + "[SdkBridge] S&F server discovered: ${DataPacket.nodeNumToDefaultId(event.nodeId.raw)}" + } + } + + is StoreForwardEvent.ServerLost -> { + Logger.i { + "[SdkBridge] S&F server lost: ${DataPacket.nodeNumToDefaultId(event.nodeId.raw)}" + } + } + + is StoreForwardEvent.HistoryReplayStarted -> { + Logger.i { + "[SdkBridge] S&F history replay started from " + + "${DataPacket.nodeNumToDefaultId(event.server.raw)} count=${event.messageCount}" + } + } + + is StoreForwardEvent.HistoryReplayComplete -> { + Logger.i { + "[SdkBridge] S&F history replay complete from " + + "${DataPacket.nodeNumToDefaultId(event.server.raw)} delivered=${event.delivered}" + } + } + + is StoreForwardEvent.Heartbeat -> { + Logger.d { + "[SdkBridge] S&F heartbeat from ${DataPacket.nodeNumToDefaultId(event.server.raw)}" + } + } + + is StoreForwardEvent.SfppLinkProvided -> { + event.messageHash?.let { hash -> + val status = if (event.confirmed) MessageStatus.SFPP_CONFIRMED else MessageStatus.SFPP_ROUTING + packetRepository.value.updateSFPPStatus( + packetId = event.packetId, + from = event.from, + to = event.to, + hash = hash, + status = status, + rxTime = 0L, + myNodeNum = nodeRepository.myNodeNum.value ?: 0, + ) + } + } + + is StoreForwardEvent.SfppCanonAnnounced -> { + packetRepository.value.updateSFPPStatusByHash( + hash = event.messageHash, + status = MessageStatus.SFPP_CONFIRMED, + rxTime = event.rxTime, + ) + } + } + } +} 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 0000000000..68ce0a897a --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketHandler.kt @@ -0,0 +1,93 @@ +/* + * 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.model.MeshActivity +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.ServiceRepository +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 serviceRepository: ServiceRepository, + 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) + serviceRepository.emitMeshActivity(MeshActivity.Send) + } + + 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/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 new file mode 100644 index 0000000000..fb32e0af70 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -0,0 +1,411 @@ +/* + * 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.atomicfu.atomic +import kotlinx.coroutines.flow.StateFlow +import org.koin.core.annotation.Single +import org.meshtastic.core.model.AdminException +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.DataRequester +import org.meshtastic.core.model.DeviceAdmin +import org.meshtastic.core.model.DeviceAdminEdit +import org.meshtastic.core.model.DeviceControl +import org.meshtastic.core.model.MeshActivity +import org.meshtastic.core.model.MessageSender +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.RemoteAdmin +import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioPrefs +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User +import org.meshtastic.sdk.AdminResult +import org.meshtastic.sdk.ChannelIndex +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.RadioClient + +/** + * Shared KMP [RadioController] implementation that delegates all operations through the meshtastic-sdk. + * + * 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 by [SdkStateBridge], which feeds SDK flows into + * [ServiceRepository] and [org.meshtastic.core.repository.NodeRepository]. + */ +@Single( + binds = [ + RadioController::class, + MessageSender::class, + DeviceAdmin::class, + RemoteAdmin::class, + DeviceControl::class, + DataRequester::class, + ], +) +@Suppress("TooManyFunctions", "LongParameterList") +class SdkRadioController( + private val accessor: RadioClientAccessor, + private val serviceRepository: ServiceRepository, + private val nodeRepository: NodeRepository, + private val locationManager: MeshLocationManager, + private val deliveryTracker: MessageDeliveryTracker, + private val radioPrefs: RadioPrefs, +) : RadioController { + + private val packetIdCounter = atomic(1) + + private val client: RadioClient? + get() = accessor.client.value + + private fun requireClient(): RadioClient { + return client ?: run { + Logger.w { "SdkRadioController: no active RadioClient" } + throw IllegalStateException("RadioClient not connected") + } + } + + // ── Observable state ──────────────────────────────────────────────────── + + override val connectionState: StateFlow + get() = serviceRepository.connectionState + + override val clientNotification: StateFlow + get() = serviceRepository.clientNotification + + override fun clearClientNotification() { + serviceRepository.clearClientNotification() + } + + // ── Messaging ─────────────────────────────────────────────────────────── + + override suspend fun sendMessage(packet: DataPacket) { + val c = client ?: run { + Logger.w { "sendMessage: no client, dropping packet" } + return + } + val packetId = packet.id.takeIf { it != 0 } ?: getPacketId() + try { + val handle = c.send( + portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP, + payload = packet.bytes?.toByteArray() ?: byteArrayOf(), + to = NodeId(packet.to), + channel = ChannelIndex(packet.channel), + wantAck = packet.wantAck, + hopLimit = packet.hopLimit.takeIf { it > 0 }, + ) + deliveryTracker.track(packetId, handle) + serviceRepository.emitMeshActivity(MeshActivity.Send) + } catch (e: Exception) { + Logger.e(e) { "sendMessage failed" } + throw e + } + } + + // ── Node operations ───────────────────────────────────────────────────── + + override suspend fun favoriteNode(nodeNum: Int) { + val c = requireClient() + val node = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) + val currentlyFavorite = node.isFavorite + c.admin.setFavorite(NodeId(nodeNum), !currentlyFavorite) + } + + override suspend fun sendSharedContact(nodeNum: Int): Boolean { + val c = client ?: return false + val node = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) + val contact = SharedContact( + node_num = node.num, + user = node.user, + manually_verified = node.manuallyVerified, + ) + return when (c.admin.addContact(contact)) { + is AdminResult.Success -> true + else -> false + } + } + + // ── Local config ──────────────────────────────────────────────────────── + + override suspend fun setLocalConfig(config: Config) { + val c = requireClient() + c.admin.setConfig(config) + } + + override suspend fun setLocalChannel(channel: Channel) { + val c = requireClient() + c.admin.setChannel(channel) + } + + // ── Remote admin (config/owner/channel) ───────────────────────────────── + + override suspend fun setOwner(destNum: Int, user: User) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).setOwner(user).unwrap() + } + + override suspend fun setConfig(destNum: Int, config: Config) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).setConfig(config).unwrap() + } + + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).setModuleConfig(config).unwrap() + } + + override suspend fun setRemoteChannel(destNum: Int, channel: Channel) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).setChannel(channel).unwrap() + } + + override suspend fun setFixedPosition(destNum: Int, position: Position) { + val c = requireClient() + val protoPos = org.meshtastic.proto.Position( + latitude_i = Position.degI(position.latitude), + longitude_i = Position.degI(position.longitude), + altitude = position.altitude, + time = position.time, + ) + c.admin.forNode(NodeId(destNum)).setFixedPosition(protoPos) + } + + override suspend fun setRingtone(destNum: Int, ringtone: String) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).setRingtone(ringtone) + } + + override suspend fun setCannedMessages(destNum: Int, messages: String) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).setCannedMessages(messages) + } + + // ── Remote admin (getters) ────────────────────────────────────────────── + + override suspend fun getOwner(destNum: Int): User { + val c = requireClient() + return c.admin.forNode(NodeId(destNum)).getOwner().unwrap() + } + + override suspend fun getConfig(destNum: Int, configType: Int): Config { + val c = requireClient() + val type = AdminMessage.ConfigType.fromValue(configType) + ?: throw IllegalArgumentException("Unknown config type: $configType") + return c.admin.forNode(NodeId(destNum)).getConfig(type).unwrap() + } + + override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): ModuleConfig { + val c = requireClient() + val type = AdminMessage.ModuleConfigType.fromValue(moduleConfigType) + ?: throw IllegalArgumentException("Unknown module config type: $moduleConfigType") + return c.admin.forNode(NodeId(destNum)).getModuleConfig(type).unwrap() + } + + override suspend fun getChannel(destNum: Int, index: Int): Channel { + val c = requireClient() + return c.admin.forNode(NodeId(destNum)).getChannel(ChannelIndex(index)).unwrap() + } + + override suspend fun listChannels(destNum: Int): List { + val c = requireClient() + return c.admin.forNode(NodeId(destNum)).listChannels().unwrap() + } + + override suspend fun getRingtone(destNum: Int): String { + val c = requireClient() + return c.admin.forNode(NodeId(destNum)).getRingtone().unwrap() + } + + override suspend fun getCannedMessages(destNum: Int): String { + val c = requireClient() + return c.admin.forNode(NodeId(destNum)).getCannedMessages().unwrap() + } + + override suspend fun getDeviceConnectionStatus(destNum: Int): DeviceConnectionStatus { + val c = requireClient() + return c.admin.forNode(NodeId(destNum)).getDeviceConnectionStatus().unwrap() + } + + // ── Lifecycle commands ─────────────────────────────────────────────────── + + override suspend fun reboot(destNum: Int) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).reboot().unwrap() + } + + override suspend fun rebootToDfu(nodeNum: Int) { + val c = requireClient() + c.admin.forNode(NodeId(nodeNum)).enterDfuMode().unwrap() + } + + override suspend fun requestRebootOta(destNum: Int, mode: Int, hash: ByteArray?) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).rebootOta().unwrap() + } + + override suspend fun shutdown(destNum: Int) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).shutdown().unwrap() + } + + override suspend fun factoryReset(destNum: Int) { + val c = requireClient() + c.admin.forNode(NodeId(destNum)).factoryReset().unwrap() + } + + override suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean) { + val c = requireClient() + // Note: The firmware's nodedb_reset command always preserves favorites (proto3 boolean + // semantics — only true can be encoded). The preserveFavorites parameter only affects + // local Room DB cleanup in AdminActionsUseCase. + c.admin.forNode(NodeId(destNum)).nodeDbReset().unwrap() + } + + override suspend fun removeByNodenum(nodeNum: Int) { + val c = requireClient() + c.admin.removeNode(NodeId(nodeNum)).unwrap() + } + + // ── Data requests ─────────────────────────────────────────────────────── + + override suspend fun requestPosition(destNum: Int, currentPosition: Position) { + val c = client ?: return + val posBytes = org.meshtastic.proto.Position( + latitude_i = Position.degI(currentPosition.latitude), + longitude_i = Position.degI(currentPosition.longitude), + altitude = currentPosition.altitude, + time = currentPosition.time, + ).encode() + c.send( + portnum = PortNum.POSITION_APP, + payload = posBytes, + to = NodeId(destNum), + wantAck = true, + ) + } + + override suspend fun requestUserInfo(destNum: Int) { + val c = client ?: return + c.requestNodeInfo(NodeId(destNum)) + } + + override suspend fun requestTraceroute(destNum: Int) { + val c = requireClient() + c.routing.traceRoute(NodeId(destNum)) + } + + override suspend fun requestTelemetry(destNum: Int, type: TelemetryType) { + val c = requireClient() + val node = NodeId(destNum) + when (type) { + TelemetryType.DEVICE -> c.telemetry.requestDevice(node) + TelemetryType.ENVIRONMENT -> c.telemetry.requestEnvironment(node) + TelemetryType.AIR_QUALITY -> c.telemetry.requestAirQuality(node) + TelemetryType.POWER -> c.telemetry.requestPower(node) + TelemetryType.LOCAL_STATS -> c.telemetry.requestLocalStats() + TelemetryType.HEALTH -> c.telemetry.requestHealth(node) + TelemetryType.HOST -> c.telemetry.requestHost(node) + TelemetryType.TRAFFIC_MANAGEMENT -> c.telemetry.requestTrafficManagement(node) + } + } + + override suspend fun requestNeighborInfo(destNum: Int) { + val c = requireClient() + c.routing.requestNeighborInfo(NodeId(destNum)) + } + + override suspend fun requestStoreForwardHistory(since: Int?, serverNodeNum: Int?): Boolean { + val c = requireClient() + val server = serverNodeNum?.let(::NodeId) + return when (val result = c.storeForward.requestHistory(since = since, server = server)) { + is AdminResult.Success -> { + Logger.i { + "Requested S&F history since=${since ?: 0} server=${serverNodeNum ?: "auto"} pending=${result.value}" + } + true + } + else -> { + Logger.w { + "S&F history request failed since=${since ?: 0} server=${serverNodeNum ?: "auto"} result=$result" + } + false + } + } + } + + // ── Edit settings (transactional) ─────────────────────────────────────── + + override suspend fun editSettings(destNum: Int, block: suspend DeviceAdminEdit.() -> Unit) { + val c = requireClient() + val admin = c.admin.forNode(NodeId(destNum)) + admin.editSettings { + val edit = this + val bridge = object : DeviceAdminEdit { + override suspend fun setConfig(config: Config) { edit.setConfig(config) } + override suspend fun setModuleConfig(config: ModuleConfig) { edit.setModuleConfig(config) } + override suspend fun setOwner(user: User) { edit.setOwner(user) } + override suspend fun setChannel(channel: Channel) { edit.setChannel(channel) } + } + block(bridge) + }.unwrap() + } + + // ── Utility ───────────────────────────────────────────────────────────── + + private fun getPacketId(): Int = packetIdCounter.getAndIncrement() + + override fun startProvideLocation() { + // Location provision is managed at the app level; no-op here + } + + override fun stopProvideLocation() { + locationManager.stop() + } + + override fun setDeviceAddress(address: String) { + radioPrefs.setDevAddr(address) + accessor.rebuildAndConnectAsync() + } + + // ── Private helpers ───────────────────────────────────────────────────── + + /** Unwrap an [AdminResult], returning the value on success or throwing [AdminException] on failure. */ + private fun AdminResult.unwrap(): T = when (this) { + is AdminResult.Success -> value + is AdminResult.Timeout -> throw AdminException.Timeout() + is AdminResult.Unauthorized -> throw AdminException.Unauthorized() + is AdminResult.NodeUnreachable -> throw AdminException.NodeUnreachable() + is AdminResult.SessionKeyExpired -> throw AdminException.SessionKeyExpired() + is AdminResult.RateLimited -> throw AdminException.RateLimited() + is AdminResult.Failed -> throw AdminException.RoutingError(routingError.name) + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceService.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceService.kt new file mode 100644 index 0000000000..a3ed6ea57b --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceService.kt @@ -0,0 +1,67 @@ +/* + * 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.koin.core.annotation.Single +import org.meshtastic.core.model.DeviceType +import org.meshtastic.core.model.InterfaceId +import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioPrefs + +/** + * SDK-backed implementation of [RadioInterfaceService]. + * + * Delegates device-address management to [RadioPrefs] and connection lifecycle to [RadioClientAccessor]. + * The heavy transport work (BLE, TCP, Serial) is handled by the SDK internally. + */ +@Single(binds = [RadioInterfaceService::class]) +class SdkRadioInterfaceService( + private val radioPrefs: RadioPrefs, + private val accessor: RadioClientAccessor, +) : RadioInterfaceService { + + override val supportedDeviceTypes: List = + listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB) + + override val currentDeviceAddressFlow: StateFlow = radioPrefs.devAddr + + override fun isMockTransport(): Boolean { + val addr = radioPrefs.devAddr.value ?: return false + return addr.firstOrNull() == InterfaceId.MOCK.id + } + + override fun getDeviceAddress(): String? = radioPrefs.devAddr.value + + override fun setDeviceAddress(deviceAddr: String?): Boolean { + val current = radioPrefs.devAddr.value + if (current == deviceAddr) return false + radioPrefs.setDevAddr(deviceAddr) + return true + } + + override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = + "${interfaceId.id}$rest" + + override fun connect() { + accessor.rebuildAndConnectAsync() + } + + override suspend fun disconnect() { + accessor.disconnect() + } +} 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 new file mode 100644 index 0000000000..a506394f51 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -0,0 +1,249 @@ +/* + * 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.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 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.MessageStatus +import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position +import org.meshtastic.proto.User +import org.meshtastic.sdk.AdminResult +import org.meshtastic.sdk.ChannelIndex +import org.meshtastic.sdk.ConnectionState as SdkConnectionState +import org.meshtastic.sdk.NodeId + +/** + * 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 [NodeRepository] so that existing feature-module UI code (which observes those repositories) + * continues to work without modification. + */ +@Single +class SdkStateBridge( + private val accessor: RadioClientAccessor, + private val serviceRepository: ServiceRepository, + private val nodeRepository: NodeRepository, + private val packetRepository: Lazy, + private val locationManager: MeshLocationManager, + topologyService: MeshTopologyService, + private val uiPrefs: UiPrefs, + private val dispatchers: CoroutineDispatchers, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) + private var locationJob: Job? = null + + private val nodeBridge = SdkNodeBridge(nodeRepository = nodeRepository, topologyService = topologyService) + private val packetBridge = SdkPacketBridge( + serviceRepository = serviceRepository, + packetRepository = packetRepository, + nodeRepository = nodeRepository, + ) + private val topologyBridge = SdkTopologyBridge(topologyService = topologyService) + private val eventBridge = SdkEventBridge(serviceRepository = serviceRepository) + + init { + bind() + } + + private fun bind() { + bindConnectionState() + nodeBridge.observe(accessor, scope) + packetBridge.observe(accessor, scope) + topologyBridge.observe(accessor, scope) + eventBridge.observe(accessor, scope) + bindServiceActions() + bindLocationPublishing() + Logger.i { "SdkStateBridge started — SDK owns transport + ServiceAction dispatch" } + } + + private fun bindConnectionState() { + accessor.client + .flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) } + .onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) } + .launchIn(scope) + } + + private fun bindServiceActions() { + serviceRepository.serviceAction + .onEach { action -> handleServiceAction(action) } + .launchIn(scope) + } + + private fun bindLocationPublishing() { + 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 client = accessor.client.value ?: return@launch + val posBytes = org.meshtastic.proto.Position.ADAPTER.encode(pos) + client.send( + portnum = PortNum.POSITION_APP, + payload = posBytes, + wantAck = false, + ) + } + } + } else { + locationManager.stop() + } + } + .launchIn(scope) + } + } + .launchIn(scope) + } + + private suspend fun handleServiceAction(action: ServiceAction) { + 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) + return + } + + try { + dispatchAction(client, action) + } catch (e: Exception) { + Logger.e(e) { "[SdkBridge] ServiceAction ${action::class.simpleName} failed" } + if (action is ServiceAction.SendContact) action.result.complete(false) + } + } + + @Suppress("CyclomaticComplexMethod") + private suspend fun dispatchAction(client: org.meshtastic.sdk.RadioClient, action: ServiceAction) { + when (action) { + is ServiceAction.Favorite -> { + val node = action.node + val result = runCatching { client.admin.setFavorite(NodeId(node.num), !node.isFavorite) } + if (result.isSuccess) { + nodeRepository.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } + } else { + Logger.w(result.exceptionOrNull()) { "[SdkBridge] setFavorite failed for ${node.num}" } + } + } + + is ServiceAction.Ignore -> { + val node = action.node + val newIgnored = !node.isIgnored + val result = runCatching { client.admin.setIgnored(NodeId(node.num), newIgnored) } + if (result.isSuccess) { + nodeRepository.updateNode(node.num) { n -> + if (newIgnored) { + n.copy( + isIgnored = true, + position = Position(), + deviceMetrics = DeviceMetrics(), + publicKey = null, + ) + } else { + n.copy(isIgnored = false) + } + } + packetRepository.value.updateFilteredBySender(node.user.id, newIgnored) + } else { + Logger.w(result.exceptionOrNull()) { "[SdkBridge] setIgnored failed for ${node.num}" } + } + } + + is ServiceAction.Mute -> { + val node = action.node + val result = runCatching { client.admin.toggleMuted(NodeId(node.num)) } + if (result.isSuccess) { + nodeRepository.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } + } else { + Logger.w(result.exceptionOrNull()) { "[SdkBridge] toggleMuted failed for ${node.num}" } + } + } + + is ServiceAction.Reaction -> { + val channel = action.contactKey[0].digitToInt() + val destId = action.contactKey.substring(1) + val destNum = runCatching { DataPacket.parseNodeNum(destId) }.getOrDefault(DataPacket.BROADCAST) + client.sendReaction( + emoji = action.emoji, + to = NodeId(destNum), + channel = ChannelIndex(channel), + replyId = action.replyId, + ) + } + + is ServiceAction.ImportContact -> { + val verified = action.contact.copy(manually_verified = true) + val result = runCatching { client.admin.addContact(verified) } + if (result.isSuccess) { + nodeRepository.handleReceivedUser( + verified.node_num, + verified.user ?: User(), + manuallyVerified = true, + ) + } else { + Logger.w(result.exceptionOrNull()) { "[SdkBridge] importContact failed" } + } + } + + is ServiceAction.SendContact -> { + val result = runCatching { client.admin.addContact(action.contact) } + action.result.complete(result.getOrNull() is AdminResult.Success) + } + + is ServiceAction.GetDeviceMetadata -> { + client.admin.forNode(NodeId(action.destNum)).getDeviceMetadata() + } + } + } + + companion object { + private fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) { + is SdkConnectionState.Disconnected -> AppConnectionState.Disconnected + is SdkConnectionState.Connecting -> AppConnectionState.Connecting(attempt = sdkState.attempt) + is SdkConnectionState.Configuring -> AppConnectionState.Configuring( + phase = sdkState.phase.name, + progress = sdkState.progress, + ) + is SdkConnectionState.Connected -> AppConnectionState.Connected + is SdkConnectionState.Reconnecting -> AppConnectionState.Reconnecting(attempt = sdkState.attempt) + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkTopologyBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkTopologyBridge.kt new file mode 100644 index 0000000000..8a84b4e06b --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkTopologyBridge.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.radio + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.sdk.NeighborInfo + +internal class SdkTopologyBridge( + private val topologyService: MeshTopologyService, +) { + fun observe(accessor: RadioClientAccessor, scope: CoroutineScope) { + accessor.client + .flatMapLatest { client -> client?.packets ?: emptyFlow() } + .filter { it.decoded?.portnum == PortNum.NEIGHBORINFO_APP } + .onEach(::handleNeighborInfoPacket) + .launchIn(scope) + } + + internal suspend fun handleNeighborInfoPacket(packet: MeshPacket) { + val payload = packet.decoded?.payload?.toByteArray() ?: return + runCatching { + val proto = org.meshtastic.proto.NeighborInfo.ADAPTER.decode(payload) + val info = NeighborInfo.fromProto( + reportingNode = packet.from, + neighborNodeIds = proto.neighbors.map { it.node_id }, + snrValues = proto.neighbors.map { it.snr }, + timestamp = proto.last_sent_by_id, + ) + topologyService.ingestNeighborInfo(info) + }.onFailure { e -> Logger.w(e) { "[SdkBridge] Failed to parse NeighborInfo" } } + } +} 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 0000000000..faf5934463 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt @@ -0,0 +1,72 @@ +/* + * 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 + +/** + * Stores app-managed node metadata such as favorites, mute state, and notes. + */ +@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) { + dbManager.withDb { it.nodeMetadataDao().setFavoriteEnsuringExists(nodeNum, isFavorite) } + } + + override suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean) { + dbManager.withDb { it.nodeMetadataDao().setIgnoredEnsuringExists(nodeNum, isIgnored) } + } + + override suspend fun setMuted(nodeNum: Int, isMuted: Boolean) { + dbManager.withDb { it.nodeMetadataDao().setMutedEnsuringExists(nodeNum, isMuted) } + } + + override suspend fun setNotes(nodeNum: Int, notes: String) { + dbManager.withDb { it.nodeMetadataDao().setNotesEnsuringExists(nodeNum, notes) } + } + + override suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean) { + dbManager.withDb { it.nodeMetadataDao().setManuallyVerifiedEnsuringExists(nodeNum, verified) } + } + + override suspend fun delete(nodeNum: Int) { + dbManager.withDb { it.nodeMetadataDao().delete(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/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt index 1ff5657040..d72e669add 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt @@ -33,7 +33,9 @@ import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.network.DeviceHardwareRemoteDataSource import org.meshtastic.core.repository.DeviceHardwareRepository -// Annotating with Singleton to ensure a single instance manages the cache +/** + * Resolves device hardware metadata from cache, remote data, and bundled JSON fallbacks. + */ @Single class DeviceHardwareRepositoryImpl( private val remoteDataSource: DeviceHardwareRemoteDataSource, @@ -189,15 +191,17 @@ class DeviceHardwareRepositoryImpl( } } - private fun disambiguate(entities: List, target: String?): DeviceHardwareEntity? = when { - entities.isEmpty() -> null + private fun disambiguate(entities: List, target: String?): DeviceHardwareEntity? { + if (entities.isEmpty()) return null - target == null -> entities.first() + val preferred = entities.sortedWith(compareBy { it.isIncomplete() }.thenByDescending { it.lastUpdated }) - else -> { + return if (target == null) { + preferred.first() + } else { entities.find { it.platformioTarget == target } ?: entities.find { it.platformioTarget.equals(target, ignoreCase = true) } - ?: entities.first() + ?: preferred.first() } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt index cfb0452271..73dad222e9 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt @@ -32,6 +32,9 @@ import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource import org.meshtastic.core.repository.FirmwareReleaseRepository +/** + * Serves firmware release data from local cache, bundled assets, and remote updates. + */ @Single open class FirmwareReleaseRepositoryImpl( private val remoteDataSource: FirmwareReleaseRemoteDataSource, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt index 56799b7c7c..7b9b45540c 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt @@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.withContext import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.data.datasource.NodeInfoReadDataSource import org.meshtastic.core.database.DatabaseProvider import org.meshtastic.core.database.entity.asEntity import org.meshtastic.core.database.entity.asExternalModel @@ -36,6 +35,7 @@ import org.meshtastic.core.model.MeshLog import org.meshtastic.core.repository.MeshLogPrefs import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.MeshLogRepository.Companion.DEFAULT_MAX_LOGS +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.PortNum @@ -53,7 +53,7 @@ open class MeshLogRepositoryImpl( private val dbManager: DatabaseProvider, private val dispatchers: CoroutineDispatchers, private val meshLogPrefs: MeshLogPrefs, - private val nodeInfoReadDataSource: NodeInfoReadDataSource, + private val nodeRepository: NodeRepository, ) : MeshLogRepository { /** Retrieves all [MeshLog]s in the database, up to [maxItem]. */ @@ -142,8 +142,8 @@ open class MeshLogRepositoryImpl( .getOrNull() /** Returns a flow that maps a [nodeNum] to [MeshLog.NODE_NUM_LOCAL] if it is the locally connected node. */ - private fun effectiveLogId(nodeNum: Int): Flow = nodeInfoReadDataSource - .myNodeInfoFlow() + private fun effectiveLogId(nodeNum: Int): Flow = nodeRepository + .myNodeInfo .map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum } .distinctUntilChanged() @@ -169,7 +169,7 @@ open class MeshLogRepositoryImpl( /** Deletes all logs associated with a specific [nodeNum] and [portNum]. */ override suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(dispatchers.io) { - val myNodeNum = nodeInfoReadDataSource.myNodeInfoFlow().firstOrNull()?.myNodeNum + val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum val logId = if (nodeNum == myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum dbManager.currentDb.value.meshLogDao().deleteLogs(logId, portNum) } 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 14cc42b302..0000000000 --- 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 -@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/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index c47fe5bf15..46c9415273 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt @@ -21,6 +21,8 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest @@ -28,7 +30,6 @@ import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.database.DatabaseProvider -import org.meshtastic.core.database.dao.NodeInfoDao import org.meshtastic.core.database.entity.PacketEntity import org.meshtastic.core.database.entity.toReaction import org.meshtastic.core.di.CoroutineDispatchers @@ -45,17 +46,34 @@ import org.meshtastic.core.database.entity.Packet as RoomPacket import org.meshtastic.core.database.entity.ReactionEntity as RoomReaction import org.meshtastic.core.repository.PacketRepository as SharedPacketRepository +/** + * Provides reactive access to packets, messages, contacts, and related packet metadata. + */ @Suppress("TooManyFunctions", "LongParameterList") @Single -class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val dispatchers: CoroutineDispatchers) : - SharedPacketRepository { +class PacketRepositoryImpl( + private val dbManager: DatabaseProvider, + private val dispatchers: CoroutineDispatchers, + private val nodeRepository: org.meshtastic.core.repository.NodeRepository, +) : SharedPacketRepository { + + /** Current myNodeNum snapshot — 0 means "no node connected yet" (matches legacy behavior). */ + private val currentMyNodeNum: Int get() = nodeRepository.myNodeInfo.value?.myNodeNum ?: 0 + + /** Reactive myNodeNum flow, only re-emits when the number actually changes. */ + private val myNodeNumFlow: Flow = nodeRepository.myNodeInfo + .map { it?.myNodeNum ?: 0 } + .distinctUntilChanged() - override fun getWaypoints(): Flow> = dbManager.currentDb - .flatMapLatest { db -> db.packetDao().getAllWaypointsFlow() } + /** Cached upstream combining myNodeNum + currentDb — avoids creating duplicate flatMapLatest chains. */ + private val numAndDb = combine(myNodeNumFlow, dbManager.currentDb) { num, db -> num to db } + + override fun getWaypoints(): Flow> = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().getAllWaypointsFlow(num) } .map { list -> list.map { it.data } } - override fun getContacts(): Flow> = dbManager.currentDb - .flatMapLatest { db -> db.packetDao().getContactKeys() } + override fun getContacts(): Flow> = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().getContactKeys(num) } .map { map -> map.mapValues { it.value.data } } override fun getContactsPaged(): Flow> = Pager( @@ -65,34 +83,34 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val enablePlaceholders = false, initialLoadSize = CONTACTS_PAGE_SIZE, ), - pagingSourceFactory = { dbManager.currentDb.value.packetDao().getContactKeysPaged() }, + pagingSourceFactory = { dbManager.currentDb.value.packetDao().getContactKeysPaged(currentMyNodeNum) }, ) .flow .map { pagingData -> pagingData.map { it.data } } override suspend fun getMessageCount(contact: String): Int = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getMessageCount(contact) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getMessageCount(currentMyNodeNum, contact) } override suspend fun getUnreadCount(contact: String): Int = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(currentMyNodeNum, contact) } - override fun getUnreadCountFlow(contact: String): Flow = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountFlow(contact) } + override fun getUnreadCountFlow(contact: String): Flow = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().getUnreadCountFlow(num, contact) } - override fun getFirstUnreadMessageUuid(contact: String): Flow = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(contact) } + override fun getFirstUnreadMessageUuid(contact: String): Flow = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().getFirstUnreadMessageUuid(num, contact) } - override fun hasUnreadMessages(contact: String): Flow = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(contact) } + override fun hasUnreadMessages(contact: String): Flow = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().hasUnreadMessages(num, contact) } - override fun getUnreadCountTotal(): Flow = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() } + override fun getUnreadCountTotal(): Flow = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().getUnreadCountTotal(num) } override suspend fun clearUnreadCount(contact: String, timestamp: Long) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(currentMyNodeNum, contact, timestamp) } override suspend fun clearAllUnreadCounts() = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearAllUnreadCounts() } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearAllUnreadCounts(currentMyNodeNum) } override suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) = withContext(dispatchers.io) { @@ -111,7 +129,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val } override suspend fun getQueuedPackets(): List = withContext(dispatchers.io) { - dbManager.currentDb.value.packetDao().getAllDataPackets().filter { it.status == MessageStatus.QUEUED } + dbManager.currentDb.value.packetDao().getAllDataPackets(currentMyNodeNum).filter { it.status == MessageStatus.QUEUED } } suspend fun insertRoomPacket(packet: RoomPacket) = @@ -150,11 +168,12 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val getNode: suspend (String?) -> Node, ): Flow> = withContext(dispatchers.io) { val dao = dbManager.currentDb.value.packetDao() + val num = currentMyNodeNum val flow = when { - limit != null -> dao.getMessagesFrom(contact, limit) - !includeFiltered -> dao.getMessagesFrom(contact, includeFiltered = false) - else -> dao.getMessagesFrom(contact) + limit != null -> dao.getMessagesFrom(num, contact, limit) + !includeFiltered -> dao.getMessagesFrom(num, contact, includeFiltered = false) + else -> dao.getMessagesFrom(num, contact) } flow.mapLatest { packets -> val cachedGetNode = memoize(getNode) @@ -177,7 +196,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val enablePlaceholders = false, initialLoadSize = MESSAGES_PAGE_SIZE, ), - pagingSourceFactory = { dbManager.currentDb.value.packetDao().getMessagesFromPaged(contact) }, + pagingSourceFactory = { dbManager.currentDb.value.packetDao().getMessagesFromPaged(currentMyNodeNum, contact) }, ) .flow .map { pagingData -> @@ -206,7 +225,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val initialLoadSize = MESSAGES_PAGE_SIZE, ), pagingSourceFactory = { - dbManager.currentDb.value.packetDao().getMessagesFromPaged(contactKey, includeFiltered) + dbManager.currentDb.value.packetDao().getMessagesFromPaged(currentMyNodeNum, contactKey, includeFiltered) }, ) .flow @@ -225,28 +244,29 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val } override suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageStatus(d, m) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageStatus(currentMyNodeNum, d, m) } override suspend fun updateMessageId(d: DataPacket, id: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageId(d, id) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageId(currentMyNodeNum, d, id) } override suspend fun getPacketById(id: Int): DataPacket? = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketById(id)?.data } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketById(currentMyNodeNum, id)?.data } override suspend fun getPacketByPacketId(packetId: Int): DataPacket? = withContext(dispatchers.io) { - dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId)?.packet?.data + dbManager.currentDb.value.packetDao().getPacketByPacketId(currentMyNodeNum, packetId)?.packet?.data } private suspend fun getPacketByPacketIdInternal(packetId: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(currentMyNodeNum, packetId) } private suspend fun batchGetPacketsByIds(ids: List): Map = if (ids.isEmpty()) { emptyMap() } else { withContext(dispatchers.io) { val dao = dbManager.currentDb.value.packetDao() - ids.chunked(NodeInfoDao.MAX_BIND_PARAMS) - .flatMap { dao.getPacketsByPacketIds(it) } + val num = currentMyNodeNum + ids.chunked(MAX_SQLITE_BIND_PARAMS) + .flatMap { dao.getPacketsByPacketIds(num, it) } .associateBy { it.packet.packetId } } } @@ -284,8 +304,8 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val override suspend fun update(packet: DataPacket, routingError: Int): Unit = withContext(dispatchers.io) { val dao = dbManager.currentDb.value.packetDao() - // Match on key fields that identify the packet, rather than the entire data object - dao.findPacketsWithId(packet.id) + val num = currentMyNodeNum + dao.findPacketsWithId(num, packet.id) .find { it.data.id == packet.id && it.data.from == packet.from && it.data.to == packet.to } ?.let { existing -> val updated = @@ -303,28 +323,28 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val override suspend fun updateReaction(reaction: Reaction) = withContext(dispatchers.io) { val dao = dbManager.currentDb.value.packetDao() - dao.findReactionsWithId(reaction.packetId) + dao.findReactionsWithId(currentMyNodeNum, reaction.packetId) .find { it.userId == reaction.user.id && it.emoji == reaction.emoji } ?.let { dao.update(reaction.toEntity(it.myNodeNum)) } ?: Unit } override suspend fun getReactionByPacketId(packetId: Int): Reaction? = withContext(dispatchers.io) { - dbManager.currentDb.value.packetDao().getReactionByPacketId(packetId)?.toReaction { null } + dbManager.currentDb.value.packetDao().getReactionByPacketId(currentMyNodeNum, packetId)?.toReaction { null } } override suspend fun findPacketsWithId(packetId: Int): List = withContext(dispatchers.io) { - dbManager.currentDb.value.packetDao().findPacketsWithId(packetId).map { it.data } + dbManager.currentDb.value.packetDao().findPacketsWithId(currentMyNodeNum, packetId).map { it.data } } private suspend fun findPacketsWithIdInternal(packetId: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findPacketsWithId(packetId) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findPacketsWithId(currentMyNodeNum, packetId) } override suspend fun findReactionsWithId(packetId: Int): List = withContext(dispatchers.io) { - dbManager.currentDb.value.packetDao().findReactionsWithId(packetId).toReaction { null } + dbManager.currentDb.value.packetDao().findReactionsWithId(currentMyNodeNum, packetId).toReaction { null } } private suspend fun findReactionsWithIdInternal(packetId: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findReactionsWithId(packetId) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findReactionsWithId(currentMyNodeNum, packetId) } @Suppress("CyclomaticComplexMethod") override suspend fun updateSFPPStatus( @@ -339,27 +359,22 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val val dao = dbManager.currentDb.value.packetDao() val packets = findPacketsWithIdInternal(packetId) val reactions = findReactionsWithIdInternal(packetId) - val fromId = DataPacket.nodeNumToDefaultId(from) + val fromId = from + val fromIdString = DataPacket.nodeNumToId(from) val isFromLocalNode = myNodeNum != null && from == myNodeNum - val toId = - if (to == 0 || to == DataPacket.NODENUM_BROADCAST) { - DataPacket.ID_BROADCAST - } else { - DataPacket.nodeNumToDefaultId(to) - } + val toNodeNum = if (to == 0 || to == DataPacket.BROADCAST) DataPacket.BROADCAST else to + val toId = DataPacket.nodeNumToId(toNodeNum) val hashByteString = hash.toByteString() packets.forEach { packet -> - // For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number - val fromMatches = - packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.ID_LOCAL) + val fromMatches = packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.LOCAL) co.touchlab.kermit.Logger.d { "SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " + "isFromLocal=$isFromLocalNode fromMatches=$fromMatches " + - "packetTo=${packet.data.to} toId=$toId toMatches=${packet.data.to == toId}" + "packetTo=${packet.data.to} toId=$toNodeNum toMatches=${packet.data.to == toNodeNum}" } - if (fromMatches && packet.data.to == toId) { + if (fromMatches && packet.data.to == toNodeNum) { // If it's already confirmed, don't downgrade it to routing if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { return@forEach @@ -372,8 +387,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val reactions.forEach { reaction -> val reactionFrom = reaction.userId - // For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number - val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == DataPacket.ID_LOCAL) + val fromMatches = reactionFrom == fromIdString || (isFromLocalNode && reactionFrom == DataPacket.nodeNumToId(DataPacket.LOCAL)) val toMatches = reaction.to == toId @@ -398,8 +412,9 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val override suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long): Unit = withContext(dispatchers.io) { val dao = dbManager.currentDb.value.packetDao() + val num = currentMyNodeNum val hashByteString = hash.toByteString() - dao.findPacketBySfppHash(hashByteString)?.let { packet -> + dao.findPacketBySfppHash(num, hashByteString)?.let { packet -> // If it's already confirmed, don't downgrade it if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { return@let @@ -409,7 +424,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime)) } - dao.findReactionBySfppHash(hashByteString)?.let { reaction -> + dao.findReactionBySfppHash(num, hashByteString)?.let { reaction -> if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) { return@let } @@ -420,17 +435,17 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val } override suspend fun deleteMessages(uuidList: List) = withContext(dispatchers.io) { + val num = currentMyNodeNum for (chunk in uuidList.chunked(DELETE_CHUNK_SIZE)) { - // Fetch DAO per chunk to avoid holding a stale reference if the active DB switches - dbManager.currentDb.value.packetDao().deleteMessages(chunk) + dbManager.currentDb.value.packetDao().deleteMessages(num, chunk) } } override suspend fun deleteContacts(contactList: List) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteContacts(contactList) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteContacts(currentMyNodeNum, contactList) } override suspend fun deleteWaypoint(id: Int) = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteWaypoint(id) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteWaypoint(currentMyNodeNum, id) } suspend fun delete(packet: RoomPacket) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().delete(packet) } @@ -455,11 +470,11 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val suspend fun updateReaction(reaction: RoomReaction) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(reaction) } - override fun getFilteredCountFlow(contactKey: String): Flow = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFilteredCountFlow(contactKey) } + override fun getFilteredCountFlow(contactKey: String): Flow = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().getFilteredCountFlow(num, contactKey) } override suspend fun getFilteredCount(contactKey: String): Int = - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getFilteredCount(contactKey) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getFilteredCount(currentMyNodeNum, contactKey) } override suspend fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) = withContext(dispatchers.io) { @@ -476,11 +491,11 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val override suspend fun updateFilteredBySender(senderId: String, filtered: Boolean) { val pattern = "%\"from\":\"${senderId}\"%" - withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateFilteredBySender(pattern, filtered) } + withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateFilteredBySender(currentMyNodeNum, pattern, filtered) } } - private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow> = - getAllPackets(PortNum.WAYPOINT_APP.value) + private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(myNodeNum: Int): Flow> = + getAllPackets(myNodeNum, PortNum.WAYPOINT_APP.value) private fun ContactSettingsEntity.toShared() = ContactSettings( contactKey = contact_key, @@ -515,5 +530,6 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val private const val MESSAGES_PAGE_SIZE = 50 private const val DELETE_CHUNK_SIZE = 500 private const val MILLISECONDS_IN_SECOND = 1000L + private const val MAX_SQLITE_BIND_PARAMS = 999 } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepositoryImpl.kt index 6546b6504b..298855607b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/QuickChatActionRepositoryImpl.kt @@ -26,6 +26,9 @@ import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.QuickChatActionRepository +/** + * Manages persisted quick chat actions and their ordering. + */ @Single class QuickChatActionRepositoryImpl( private val dbManager: DatabaseProvider, 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 new file mode 100644 index 0000000000..6bbdefc557 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -0,0 +1,468 @@ +/* + * 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 co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +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.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 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.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 +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.core.model.util.onlineTimeThreshold +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.getStringSuspend +import org.meshtastic.core.resources.new_node_seen +import org.meshtastic.proto.FirmwareEdition +import org.meshtastic.proto.HardwareModel +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 + +/** + * Maintains live mesh node state and exposes reactive node data for the app layer. + */ +@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 { + + private val _nodeDBbyNum = MutableStateFlow>(emptyMap()) + private val _myNodeInfo = MutableStateFlow(null) + private val _myNodeNum = MutableStateFlow(null) + + // 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 } } + } + } + + // ── NodeRepository read surface ───────────────────────────────────────── + + override val nodeDBbyNum: StateFlow> = _nodeDBbyNum + + override val myNodeInfo: StateFlow = _myNodeInfo + + override val ourNodeInfo: StateFlow = + combine(_nodeDBbyNum, _myNodeNum) { db, myNum -> myNum?.let { db[it] } } + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + + override val myId: StateFlow = + ourNodeInfo.map { it?.user?.id } + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + + override val localStats: StateFlow = + localStatsDataSource.localStatsFlow + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), LocalStats()) + + override fun updateLocalStats(stats: LocalStats) { + scope.launch { localStatsDataSource.setLocalStats(stats) } + } + + override val onlineNodeCount: Flow = + _nodeDBbyNum.map { map -> map.values.count { it.lastHeard > onlineTimeThreshold() } } + + override val totalNodeCount: Flow = + _nodeDBbyNum.map { it.size } + + override fun effectiveLogNodeId(nodeNum: Int): Flow = + _myNodeNum.map { myNum -> if (nodeNum == myNum) MeshLog.NODE_NUM_LOCAL else nodeNum } + .distinctUntilChanged() + + override fun getNode(userId: String): Node = + _nodeDBbyNum.value.values.find { it.user.id == userId } + ?: Node(num = runCatching { DataPacket.parseNodeNum(userId) }.getOrDefault(0), user = getUser(userId)) + + override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToId(nodeNum)) + + private val last4 = 4 + + 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.nodeNumToId(DataPacket.LOCAL)) { + ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local" + } else { + "Meshtastic $fallbackId" + } + val defaultShort = + if (userId == DataPacket.nodeNumToId(DataPacket.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) + } + + override fun getNodes( + sort: NodeSortOption, + filter: String, + includeUnknown: Boolean, + onlyOnline: Boolean, + onlyDirect: Boolean, + ): Flow> = _nodeDBbyNum.map { map -> + map.values + .filter { node -> + val matchesFilter = filter.isBlank() || + node.user.long_name.contains(filter, ignoreCase = true) || + node.user.short_name.contains(filter, ignoreCase = true) || + node.user.id.contains(filter, ignoreCase = true) + val matchesUnknown = includeUnknown || node.user.hw_model != HardwareModel.UNSET + val matchesOnline = !onlyOnline || node.lastHeard > onlineTimeThreshold() + val matchesDirect = !onlyDirect || node.hopsAway == 0 + matchesFilter && matchesUnknown && matchesOnline && matchesDirect + } + .sortedWith(sortComparator(sort)) + .toList() + } + + // ── NodeRepository write surface ──────────────────────────────────────── + + override suspend fun upsert(node: Node) { + writeNode(node) + } + + override suspend fun installConfig(mi: MyNodeInfo, nodes: List) { + _myNodeInfo.value = mi + _myNodeNum.value = mi.myNodeNum + _nodeDBbyNum.value = nodes.associateBy { it.num } + } + + override suspend fun clearNodeDB(preserveFavorites: Boolean) { + if (preserveFavorites) { + _nodeDBbyNum.update { map -> map.filter { (_, node) -> node.isFavorite } } + } else { + _nodeDBbyNum.value = emptyMap() + } + } + + override suspend fun clearMyNodeInfo() { + _myNodeInfo.value = null + } + + override suspend fun deleteNode(num: Int) { + _nodeDBbyNum.update { it - num } + dbManager.withDb { it.nodeMetadataDao().delete(num) } + } + + override suspend fun deleteNodes(nodeNums: List) { + _nodeDBbyNum.update { map -> map - nodeNums.toSet() } + dbManager.withDb { db -> nodeNums.forEach { db.nodeMetadataDao().delete(it) } } + } + + override suspend fun getNodesOlderThan(lastHeard: Int): List = + _nodeDBbyNum.value.values.filter { it.lastHeard < lastHeard } + + override suspend fun getUnknownNodes(): List = + _nodeDBbyNum.value.values.filter { it.user.hw_model == HardwareModel.UNSET } + + override suspend fun setNodeNotes(num: Int, notes: String) { + 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)) + } + } + + // ── Runtime node state management ──────────────────────────────────────── + + override val nodeDBbyNodeNum: Map + get() = _nodeDBbyNum.value + + override val nodeDBbyID: Map + get() = _nodeDBbyNum.value.values.associateBy { it.user.id } + + override val isNodeDbReady = MutableStateFlow(false) + + override fun setNodeDbReady(ready: Boolean) { + isNodeDbReady.value = ready + } + + override val myNodeNum: StateFlow + get() = _myNodeNum + + override fun setMyNodeNum(num: Int?) { + _myNodeNum.value = num + } + + override val firmwareEdition = MutableStateFlow(null) + + override fun setFirmwareEdition(edition: FirmwareEdition?) { + firmwareEdition.value = edition + } + + override fun clear() { + _nodeDBbyNum.value = emptyMap() + isNodeDbReady.value = false + _myNodeNum.value = null + firmwareEdition.value = null + } + + override fun getMyNodeInfo(): MyNodeInfo? { + val mi = _myNodeInfo.value ?: return null + val myNode = _nodeDBbyNum.value[mi.myNodeNum] + return MyNodeInfo( + myNodeNum = mi.myNodeNum, + hasGPS = (myNode?.position?.latitude_i ?: 0) != 0, + model = mi.model ?: myNode?.user?.hw_model?.name, + firmwareVersion = mi.firmwareVersion, + couldUpdate = mi.couldUpdate, + shouldUpdate = mi.shouldUpdate, + currentPacketId = mi.currentPacketId, + messageTimeoutMsec = mi.messageTimeoutMsec, + minAppVersion = mi.minAppVersion, + maxChannels = mi.maxChannels, + hasWifi = mi.hasWifi, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = mi.deviceId ?: myNode?.user?.id, + ) + } + + override fun getMyId(): String { + val num = _myNodeNum.value ?: _myNodeInfo.value?.myNodeNum ?: return "" + return _nodeDBbyNum.value[num]?.user?.id ?: "" + } + + override fun removeByNodenum(nodeNum: Int) { + _nodeDBbyNum.update { it - nodeNum } + } + + override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) { + _nodeDBbyNum.update { map -> + val current = map[nodeNum] ?: getOrCreateNode(nodeNum, channel) + val transformed = transform(current) + val enriched = enrichWithMetadata(transformed) + map + (nodeNum to enriched) + } + } + + override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) { + updateNode(fromNum, channel = channel) { node -> + val newNode = (node.isUnknownUser && p.hw_model != HardwareModel.UNSET) + val shouldPreserve = shouldPreserveExistingUser(node.user, p) + + val next = + if (shouldPreserve) { + node.copy(channel = channel, manuallyVerified = manuallyVerified) + } else { + val keyMatch = !node.hasPKC || node.user.public_key == p.public_key + val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY) + node.copy( + user = newUser, + publicKey = newUser.public_key, + channel = channel, + manuallyVerified = manuallyVerified, + ) + } + if (newNode && !shouldPreserve) { + scope.handledLaunch { + notificationManager.dispatch( + Notification( + title = getStringSuspend(Res.string.new_node_seen, next.user.short_name), + message = next.user.long_name, + category = Notification.Category.NodeEvent, + ), + ) + } + } + next + } + } + + override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) { + val isZeroPos = (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0 + @Suppress("ComplexCondition") + if (myNodeNum == fromNum && isZeroPos && p.sats_in_view == 0 && p.time == 0) { + Logger.d { "Ignoring empty position update for the local node" } + return + } + + updateNode(fromNum) { node -> + val posTime = if (p.time != 0) p.time else (defaultTime / TIME_MS_TO_S).toInt() + val newLastHeard = maxOf(node.lastHeard, posTime) + + val newPos = + if (isZeroPos) { + p.copy( + time = posTime, + latitude_i = node.position.latitude_i, + longitude_i = node.position.longitude_i, + altitude = p.altitude ?: node.position.altitude, + sats_in_view = p.sats_in_view, + ) + } else { + p.copy(time = posTime) + } + + node.copy(position = newPos, lastHeard = newLastHeard) + } + } + + override fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) { + updateNode(fromNum) { node -> + var nextNode = node + telemetry.device_metrics?.let { nextNode = nextNode.copy(deviceMetrics = it) } + telemetry.environment_metrics?.let { nextNode = nextNode.copy(environmentMetrics = it) } + telemetry.power_metrics?.let { nextNode = nextNode.copy(powerMetrics = it) } + val telemetryTime = if (telemetry.time != 0) telemetry.time else node.lastHeard + val newLastHeard = maxOf(node.lastHeard, telemetryTime) + nextNode.copy(lastHeard = newLastHeard) + } + } + + override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) { + updateNode(info.num) { node -> + var next = node + val user = info.user + if (user != null) { + if (shouldPreserveExistingUser(node.user, user)) { + // keep existing names + } else { + var newUser = + user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it } + if (info.via_mqtt && !newUser.long_name.endsWith(" (MQTT)")) { + newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)") + } + next = next.copy(user = newUser, publicKey = newUser.public_key) + } + } + val position = info.position + if (position != null) { + next = next.copy(position = position) + } + next.copy( + lastHeard = info.last_heard, + deviceMetrics = info.device_metrics ?: next.deviceMetrics, + channel = info.channel, + viaMqtt = info.via_mqtt, + hopsAway = info.hops_away ?: -1, + isFavorite = info.is_favorite, + isIgnored = info.is_ignored, + isMuted = info.is_muted, + ) + } + } + + // ── NodeIdLookup ──────────────────────────────────────────────────────── + + override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.BROADCAST) { + DataPacket.nodeNumToId(DataPacket.BROADCAST) + } else { + _nodeDBbyNum.value[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum) + } + + // ── Private helpers ───────────────────────────────────────────────────── + + private companion object { + private const val TIME_MS_TO_S = 1000L + } + + private fun getOrCreateNode(n: Int, channel: Int = 0): Node = _nodeDBbyNum.value[n] + ?: run { + val userId = DataPacket.nodeNumToDefaultId(n) + val defaultUser = User( + id = userId, + long_name = "Meshtastic ${userId.takeLast(n = 4)}", + short_name = userId.takeLast(n = 4), + hw_model = HardwareModel.UNSET, + ) + Node(num = n, user = defaultUser, channel = channel) + } + + /** Enriches a node with persisted local metadata (favorites, notes, ignore, mute). */ + private fun enrichWithMetadata(node: Node): Node { + val meta = _metadataCache.value[node.num] ?: return node + return node.copy( + isFavorite = meta.isFavorite, + isIgnored = meta.isIgnored, + isMuted = meta.isMuted, + notes = meta.notes, + manuallyVerified = meta.manuallyVerified, + ) + } + + /** Writes a node directly to the map with metadata enrichment. */ + private fun writeNode(node: Node) { + val enriched = enrichWithMetadata(node) + _nodeDBbyNum.update { map -> map + (enriched.num to enriched) } + } + + private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean { + val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) + val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET + val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET + return hasExistingUser && isDefaultName && isDefaultHwModel + } + + /** 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 } + NodeSortOption.DISTANCE -> compareBy { it.hopsAway } + NodeSortOption.HOPS_AWAY -> compareBy { it.hopsAway } + NodeSortOption.CHANNEL -> compareBy { it.channel } + NodeSortOption.VIA_MQTT -> compareByDescending { it.viaMqtt } + NodeSortOption.VIA_FAVORITE -> compareByDescending { it.isFavorite } + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt index 4ad7afc241..931f6b35c2 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/TracerouteSnapshotRepositoryImpl.kt @@ -30,6 +30,9 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.TracerouteSnapshotRepository import org.meshtastic.proto.Position +/** + * Persists and exposes traceroute snapshot positions for a traceroute log entry. + */ @Single class TracerouteSnapshotRepositoryImpl( private val dbManager: DatabaseProvider, 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 e6c2841f14..0000000000 --- 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/FromRadioPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt deleted file mode 100644 index 7b5c39b8ba..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt +++ /dev/null @@ -1,179 +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.mock -import dev.mokkery.verify -import org.meshtastic.core.repository.MeshConfigFlowManager -import org.meshtastic.core.repository.MeshConfigHandler -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.MqttManager -import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.PacketHandler -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.DeviceMetadata -import org.meshtastic.proto.FromRadio -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.MqttClientProxyMessage -import org.meshtastic.proto.MyNodeInfo -import org.meshtastic.proto.QueueStatus -import kotlin.test.BeforeTest -import kotlin.test.Test -import org.meshtastic.proto.NodeInfo as ProtoNodeInfo - -class FromRadioPacketHandlerImplTest { - - private val serviceRepository: ServiceRepository = mock(MockMode.autofill) - private val mqttManager: MqttManager = mock(MockMode.autofill) - private val packetHandler: PacketHandler = mock(MockMode.autofill) - private val notificationManager: NotificationManager = mock(MockMode.autofill) - private val configFlowManager: MeshConfigFlowManager = mock(MockMode.autofill) - private val configHandler: MeshConfigHandler = mock(MockMode.autofill) - private val router: MeshRouter = mock(MockMode.autofill) - - private lateinit var handler: FromRadioPacketHandlerImpl - - @BeforeTest - fun setup() { - every { router.configFlowManager } returns configFlowManager - every { router.configHandler } returns configHandler - - handler = - FromRadioPacketHandlerImpl( - serviceRepository, - lazy { router }, - mqttManager, - packetHandler, - notificationManager, - ) - } - - @Test - fun `handleFromRadio routes MY_INFO to configFlowManager`() { - val myInfo = MyNodeInfo(my_node_num = 1234) - val proto = FromRadio(my_info = myInfo) - - handler.handleFromRadio(proto) - - verify { configFlowManager.handleMyInfo(myInfo) } - } - - @Test - fun `handleFromRadio routes METADATA to configFlowManager`() { - val metadata = DeviceMetadata(firmware_version = "v1.0") - val proto = FromRadio(metadata = metadata) - - handler.handleFromRadio(proto) - - verify { configFlowManager.handleLocalMetadata(metadata) } - } - - @Test - fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() { - val nodeInfo = ProtoNodeInfo(num = 1234) - val proto = FromRadio(node_info = nodeInfo) - - every { configFlowManager.newNodeCount } returns 1 - - handler.handleFromRadio(proto) - - verify { configFlowManager.handleNodeInfo(nodeInfo) } - verify { serviceRepository.setConnectionProgress("Nodes (1)") } - } - - @Test - fun `handleFromRadio routes CONFIG_COMPLETE_ID to configFlowManager`() { - val nonce = 69420 - val proto = FromRadio(config_complete_id = nonce) - - handler.handleFromRadio(proto) - - verify { configFlowManager.handleConfigComplete(nonce) } - } - - @Test - fun `handleFromRadio routes QUEUESTATUS to packetHandler`() { - val queueStatus = QueueStatus(free = 10) - val proto = FromRadio(queueStatus = queueStatus) - - handler.handleFromRadio(proto) - - verify { packetHandler.handleQueueStatus(queueStatus) } - } - - @Test - fun `handleFromRadio routes CONFIG to configHandler`() { - val config = Config(lora = Config.LoRaConfig(use_preset = true)) - val proto = FromRadio(config = config) - - handler.handleFromRadio(proto) - - verify { configHandler.handleDeviceConfig(config) } - } - - @Test - fun `handleFromRadio routes MODULE_CONFIG to configHandler`() { - val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) - val proto = FromRadio(moduleConfig = moduleConfig) - - handler.handleFromRadio(proto) - - verify { configHandler.handleModuleConfig(moduleConfig) } - } - - @Test - fun `handleFromRadio routes CHANNEL to configHandler`() { - val channel = Channel(index = 0) - val proto = FromRadio(channel = channel) - - handler.handleFromRadio(proto) - - verify { configHandler.handleChannel(channel) } - } - - @Test - fun `handleFromRadio routes MQTT_CLIENT_PROXY_MESSAGE to mqttManager`() { - val proxyMsg = MqttClientProxyMessage(topic = "test/topic") - val proto = FromRadio(mqttClientProxyMessage = proxyMsg) - - handler.handleFromRadio(proto) - - verify { mqttManager.handleMqttProxyMessage(proxyMsg) } - } - - @Test - fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository`() { - val notification = ClientNotification(message = "test") - val proto = FromRadio(clientNotification = notification) - - // Note: getString() from Compose Resources requires Skiko native lib which - // is not available in headless JVM tests. We test the parts that don't trigger it. - try { - handler.handleFromRadio(proto) - } catch (_: Throwable) { - // Expected: Skiko can't load in headless JVM/native - } - - verify { serviceRepository.setClientNotification(notification) } - } -} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt deleted file mode 100644 index 4223b47f44..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt +++ /dev/null @@ -1,70 +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.meshtastic.proto.StoreAndForward -import kotlin.test.Test -import kotlin.test.assertEquals - -class HistoryManagerImplTest { - - @Test - fun `buildStoreForwardHistoryRequest copies positive parameters`() { - val request = - HistoryManagerImpl.buildStoreForwardHistoryRequest( - lastRequest = 42, - historyReturnWindow = 15, - historyReturnMax = 25, - ) - - assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr) - assertEquals(42, request.history?.last_request) - assertEquals(15, request.history?.window) - assertEquals(25, request.history?.history_messages) - } - - @Test - fun `buildStoreForwardHistoryRequest clamps non-positive parameters`() { - val request = - HistoryManagerImpl.buildStoreForwardHistoryRequest( - lastRequest = 0, - historyReturnWindow = -1, - historyReturnMax = 0, - ) - - assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr) - assertEquals(0, request.history?.last_request) - assertEquals(0, request.history?.window) - assertEquals(0, request.history?.history_messages) - } - - @Test - fun `resolveHistoryRequestParameters uses config values when positive`() { - val (window, max) = HistoryManagerImpl.resolveHistoryRequestParameters(window = 30, max = 10) - - assertEquals(30, window) - assertEquals(10, max) - } - - @Test - fun `resolveHistoryRequestParameters falls back to defaults when non-positive`() { - val (window, max) = HistoryManagerImpl.resolveHistoryRequestParameters(window = 0, max = -5) - - assertEquals(1440, window) - assertEquals(100, max) - } -} 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 5b29e9f262..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ /dev/null @@ -1,587 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import dev.mokkery.MockMode -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.everySuspend -import dev.mokkery.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import dev.mokkery.verify.VerifyMode.Companion.not -import dev.mokkery.verifySuspend -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.MeshMessageProcessor -import org.meshtastic.core.repository.MeshPrefs -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.Channel -import org.meshtastic.proto.Config -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.SharedContact -import org.meshtastic.proto.User -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class MeshActionHandlerImplTest { - - private val nodeManager = mock(MockMode.autofill) - private val commandSender = mock(MockMode.autofill) - private val packetRepository = mock(MockMode.autofill) - private val serviceBroadcasts = mock(MockMode.autofill) - private val dataHandler = mock(MockMode.autofill) - private val analytics = mock(MockMode.autofill) - private val meshPrefs = mock(MockMode.autofill) - private val uiPrefs = mock(MockMode.autofill) - private val databaseManager = mock(MockMode.autofill) - private val notificationManager = mock(MockMode.autofill) - private val messageProcessor = mock(MockMode.autofill) - private val radioConfigRepository = mock(MockMode.autofill) - - private val myNodeNumFlow = MutableStateFlow(MY_NODE_NUM) - - private lateinit var handler: MeshActionHandlerImpl - - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) - - companion object { - private const val MY_NODE_NUM = 12345 - private const val REMOTE_NODE_NUM = 67890 - } - - @BeforeTest - fun setUp() { - every { nodeManager.myNodeNum } returns myNodeNumFlow - every { nodeManager.getMyId() } returns "!12345678" - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - } - - private fun createHandler(scope: CoroutineScope): MeshActionHandlerImpl = MeshActionHandlerImpl( - nodeManager = nodeManager, - commandSender = commandSender, - packetRepository = lazy { packetRepository }, - serviceBroadcasts = serviceBroadcasts, - dataHandler = lazy { dataHandler }, - analytics = analytics, - meshPrefs = meshPrefs, - uiPrefs = uiPrefs, - databaseManager = databaseManager, - notificationManager = notificationManager, - messageProcessor = lazy { messageProcessor }, - radioConfigRepository = radioConfigRepository, - scope = scope, - ) - - // ---- handleUpdateLastAddress (device-switch path — P0 critical) ---- - - @Test - fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") - everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit - - handler.handleUpdateLastAddress("new_addr") - advanceUntilIdle() - - verify { meshPrefs.setDeviceAddress("new_addr") } - verify { nodeManager.clear() } - verifySuspend { messageProcessor.clearEarlyPackets() } - verifySuspend { databaseManager.switchActiveDatabase("new_addr") } - verify { notificationManager.cancelAll() } - verify { nodeManager.loadCachedNodeDB() } - } - - @Test - fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr") - - handler.handleUpdateLastAddress("same_addr") - advanceUntilIdle() - - verify(not) { meshPrefs.setDeviceAddress(any()) } - verify(not) { nodeManager.clear() } - } - - @Test - fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") - everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit - - handler.handleUpdateLastAddress(null) - advanceUntilIdle() - - verify { meshPrefs.setDeviceAddress(null) } - verify { nodeManager.clear() } - verifySuspend { databaseManager.switchActiveDatabase(null) } - } - - @Test - fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow(null) - - handler.handleUpdateLastAddress(null) - advanceUntilIdle() - - verify(not) { meshPrefs.setDeviceAddress(any()) } - } - - @Test - fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow("old") - everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit - - handler.handleUpdateLastAddress("new") - advanceUntilIdle() - - // Verify critical sequence: clear -> switchDB -> cancelNotifications -> loadCachedNodeDB - verify { nodeManager.clear() } - verifySuspend { databaseManager.switchActiveDatabase("new") } - verify { notificationManager.cancelAll() } - verify { nodeManager.loadCachedNodeDB() } - } - - // ---- onServiceAction: null myNodeNum early-return ---- - - @Test - fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - myNodeNumFlow.value = null - - val node = createTestNode(REMOTE_NODE_NUM) - handler.onServiceAction(ServiceAction.Favorite(node)) - advanceUntilIdle() - - verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- onServiceAction: Favorite ---- - - @Test - fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false) - - handler.onServiceAction(ServiceAction.Favorite(node)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.updateNode(any(), any(), any(), any()) } - } - - @Test - fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true) - - handler.onServiceAction(ServiceAction.Favorite(node)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.updateNode(any(), any(), any(), any()) } - } - - // ---- onServiceAction: Ignore ---- - - @Test - fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false) - - handler.onServiceAction(ServiceAction.Ignore(node)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.updateNode(any(), any(), any(), any()) } - verifySuspend { packetRepository.updateFilteredBySender(any(), any()) } - } - - // ---- onServiceAction: Mute ---- - - @Test - fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val node = createTestNode(REMOTE_NODE_NUM, isMuted = false) - - handler.onServiceAction(ServiceAction.Mute(node)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.updateNode(any(), any(), any(), any()) } - } - - // ---- onServiceAction: GetDeviceMetadata ---- - - @Test - fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- onServiceAction: SendContact ---- - - @Test - fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true - - val action = ServiceAction.SendContact(SharedContact()) - handler.onServiceAction(action) - advanceUntilIdle() - - assertTrue(action.result.isCompleted) - assertTrue(action.result.await()) - } - - @Test - fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false - - val action = ServiceAction.SendContact(SharedContact()) - handler.onServiceAction(action) - advanceUntilIdle() - - assertTrue(action.result.isCompleted) - assertFalse(action.result.await()) - } - - // ---- onServiceAction: ImportContact ---- - - @Test - fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - val contact = - SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser")) - handler.onServiceAction(ServiceAction.ImportContact(contact)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) } - } - - // ---- handleSetOwner ---- - - @Test - fun handleSetOwner_sendsAdminAndUpdatesLocalNode() { - handler = createHandler(testScope) - val meshUser = - MeshUser( - id = "!12345678", - longName = "Test Long", - shortName = "TL", - hwModel = HardwareModel.UNSET, - isLicensed = false, - ) - - handler.handleSetOwner(meshUser, MY_NODE_NUM) - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) } - } - - // ---- handleSend ---- - - @Test - fun handleSend_sendsDataAndBroadcastsStatus() { - handler = createHandler(testScope) - val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0) - - handler.handleSend(packet, MY_NODE_NUM) - - verify { commandSender.sendData(any()) } - verify { serviceBroadcasts.broadcastMessageStatus(any(), any()) } - verify { dataHandler.rememberDataPacket(any(), any(), any()) } - } - - // ---- handleRequestPosition: 3 branches ---- - - @Test - fun handleRequestPosition_sameNode_doesNothing() { - handler = createHandler(testScope) - - handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM) - - verify(not) { commandSender.requestPosition(any(), any()) } - } - - @Test - fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() { - handler = createHandler(testScope) - every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) - - val validPosition = Position(37.7749, -122.4194, 10) - handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) - - verify { commandSender.requestPosition(REMOTE_NODE_NUM, validPosition) } - } - - @Test - fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() { - handler = createHandler(testScope) - every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - val invalidPosition = Position(0.0, 0.0, 0) - handler.handleRequestPosition(REMOTE_NODE_NUM, invalidPosition, MY_NODE_NUM) - - // Falls back to Position(0.0, 0.0, 0) when node has no position in DB - verify { commandSender.requestPosition(any(), any()) } - } - - @Test - fun handleRequestPosition_doNotProvide_sendsZeroPosition() { - handler = createHandler(testScope) - every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) - - val validPosition = Position(37.7749, -122.4194, 10) - handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) - - // Should send zero position regardless of valid input - verify { commandSender.requestPosition(any(), any()) } - } - - // ---- handleSetConfig: optimistic persist ---- - - @Test - fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit - - val config = Config(lora = Config.LoRaConfig(hop_limit = 5)) - val payload = Config.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 af0925d38c..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ /dev/null @@ -1,475 +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.CommandSender -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 commandSender = 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 { commandSender.getCurrentPacketId() } returns 100 - 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, - commandSender = commandSender, - 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/MeshConfigHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt deleted file mode 100644 index bf3247815b..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt +++ /dev/null @@ -1,231 +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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.Channel -import org.meshtastic.proto.Config -import org.meshtastic.proto.DeviceUIConfig -import org.meshtastic.proto.LocalConfig -import org.meshtastic.proto.LocalModuleConfig -import org.meshtastic.proto.ModuleConfig -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(ExperimentalCoroutinesApi::class) -class MeshConfigHandlerImplTest { - - private val radioConfigRepository = mock(MockMode.autofill) - private val serviceRepository = mock(MockMode.autofill) - private val nodeManager = mock(MockMode.autofill) - - private val localConfigFlow = MutableStateFlow(LocalConfig()) - private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig()) - - private val testDispatcher = UnconfinedTestDispatcher() - - private lateinit var handler: MeshConfigHandlerImpl - - @BeforeTest - fun setUp() { - every { radioConfigRepository.localConfigFlow } returns localConfigFlow - every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow - } - - private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl( - radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, - nodeManager = nodeManager, - scope = scope, - ) - - // ---------- start and flow wiring ---------- - - @Test - fun `start wires localConfig flow from repository`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val config = LocalConfig(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) - localConfigFlow.value = config - advanceUntilIdle() - - assertEquals(config, handler.localConfig.value) - } - - @Test - fun `start wires moduleConfig flow from repository`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val config = LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) - moduleConfigFlow.value = config - advanceUntilIdle() - - assertEquals(config, handler.moduleConfig.value) - } - - // ---------- handleDeviceConfig ---------- - - @Test - fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) - handler.handleDeviceConfig(config) - advanceUntilIdle() - - verifySuspend { radioConfigRepository.setLocalConfig(config) } - verify { serviceRepository.setConnectionProgress("Device config received") } - } - - @Test - fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val configs = - listOf( - Config(position = Config.PositionConfig()), - Config(power = Config.PowerConfig()), - Config(network = Config.NetworkConfig()), - Config(display = Config.DisplayConfig()), - Config(lora = Config.LoRaConfig()), - Config(bluetooth = Config.BluetoothConfig()), - Config(security = Config.SecurityConfig()), - ) - - for (config in configs) { - handler.handleDeviceConfig(config) - advanceUntilIdle() - } - - // All should have been persisted (7 configs) - verifySuspend { radioConfigRepository.setLocalConfig(any()) } - } - - // ---------- handleModuleConfig ---------- - - @Test - fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) - handler.handleModuleConfig(config) - advanceUntilIdle() - - verifySuspend { radioConfigRepository.setLocalModuleConfig(config) } - verify { serviceRepository.setConnectionProgress("Module config received") } - } - - @Test - fun `handleModuleConfig with statusmessage updates node status`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val myNum = 123 - every { nodeManager.myNodeNum } returns MutableStateFlow(myNum) - - val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active")) - handler.handleModuleConfig(config) - advanceUntilIdle() - - verify { nodeManager.updateNodeStatus(myNum, "Active") } - } - - @Test - fun `handleModuleConfig with statusmessage skipped when myNodeNum is null`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - every { nodeManager.myNodeNum } returns MutableStateFlow(null) - - val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active")) - handler.handleModuleConfig(config) - advanceUntilIdle() - // No crash — updateNodeStatus should not be called - } - - // ---------- handleChannel ---------- - - @Test - fun `handleChannel persists channel settings`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val channel = Channel(index = 0) - handler.handleChannel(channel) - advanceUntilIdle() - - verifySuspend { radioConfigRepository.updateChannelSettings(channel) } - } - - @Test - fun `handleChannel shows progress with max channels when myNodeInfo available`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - every { nodeManager.getMyNodeInfo() } returns - MyNodeInfo( - myNodeNum = 123, - hasGPS = false, - model = null, - firmwareVersion = null, - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 0L, - messageTimeoutMsec = 0, - minAppVersion = 0, - maxChannels = 8, - hasWifi = false, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = null, - ) - - val channel = Channel(index = 2) - handler.handleChannel(channel) - advanceUntilIdle() - - verify { serviceRepository.setConnectionProgress("Channels (3 / 8)") } - } - - @Test - fun `handleChannel shows progress without max channels when myNodeInfo unavailable`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - every { nodeManager.getMyNodeInfo() } returns null - - val channel = Channel(index = 0) - handler.handleChannel(channel) - advanceUntilIdle() - - verify { serviceRepository.setConnectionProgress("Channels (1)") } - } - - // ---------- handleDeviceUIConfig ---------- - - @Test - fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val config = DeviceUIConfig() - handler.handleDeviceUIConfig(config) - advanceUntilIdle() - - verifySuspend { radioConfigRepository.setDeviceUIConfig(config) } - } -} 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 fadd19542e..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ /dev/null @@ -1,431 +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.repository.AppWidgetUpdater -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.HistoryManager -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.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 commandSender = 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 { commandSender.sendAdmin(any(), any(), 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, - commandSender, - 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 { commandSender.requestTelemetry(any(), any(), any()) } returns Unit - every { nodeManager.myNodeNum } returns MutableStateFlow(123) - every { mqttManager.startProxy(any(), any()) } returns Unit - every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit - 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 5327449e9c..0000000000 --- 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/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt deleted file mode 100644 index 251aefabee..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt +++ /dev/null @@ -1,356 +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 kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import okio.ByteString -import org.meshtastic.core.repository.FromRadioPacketHandler -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.MeshLogRepository -import org.meshtastic.core.repository.MeshRouter -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.Data -import org.meshtastic.proto.FromRadio -import org.meshtastic.proto.LogRecord -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum -import kotlin.test.BeforeTest -import kotlin.test.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class MeshMessageProcessorImplTest { - - private val nodeManager = mock(MockMode.autofill) - private val serviceRepository = mock(MockMode.autofill) - private val meshLogRepository = mock(MockMode.autofill) - private val router = mock(MockMode.autofill) - private val fromRadioDispatcher = mock(MockMode.autofill) - private val dataHandler = mock(MockMode.autofill) - - private val testDispatcher = UnconfinedTestDispatcher() - - private lateinit var processor: MeshMessageProcessorImpl - - private val myNodeNum = 12345 - private val isNodeDbReady = MutableStateFlow(false) - - @BeforeTest - fun setUp() { - every { nodeManager.isNodeDbReady } returns isNodeDbReady - every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) - every { router.dataHandler } returns dataHandler - } - - private fun createProcessor(scope: CoroutineScope): MeshMessageProcessorImpl = MeshMessageProcessorImpl( - nodeManager = nodeManager, - serviceRepository = serviceRepository, - meshLogRepository = lazy { meshLogRepository }, - router = lazy { router }, - fromRadioDispatcher = fromRadioDispatcher, - scope = scope, - ) - - // ---------- handleFromRadio: non-packet variants ---------- - - @Test - fun `handleFromRadio dispatches non-packet variants to fromRadioDispatcher`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - val logRecord = LogRecord(message = "test log") - val fromRadio = FromRadio(log_record = logRecord) - val bytes = FromRadio.ADAPTER.encode(fromRadio) - - processor.handleFromRadio(bytes, myNodeNum) - advanceUntilIdle() - - verify { fromRadioDispatcher.handleFromRadio(any()) } - } - - @Test - fun `handleFromRadio falls back to LogRecord parsing when FromRadio fails`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - // Encode a raw LogRecord (not wrapped in FromRadio) — first decode as FromRadio fails, - // fallback decode as LogRecord succeeds - val logRecord = LogRecord(message = "fallback log") - val bytes = LogRecord.ADAPTER.encode(logRecord) - - processor.handleFromRadio(bytes, myNodeNum) - advanceUntilIdle() - - // Should have been dispatched as a FromRadio with log_record set - verify { fromRadioDispatcher.handleFromRadio(any()) } - } - - @Test - fun `handleFromRadio with completely invalid bytes does not crash`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - // Invalid protobuf bytes — both parses should fail - val garbage = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte()) - - processor.handleFromRadio(garbage, myNodeNum) - advanceUntilIdle() - // No crash - } - - // ---------- handleReceivedMeshPacket: early buffering ---------- - - @Test - fun `packets are buffered when node DB is not ready`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = false - - val packet = - MeshPacket( - id = 1, - from = 999, - decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), - rx_time = 1000, - ) - - processor.handleReceivedMeshPacket(packet, myNodeNum) - advanceUntilIdle() - - // Packet should be buffered, not processed - // (no emitMeshPacket call since DB is not ready) - } - - @Test - fun `buffered packets are flushed when node DB becomes ready`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = false - - val packet = - MeshPacket( - id = 1, - from = 999, - decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), - rx_time = 1000, - ) - - processor.handleReceivedMeshPacket(packet, myNodeNum) - advanceUntilIdle() - - // Now make DB ready - isNodeDbReady.value = true - advanceUntilIdle() - - // Buffered packet should have been flushed and processed - verifySuspend { serviceRepository.emitMeshPacket(any()) } - } - - @Test - fun `early buffer overflow drops oldest packet`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = false - - // The maxEarlyPacketBuffer is 10240 — we won't actually fill it in this test, - // but we test the boundary behavior conceptually. Instead, test that multiple - // packets are accumulated properly. - repeat(5) { i -> - val packet = - MeshPacket( - id = i, - from = 999, - decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), - rx_time = 1000 + i, - ) - processor.handleReceivedMeshPacket(packet, myNodeNum) - } - advanceUntilIdle() - - // Flush - isNodeDbReady.value = true - advanceUntilIdle() - - // All 5 packets should have been processed - verifySuspend { serviceRepository.emitMeshPacket(any()) } - } - - // ---------- handleReceivedMeshPacket: rx_time normalization ---------- - - @Test - fun `packets with rx_time 0 get current time`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = true - - val packet = - MeshPacket( - id = 1, - from = myNodeNum, - decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), - rx_time = 0, // should be replaced with current time - ) - - processor.handleReceivedMeshPacket(packet, myNodeNum) - advanceUntilIdle() - - verifySuspend { serviceRepository.emitMeshPacket(any()) } - } - - @Test - fun `packets with non-zero rx_time keep their time`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = true - - val packet = - MeshPacket( - id = 2, - from = myNodeNum, - decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), - rx_time = 1700000000, - ) - - processor.handleReceivedMeshPacket(packet, myNodeNum) - advanceUntilIdle() - - verifySuspend { serviceRepository.emitMeshPacket(any()) } - } - - // ---------- handleReceivedMeshPacket: node updates ---------- - - @Test - fun `processReceivedMeshPacket updates myNode lastHeard`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = true - - val packet = - MeshPacket( - id = 10, - from = 999, - decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), - rx_time = 1700000000, - ) - - processor.handleReceivedMeshPacket(packet, myNodeNum) - advanceUntilIdle() - - // Should have called updateNode for myNodeNum (lastHeard update) - verify { nodeManager.updateNode(myNodeNum, withBroadcast = true, any(), any()) } - } - - @Test - fun `processReceivedMeshPacket updates sender node`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = true - - val senderNode = 999 - val packet = - MeshPacket( - id = 10, - from = senderNode, - decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), - rx_time = 1700000000, - channel = 1, - ) - - processor.handleReceivedMeshPacket(packet, myNodeNum) - advanceUntilIdle() - - // Should have called updateNode for the sender - verify { nodeManager.updateNode(senderNode, withBroadcast = false, any(), any()) } - } - - // ---------- handleReceivedMeshPacket: null decoded ---------- - - @Test - fun `packet with null decoded is skipped`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = true - - val packet = MeshPacket(id = 1, from = 999, decoded = null) - - processor.handleReceivedMeshPacket(packet, myNodeNum) - advanceUntilIdle() - // No crash, no emitMeshPacket call (decoded is null so processReceivedMeshPacket returns early) - } - - // ---------- handleReceivedMeshPacket: null myNodeNum ---------- - - @Test - fun `processReceivedMeshPacket with null myNodeNum skips node updates`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = true - - val packet = - MeshPacket( - id = 10, - from = 999, - decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), - rx_time = 1700000000, - ) - - processor.handleReceivedMeshPacket(packet, null) - advanceUntilIdle() - - // emitMeshPacket should still be called, but node updates should be skipped - verifySuspend { serviceRepository.emitMeshPacket(any()) } - } - - // ---------- clearEarlyPackets ---------- - - @Test - fun `clearEarlyPackets empties the buffer`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - isNodeDbReady.value = false - - val packet = - MeshPacket( - id = 1, - from = 999, - decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = ByteString.EMPTY), - rx_time = 1000, - ) - processor.handleReceivedMeshPacket(packet, myNodeNum) - advanceUntilIdle() - - processor.clearEarlyPackets() - advanceUntilIdle() - - // Now make DB ready — the buffer should be empty, nothing to flush - isNodeDbReady.value = true - advanceUntilIdle() - - // emitMeshPacket should NOT have been called (buffer was cleared) - } - - // ---------- logVariant ---------- - - @Test - fun `FromRadio log_record variant is logged as MeshLog`() = runTest(testDispatcher) { - processor = createProcessor(backgroundScope) - val logRecord = LogRecord(message = "device log") - val fromRadio = FromRadio(log_record = logRecord) - val bytes = FromRadio.ADAPTER.encode(fromRadio) - - processor.handleFromRadio(bytes, myNodeNum) - advanceUntilIdle() - - verifySuspend { meshLogRepository.insert(any()) } - } -} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MqttManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MqttManagerImplTest.kt new file mode 100644 index 0000000000..294f9f06aa --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MqttManagerImplTest.kt @@ -0,0 +1,259 @@ +/* + * 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.Companion.exactly +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.network.repository.MQTTRepository +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.mqtt.ConnectionState +import org.meshtastic.mqtt.MqttException +import org.meshtastic.mqtt.ReasonCode +import org.meshtastic.proto.MqttClientProxyMessage +import org.meshtastic.proto.ToRadio +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MqttManagerImplTest { + + private data class PublishCall( + val topic: String, + val data: ByteArray, + val retained: Boolean, + ) + + private lateinit var mqttRepository: MQTTRepository + private lateinit var packetHandler: PacketHandler + private lateinit var serviceRepository: FakeServiceRepository + private lateinit var nodeRepository: NodeRepository + private lateinit var serviceScope: TestScope + private lateinit var connectionStateFlow: MutableStateFlow + private lateinit var proxyMessageFlow: MutableSharedFlow + private lateinit var mqttManager: MqttManagerImpl + + private val publishCalls = mutableListOf() + + @BeforeTest + fun setUp() { + serviceScope = TestScope(UnconfinedTestDispatcher()) + connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected.Idle) + proxyMessageFlow = MutableSharedFlow(extraBufferCapacity = 1) + mqttRepository = mock(MockMode.autofill) + packetHandler = mock(MockMode.autofill) + serviceRepository = FakeServiceRepository() + nodeRepository = mock(MockMode.autofill) + publishCalls.clear() + + every { mqttRepository.connectionState } returns connectionStateFlow + every { mqttRepository.proxyMessageFlow } returns proxyMessageFlow + every { mqttRepository.publish(any(), any(), any()) } calls { args -> + publishCalls += + PublishCall( + topic = args.arg(0), + data = args.arg(1), + retained = args.arg(2), + ) + } + every { packetHandler.sendToRadio(any()) } returns Unit + + mqttManager = MqttManagerImpl(mqttRepository, packetHandler, serviceRepository, nodeRepository, serviceScope) + } + + @AfterTest + fun tearDown() { + serviceScope.cancel() + } + + @Test + fun mqttConnectionState_whenInactive_emitsInactive() = runTest { + connectionStateFlow.value = ConnectionState.Connected + + assertEquals(MqttConnectionState.Inactive, mqttManager.mqttConnectionState.value) + } + + @Test + fun mqttConnectionState_whenActive_mapsConnecting() = runTest { + connectionStateFlow.value = ConnectionState.Connecting + + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + + assertEquals(MqttConnectionState.Connecting, mqttManager.mqttConnectionState.value) + } + + @Test + fun mqttConnectionState_whenActive_mapsConnected() = runTest { + connectionStateFlow.value = ConnectionState.Connected + + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + + assertEquals(MqttConnectionState.Connected, mqttManager.mqttConnectionState.value) + } + + @Test + fun mqttConnectionState_whenActive_mapsReconnecting() = runTest { + val error = MqttException.ConnectionLost(ReasonCode.SERVER_UNAVAILABLE, "network down") + connectionStateFlow.value = ConnectionState.Reconnecting(attempt = 3, lastError = error) + + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + + assertEquals( + MqttConnectionState.Reconnecting(attempt = 3, lastError = "network down"), + mqttManager.mqttConnectionState.value, + ) + } + + @Test + fun mqttConnectionState_whenActive_mapsDisconnectedWithReason() = runTest { + val reason = MqttException.ConnectionLost(ReasonCode.KEEP_ALIVE_TIMEOUT, "timed out") + connectionStateFlow.value = ConnectionState.Disconnected(reason = reason) + + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + + assertEquals( + MqttConnectionState.Disconnected(reason = "timed out"), + mqttManager.mqttConnectionState.value, + ) + } + + @Test + fun mqttConnectionState_whenActive_mapsDisconnectedIdle() = runTest { + connectionStateFlow.value = ConnectionState.Disconnected.Idle + + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + + assertEquals(MqttConnectionState.Disconnected.Idle, mqttManager.mqttConnectionState.value) + } + + @Test + fun startProxy_whenAlreadyRunning_doesNotDuplicate() = runTest { + val message = MqttClientProxyMessage(topic = "msh/test", text = "hello") + + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + proxyMessageFlow.emit(message) + + verify(exactly(1)) { packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) } + } + + @Test + fun startProxy_whenNotEnabled_doesNotStart() = runTest { + val message = MqttClientProxyMessage(topic = "msh/test", text = "hello") + connectionStateFlow.value = ConnectionState.Connected + + mqttManager.startProxy(enabled = false, proxyToClientEnabled = true) + assertTrue(proxyMessageFlow.tryEmit(message)) + + assertEquals(MqttConnectionState.Inactive, mqttManager.mqttConnectionState.value) + verify(exactly(0)) { packetHandler.sendToRadio(any()) } + } + + @Test + fun startProxy_collectsProxyMessages_sendsToRadio() = runTest { + val message = MqttClientProxyMessage(topic = "msh/test", text = "hello") + + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + proxyMessageFlow.emit(message) + + verify(exactly(1)) { packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) } + } + + @Test + fun startProxy_onConnectionRejected_setsErrorMessage() = runTest { + every { mqttRepository.proxyMessageFlow } returns + flow { + throw MqttException.ConnectionRejected( + reasonCode = ReasonCode.NOT_AUTHORIZED, + message = "bad credentials", + ) + } + + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + + assertEquals( + "MQTT: connection rejected (check credentials)", + serviceRepository.errorMessage.value, + ) + assertEquals(MqttConnectionState.Inactive, mqttManager.mqttConnectionState.value) + } + + @Test + fun stop_cancelsJobAndSetsInactive() = runTest { + val message = MqttClientProxyMessage(topic = "msh/test", text = "hello") + + mqttManager.startProxy(enabled = true, proxyToClientEnabled = true) + mqttManager.stop() + assertTrue(proxyMessageFlow.tryEmit(message)) + + assertEquals(MqttConnectionState.Inactive, mqttManager.mqttConnectionState.value) + verify(exactly(0)) { packetHandler.sendToRadio(any()) } + } + + @Test + fun handleMqttProxyMessage_withText_publishesText() = runTest { + val message = MqttClientProxyMessage(topic = "msh/json/test", text = "hello world", retained = true) + + mqttManager.handleMqttProxyMessage(message) + + assertEquals(1, publishCalls.size) + assertEquals("msh/json/test", publishCalls.single().topic) + assertTrue("hello world".encodeToByteArray().contentEquals(publishCalls.single().data)) + assertEquals(true, publishCalls.single().retained) + verify(exactly(1)) { mqttRepository.publish(any(), any(), any()) } + } + + @Test + fun handleMqttProxyMessage_withData_publishesData() = runTest { + val payload = byteArrayOf(1, 2, 3, 4) + val message = MqttClientProxyMessage(topic = "msh/data/test", data_ = payload.toByteString(), retained = false) + + mqttManager.handleMqttProxyMessage(message) + + assertEquals(1, publishCalls.size) + assertEquals("msh/data/test", publishCalls.single().topic) + assertTrue(payload.contentEquals(publishCalls.single().data)) + assertEquals(false, publishCalls.single().retained) + verify(exactly(1)) { mqttRepository.publish(any(), any(), any()) } + } + + @Test + fun handleMqttProxyMessage_withNeither_doesNotPublish() = runTest { + mqttManager.handleMqttProxyMessage(MqttClientProxyMessage(topic = "msh/empty/test")) + + assertTrue(publishCalls.isEmpty()) + verify(exactly(0)) { mqttRepository.publish(any(), any(), any()) } + } +} 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 e0bda60759..0000000000 --- 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/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt similarity index 71% rename from core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt rename to core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt index 5090668672..1cccfc73f9 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/SdkNodeRepositoryImplTest.kt @@ -21,11 +21,12 @@ import dev.mokkery.mock import kotlinx.coroutines.test.TestScope import okio.ByteString import okio.ByteString.Companion.toByteString +import org.meshtastic.core.data.repository.SdkNodeRepositoryImpl +import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.datastore.LocalStatsDataSource 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 @@ -40,24 +41,25 @@ import kotlin.test.assertTrue import org.meshtastic.proto.NodeInfo as ProtoNodeInfo import org.meshtastic.proto.Position as ProtoPosition -class NodeManagerImplTest { +class SdkNodeRepositoryImplTest { - 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() - private lateinit var nodeManager: NodeManagerImpl + private lateinit var nodeRepository: SdkNodeRepositoryImpl @BeforeTest fun setUp() { - nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope) + val dbProvider: DatabaseProvider = mock(MockMode.autofill) + val localStatsDataSource: LocalStatsDataSource = mock(MockMode.autofill) + nodeRepository = SdkNodeRepositoryImpl(localStatsDataSource, dbProvider, notificationManager, testScope) } @Test fun `getOrCreateNode creates default user for unknown node`() { val nodeNum = 1234 - val result = nodeManager.getOrCreateNode(nodeNum) + nodeRepository.updateNode(nodeNum) { it } + val result = nodeRepository.nodeDBbyNodeNum[nodeNum] assertNotNull(result) assertEquals(nodeNum, result.num) @@ -71,15 +73,14 @@ class NodeManagerImplTest { val existingUser = User(id = "!12345678", long_name = "My Custom Name", short_name = "MCN", hw_model = HardwareModel.TLORA_V2) - // Setup existing node - nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) } + nodeRepository.updateNode(nodeNum) { it.copy(user = existingUser) } val incomingDefaultUser = User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET) - nodeManager.handleReceivedUser(nodeNum, incomingDefaultUser) + nodeRepository.handleReceivedUser(nodeNum, incomingDefaultUser) - val result = nodeManager.nodeDBbyNodeNum[nodeNum] + val result = nodeRepository.nodeDBbyNodeNum[nodeNum] assertEquals("My Custom Name", result!!.user.long_name) assertEquals(HardwareModel.TLORA_V2, result.user.hw_model) } @@ -87,18 +88,17 @@ class NodeManagerImplTest { @Test fun `handleReceivedUser updates user if incoming is higher detail`() { val nodeNum = 1234 - // Use a non-UNSET hw_model so isUnknownUser=false (avoids new-node notification + getString) val existingUser = User(id = "!12345678", long_name = "Old Name", short_name = "ON", hw_model = HardwareModel.TLORA_V2) - nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) } + nodeRepository.updateNode(nodeNum) { it.copy(user = existingUser) } val incomingDetailedUser = User(id = "!12345678", long_name = "Real User", short_name = "RU", hw_model = HardwareModel.TLORA_V1) - nodeManager.handleReceivedUser(nodeNum, incomingDetailedUser) + nodeRepository.handleReceivedUser(nodeNum, incomingDetailedUser) - val result = nodeManager.nodeDBbyNodeNum[nodeNum] + val result = nodeRepository.nodeDBbyNodeNum[nodeNum] assertEquals("Real User", result!!.user.long_name) assertEquals(HardwareModel.TLORA_V1, result.user.hw_model) } @@ -108,9 +108,9 @@ class NodeManagerImplTest { val nodeNum = 1234 val position = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000) - nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0) + nodeRepository.handleReceivedPosition(nodeNum, 9999, position, 0) - val result = nodeManager.nodeDBbyNodeNum[nodeNum] + val result = nodeRepository.nodeDBbyNodeNum[nodeNum] assertNotNull(result) assertNotNull(result.position) assertEquals(450000000, result.position.latitude_i) @@ -121,13 +121,13 @@ class NodeManagerImplTest { fun `handleReceivedPosition with zero coordinates preserves last known location but updates satellites`() { val nodeNum = 1234 val initialPosition = ProtoPosition(latitude_i = 450000000, longitude_i = 900000000, sats_in_view = 10) - nodeManager.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L) + nodeRepository.handleReceivedPosition(nodeNum, 9999, initialPosition, 1000000L) // Receive "zero" position with new satellite count val zeroPosition = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 5, time = 1001) - nodeManager.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L) + nodeRepository.handleReceivedPosition(nodeNum, 9999, zeroPosition, 1001000L) - val result = nodeManager.nodeDBbyNodeNum[nodeNum] + val result = nodeRepository.nodeDBbyNodeNum[nodeNum] assertEquals(450000000, result!!.position.latitude_i) assertEquals(900000000, result.position.longitude_i) assertEquals(5, result.position.sats_in_view) @@ -139,9 +139,9 @@ class NodeManagerImplTest { val myNum = 1111 val emptyPos = ProtoPosition(latitude_i = 0, longitude_i = 0, sats_in_view = 0, time = 0) - nodeManager.handleReceivedPosition(myNum, myNum, emptyPos, 0) + nodeRepository.handleReceivedPosition(myNum, myNum, emptyPos, 0) - val result = nodeManager.nodeDBbyNodeNum[myNum] + val result = nodeRepository.nodeDBbyNodeNum[myNum] // Should still be null since the empty position for local node is ignored assertNull(result) } @@ -149,13 +149,13 @@ class NodeManagerImplTest { @Test fun `handleReceivedTelemetry updates lastHeard`() { val nodeNum = 1234 - nodeManager.updateNode(nodeNum) { it.copy(lastHeard = 1000) } + nodeRepository.updateNode(nodeNum) { it.copy(lastHeard = 1000) } val telemetry = Telemetry(time = 2000, device_metrics = DeviceMetrics(battery_level = 50)) - nodeManager.handleReceivedTelemetry(nodeNum, telemetry) + nodeRepository.handleReceivedTelemetry(nodeNum, telemetry) - val result = nodeManager.nodeDBbyNodeNum[nodeNum] + val result = nodeRepository.nodeDBbyNodeNum[nodeNum] assertEquals(2000, result!!.lastHeard) } @@ -164,9 +164,9 @@ class NodeManagerImplTest { val nodeNum = 1234 val telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 75, voltage = 3.8f)) - nodeManager.handleReceivedTelemetry(nodeNum, telemetry) + nodeRepository.handleReceivedTelemetry(nodeNum, telemetry) - val result = nodeManager.nodeDBbyNodeNum[nodeNum] + val result = nodeRepository.nodeDBbyNodeNum[nodeNum] assertNotNull(result!!.deviceMetrics) assertEquals(75, result.deviceMetrics.battery_level) assertEquals(3.8f, result.deviceMetrics.voltage) @@ -178,9 +178,9 @@ class NodeManagerImplTest { val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = 22.5f, relative_humidity = 45.0f)) - nodeManager.handleReceivedTelemetry(nodeNum, telemetry) + nodeRepository.handleReceivedTelemetry(nodeNum, telemetry) - val result = nodeManager.nodeDBbyNodeNum[nodeNum] + val result = nodeRepository.nodeDBbyNodeNum[nodeNum] assertNotNull(result!!.environmentMetrics) assertEquals(22.5f, result.environmentMetrics.temperature) assertEquals(45.0f, result.environmentMetrics.relative_humidity) @@ -188,23 +188,23 @@ class NodeManagerImplTest { @Test fun `clear resets internal state`() { - nodeManager.updateNode(1234) { it.copy(user = it.user.copy(long_name = "Test")) } - nodeManager.clear() + nodeRepository.updateNode(1234) { it.copy(user = it.user.copy(long_name = "Test")) } + nodeRepository.clear() - assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty()) - assertTrue(nodeManager.nodeDBbyID.isEmpty()) - assertNull(nodeManager.myNodeNum.value) + assertTrue(nodeRepository.nodeDBbyNodeNum.isEmpty()) + assertTrue(nodeRepository.nodeDBbyID.isEmpty()) + assertNull(nodeRepository.myNodeNum.value) } @Test fun `toNodeID returns broadcast ID for broadcast nodeNum`() { - val result = nodeManager.toNodeID(DataPacket.NODENUM_BROADCAST) - assertEquals(DataPacket.ID_BROADCAST, result) + val result = nodeRepository.toNodeID(DataPacket.BROADCAST) + assertEquals(DataPacket.nodeNumToId(DataPacket.BROADCAST), result) } @Test fun `toNodeID returns default hex ID for unknown node`() { - val result = nodeManager.toNodeID(0x1234) + val result = nodeRepository.toNodeID(0x1234) assertEquals(DataPacket.nodeNumToDefaultId(0x1234), result) } @@ -212,24 +212,24 @@ class NodeManagerImplTest { fun `toNodeID returns user ID for known node`() { val nodeNum = 5678 val userId = "!customid" - nodeManager.updateNode(nodeNum) { it.copy(user = it.user.copy(id = userId)) } - val result = nodeManager.toNodeID(nodeNum) + nodeRepository.updateNode(nodeNum) { it.copy(user = it.user.copy(id = userId)) } + val result = nodeRepository.toNodeID(nodeNum) assertEquals(userId, result) } @Test fun `removeByNodenum removes node from both maps`() { val nodeNum = 1234 - nodeManager.updateNode(nodeNum) { + nodeRepository.updateNode(nodeNum) { Node(num = nodeNum, user = User(id = "!testnode", long_name = "Test", short_name = "T")) } - assertTrue(nodeManager.nodeDBbyNodeNum.containsKey(nodeNum)) - assertTrue(nodeManager.nodeDBbyID.containsKey("!testnode")) + assertTrue(nodeRepository.nodeDBbyNodeNum.containsKey(nodeNum)) + assertTrue(nodeRepository.nodeDBbyID.containsKey("!testnode")) - nodeManager.removeByNodenum(nodeNum) + nodeRepository.removeByNodenum(nodeNum) - assertTrue(!nodeManager.nodeDBbyNodeNum.containsKey(nodeNum)) - assertTrue(!nodeManager.nodeDBbyID.containsKey("!testnode")) + assertTrue(!nodeRepository.nodeDBbyNodeNum.containsKey(nodeNum)) + assertTrue(!nodeRepository.nodeDBbyID.containsKey("!testnode")) } @Test @@ -238,7 +238,7 @@ class NodeManagerImplTest { val pk = ByteArray(32) { (it + 1).toByte() }.toByteString() val existingUser = User(id = "!12345678", long_name = "Existing", short_name = "EX", hw_model = HardwareModel.TLORA_V2) - nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) } + nodeRepository.updateNode(nodeNum) { it.copy(user = existingUser) } val incomingUser = User( @@ -248,9 +248,9 @@ class NodeManagerImplTest { hw_model = HardwareModel.TLORA_V2, public_key = pk, ) - nodeManager.handleReceivedUser(nodeNum, incomingUser) + nodeRepository.handleReceivedUser(nodeNum, incomingUser) - val result = nodeManager.nodeDBbyNodeNum[nodeNum]!! + val result = nodeRepository.nodeDBbyNodeNum[nodeNum]!! assertEquals(pk, result.publicKey) assertEquals(pk, result.user.public_key) assertTrue(result.hasPKC) @@ -268,7 +268,7 @@ class NodeManagerImplTest { hw_model = HardwareModel.TLORA_V2, public_key = existingPk, ) - nodeManager.updateNode(nodeNum) { it.copy(user = existingUser, publicKey = existingPk) } + nodeRepository.updateNode(nodeNum) { it.copy(user = existingUser, publicKey = existingPk) } val differentPk = ByteArray(32) { (it + 10).toByte() }.toByteString() val incomingUser = @@ -279,9 +279,9 @@ class NodeManagerImplTest { hw_model = HardwareModel.TLORA_V2, public_key = differentPk, ) - nodeManager.handleReceivedUser(nodeNum, incomingUser) + nodeRepository.handleReceivedUser(nodeNum, incomingUser) - val result = nodeManager.nodeDBbyNodeNum[nodeNum]!! + val result = nodeRepository.nodeDBbyNodeNum[nodeNum]!! // Key mismatch: newUser gets public_key cleared to EMPTY, and publicKey should match assertEquals(ByteString.EMPTY, result.publicKey) assertEquals(ByteString.EMPTY, result.user.public_key) @@ -301,9 +301,9 @@ class NodeManagerImplTest { ) val info = ProtoNodeInfo(num = nodeNum, user = user, last_heard = 1000, channel = 0) - nodeManager.installNodeInfo(info) + nodeRepository.installNodeInfo(info) - val result = nodeManager.nodeDBbyNodeNum[nodeNum]!! + val result = nodeRepository.nodeDBbyNodeNum[nodeNum]!! assertEquals(pk, result.publicKey) assertEquals(pk, result.user.public_key) assertTrue(result.hasPKC) @@ -324,9 +324,9 @@ class NodeManagerImplTest { ) val info = ProtoNodeInfo(num = nodeNum, user = user, last_heard = 1000, channel = 0) - nodeManager.installNodeInfo(info) + nodeRepository.installNodeInfo(info) - val result = nodeManager.nodeDBbyNodeNum[nodeNum]!! + val result = nodeRepository.nodeDBbyNodeNum[nodeNum]!! assertEquals(ByteString.EMPTY, result.publicKey) assertEquals(ByteString.EMPTY, result.user.public_key) } 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 deleted file mode 100644 index 900245332f..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ /dev/null @@ -1,341 +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 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 -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.PacketRepository -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.proto.Data -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.StoreAndForward -import org.meshtastic.proto.StoreForwardPlusPlus -import kotlin.test.BeforeTest -import kotlin.test.Test - -@OptIn(ExperimentalCoroutinesApi::class) -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) - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private lateinit var handler: StoreForwardPacketHandlerImpl - - private val myNodeNum = 12345 - - @BeforeTest - fun setUp() { - every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) - - handler = - StoreForwardPacketHandlerImpl( - nodeManager = nodeManager, - packetRepository = lazy { packetRepository }, - serviceBroadcasts = serviceBroadcasts, - historyManager = historyManager, - dataHandler = lazy { dataHandler }, - scope = testScope, - ) - } - - private fun makeSfPacket(from: Int, sf: StoreAndForward): MeshPacket { - val payload = StoreAndForward.ADAPTER.encode(sf).toByteString() - return MeshPacket(from = from, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = payload)) - } - - private fun makeSfppPacket(from: Int, sfpp: StoreForwardPlusPlus): MeshPacket { - val payload = StoreForwardPlusPlus.ADAPTER.encode(sfpp).toByteString() - return MeshPacket(from = from, decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = payload)) - } - - private fun makeDataPacket(from: Int): DataPacket = DataPacket( - id = 1, - time = 1700000000000L, - to = DataPacket.ID_BROADCAST, - from = DataPacket.nodeNumToDefaultId(from), - bytes = null, - dataType = PortNum.STORE_FORWARD_APP.value, - ) - - // ---------- Legacy S&F: stats ---------- - - @Test - fun `handleStoreAndForward stats creates text data packet`() = testScope.runTest { - val sf = - StoreAndForward( - stats = StoreAndForward.Statistics(messages_total = 100, messages_saved = 50, messages_max = 200), - ) - val packet = makeSfPacket(999, sf) - val dataPacket = makeDataPacket(999) - - handler.handleStoreAndForward(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { dataHandler.rememberDataPacket(any(), myNodeNum) } - } - - // ---------- Legacy S&F: history ---------- - - @Test - fun `handleStoreAndForward history creates text packet and updates last request`() = testScope.runTest { - val sf = - StoreAndForward( - history = - StoreAndForward.History(history_messages = 42, window = 3600000, last_request = 1700000000), - ) - val packet = makeSfPacket(999, sf) - val dataPacket = makeDataPacket(999) - - handler.handleStoreAndForward(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { dataHandler.rememberDataPacket(any(), myNodeNum) } - verify { historyManager.updateStoreForwardLastRequest("router_history", 1700000000, "Unknown") } - } - - // ---------- Legacy S&F: heartbeat ---------- - - @Test - fun `handleStoreAndForward heartbeat does not crash`() = testScope.runTest { - val sf = StoreAndForward(heartbeat = StoreAndForward.Heartbeat(period = 900, secondary = 1)) - val packet = makeSfPacket(999, sf) - val dataPacket = makeDataPacket(999) - - handler.handleStoreAndForward(packet, dataPacket, myNodeNum) - advanceUntilIdle() - // No crash, just logs - } - - // ---------- Legacy S&F: text ---------- - - @Test - fun `handleStoreAndForward text with broadcast rr sets to broadcast`() = testScope.runTest { - val sf = - StoreAndForward( - text = "Hello from router".encodeToByteArray().toByteString(), - rr = StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, - ) - val packet = makeSfPacket(999, sf) - val dataPacket = makeDataPacket(999) - - handler.handleStoreAndForward(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { dataHandler.rememberDataPacket(any(), myNodeNum) } - } - - @Test - fun `handleStoreAndForward text without broadcast rr preserves destination`() = testScope.runTest { - val sf = - StoreAndForward( - text = "Direct message".encodeToByteArray().toByteString(), - rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, - ) - val packet = makeSfPacket(999, sf) - val dataPacket = makeDataPacket(999) - - handler.handleStoreAndForward(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { dataHandler.rememberDataPacket(any(), myNodeNum) } - } - - // ---------- Legacy S&F: null payload ---------- - - @Test - fun `handleStoreAndForward with null payload returns early`() = testScope.runTest { - val packet = MeshPacket(from = 999, decoded = null) - val dataPacket = makeDataPacket(999) - - handler.handleStoreAndForward(packet, dataPacket, myNodeNum) - advanceUntilIdle() - // No crash - } - - // ---------- Legacy S&F: empty message ---------- - - @Test - fun `handleStoreAndForward with no fields set does not crash`() = testScope.runTest { - val sf = StoreAndForward() - val packet = makeSfPacket(999, sf) - val dataPacket = makeDataPacket(999) - - handler.handleStoreAndForward(packet, dataPacket, myNodeNum) - advanceUntilIdle() - // No crash — falls through to else branch - } - - // ---------- SF++: LINK_PROVIDE ---------- - - @Test - fun `handleStoreForwardPlusPlus LINK_PROVIDE with message_hash updates status`() = testScope.runTest { - val sfpp = - StoreForwardPlusPlus( - sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, - encapsulated_id = 42, - encapsulated_from = 1000, - encapsulated_to = 2000, - message_hash = ByteString.of(0x01, 0x02, 0x03, 0x04), - commit_hash = ByteString.EMPTY, - ) - val packet = makeSfppPacket(999, sfpp) - - handler.handleStoreForwardPlusPlus(packet) - advanceUntilIdle() - - verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) } - verify { serviceBroadcasts.broadcastMessageStatus(42, any()) } - } - - // ---------- SF++: CANON_ANNOUNCE ---------- - - @Test - fun `handleStoreForwardPlusPlus CANON_ANNOUNCE updates status by hash`() = testScope.runTest { - val sfpp = - StoreForwardPlusPlus( - sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE, - message_hash = ByteString.of(0xAA.toByte(), 0xBB.toByte()), - encapsulated_rxtime = 1700000000, - ) - val packet = makeSfppPacket(999, sfpp) - - handler.handleStoreForwardPlusPlus(packet) - advanceUntilIdle() - - verifySuspend { packetRepository.updateSFPPStatusByHash(any(), any(), any()) } - } - - // ---------- SF++: CHAIN_QUERY ---------- - - @Test - fun `handleStoreForwardPlusPlus CHAIN_QUERY logs info without crash`() = testScope.runTest { - val sfpp = StoreForwardPlusPlus(sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY) - val packet = makeSfppPacket(999, sfpp) - - handler.handleStoreForwardPlusPlus(packet) - advanceUntilIdle() - // No crash, just logs - } - - // ---------- SF++: LINK_REQUEST ---------- - - @Test - fun `handleStoreForwardPlusPlus LINK_REQUEST logs info without crash`() = testScope.runTest { - val sfpp = StoreForwardPlusPlus(sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST) - val packet = makeSfppPacket(999, sfpp) - - handler.handleStoreForwardPlusPlus(packet) - advanceUntilIdle() - // No crash, just logs - } - - // ---------- SF++: invalid payload ---------- - - @Test - fun `handleStoreForwardPlusPlus with null payload returns early`() = testScope.runTest { - val packet = MeshPacket(from = 999, decoded = null) - - handler.handleStoreForwardPlusPlus(packet) - advanceUntilIdle() - // No crash - } - - // ---------- SF++: fragment types ---------- - - @Test - fun `handleStoreForwardPlusPlus LINK_PROVIDE_FIRSTHALF handled as link provide`() = testScope.runTest { - val sfpp = - StoreForwardPlusPlus( - sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, - encapsulated_id = 55, - encapsulated_from = 1000, - encapsulated_to = 2000, - message_hash = ByteString.of(0x01, 0x02), - commit_hash = ByteString.EMPTY, - ) - val packet = makeSfppPacket(999, sfpp) - - handler.handleStoreForwardPlusPlus(packet) - advanceUntilIdle() - - verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) } - } - - @Test - fun `handleStoreForwardPlusPlus LINK_PROVIDE_SECONDHALF handled as link provide`() = testScope.runTest { - val sfpp = - StoreForwardPlusPlus( - sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, - encapsulated_id = 56, - encapsulated_from = 1000, - encapsulated_to = 2000, - message_hash = ByteString.of(0x03, 0x04), - commit_hash = ByteString.EMPTY, - ) - val packet = makeSfppPacket(999, sfpp) - - handler.handleStoreForwardPlusPlus(packet) - advanceUntilIdle() - - verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) } - } - - // ---------- SF++: commit_hash present changes status ---------- - - @Test - fun `handleStoreForwardPlusPlus LINK_PROVIDE with commit_hash sets SFPP_CONFIRMED`() = testScope.runTest { - val sfpp = - StoreForwardPlusPlus( - sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, - encapsulated_id = 77, - encapsulated_from = 1000, - encapsulated_to = 2000, - message_hash = ByteString.of(0x01, 0x02), - commit_hash = ByteString.of(0xAA.toByte()), // non-empty - ) - val packet = makeSfppPacket(999, sfpp) - - handler.handleStoreForwardPlusPlus(packet) - advanceUntilIdle() - - verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) } - } -} 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 deleted file mode 100644 index 28bf22fdcc..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt +++ /dev/null @@ -1,204 +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.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -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.DataPacket -import org.meshtastic.core.repository.MeshConnectionManager -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.proto.Data -import org.meshtastic.proto.DeviceMetrics -import org.meshtastic.proto.EnvironmentMetrics -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.PowerMetrics -import org.meshtastic.proto.Telemetry -import kotlin.test.BeforeTest -import kotlin.test.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class TelemetryPacketHandlerImplTest { - - private val nodeManager = mock(MockMode.autofill) - private val connectionManager = mock(MockMode.autofill) - private val notificationManager = mock(MockMode.autofill) - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private lateinit var handler: TelemetryPacketHandlerImpl - - private val myNodeNum = 12345 - private val remoteNodeNum = 99999 - - @BeforeTest - fun setUp() { - handler = - TelemetryPacketHandlerImpl( - nodeManager = nodeManager, - connectionManager = lazy { connectionManager }, - notificationManager = notificationManager, - scope = testScope, - ) - } - - private fun makeTelemetryPacket(from: Int, telemetry: Telemetry): MeshPacket { - val payload = Telemetry.ADAPTER.encode(telemetry).toByteString() - return MeshPacket( - from = from, - decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = payload), - rx_time = 1700000000, - ) - } - - private fun makeDataPacket(from: Int): DataPacket = DataPacket( - id = 1, - time = 1700000000000L, - to = DataPacket.ID_BROADCAST, - from = DataPacket.nodeNumToDefaultId(from), - bytes = null, - dataType = PortNum.TELEMETRY_APP.value, - ) - - // ---------- Device metrics from local node ---------- - - @Test - fun `local device metrics updates telemetry on connectionManager`() = testScope.runTest { - val telemetry = - Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.1f)) - val packet = makeTelemetryPacket(myNodeNum, telemetry) - val dataPacket = makeDataPacket(myNodeNum) - - 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 { - val telemetry = - Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 90, voltage = 4.2f)) - val packet = makeTelemetryPacket(remoteNodeNum, telemetry) - val dataPacket = makeDataPacket(remoteNodeNum) - - handler.handleTelemetry(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) } - } - - // ---------- Environment metrics ---------- - - @Test - fun `environment metrics updates node with environment data`() = testScope.runTest { - val telemetry = - Telemetry( - time = 1700000000, - environment_metrics = EnvironmentMetrics(temperature = 25.5f, relative_humidity = 60.0f), - ) - val packet = makeTelemetryPacket(remoteNodeNum, telemetry) - val dataPacket = makeDataPacket(remoteNodeNum) - - handler.handleTelemetry(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) } - } - - // ---------- Power metrics ---------- - - @Test - fun `power metrics updates node with power data`() = testScope.runTest { - val telemetry = Telemetry(time = 1700000000, power_metrics = PowerMetrics(ch1_voltage = 3.3f)) - val packet = makeTelemetryPacket(remoteNodeNum, telemetry) - val dataPacket = makeDataPacket(remoteNodeNum) - - handler.handleTelemetry(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) } - } - - // ---------- Telemetry time handling ---------- - - @Test - fun `telemetry with time 0 gets time from dataPacket`() = testScope.runTest { - val telemetry = Telemetry(time = 0, device_metrics = DeviceMetrics(battery_level = 50, voltage = 3.8f)) - val packet = makeTelemetryPacket(myNodeNum, telemetry) - val dataPacket = makeDataPacket(myNodeNum) - - handler.handleTelemetry(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) } - } - - // ---------- Null payload ---------- - - @Test - fun `handleTelemetry with null decoded payload returns early`() = testScope.runTest { - val packet = MeshPacket(from = myNodeNum, decoded = null) - val dataPacket = makeDataPacket(myNodeNum) - - handler.handleTelemetry(packet, dataPacket, myNodeNum) - advanceUntilIdle() - // No crash - } - - @Test - fun `handleTelemetry with empty payload bytes returns early`() = testScope.runTest { - val packet = - MeshPacket( - from = myNodeNum, - decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = okio.ByteString.EMPTY), - ) - val dataPacket = makeDataPacket(myNodeNum) - - handler.handleTelemetry(packet, dataPacket, myNodeNum) - advanceUntilIdle() - // No crash — decodeOrNull returns null for empty payload - } - - // ---------- Battery notification: healthy battery does NOT trigger ---------- - - @Test - fun `healthy battery level does not trigger low battery notification`() = testScope.runTest { - val telemetry = - Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.0f)) - val packet = makeTelemetryPacket(myNodeNum, telemetry) - val dataPacket = makeDataPacket(myNodeNum) - - handler.handleTelemetry(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - // No dispatch call — battery is healthy - } -} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/MeshTopologyServiceTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/MeshTopologyServiceTest.kt new file mode 100644 index 0000000000..5e5715dd12 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/MeshTopologyServiceTest.kt @@ -0,0 +1,172 @@ +/* + * 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.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.meshtastic.sdk.MeshTopology +import org.meshtastic.sdk.NeighborInfo +import org.meshtastic.sdk.NodeId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class MeshTopologyServiceTest { + + @Test + fun `ingest neighbor info creates graph edges and node count`() = runTest { + val service = MeshTopologyService() + + service.ingestNeighborInfo(neighborInfo(1, 2 to 7.5f, 3 to -1.0f, lastUpdated = 99)) + + assertEquals( + setOf( + edge(1, 2, 7.5f, 99), + edge(1, 3, -1.0f, 99), + ), + service.edges.value.toSet(), + ) + assertEquals(3, service.nodeCount.value) + } + + @Test + fun `shortest path returns the bfs route`() = runTest { + val service = MeshTopologyService() + + service.ingestNeighborInfo(neighborInfo(1, 2 to 5.0f, 3 to 4.0f)) + service.ingestNeighborInfo(neighborInfo(2, 4 to 3.0f)) + service.ingestNeighborInfo(neighborInfo(3, 5 to 2.0f)) + service.ingestNeighborInfo(neighborInfo(5, 4 to 1.0f)) + + assertEquals( + listOf(NodeId(1), NodeId(2), NodeId(4)), + service.shortestPath(NodeId(1), NodeId(4)), + ) + } + + @Test + fun `direct reach is true for one hop neighbors`() = runTest { + val service = MeshTopologyService() + + service.ingestNeighborInfo(neighborInfo(1, 2 to 6.0f)) + + assertTrue(service.isDirectReach(NodeId(1), NodeId(2))) + assertTrue(service.isDirectReach(NodeId(2), NodeId(1))) + assertFalse(service.isDirectReach(NodeId(1), NodeId(3))) + } + + @Test + fun `remove node cleans all associated edges`() = runTest { + val service = MeshTopologyService() + + service.ingestNeighborInfo(neighborInfo(1, 2 to 5.0f, 3 to 1.0f)) + service.ingestNeighborInfo(neighborInfo(4, 2 to 2.5f)) + + service.removeNode(NodeId(2)) + + assertEquals(setOf(edge(1, 3, 1.0f)), service.edges.value.toSet()) + assertEquals(3, service.nodeCount.value) + assertFalse(service.isDirectReach(NodeId(1), NodeId(2))) + assertEquals(emptyList(), service.getNeighbors(NodeId(2))) + } + + @Test + fun `concurrent access keeps reporter edges consistent`() = runTest { + val service = MeshTopologyService() + val firstSnapshot = neighborInfo(1, 2 to 5.0f, 3 to 4.0f) + val secondSnapshot = neighborInfo(1, 4 to 9.0f) + val expectedFirst = setOf(edge(1, 2, 5.0f), edge(1, 3, 4.0f)) + val expectedSecond = setOf(edge(1, 4, 9.0f)) + + coroutineScope { + repeat(100) { index -> + launch { + if (index % 2 == 0) { + service.ingestNeighborInfo(firstSnapshot) + } else { + service.ingestNeighborInfo(secondSnapshot) + } + service.shortestPath(NodeId(1), NodeId(4)) + service.isDirectReach(NodeId(1), NodeId(2)) + } + } + } + + val actualNeighbors = service.getNeighbors(NodeId(1)).toSet() + assertTrue(actualNeighbors == expectedFirst || actualNeighbors == expectedSecond) + assertEquals(actualNeighbors, service.edges.value.toSet()) + assertTrue(service.nodeCount.value == 3 || service.nodeCount.value == 2) + } + + @Test + fun `circular topology path search terminates`() = runTest { + val service = MeshTopologyService() + + service.ingestNeighborInfo(neighborInfo(1, 2 to 1.0f)) + service.ingestNeighborInfo(neighborInfo(2, 3 to 1.0f)) + service.ingestNeighborInfo(neighborInfo(3, 1 to 1.0f, 4 to 1.0f)) + + val path = withTimeout(1.seconds) { service.shortestPath(NodeId(1), NodeId(4)) } + + assertEquals(NodeId(1), path.first()) + assertEquals(NodeId(4), path.last()) + assertTrue(path.size in 3..4) + } + + @Test + fun `empty graph returns empty path and no direct reach`() = runTest { + val service = MeshTopologyService() + + assertEquals(emptyList(), service.shortestPath(NodeId(1), NodeId(2))) + assertFalse(service.isDirectReach(NodeId(1), NodeId(2))) + assertEquals(emptyList(), service.edges.value) + assertEquals(0, service.nodeCount.value) + } + + @Test + fun `clear removes all topology state`() = runTest { + val service = MeshTopologyService() + + service.ingestNeighborInfo(neighborInfo(1, 2 to 5.0f)) + service.clear() + + assertEquals(emptyList(), service.edges.value) + assertEquals(0, service.nodeCount.value) + assertEquals(emptyList(), service.getNeighbors(NodeId(1))) + } + + private fun neighborInfo( + reporter: Int, + vararg neighbors: Pair, + lastUpdated: Int = 0, + ): NeighborInfo = NeighborInfo( + nodeId = NodeId(reporter), + neighbors = neighbors.map { (neighbor, snr) -> NeighborInfo.Neighbor(NodeId(neighbor), snr) }, + lastUpdated = lastUpdated, + ) + + private fun edge( + from: Int, + to: Int, + snr: Float, + lastUpdated: Int = 0, + ): MeshTopology.Edge = MeshTopology.Edge(NodeId(from), NodeId(to), snr, lastUpdated) +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTrackerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTrackerTest.kt new file mode 100644 index 0000000000..fddb57083f --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTrackerTest.kt @@ -0,0 +1,326 @@ +/* + * 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 dev.mokkery.MockMode +import dev.mokkery.answering.calls +import dev.mokkery.everySuspend +import dev.mokkery.matcher.any +import dev.mokkery.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Routing +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.RetryPolicy +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class MessageDeliveryTrackerTest { + + @Test + fun `sent acked flow is persisted as delivered`() = runTest { + val updates = linkedMapOf>() + val repository = mockPacketRepository(updates) + val tracker = buildTracker(repository) + val transport = fakeTransport("fake:acked") + val client = buildClient(transport) + + client.connect() + val handle = client.send(unicastPacket("acked")) + tracker.track(101, handle, RetryPolicy.None) + + runCurrent() + transport.injectRoutingAck(transport.lastTextPacketId()) + runCurrent() + advanceUntilIdle() + + assertEquals( + listOf( + MessageStatus.ENROUTE, + MessageStatus.DELIVERED, + MessageStatus.DELIVERED, + ), + updates.getValue(101), + ) + + client.disconnect() + } + + @Test + fun `retry exhaustion ends in error after the final attempt`() = runTest { + val updates = linkedMapOf>() + val repository = mockPacketRepository(updates) + val tracker = buildTracker(repository) + val transport = fakeTransport("fake:retry-exhausted") + val client = buildClient(transport, sendTimeout = 100.milliseconds) + + client.connect() + val handle = client.send(unicastPacket("retry-exhausted")) + tracker.track(102, handle, RetryPolicy.Fixed(maxAttempts = 1, delay = 100.milliseconds)) + + runCurrent() + assertEquals(1, transport.sentTextPackets().size) + + advanceTimeBy(100.milliseconds) + runCurrent() + advanceTimeBy(100.milliseconds) + runCurrent() + assertEquals(2, transport.sentTextPackets().size) + + advanceTimeBy(100.milliseconds) + runCurrent() + advanceUntilIdle() + + assertEquals(MessageStatus.ERROR, updates.getValue(102).last()) + + client.disconnect() + } + + @Test + fun `routing failure transitions from enroute to error`() = runTest { + val updates = linkedMapOf>() + val repository = mockPacketRepository(updates) + val tracker = buildTracker(repository) + val transport = fakeTransport("fake:routing-error") + val client = buildClient(transport) + + client.connect() + val handle = client.send(unicastPacket("fail")) + tracker.track(103, handle, RetryPolicy.None) + + runCurrent() + transport.injectRoutingError(transport.lastTextPacketId(), Routing.Error.NO_ROUTE) + runCurrent() + advanceUntilIdle() + + assertEquals( + listOf( + MessageStatus.ENROUTE, + MessageStatus.ENROUTE, + MessageStatus.ERROR, + ), + updates.getValue(103), + ) + + client.disconnect() + } + + @Test + fun `retry policy resends after exponential backoff`() = runTest { + val updates = linkedMapOf>() + val repository = mockPacketRepository(updates) + val tracker = buildTracker(repository) + val transport = fakeTransport("fake:retry") + val client = buildClient(transport, sendTimeout = 100.milliseconds) + + client.connect() + val handle = client.send(unicastPacket("retry")) + tracker.track( + 104, + handle, + RetryPolicy.ExponentialBackoff( + maxAttempts = 2, + initialDelay = 1.seconds, + maxDelay = 2.seconds, + jitterFactor = 0.0, + ), + ) + + runCurrent() + assertEquals(1, transport.sentTextPackets().size) + + advanceTimeBy(100.milliseconds) + runCurrent() + assertEquals(1, transport.sentTextPackets().size) + + advanceTimeBy(999.milliseconds) + runCurrent() + assertEquals(1, transport.sentTextPackets().size) + + advanceTimeBy(1.milliseconds) + runCurrent() + assertEquals(2, transport.sentTextPackets().size) + + transport.injectRoutingAck(transport.lastTextPacketId()) + runCurrent() + advanceUntilIdle() + + assertEquals(MessageStatus.DELIVERED, updates.getValue(104).last()) + + client.disconnect() + } + + @Test + fun `concurrent messages are tracked independently`() = runTest { + val updates = linkedMapOf>() + val repository = mockPacketRepository(updates) + val tracker = buildTracker(repository) + val transport = fakeTransport("fake:concurrent") + val client = buildClient(transport, sendTimeout = 200.milliseconds) + + client.connect() + val firstHandle = client.send(unicastPacket("first")) + val secondHandle = client.send(unicastPacket("second")) + tracker.track(201, firstHandle, RetryPolicy.None) + tracker.track(202, secondHandle, RetryPolicy.None) + + runCurrent() + val requestIds = transport.sentTextPackets().takeLast(2).map { it.id } + transport.injectRoutingAck(requestIds.first()) + runCurrent() + + advanceTimeBy(200.milliseconds) + runCurrent() + advanceUntilIdle() + + assertEquals(MessageStatus.DELIVERED, updates.getValue(201).last()) + assertEquals(MessageStatus.ERROR, updates.getValue(202).last()) + + client.disconnect() + } + + @Test + fun `timeout marks message as error`() = runTest { + val updates = linkedMapOf>() + val repository = mockPacketRepository(updates) + val tracker = buildTracker(repository) + val transport = fakeTransport("fake:timeout") + val client = buildClient(transport, sendTimeout = 150.milliseconds) + + client.connect() + val handle = client.send(unicastPacket("timeout")) + tracker.track(203, handle, RetryPolicy.None) + + runCurrent() + advanceTimeBy(150.milliseconds) + runCurrent() + advanceUntilIdle() + + assertEquals(MessageStatus.ERROR, updates.getValue(203).last()) + + client.disconnect() + } + + @Test + fun `duplicate ack after delivery does not add extra updates`() = runTest { + val updates = linkedMapOf>() + val repository = mockPacketRepository(updates) + val tracker = buildTracker(repository) + val transport = fakeTransport("fake:duplicate-ack") + val client = buildClient(transport) + + client.connect() + val handle = client.send(unicastPacket("duplicate")) + tracker.track(204, handle, RetryPolicy.None) + + runCurrent() + val requestId = transport.lastTextPacketId() + transport.injectRoutingAck(requestId) + runCurrent() + advanceUntilIdle() + + val completedUpdates = updates.getValue(204).toList() + + transport.injectRoutingAck(requestId) + runCurrent() + advanceUntilIdle() + + assertEquals(completedUpdates, updates.getValue(204)) + + client.disconnect() + } + + private fun TestScope.buildClient( + transport: FakeRadioTransport, + sendTimeout: Duration = 5.seconds, + ): RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .sendTimeout(sendTimeout) + .build() + + private fun TestScope.buildTracker(packetRepository: PacketRepository): MessageDeliveryTracker { + val dispatcher = StandardTestDispatcher(testScheduler) + return MessageDeliveryTracker( + packetRepository = lazyOf(packetRepository), + dispatchers = CoroutineDispatchers(dispatcher, dispatcher, dispatcher), + ) + } + + private fun mockPacketRepository( + updates: MutableMap>, + ): PacketRepository { + val repository = mock(MockMode.autofill) + + everySuspend { repository.getPacketByPacketId(any()) } calls { args -> + DataPacket(bytes = null, dataType = 0, id = args.arg(0)) + } + everySuspend { repository.updateMessageStatus(any(), any()) } calls { args -> + updates.record(args.arg(0), args.arg(1)) + } + everySuspend { repository.updateMessageStatus(any(), any()) } calls { args -> + updates.record(args.arg(0).id, args.arg(1)) + } + + return repository + } + + private fun MutableMap>.record(packetId: Int, status: MessageStatus) { + getOrPut(packetId) { mutableListOf() }.add(status) + } + + private fun fakeTransport(identity: String) = FakeRadioTransport( + identity = TransportIdentity(identity), + autoHandshake = true, + ) + + private fun unicastPacket(text: String) = MeshPacket( + to = 0x12345678, + channel = 0, + want_ack = true, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = text.encodeToByteArray().toByteString(), + ), + ) + + private fun FakeRadioTransport.sentTextPackets(): List = + outboundPackets().filter { it.decoded?.portnum == PortNum.TEXT_MESSAGE_APP } + + private fun FakeRadioTransport.lastTextPacketId(): Int = sentTextPackets().last().id +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkEventBridgeTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkEventBridgeTest.kt new file mode 100644 index 0000000000..33154cd869 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkEventBridgeTest.kt @@ -0,0 +1,141 @@ +/* + * 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.io.bytestring.ByteString as KByteString +import org.meshtastic.core.model.CongestionLevel +import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.FromRadio +import org.meshtastic.sdk.CongestionMetrics +import org.meshtastic.sdk.DroppedFlow +import org.meshtastic.sdk.Frame +import org.meshtastic.sdk.MeshEvent +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class SdkEventBridgeTest { + + @Test + fun `device rebooted event emits notification`() = runTest { + val serviceRepository = FakeServiceRepository() + val bridge = SdkEventBridge(serviceRepository) + val (transport, client) = connectedClient() + + bridge.observe(TestRadioClientAccessor(client), backgroundScope) + client.connect() + runCurrent() + + transport.injectFrame(encodeFromRadio(FromRadio(rebooted = true))) + runCurrent() + + assertEquals("Device rebooted", serviceRepository.clientNotification.value?.message) + client.disconnect() + } + + @Test + fun `congestion warning sets congestion level`() = runTest { + val serviceRepository = FakeServiceRepository() + val bridge = SdkEventBridge(serviceRepository) + + bridge.handleEvent(MeshEvent.CongestionWarning(CongestionMetrics(airUtilTx = 80f, channelUtil = 30f))) + + assertEquals(CongestionLevel.CRITICAL, serviceRepository.congestionLevel.value) + } + + @Test + fun `duplicated public key warning is logged without changing service state`() = runTest { + val serviceRepository = FakeServiceRepository().apply { + setClientNotification(ClientNotification(message = "existing")) + setCongestionLevel(CongestionLevel.HIGH) + } + val bridge = SdkEventBridge(serviceRepository) + + bridge.handleEvent(MeshEvent.SecurityWarning.DuplicatedPublicKey) + + assertEquals("existing", serviceRepository.clientNotification.value?.message) + assertEquals(CongestionLevel.HIGH, serviceRepository.congestionLevel.value) + } + + @Test + fun `low entropy key warning is logged without changing service state`() = runTest { + val serviceRepository = FakeServiceRepository().apply { + setCongestionLevel(CongestionLevel.MEDIUM) + } + val bridge = SdkEventBridge(serviceRepository) + + bridge.handleEvent(MeshEvent.SecurityWarning.LowEntropyKey) + + assertNull(serviceRepository.clientNotification.value) + assertEquals(CongestionLevel.MEDIUM, serviceRepository.congestionLevel.value) + } + + @Test + fun `packets dropped event is handled without crashing`() = runTest { + val serviceRepository = FakeServiceRepository().apply { + setClientNotification(ClientNotification(message = "keep")) + } + val bridge = SdkEventBridge(serviceRepository) + + bridge.handleEvent(MeshEvent.PacketsDropped(flow = DroppedFlow.Events, count = 4)) + + assertEquals("keep", serviceRepository.clientNotification.value?.message) + } + + private fun TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport(identity = TransportIdentity("fake:event-bridge"), autoHandshake = true) + val client = + RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .build() + return transport to client + } + + private fun encodeFromRadio(fromRadio: FromRadio): Frame { + val proto = FromRadio.ADAPTER.encode(fromRadio) + val frameBytes = 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) + } + return Frame(KByteString(frameBytes)) + } + + private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor { + override val client = MutableStateFlow(client) + + override fun rebuildAndConnectAsync() = Unit + + override fun disconnect() = Unit + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkIntegrationTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkIntegrationTest.kt new file mode 100644 index 0000000000..636c137e4f --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkIntegrationTest.kt @@ -0,0 +1,520 @@ +/* + * 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. + */ +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 okio.ByteString.Companion.toByteString +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.core.testing.FakeUiPrefs +import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.proto.Telemetry +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +/** + * Integration tests verifying the full SDK → Bridge → Repository chain, + * connection lifecycle state transitions, and error resilience. + * + * These tests spin up a real [RadioClient] backed by [FakeRadioTransport] + * (with autoHandshake) and wire it through [SdkStateBridge] to real + * repository fakes, verifying the complete data flow end-to-end. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SdkIntegrationTest { + + // ─────────────────────────────────────────────────────────────────────────── + // T4a: SDK → Bridge → Repository chain tests + // ─────────────────────────────────────────────────────────────────────────── + + @Test + fun `text message packet from SDK reaches service repository`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + client.connect() + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + transport.injectPacket( + MeshPacket( + id = 42, + from = 0x22222222, + to = 0x11111111, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "Hello mesh!".encodeToByteArray().toByteString(), + ), + ), + ) + runCurrent() + + // Connection should remain active after packet processing + assertTrue(serviceRepo.connectionState.value.isConnected) + + client.disconnect() + } + + @Test + fun `telemetry packet updates congestion level`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + client.connect() + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + // Inject telemetry with high air utilization from own node + transport.injectPacket( + MeshPacket( + from = 0x11111111, // own node — triggers local congestion tracking + to = 0, + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = Telemetry( + device_metrics = DeviceMetrics( + air_util_tx = 80f, + channel_utilization = 85f, + ), + ).let { Telemetry.ADAPTER.encode(it).toByteString() }, + ), + ), + ) + runCurrent() + + // Congestion level should be set (CRITICAL for >=75% utilization) + val congestion = serviceRepo.congestionLevel.value + assertTrue(congestion != null, "Congestion level should be set for high air utilization") + + client.disconnect() + } + + @Test + fun `store forward heartbeat registers server`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + client.connect() + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + transport.injectStoreForwardResponse( + requestId = 0, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 900, secondary = 0), + ), + fromNode = 0xABCD1234.toInt(), + ) + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + assertTrue(serviceRepo.storeForwardServers.value.contains(0xABCD1234.toInt())) + + client.disconnect() + } + + @Test + fun `topology update from neighbor info packet reaches topology service`() = runTest { + val serviceRepo = FakeServiceRepository() + val topologyService = MeshTopologyService() + val (transport, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo, topologyService = topologyService) + + client.connect() + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + // Inject a NEIGHBORINFO_APP packet + val neighborInfo = org.meshtastic.proto.NeighborInfo( + node_id = 0x22222222, + neighbors = listOf( + org.meshtastic.proto.Neighbor(node_id = 0x33333333, snr = 7.5f), + org.meshtastic.proto.Neighbor(node_id = 0x44444444, snr = -3.0f), + ), + ) + transport.injectPacket( + MeshPacket( + from = 0x22222222, + to = 0xFFFFFFFF.toInt(), + decoded = Data( + portnum = PortNum.NEIGHBORINFO_APP, + payload = org.meshtastic.proto.NeighborInfo.ADAPTER.encode(neighborInfo).toByteString(), + ), + ), + ) + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + // Topology service should have edges + assertTrue(topologyService.edges.value.isNotEmpty()) + + client.disconnect() + } + + // ─────────────────────────────────────────────────────────────────────────── + // T4b: Connection lifecycle tests + // ─────────────────────────────────────────────────────────────────────────── + + @Test + fun `connect transitions service repository to connected state`() = runTest { + val serviceRepo = FakeServiceRepository() + val (_, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + // Before connect + assertEquals(ConnectionState.Disconnected, serviceRepo.connectionState.value) + + client.connect() + runCurrent() + advanceTimeBy(2.seconds) + runCurrent() + + // After connect + handshake, should be connected + assertTrue(serviceRepo.connectionState.value.isConnected) + + client.disconnect() + } + + @Test + fun `disconnect transitions service repository to disconnected state`() = runTest { + val serviceRepo = FakeServiceRepository() + val (_, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + client.connect() + runCurrent() + advanceTimeBy(2.seconds) + runCurrent() + assertTrue(serviceRepo.connectionState.value.isConnected) + + client.disconnect() + runCurrent() + advanceTimeBy(2.seconds) + runCurrent() + + assertEquals(ConnectionState.Disconnected, serviceRepo.connectionState.value) + } + + @Test + fun `new client after disconnect restores connected state`() = runTest { + val serviceRepo = FakeServiceRepository() + val accessor = MutableRadioClientAccessor() + val (_, client1) = connectedClient() + buildBridgeWithAccessor(accessor, serviceRepository = serviceRepo) + + // First connect + accessor.client.value = client1 + client1.connect() + runCurrent() + advanceTimeBy(2.seconds) + runCurrent() + assertTrue(serviceRepo.connectionState.value.isConnected) + + // Disconnect + client1.disconnect() + runCurrent() + advanceTimeBy(2.seconds) + runCurrent() + assertEquals(ConnectionState.Disconnected, serviceRepo.connectionState.value) + + // "Reconnect" — new transport + client (as the real app does) + val (_, client2) = connectedClient() + accessor.client.value = client2 + client2.connect() + runCurrent() + advanceTimeBy(2.seconds) + runCurrent() + assertTrue(serviceRepo.connectionState.value.isConnected) + + client2.disconnect() + } + + @Test + fun `disconnect clears congestion level`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + client.connect() + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + // Set congestion via telemetry + transport.injectPacket( + MeshPacket( + from = 0x11111111, + to = 0, + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = Telemetry( + device_metrics = DeviceMetrics(air_util_tx = 90f, channel_utilization = 90f), + ).let { Telemetry.ADAPTER.encode(it).toByteString() }, + ), + ), + ) + runCurrent() + + // Disconnect — congestion should clear + client.disconnect() + runCurrent() + advanceTimeBy(2.seconds) + runCurrent() + + // Service repo clears congestion on disconnect + assertEquals(null, serviceRepo.congestionLevel.value) + } + + // ─────────────────────────────────────────────────────────────────────────── + // T4c: Error resilience tests + // ─────────────────────────────────────────────────────────────────────────── + + @Test + fun `malformed proto payload does not crash bridge`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + client.connect() + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + // Inject packet with garbage payload for a port that expects proto + transport.injectPacket( + MeshPacket( + id = 99, + from = 0x33333333, + to = 0x11111111, + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0x00, 0x01).toByteString(), + ), + ), + ) + runCurrent() + + // Bridge should still be functional — subsequent valid packet works + transport.injectPacket( + MeshPacket( + id = 100, + from = 0x33333333, + to = 0x11111111, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "still alive".encodeToByteArray().toByteString(), + ), + ), + ) + runCurrent() + + // Connection still active — not crashed + assertTrue(serviceRepo.connectionState.value.isConnected) + + client.disconnect() + } + + @Test + fun `unknown port number does not disrupt bridge processing`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + client.connect() + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + // Inject packet with a port number the bridge doesn't handle + transport.injectPacket( + MeshPacket( + id = 200, + from = 0x88888888.toInt(), + to = 0x11111111, + decoded = Data( + portnum = PortNum.UNKNOWN_APP, + payload = "mystery data".encodeToByteArray().toByteString(), + ), + ), + ) + runCurrent() + + // Still connected + assertTrue(serviceRepo.connectionState.value.isConnected) + + client.disconnect() + } + + @Test + fun `rapid fire packets all processed without loss`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + client.connect() + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + // Fire 20 packets rapidly + repeat(20) { i -> + transport.injectPacket( + MeshPacket( + id = 1000 + i, + from = 0x22222222, + to = 0x11111111, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "msg$i".encodeToByteArray().toByteString(), + ), + ), + ) + } + runCurrent() + + // Connection still healthy after burst + assertTrue(serviceRepo.connectionState.value.isConnected) + + client.disconnect() + } + + @Test + fun `empty payload packet handled gracefully`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient() + buildBridge(client, serviceRepository = serviceRepo) + + client.connect() + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + // Inject packet with no payload + transport.injectPacket( + MeshPacket( + id = 300, + from = 0x44444444, + to = 0x11111111, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = okio.ByteString.EMPTY, + ), + ), + ) + runCurrent() + + // Not crashed + assertTrue(serviceRepo.connectionState.value.isConnected) + + client.disconnect() + } + + // ─────────────────────────────────────────────────────────────────────────── + // Helpers + // ─────────────────────────────────────────────────────────────────────────── + + private class MutableRadioClientAccessor : RadioClientAccessor { + override val client = MutableStateFlow(null) + override fun rebuildAndConnectAsync() = Unit + override fun disconnect() = Unit + } + + private fun TestScope.connectedClient( + myNodeNum: Int = 0x11111111, + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:integration"), + autoHandshake = true, + nodeNum = myNodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .presenceTimeout(2.seconds) + .build() + return transport to client + } + + private fun TestScope.buildBridge( + client: RadioClient, + nodeRepository: FakeNodeRepository = FakeNodeRepository(), + packetRepository: PacketRepository = mock(MockMode.autofill), + serviceRepository: FakeServiceRepository = FakeServiceRepository(), + topologyService: MeshTopologyService = MeshTopologyService(), + ): SdkStateBridge = buildBridgeWithAccessor( + accessor = object : RadioClientAccessor { + override val client = MutableStateFlow(client) + override fun rebuildAndConnectAsync() = Unit + override fun disconnect() = Unit + }, + nodeRepository = nodeRepository, + packetRepository = packetRepository, + serviceRepository = serviceRepository, + topologyService = topologyService, + ) + + private fun TestScope.buildBridgeWithAccessor( + accessor: RadioClientAccessor, + nodeRepository: FakeNodeRepository = FakeNodeRepository(), + packetRepository: PacketRepository = mock(MockMode.autofill), + serviceRepository: FakeServiceRepository = FakeServiceRepository(), + topologyService: MeshTopologyService = MeshTopologyService(), + ): SdkStateBridge = SdkStateBridge( + accessor = accessor, + serviceRepository = serviceRepository, + nodeRepository = nodeRepository, + packetRepository = lazyOf(packetRepository), + locationManager = object : MeshLocationManager { + override fun start(scope: CoroutineScope, sendPositionFn: (Position) -> Unit) = Unit + override fun stop() = Unit + }, + topologyService = topologyService, + uiPrefs = FakeUiPrefs(), + 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, + ), + ) +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkNodeBridgeTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkNodeBridgeTest.kt new file mode 100644 index 0000000000..c1d36e0c33 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkNodeBridgeTest.kt @@ -0,0 +1,336 @@ +/* + * 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.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.io.bytestring.ByteString as KByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.util.onlineTimeThreshold +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.testing.FakeNodeRepository +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.Frame +import org.meshtastic.sdk.NodeChange +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.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SdkNodeBridgeTest { + + @Test + fun `snapshot clears repository and reinstalls nodes`() = runTest { + val nodeRepository = RecordingNodeRepository().apply { + setNodes( + listOf( + Node(num = 0xAAAA0001.toInt(), user = User(id = "!AAAA0001", long_name = "stale")), + ), + ) + } + val topologyService = MeshTopologyService().apply { + ingestNeighborInfo( + org.meshtastic.sdk.NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf(org.meshtastic.sdk.NeighborInfo.Neighbor(NodeId(2), 7.5f)), + ), + ) + } + val bridge = SdkNodeBridge(nodeRepository, topologyService) + val first = nodeInfo(0x11111111, "!11111111", "Alpha") + val second = nodeInfo(0x22222222, "!22222222", "Bravo") + + bridge.handleNodeChange( + NodeChange.Snapshot( + mapOf( + NodeId(first.num) to first, + NodeId(second.num) to second, + ), + ), + ) + + assertEquals(1, nodeRepository.clearCalls) + assertEquals(listOf(false, false), nodeRepository.installCalls.map { it.second }) + assertEquals(setOf(first.num, second.num), nodeRepository.nodeDBbyNum.value.keys) + assertTrue(nodeRepository.isNodeDbReady.value) + assertTrue(topologyService.edges.value.isEmpty()) + assertEquals(0, topologyService.nodeCount.value) + } + + @Test + fun `added event installs node with broadcast enabled`() = runTest { + val nodeRepository = RecordingNodeRepository() + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + val added = nodeInfo(0x33333333, "!33333333", "Added") + + bridge.handleNodeChange(NodeChange.Added(added)) + + assertEquals(listOf(true), nodeRepository.installCalls.map { it.second }) + assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(added.num)) + } + + @Test + fun `updated event reinstalls node with broadcast enabled`() = runTest { + val nodeRepository = RecordingNodeRepository() + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + val updated = nodeInfo(0x44444444, "!44444444", "Updated") + + bridge.handleNodeChange(NodeChange.Updated(updated, emptySet())) + + assertEquals(listOf(true), nodeRepository.installCalls.map { it.second }) + assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(updated.num)) + } + + @Test + fun `removed event deletes node from repository`() = runTest { + val nodeNum = 0x55555555 + val nodeRepository = RecordingNodeRepository().apply { + setNodes(listOf(Node(num = nodeNum, user = User(id = "!55555555", long_name = "Gone")))) + } + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + + bridge.handleNodeChange(NodeChange.Removed(NodeId(nodeNum))) + + assertEquals(listOf(nodeNum), nodeRepository.removeCalls) + assertFalse(nodeRepository.nodeDBbyNum.value.containsKey(nodeNum)) + } + + @Test + fun `went offline updates last heard and marks node offline`() = runTest { + val nodeNum = 0x66666666 + val nodeRepository = RecordingNodeRepository().apply { + setNodes( + listOf( + Node( + num = nodeNum, + user = User(id = "!66666666", long_name = "Offline"), + lastHeard = Clock.System.now().epochSeconds.toInt(), + ), + ), + ) + } + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + val staleLastHeard = onlineTimeThreshold() - 20 + + bridge.handleNodeChange(NodeChange.WentOffline(NodeId(nodeNum), staleLastHeard)) + + val updated = nodeRepository.nodeDBbyNum.value.getValue(nodeNum) + assertEquals(minOf(Clock.System.now().epochSeconds.toInt(), staleLastHeard, onlineTimeThreshold()), updated.lastHeard) + assertFalse(updated.isOnline) + } + + @Test + fun `went offline ignores unknown node`() = runTest { + val nodeRepository = RecordingNodeRepository() + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + + bridge.handleNodeChange(NodeChange.WentOffline(NodeId(0x77777777), onlineTimeThreshold() - 10)) + + assertTrue(nodeRepository.nodeDBbyNum.value.isEmpty()) + } + + @Test + fun `came online updates last heard and marks node online`() = runTest { + val nodeNum = 0x88888888.toInt() + val nodeRepository = RecordingNodeRepository().apply { + setNodes( + listOf( + Node( + num = nodeNum, + user = User(id = "!88888888", long_name = "Online"), + lastHeard = onlineTimeThreshold() - 120, + ), + ), + ) + } + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + + bridge.handleNodeChange(NodeChange.CameOnline(NodeId(nodeNum))) + + val updated = nodeRepository.nodeDBbyNum.value.getValue(nodeNum) + assertTrue(updated.lastHeard >= onlineTimeThreshold()) + assertTrue(updated.isOnline) + } + + @Test + fun `came online ignores unknown node`() = runTest { + val nodeRepository = RecordingNodeRepository() + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + + bridge.handleNodeChange(NodeChange.CameOnline(NodeId(0x99999999.toInt()))) + + assertTrue(nodeRepository.nodeDBbyNum.value.isEmpty()) + } + + @Test + fun `own node discovered sets my node num`() = runTest { + val myNodeNum = 0x12345678 + val nodeRepository = RecordingNodeRepository() + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + val (transport, client) = connectedClient(myNodeNum = myNodeNum) + + bridge.observe(TestRadioClientAccessor(client), backgroundScope) + client.connect() + runCurrent() + + transport.injectFrame(encodeFromRadio(FromRadio(node_info = nodeInfo(myNodeNum, "!12345678", "Self")))) + runCurrent() + + assertEquals(myNodeNum, nodeRepository.myNodeNum.value) + client.disconnect() + } + + @Test + fun `node status packet populates node status`() = runTest { + val nodeNum = 0xABCDEF01.toInt() + val nodeRepository = RecordingNodeRepository().apply { + setNodes(listOf(Node(num = nodeNum, user = User(id = "!ABCDEF01", long_name = "Status")))) + } + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + + bridge.handleNodeStatusPacket( + MeshPacket( + from = nodeNum, + decoded = Data( + portnum = PortNum.NODE_STATUS_APP, + payload = "nomad active".encodeToByteArray().toByteString(), + ), + ), + ) + + assertEquals("nomad active", nodeRepository.nodeDBbyNum.value.getValue(nodeNum).nodeStatus) + } + + @Test + fun `node status packet with empty payload stores empty status`() = runTest { + val nodeNum = 0x0BADF00D + val nodeRepository = RecordingNodeRepository().apply { + setNodes(listOf(Node(num = nodeNum, user = User(id = "!0BADF00D", long_name = "Status")))) + } + val bridge = SdkNodeBridge(nodeRepository, MeshTopologyService()) + + bridge.handleNodeStatusPacket( + MeshPacket( + from = nodeNum, + decoded = Data(portnum = PortNum.NODE_STATUS_APP), + ), + ) + + assertEquals("", nodeRepository.nodeDBbyNum.value.getValue(nodeNum).nodeStatus) + } + + private fun TestScope.connectedClient( + storage: StorageProvider = NodeBridgeSeededHeartbeatStorageProvider(emptyMap()), + myNodeNum: Int = 0x11111111, + presenceTimeout: Duration = 1.seconds, + ): Pair { + val transport = FakeRadioTransport(identity = TransportIdentity("fake:node-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 nodeInfo(num: Int, id: String, longName: String) = + NodeInfo( + num = num, + user = User(id = id, long_name = longName, short_name = longName.take(4)), + ) + + private fun encodeFromRadio(fromRadio: FromRadio): Frame { + val proto = FromRadio.ADAPTER.encode(fromRadio) + val frameBytes = 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) + } + return Frame(KByteString(frameBytes)) + } + + private class RecordingNodeRepository( + private val delegate: FakeNodeRepository = FakeNodeRepository(), + ) : NodeRepository by delegate { + val installCalls = mutableListOf>() + val removeCalls = mutableListOf() + var clearCalls = 0 + + override fun clear() { + clearCalls += 1 + delegate.clear() + } + + override fun installNodeInfo(info: NodeInfo, withBroadcast: Boolean) { + installCalls += info to withBroadcast + delegate.installNodeInfo(info, withBroadcast) + } + + override fun removeByNodenum(nodeNum: Int) { + removeCalls += nodeNum + delegate.removeByNodenum(nodeNum) + } + + fun setNodes(nodes: List) { + delegate.setNodes(nodes) + } + } + + private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor { + override val client = MutableStateFlow(client) + + override fun rebuildAndConnectAsync() = Unit + + override fun disconnect() = Unit + } +} + +private class NodeBridgeSeededHeartbeatStorageProvider( + private val heartbeats: Map, +) : StorageProvider { + override suspend fun activate(identity: TransportIdentity) = + InMemoryStorage().also { storage -> + heartbeats.forEach { (nodeId, heartbeatMs) -> + storage.saveHeartbeat(nodeId, heartbeatMs) + } + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketBridgeTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketBridgeTest.kt new file mode 100644 index 0000000000..f6711fc0b4 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketBridgeTest.kt @@ -0,0 +1,268 @@ +/* + * 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 dev.mokkery.MockMode +import dev.mokkery.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.model.MessageStatus +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreForwardPlusPlus +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.StoreForwardEvent +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SdkPacketBridgeTest { + + @Test + fun `packet received is emitted to service repository`() = runTest { + val serviceRepository = FakeServiceRepository() + val bridge = SdkPacketBridge(serviceRepository, lazyOf(mock(MockMode.autofill)), FakeNodeRepository()) + val (transport, client) = connectedClient() + + bridge.observe(TestRadioClientAccessor(client), backgroundScope) + client.connect() + runCurrent() + + val packet = MeshPacket(from = 0x10101010, to = 0, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)) + val packetAwaiter = backgroundScope.async { serviceRepository.meshPacketFlow.first() } + runCurrent() + + transport.injectPacket(packet) + runCurrent() + + assertEquals(packet, packetAwaiter.await()) + client.disconnect() + } + + @Test + fun `store forward server list tracks discovered servers`() = runTest { + val serviceRepository = FakeServiceRepository() + val bridge = SdkPacketBridge(serviceRepository, lazyOf(mock(MockMode.autofill)), FakeNodeRepository()) + val (transport, client) = connectedClient() + + bridge.observe(TestRadioClientAccessor(client), backgroundScope) + client.connect() + runCurrent() + + transport.injectStoreForwardResponse( + requestId = 0, + message = org.meshtastic.proto.StoreAndForward( + rr = org.meshtastic.proto.StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = org.meshtastic.proto.StoreAndForward.Heartbeat(period = 300), + ), + fromNode = 0x0A0A0A0A, + ) + transport.injectStoreForwardResponse( + requestId = 0, + message = org.meshtastic.proto.StoreAndForward( + rr = org.meshtastic.proto.StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = org.meshtastic.proto.StoreAndForward.Heartbeat(period = 300), + ), + fromNode = 0x0B0B0B0B, + ) + runCurrent() + + assertEquals(listOf(0x0A0A0A0A, 0x0B0B0B0B), serviceRepository.storeForwardServers.value) + client.disconnect() + } + + @Test + fun `sfpp confirmed link updates packet repository`() = runTest { + val packetRepository = RecordingPacketRepository() + val nodeRepository = FakeNodeRepository().apply { setMyNodeNum(0x11111111) } + val bridge = SdkPacketBridge(FakeServiceRepository(), lazyOf(packetRepository), nodeRepository) + + bridge.handleStoreForwardEvent( + StoreForwardEvent.SfppLinkProvided( + packetId = 0x1234, + from = 0x55667788, + to = 0x01020304, + messageHash = byteArrayOf(1, 2, 3, 4), + confirmed = true, + ), + ) + + val call = packetRepository.statusCalls.single() + assertEquals(0x1234, call.packetId) + assertEquals(0x55667788, call.from) + assertEquals(0x01020304, call.to) + assertContentEquals(byteArrayOf(1, 2, 3, 4), call.hash) + assertEquals(MessageStatus.SFPP_CONFIRMED, call.status) + assertEquals(0x11111111, call.myNodeNum) + } + + @Test + fun `sfpp routing link updates packet repository`() = runTest { + val packetRepository = RecordingPacketRepository() + val bridge = SdkPacketBridge(FakeServiceRepository(), lazyOf(packetRepository), FakeNodeRepository()) + + bridge.handleStoreForwardEvent( + StoreForwardEvent.SfppLinkProvided( + packetId = 77, + from = 0x11112222, + to = 0x33334444, + messageHash = byteArrayOf(9, 8, 7), + confirmed = false, + ), + ) + + assertEquals(MessageStatus.SFPP_ROUTING, packetRepository.statusCalls.single().status) + } + + @Test + fun `sfpp link without hash is ignored`() = runTest { + val packetRepository = RecordingPacketRepository() + val bridge = SdkPacketBridge(FakeServiceRepository(), lazyOf(packetRepository), FakeNodeRepository()) + + bridge.handleStoreForwardEvent( + StoreForwardEvent.SfppLinkProvided( + packetId = 1, + from = 2, + to = 3, + messageHash = null, + confirmed = true, + ), + ) + + assertTrue(packetRepository.statusCalls.isEmpty()) + assertTrue(packetRepository.hashCalls.isEmpty()) + } + + @Test + fun `sfpp canon announce updates packet repository by hash`() = runTest { + val packetRepository = RecordingPacketRepository() + val bridge = SdkPacketBridge(FakeServiceRepository(), lazyOf(packetRepository), FakeNodeRepository()) + + bridge.handleStoreForwardEvent( + StoreForwardEvent.SfppCanonAnnounced( + messageHash = byteArrayOf(7, 6, 5, 4), + rxTime = 0xFEDCBA98L, + ), + ) + + val call = packetRepository.hashCalls.single() + assertContentEquals(byteArrayOf(7, 6, 5, 4), call.hash) + assertEquals(MessageStatus.SFPP_CONFIRMED, call.status) + assertEquals(0xFEDCBA98L, call.rxTime) + } + + @Test + fun `unknown packet type is handled without crashing`() = runTest { + val serviceRepository = FakeServiceRepository() + val packetRepository = RecordingPacketRepository() + val bridge = SdkPacketBridge(serviceRepository, lazyOf(packetRepository), FakeNodeRepository()) + val (transport, client) = connectedClient() + + bridge.observe(TestRadioClientAccessor(client), backgroundScope) + client.connect() + runCurrent() + + transport.injectPacket( + MeshPacket( + from = 0x99990000.toInt(), + to = 0, + decoded = Data(portnum = PortNum.UNKNOWN_APP, payload = byteArrayOf(0x01, 0x02).toByteString()), + ), + ) + runCurrent() + + assertTrue(packetRepository.statusCalls.isEmpty()) + assertTrue(packetRepository.hashCalls.isEmpty()) + assertEquals(emptyList(), serviceRepository.storeForwardServers.value) + client.disconnect() + } + + private fun TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport(identity = TransportIdentity("fake:packet-bridge"), autoHandshake = true) + val client = + RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .build() + return transport to client + } + + private class RecordingPacketRepository( + private val delegate: PacketRepository = mock(MockMode.autofill), + ) : PacketRepository by delegate { + data class StatusCall( + val packetId: Int, + val from: Int, + val to: Int, + val hash: ByteArray, + val status: MessageStatus, + val rxTime: Long, + val myNodeNum: Int?, + ) + + data class HashCall( + val hash: ByteArray, + val status: MessageStatus, + val rxTime: Long, + ) + + val statusCalls = mutableListOf() + val hashCalls = mutableListOf() + + override suspend fun updateSFPPStatus( + packetId: Int, + from: Int, + to: Int, + hash: ByteArray, + status: MessageStatus, + rxTime: Long, + myNodeNum: Int?, + ) { + statusCalls += StatusCall(packetId, from, to, hash.copyOf(), status, rxTime, myNodeNum) + } + + override suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long) { + hashCalls += HashCall(hash.copyOf(), status, rxTime) + } + } + + private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor { + override val client = MutableStateFlow(client) + + override fun rebuildAndConnectAsync() = Unit + + override fun disconnect() = Unit + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt new file mode 100644 index 0000000000..cd62081ad7 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt @@ -0,0 +1,435 @@ +/* + * 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 dev.mokkery.MockMode +import dev.mokkery.mock +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.AdminException +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeRadioPrefs +import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Routing +import org.meshtastic.proto.Telemetry +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SdkRadioControllerTest { + + @Test + fun `setLocalConfig forwards admin config write`() = runTest { + val fixture = connectedFixture() + try { + val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.setLocalConfig(config) } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { adminOf(it)?.set_config == config } + assertTrue(request.want_ack) + fixture.transport.injectRoutingAck(request.id) + runCurrent() + + deferred.await() + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `setRemoteChannel forwards remote admin channel write`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x22334455 + val channel = Channel(index = 1, role = Channel.Role.SECONDARY, settings = ChannelSettings(name = "Ops")) + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.setRemoteChannel(destNum, channel) } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { adminOf(it)?.set_channel == channel } + assertEquals(destNum, request.to) + fixture.transport.injectRoutingAck(request.id, fromNode = destNum) + runCurrent() + + deferred.await() + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `setRemoteChannel forwards disabled channel for removal`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x33445566 + val channel = Channel(index = 2, role = Channel.Role.DISABLED) + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.setRemoteChannel(destNum, channel) } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { adminOf(it)?.set_channel == channel } + assertEquals(destNum, request.to) + fixture.transport.injectRoutingAck(request.id, fromNode = destNum) + runCurrent() + + deferred.await() + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `requestTelemetry forwards device request to sdk`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x44556677 + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.requestTelemetry(destNum, TelemetryType.DEVICE) } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { telemetryOf(it) != null } + assertEquals(destNum, request.to) + assertEquals(PortNum.TELEMETRY_APP, request.decoded?.portnum) + assertTrue(request.decoded?.want_response == true) + fixture.transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 87)), + fromNode = destNum, + ) + runCurrent() + + deferred.await() + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `requestTelemetry local stats targets local node`() = runTest { + val fixture = connectedFixture() + try { + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.requestTelemetry(0xCAFEBABE.toInt(), TelemetryType.LOCAL_STATS) } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { telemetryOf(it) != null } + assertEquals(fixture.myNodeNum, request.to) + fixture.transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(local_stats = LocalStats(uptime_seconds = 123)), + fromNode = fixture.myNodeNum, + ) + runCurrent() + + deferred.await() + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `requestPosition encodes coordinates and requests ack`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x55667788 + val position = Position(latitude = 37.1234567, longitude = -122.7654321, altitude = 42, time = 1_700_000_123) + val outboundBefore = fixture.transport.outboundPackets().size + + fixture.controller.requestPosition(destNum, position) + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { it.decoded?.portnum == PortNum.POSITION_APP } + val sentPosition = org.meshtastic.proto.Position.ADAPTER.decode(request.decoded!!.payload) + assertEquals(destNum, request.to) + assertTrue(request.want_ack) + assertEquals(Position.degI(position.latitude), sentPosition.latitude_i) + assertEquals(Position.degI(position.longitude), sentPosition.longitude_i) + assertEquals(position.altitude, sentPosition.altitude) + assertEquals(position.time, sentPosition.time) + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `setFixedPosition encodes coordinates for admin api`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x66778899 + val position = Position(latitude = 12.3456789, longitude = 98.7654321, altitude = 321, time = 456) + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.setFixedPosition(destNum, position) } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { adminOf(it)?.set_fixed_position != null } + val sentPosition = adminOf(request)!!.set_fixed_position!! + assertEquals(destNum, request.to) + assertEquals(Position.degI(position.latitude), sentPosition.latitude_i) + assertEquals(Position.degI(position.longitude), sentPosition.longitude_i) + assertEquals(position.altitude, sentPosition.altitude) + assertEquals(position.time, sentPosition.time) + fixture.transport.injectRoutingAck(request.id, fromNode = destNum) + runCurrent() + + deferred.await() + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `getConfig returns sdk config response`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x10203040 + val expected = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.getConfig(destNum, AdminMessage.ConfigType.DEVICE_CONFIG.value) } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.get_config_request == AdminMessage.ConfigType.DEVICE_CONFIG } + fixture.transport.injectAdminResponse( + requestId = request.id, + response = AdminMessage(get_config_response = expected), + fromNode = destNum, + ) + runCurrent() + + assertEquals(expected, deferred.await()) + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `getModuleConfig returns sdk module config response`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x11223344 + val expected = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.getModuleConfig(destNum, AdminMessage.ModuleConfigType.MQTT_CONFIG.value) } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.get_module_config_request == AdminMessage.ModuleConfigType.MQTT_CONFIG } + fixture.transport.injectAdminResponse( + requestId = request.id, + response = AdminMessage(get_module_config_response = expected), + fromNode = destNum, + ) + runCurrent() + + assertEquals(expected, deferred.await()) + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `listChannels delegates to sdk and stops at disabled slot`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x7ABCDE01 + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.listChannels(destNum) } + + repeat(3) { + runCurrent() + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { adminOf(it)?.get_channel_request != null } + assertEquals(destNum, request.to) + val wireIndex = adminOf(request)!!.get_channel_request!! + val channelIndex = wireIndex - 1 + val channel = if (channelIndex < 2) { + Channel(index = channelIndex, role = Channel.Role.PRIMARY, settings = ChannelSettings(name = "Channel $channelIndex")) + } else { + Channel(index = channelIndex, role = Channel.Role.DISABLED) + } + fixture.transport.injectAdminResponse( + requestId = request.id, + response = AdminMessage(get_channel_response = channel), + fromNode = destNum, + ) + } + runCurrent() + advanceUntilIdle() + + val channels = deferred.await() + assertEquals(listOf("Channel 0", "Channel 1"), channels.map { it.settings?.name }) + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `reboot forwards reboot command as fire and forget admin write`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x55667711 + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { fixture.controller.reboot(destNum) } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { adminOf(it)?.reboot_seconds == 0 } + assertEquals(destNum, request.to) + fixture.transport.injectRoutingAck(request.id, fromNode = destNum) + runCurrent() + + deferred.await() + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `sdk timeouts surface to callers`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x12345678 + val deferred = async { + assertFailsWith { + fixture.controller.getConfig(destNum, AdminMessage.ConfigType.DEVICE_CONFIG.value) + } + } + runCurrent() + advanceTimeBy(70.seconds) + runCurrent() + + deferred.await() + } finally { + fixture.client.disconnect() + } + } + + @Test + fun `unauthorized admin operations surface permission errors`() = runTest { + val fixture = connectedFixture() + try { + val destNum = 0x21436587 + val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + val outboundBefore = fixture.transport.outboundPackets().size + val deferred = async { + assertFailsWith { + fixture.controller.setConfig(destNum, config) + } + } + runCurrent() + + val request = fixture.transport.outboundPackets().drop(outboundBefore).last { adminOf(it)?.set_config == config } + fixture.transport.injectRoutingError(request.id, Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED, fromNode = destNum) + runCurrent() + + deferred.await() + } finally { + fixture.client.disconnect() + } + } + + private suspend fun TestScope.connectedFixture(myNodeNum: Int = 0x11111111): ControllerFixture { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:sdk-radio-controller"), + autoHandshake = true, + nodeNum = myNodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .rpcTimeout(60.seconds) + .sendTimeout(60.seconds) + .build() + val dispatcher = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as CoroutineDispatcher + val controller = SdkRadioController( + accessor = TestRadioClientAccessor(client), + serviceRepository = FakeServiceRepository(), + nodeRepository = FakeNodeRepository(), + locationManager = NoOpLocationManager, + deliveryTracker = MessageDeliveryTracker(lazyOf(mock(MockMode.autofill)), CoroutineDispatchers(dispatcher, dispatcher, dispatcher)), + radioPrefs = FakeRadioPrefs(), + ) + client.connect() + runCurrent() + return ControllerFixture(controller = controller, transport = transport, client = client, myNodeNum = myNodeNum) + } + + private fun adminOf(packet: MeshPacket): AdminMessage? { + val decoded = packet.decoded ?: return null + if (decoded.portnum != PortNum.ADMIN_APP) return null + return runCatching { AdminMessage.ADAPTER.decode(decoded.payload) }.getOrNull() + } + + private fun telemetryOf(packet: MeshPacket): Telemetry? { + val decoded = packet.decoded ?: return null + if (decoded.portnum != PortNum.TELEMETRY_APP) return null + return runCatching { Telemetry.ADAPTER.decode(decoded.payload) }.getOrNull() + } + + private data class ControllerFixture( + val controller: SdkRadioController, + val transport: FakeRadioTransport, + val client: RadioClient, + val myNodeNum: Int, + ) + + private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor { + override val client = MutableStateFlow(client) + + override fun rebuildAndConnectAsync() = Unit + + override fun disconnect() = Unit + } + + private object NoOpLocationManager : MeshLocationManager { + override fun start(scope: CoroutineScope, sendPositionFn: (org.meshtastic.proto.Position) -> Unit) = Unit + + override fun stop() = Unit + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt new file mode 100644 index 0000000000..6c26e6e842 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt @@ -0,0 +1,337 @@ +/* + * 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 dev.mokkery.MockMode +import dev.mokkery.matcher.any +import dev.mokkery.mock +import dev.mokkery.verifySuspend +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 okio.ByteString.Companion.toByteString +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.MessageStatus +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.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.StoreForwardPlusPlus +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.assertEquals +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(StateBridgeHeartbeatStorageProvider(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(StateBridgeHeartbeatStorageProvider(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() + } + + @Test + fun `sfpp link provided updates packet repository`() = runTest { + val packetRepository = mock(MockMode.autofill) + val (transport, client) = connectedClient(StateBridgeHeartbeatStorageProvider(emptyMap())) + buildBridge(client, FakeNodeRepository(), packetRepository) + + client.connect() + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + message_hash = byteArrayOf(1, 2, 3, 4).toByteString(), + commit_hash = byteArrayOf(9, 8, 7).toByteString(), + encapsulated_id = 0x1234, + encapsulated_to = 0x01020304, + encapsulated_from = 0x55667788, + ), + ) + runCurrent() + + verifySuspend { + packetRepository.updateSFPPStatus( + 0x1234, + 0x55667788, + 0x01020304, + any(), + MessageStatus.SFPP_CONFIRMED, + 0L, + 0, + ) + } + + client.disconnect() + } + + @Test + fun `sfpp canon announce updates packet repository by hash`() = runTest { + val packetRepository = mock(MockMode.autofill) + val (transport, client) = connectedClient(StateBridgeHeartbeatStorageProvider(emptyMap())) + buildBridge(client, FakeNodeRepository(), packetRepository) + + client.connect() + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE, + message_hash = byteArrayOf(7, 6, 5, 4).toByteString(), + encapsulated_rxtime = 0xFEDCBA98.toInt(), + ), + ) + runCurrent() + + verifySuspend { + packetRepository.updateSFPPStatusByHash( + any(), + MessageStatus.SFPP_CONFIRMED, + 0xFEDCBA98L, + ) + } + + client.disconnect() + } + + @Test + fun `congestion warning updates service repository congestion level`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient(StateBridgeHeartbeatStorageProvider(emptyMap())) + buildBridge(client, FakeNodeRepository(), serviceRepository = serviceRepo) + + client.connect() + runCurrent() + + // Inject a telemetry packet with high air utilization to trigger CongestionWarning + transport.injectPacket( + MeshPacket( + from = 0x11111111, // "own node" — triggers congestion from local metrics + to = 0, + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = org.meshtastic.proto.Telemetry( + device_metrics = org.meshtastic.proto.DeviceMetrics( + air_util_tx = 80f, + channel_utilization = 85f, + ), + ).let { org.meshtastic.proto.Telemetry.ADAPTER.encode(it).toByteString() }, + ), + ), + ) + runCurrent() + + assertEquals(org.meshtastic.sdk.CongestionLevel.CRITICAL, serviceRepo.congestionLevel.value) + + client.disconnect() + } + + @Test + fun `store forward server list propagates to service repository`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient(StateBridgeHeartbeatStorageProvider(emptyMap())) + buildBridge(client, FakeNodeRepository(), serviceRepository = serviceRepo) + + client.connect() + runCurrent() + + // Inject a StoreAndForward heartbeat from a server node to trigger server discovery + transport.injectStoreForwardResponse( + requestId = 0, + message = org.meshtastic.proto.StoreAndForward( + rr = org.meshtastic.proto.StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = org.meshtastic.proto.StoreAndForward.Heartbeat(period = 900, secondary = 0), + ), + fromNode = 0xABCD1234.toInt(), + ) + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + + assertTrue(serviceRepo.storeForwardServers.value.contains(0xABCD1234.toInt())) + + client.disconnect() + } + + private fun TestScope.connectedClient( + storage: StorageProvider, + myNodeNum: Int = 0x11111111, + presenceTimeout: Duration = 1.seconds, + ): Pair { + 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, + packetRepository: PacketRepository = mock(MockMode.autofill), + serviceRepository: FakeServiceRepository = FakeServiceRepository(), + ): SdkStateBridge = + SdkStateBridge( + accessor = TestRadioClientAccessor(client), + serviceRepository = serviceRepository, + nodeRepository = nodeRepository, + packetRepository = lazyOf(packetRepository), + locationManager = NoOpLocationManager, + topologyService = MeshTopologyService(), + uiPrefs = FakeUiPrefs(), + 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 fun FakeRadioTransport.injectSfpp( + message: StoreForwardPlusPlus, + fromNode: Int = 0x10203040, + ) { + injectPacket( + MeshPacket( + id = 1, + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = StoreForwardPlusPlus.ADAPTER.encode(message).toByteString(), + ), + ), + ) + } + + private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor { + override val client = MutableStateFlow(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 StateBridgeHeartbeatStorageProvider( + private val heartbeats: Map, +) : StorageProvider { + override suspend fun activate(identity: TransportIdentity): DeviceStorage = + InMemoryStorage().also { storage -> + heartbeats.forEach { (nodeId, heartbeatMs) -> + storage.saveHeartbeat(nodeId, heartbeatMs) + } + } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkTopologyBridgeTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkTopologyBridgeTest.kt new file mode 100644 index 0000000000..5376348bb5 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkTopologyBridgeTest.kt @@ -0,0 +1,146 @@ +/* + * 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.ExperimentalCoroutinesApi +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.Neighbor +import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.PortNum +import org.meshtastic.sdk.NodeId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +@OptIn(ExperimentalCoroutinesApi::class) +class SdkTopologyBridgeTest { + + @Test + fun `neighbor info packet updates topology graph`() = runTest { + val topologyService = MeshTopologyService() + val bridge = SdkTopologyBridge(topologyService) + + bridge.handleNeighborInfoPacket( + neighborInfoPacket( + from = 0x11111111, + info = NeighborInfo( + last_sent_by_id = 1234, + neighbors = listOf( + Neighbor(node_id = 0x22222222, snr = 7.5f), + Neighbor(node_id = 0x33333333, snr = -2.25f), + ), + ), + ), + ) + + val edges = topologyService.edges.value + assertEquals(2, edges.size) + assertEquals(3, topologyService.nodeCount.value) + assertEquals(NodeId(0x11111111), edges[0].from) + assertEquals(NodeId(0x22222222), edges[0].to) + assertEquals(7.5f, edges[0].snr) + assertEquals(1234, edges[0].lastUpdated) + assertEquals(NodeId(0x33333333), edges[1].to) + } + + @Test + fun `malformed proto is handled without crashing`() = runTest { + val topologyService = MeshTopologyService() + val bridge = SdkTopologyBridge(topologyService) + + bridge.handleNeighborInfoPacket( + MeshPacket( + from = 0x44444444, + decoded = Data( + portnum = PortNum.NEIGHBORINFO_APP, + payload = byteArrayOf(0x01, 0x02, 0x03).toByteString(), + ), + ), + ) + + assertTrue(topologyService.edges.value.isEmpty()) + assertEquals(0, topologyService.nodeCount.value) + } + + @Test + fun `empty neighbor list tracks node without edges`() = runTest { + val topologyService = MeshTopologyService() + val bridge = SdkTopologyBridge(topologyService) + + bridge.handleNeighborInfoPacket( + neighborInfoPacket( + from = 0x55555555, + info = NeighborInfo(last_sent_by_id = 999, neighbors = emptyList()), + ), + ) + + assertTrue(topologyService.edges.value.isEmpty()) + assertEquals(1, topologyService.nodeCount.value) + } + + @Test + fun `subsequent reports replace edges from the same reporter`() = runTest { + val topologyService = MeshTopologyService() + val bridge = SdkTopologyBridge(topologyService) + + bridge.handleNeighborInfoPacket( + neighborInfoPacket( + from = 0x66666666, + info = NeighborInfo( + neighbors = listOf( + Neighbor(node_id = 0x11110000, snr = 1f), + Neighbor(node_id = 0x22220000, snr = 2f), + ), + ), + ), + ) + bridge.handleNeighborInfoPacket( + neighborInfoPacket( + from = 0x66666666, + info = NeighborInfo(neighbors = listOf(Neighbor(node_id = 0x33330000, snr = 3f))), + ), + ) + + val edges = topologyService.edges.value + assertEquals(1, edges.size) + assertEquals(NodeId(0x33330000), edges.single().to) + assertEquals(2, topologyService.nodeCount.value) + } + + @Test + fun `empty payload tracks reporter without edges`() = runTest { + val topologyService = MeshTopologyService() + val bridge = SdkTopologyBridge(topologyService) + + bridge.handleNeighborInfoPacket(MeshPacket(from = 0x77777777, decoded = Data(portnum = PortNum.NEIGHBORINFO_APP))) + + assertTrue(topologyService.edges.value.isEmpty()) + assertEquals(1, topologyService.nodeCount.value) + } + + private fun neighborInfoPacket(from: Int, info: NeighborInfo) = + MeshPacket( + from = from, + decoded = Data( + portnum = PortNum.NEIGHBORINFO_APP, + payload = NeighborInfo.ADAPTER.encode(info).toByteString(), + ), + ) +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt index 9f57efa8a4..e3b69880e1 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt @@ -25,10 +25,10 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString -import org.meshtastic.core.data.datasource.NodeInfoReadDataSource -import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.MyNodeInfo +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.testing.FakeDatabaseProvider import org.meshtastic.core.testing.FakeMeshLogPrefs import org.meshtastic.proto.Data @@ -47,7 +47,7 @@ abstract class CommonMeshLogRepositoryTest { protected lateinit var dbProvider: FakeDatabaseProvider protected lateinit var meshLogPrefs: FakeMeshLogPrefs - protected lateinit var nodeInfoReadDataSource: NodeInfoReadDataSource + protected lateinit var nodeRepository: NodeRepository private val testDispatcher = UnconfinedTestDispatcher() private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) @@ -59,11 +59,11 @@ abstract class CommonMeshLogRepositoryTest { dbProvider = FakeDatabaseProvider() meshLogPrefs = FakeMeshLogPrefs() meshLogPrefs.setLoggingEnabled(true) - nodeInfoReadDataSource = mock(MockMode.autofill) + nodeRepository = mock(MockMode.autofill) - every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(null) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(null) - repository = MeshLogRepositoryImpl(dbProvider, dispatchers, meshLogPrefs, nodeInfoReadDataSource) + repository = MeshLogRepositoryImpl(dbProvider, dispatchers, meshLogPrefs, nodeRepository) } @AfterTest @@ -105,9 +105,10 @@ abstract class CommonMeshLogRepositoryTest { fun `deleteLogs redirects local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) { val localNodeNum = 999 val port = PortNum.TEXT_MESSAGE_APP.value - val myNodeEntity = - MyNodeEntity( + val myNodeInfo = + MyNodeInfo( myNodeNum = localNodeNum, + hasGPS = false, model = "model", firmwareVersion = "1.0", couldUpdate = false, @@ -117,8 +118,11 @@ abstract class CommonMeshLogRepositoryTest { minAppVersion = 0, maxChannels = 0, hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + deviceId = null, ) - every { nodeInfoReadDataSource.myNodeInfoFlow() } returns MutableStateFlow(myNodeEntity) + every { nodeRepository.myNodeInfo } returns MutableStateFlow(myNodeInfo) val log = MeshLog( 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 05ffae23d7..0000000000 --- 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/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt index 34fb6d14cb..897e047e1e 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt @@ -16,69 +16,273 @@ */ package org.meshtastic.core.data.repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import org.meshtastic.core.database.entity.MyNodeEntity +import okio.ByteString +import okio.ByteString.Companion.toByteString import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.Reaction import org.meshtastic.core.testing.FakeDatabaseProvider +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.TestDataFactory +import org.meshtastic.core.testing.setupTestContext +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.User +import org.meshtastic.proto.Waypoint import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue abstract class CommonPacketRepositoryTest { protected lateinit var dbProvider: FakeDatabaseProvider private val testDispatcher = UnconfinedTestDispatcher() private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) + protected val nodeRepository = FakeNodeRepository() protected lateinit var repository: PacketRepositoryImpl + private val myNodeNum = 1 + private val broadcastContact = "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}" + fun setupRepo() { + setupTestContext() dbProvider = FakeDatabaseProvider() - repository = PacketRepositoryImpl(dbProvider, dispatchers) + repository = PacketRepositoryImpl(dbProvider, dispatchers, nodeRepository) + nodeRepository.setMyNodeInfo(TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum)) } @AfterTest fun tearDown() { - dbProvider.close() + if (::dbProvider.isInitialized) { + dbProvider.close() + } } @Test fun `savePacket persists and retrieves waypoints`() = runTest(testDispatcher) { - val myNodeNum = 1 - val contact = "contact" - - // Ensure my_node is present so getMessageCount finds the packet - dbProvider.currentDb.value - .nodeInfoDao() - .setMyNodeInfo( - MyNodeEntity( - myNodeNum = myNodeNum, - model = "model", - firmwareVersion = "1.0", - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 0L, - messageTimeoutMsec = 0, - minAppVersion = 0, - maxChannels = 0, - hasWifi = false, - ), - ) + val packet = DataPacket(to = DataPacket.BROADCAST, bytes = ByteString.EMPTY, dataType = PortNum.TEXT_MESSAGE_APP.value, id = 123) - val packet = DataPacket(to = "0!ffffffff", bytes = okio.ByteString.EMPTY, dataType = 1, id = 123) + repository.savePacket(myNodeNum, broadcastContact, packet, 1000L) - repository.savePacket(myNodeNum, contact, packet, 1000L) - - // Verify it was saved. - val count = repository.getMessageCount(contact) - assertEquals(1, count) + assertEquals(1, repository.getMessageCount(broadcastContact)) } @Test fun `clearAllUnreadCounts works with real DB`() = runTest(testDispatcher) { repository.clearAllUnreadCounts() - // No exception thrown } + + @Test + fun `getMessagesFrom limit keeps newest messages within boundary`() = runTest(testDispatcher) { + val contact = "1!abcd1234" + repeat(55) { index -> + saveTextPacket(contact = contact, id = index + 1, receivedTime = 1_000L + index, text = "Message $index", read = false) + } + + val messages = repository.getMessagesFrom(contact = contact, limit = 50, getNode = ::lookupNode).first() + + assertEquals(50, messages.size) + assertEquals("Message 54", messages.first().text) + assertEquals("Message 5", messages.last().text) + } + + @Test + fun `unread counts track read and unread transitions`() = runTest(testDispatcher) { + val contact = "2!feedbeef" + saveTextPacket(contact = contact, id = 1, receivedTime = 100L, read = false) + saveTextPacket(contact = contact, id = 2, receivedTime = 200L, read = false) + saveTextPacket(contact = contact, id = 3, receivedTime = 300L, read = false) + + assertEquals(3, repository.getUnreadCount(contact)) + assertEquals(3, repository.getUnreadCountTotal().first()) + assertTrue(repository.hasUnreadMessages(contact).first()) + assertNotNull(repository.getFirstUnreadMessageUuid(contact).first()) + + repository.clearUnreadCount(contact, 200L) + + assertEquals(1, repository.getUnreadCount(contact)) + assertEquals(1, repository.getUnreadCountTotal().first()) + assertTrue(repository.hasUnreadMessages(contact).first()) + + repository.clearAllUnreadCounts() + + assertEquals(0, repository.getUnreadCount(contact)) + assertEquals(0, repository.getUnreadCountTotal().first()) + assertFalse(repository.hasUnreadMessages(contact).first()) + assertEquals(null, repository.getFirstUnreadMessageUuid(contact).first()) + } + + @Test + fun `reactions can be added listed and removed with parent message`() = runTest(testDispatcher) { + val contact = "3!react000" + val replyId = 501 + saveTextPacket(contact = contact, id = replyId, receivedTime = 1_000L, text = "Original", read = true) + + val reaction = + Reaction( + replyId = replyId, + user = User(id = "!reactor"), + emoji = "👍", + timestamp = 2_000L, + snr = 1.5f, + rssi = -70, + hopsAway = 1, + packetId = replyId, + to = "!abcd1234", + channel = 3, + ) + + repository.insertReaction(reaction, myNodeNum) + + val storedReaction = repository.getReactionByPacketId(replyId) + assertNotNull(storedReaction) + assertEquals("👍", storedReaction.emoji) + assertEquals("!reactor", storedReaction.user.id) + + val reactions = repository.findReactionsWithId(replyId) + assertEquals(1, reactions.size) + assertEquals(replyId, reactions.single().replyId) + assertEquals("👍", reactions.single().emoji) + + val messageUuid = repository.getMessagesFrom(contact = contact, getNode = ::lookupNode).first().single().uuid + repository.deleteMessages(listOf(messageUuid)) + + assertTrue(repository.findReactionsWithId(replyId).isEmpty()) + assertEquals(null, repository.getReactionByPacketId(replyId)) + } + + @Test + fun `getWaypoints preserves channel data for filtering`() = runTest(testDispatcher) { + saveWaypointPacket(contact = broadcastContact, channel = 0, waypointId = 101, receivedTime = 1_000L) + saveWaypointPacket(contact = "2${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", channel = 2, waypointId = 202, receivedTime = 2_000L) + saveTextPacket(contact = broadcastContact, id = 77, receivedTime = 3_000L) + + val waypoints = repository.getWaypoints().first() + val channelTwoWaypoints = waypoints.filter { it.channel == 2 } + + assertEquals(2, waypoints.size) + assertEquals(setOf(0, 2), waypoints.map { it.channel }.toSet()) + assertEquals(listOf(202), channelTwoWaypoints.mapNotNull { it.waypoint?.id }) + } + + @Test + fun `contact keys keep channel and destination formats distinct`() = runTest(testDispatcher) { + val channelBroadcast = broadcastContact + val secondaryBroadcast = "1${DataPacket.nodeNumToId(DataPacket.BROADCAST)}" + val directMessage = "${DataPacket.PKC_CHANNEL_INDEX}!70fdde9b" + + saveTextPacket(contact = channelBroadcast, id = 1, receivedTime = 100L, channel = 0) + saveTextPacket(contact = secondaryBroadcast, id = 2, receivedTime = 200L, channel = 1) + saveTextPacket( + contact = directMessage, + id = 3, + receivedTime = 300L, + channel = DataPacket.PKC_CHANNEL_INDEX, + to = 0x70fdde9b, + ) + + val contacts = repository.getContacts().first() + + assertEquals(setOf(channelBroadcast, secondaryBroadcast, directMessage), contacts.keys) + assertEquals(0, contacts.getValue(channelBroadcast).channel) + assertEquals(1, contacts.getValue(secondaryBroadcast).channel) + assertEquals(DataPacket.PKC_CHANNEL_INDEX, contacts.getValue(directMessage).channel) + assertEquals(1, repository.getMessageCount(channelBroadcast)) + assertEquals(1, repository.getMessageCount(secondaryBroadcast)) + assertEquals(1, repository.getMessageCount(directMessage)) + } + + @Test + fun `getMessagesFrom returns empty flow for unknown contact`() = runTest(testDispatcher) { + val messages = repository.getMessagesFrom(contact = "7!missing", getNode = ::lookupNode).first() + + assertTrue(messages.isEmpty()) + } + + @Test + fun `concurrent writes keep all packets intact`() = runTest { + val concurrentRepository = PacketRepositoryImpl( + dbManager = dbProvider, + dispatchers = CoroutineDispatchers(main = testDispatcher, io = Dispatchers.Default, default = Dispatchers.Default), + nodeRepository = nodeRepository, + ) + val contact = "4!concur00" + + coroutineScope { + repeat(100) { index -> + launch(Dispatchers.Default) { + concurrentRepository.savePacket( + myNodeNum = myNodeNum, + contactKey = contact, + packet = textPacket(id = index + 1, text = "Concurrent $index", channel = 4), + receivedTime = 10_000L + index, + read = false, + ) + } + } + } + + val messages = concurrentRepository.getMessagesFrom(contact = contact, getNode = ::lookupNode).first() + + assertEquals(100, concurrentRepository.getMessageCount(contact)) + assertEquals(100, messages.size) + assertEquals(100, messages.map { it.packetId }.distinct().size) + } + + private suspend fun saveTextPacket( + contact: String, + id: Int, + receivedTime: Long, + text: String = "Message $id", + read: Boolean = true, + filtered: Boolean = false, + channel: Int = contact.first().digitToIntOrNull() ?: 0, + to: Int = DataPacket.BROADCAST, + from: Int = 0x12345678, + ) { + repository.savePacket( + myNodeNum = myNodeNum, + contactKey = contact, + packet = textPacket(id = id, text = text, channel = channel, to = to, from = from), + receivedTime = receivedTime, + read = read, + filtered = filtered, + ) + } + + private suspend fun saveWaypointPacket(contact: String, channel: Int, waypointId: Int, receivedTime: Long) { + repository.savePacket( + myNodeNum = myNodeNum, + contactKey = contact, + packet = DataPacket(to = DataPacket.BROADCAST, channel = channel, waypoint = Waypoint(id = waypointId, name = "Waypoint $waypointId")), + receivedTime = receivedTime, + ) + } + + private fun textPacket( + id: Int, + text: String, + channel: Int, + to: Int = DataPacket.BROADCAST, + from: Int = 0x12345678, + ) = DataPacket( + to = to, + bytes = text.encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + from = from, + id = id, + channel = channel, + ) + + private fun lookupNode(userId: String?): Node = Node(num = 0, user = User(id = userId.orEmpty(), long_name = userId.orEmpty())) } diff --git a/core/database/README.md b/core/database/README.md index 6ad4d603f6..d94457b3f8 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -6,19 +6,17 @@ This module provides the local Room database persistence layer for the applicati - **`MeshtasticDatabase`**: The main Room database class, defined in `commonMain`. - **DAOs (Data Access Objects)**: - - `NodeInfoDao`: Manages storage and retrieval of node information (`NodeEntity`). Contains critical logic for handling Public Key Conflict (PKC) resolution and preventing identity wiping attacks. - `PacketDao`: Handles storage of mesh packets, including text messages, waypoints, and reactions. + - `NodeMetadataDao`: Manages app-local node annotations (favorites, notes, muting). - **Entities**: - - `NodeEntity`: Represents a node on the mesh. - `Packet`: Represents a stored packet. - `ReactionEntity`: Represents emoji reactions to packets. + - `NodeMetadataEntity`: Persists user annotations that survive process death. -## Security Considerations +## Notes -### Public Key Conflict (PKC) Handling -The `NodeInfoDao` implements specific logic to protect against impersonation and "wipe" attacks: -- **Wipe Protection**: Receiving an `is_licensed=true` packet (which normally clears the public key for compliance) will **not** clear an existing valid public key if one is already known. This prevents attackers from sending fake licensed packets to wipe keys from the DB. -- **Conflict Detection**: If a new key arrives for an existing node ID that conflicts with a known valid key, the key is set to `ERROR_BYTE_STRING` to flag the potential impersonation. +Node data (positions, telemetry, user info) is managed by the SDK's SqlDelight database. +The Room database only stores messages, logs, and user-local annotations. ## Module dependency graph 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 0000000000..887e36c1be --- /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/schemas/org.meshtastic.core.database.MeshtasticDatabase/40.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/40.json new file mode 100644 index 0000000000..c7686df02a --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/40.json @@ -0,0 +1,755 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "24d17bdf342c1f3bfa50564b0e93e6f5", + "entities": [ + { + "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": "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, '24d17bdf342c1f3bfa50564b0e93e6f5')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt index 451a621740..c06540a0b3 100644 --- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt @@ -29,7 +29,6 @@ import org.junit.runner.RunWith import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.database.MeshtasticDatabase import org.meshtastic.core.database.MeshtasticDatabaseConstructor -import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.model.DataPacket import org.meshtastic.proto.ChannelSettings @@ -42,21 +41,8 @@ import kotlin.test.assertEquals class MigrationTest { private lateinit var database: MeshtasticDatabase private lateinit var packetDao: PacketDao - private lateinit var nodeInfoDao: NodeInfoDao - - private val myNodeInfo: MyNodeEntity = - MyNodeEntity( - myNodeNum = 42424242, - model = null, - firmwareVersion = null, - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 1L, - messageTimeoutMsec = 5 * 60 * 1000, - minAppVersion = 1, - maxChannels = 8, - hasWifi = false, - ) + + private val myNodeNum = 42424242 @Before fun createDb(): Unit = runTest { @@ -67,7 +53,6 @@ class MigrationTest { factory = { MeshtasticDatabaseConstructor.initialize() }, ) .build() - nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) } packetDao = database.packetDao() } @@ -78,26 +63,20 @@ class MigrationTest { @Test fun testMigrateChannelsByPSK_duplicatePSK() = runTest { - // PSK \"AQ==\" is base64 for single byte 0x01 val pskBytes = byteArrayOf(0x01).toByteString() - // Create packets for Channel 0 insertPacket(channel = 0, text = "Message Ch0") - // Old settings: Channel 0 has PSK_A val oldSettings = listOf(ChannelSettings(psk = pskBytes, name = "LongFast")) - // New settings: Channel 0 has PSK_A, Channel 1 has PSK_A val newSettings = listOf( ChannelSettings(psk = pskBytes, name = "LongFast"), ChannelSettings(psk = pskBytes, name = "NewChan"), ) - // Perform migration packetDao.migrateChannelsByPSK(oldSettings, newSettings) - // Check packet channel val p = getFirstPacket() assertEquals(0, p.data.channel, "Packet should remain on channel 0") } @@ -130,7 +109,6 @@ class MigrationTest { val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A1"), ChannelSettings(psk = pskA, name = "A2")) - // Swap positions but keep names and PSKs val newSettings = listOf(ChannelSettings(psk = pskA, name = "A2"), ChannelSettings(psk = pskA, name = "A1")) packetDao.migrateChannelsByPSK(oldSettings, newSettings) @@ -148,7 +126,6 @@ class MigrationTest { val oldSettings = listOf(ChannelSettings(psk = pskA, name = "A")) - // New settings has two identical channels (same PSK, same Name) val newSettings = listOf(ChannelSettings(psk = pskA, name = "A"), ChannelSettings(psk = pskA, name = "A")) packetDao.migrateChannelsByPSK(oldSettings, newSettings) @@ -161,17 +138,17 @@ class MigrationTest { val packet = Packet( uuid = 0L, - myNodeNum = 42424242, + myNodeNum = myNodeNum, port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = "$channel!broadcast", received_time = nowMillis, read = false, - data = DataPacket(to = DataPacket.ID_BROADCAST, channel = channel, text = text), + data = DataPacket(to = DataPacket.BROADCAST, channel = channel, text = text), ) packetDao.insert(packet) } - private suspend fun getAllPackets() = packetDao.getAllPackets(PortNum.TEXT_MESSAGE_APP.value).first() + private suspend fun getAllPackets() = packetDao.getAllPackets(myNodeNum, PortNum.TEXT_MESSAGE_APP.value).first() private suspend fun getFirstPacket() = getAllPackets().first() } 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 d329d184cc..204bda2474 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,13 @@ 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 @@ -35,9 +37,7 @@ import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.database.entity.DeviceHardwareEntity import org.meshtastic.core.database.entity.FirmwareReleaseEntity 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 @@ -46,14 +46,12 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity @Database( entities = [ - MyNodeEntity::class, - NodeEntity::class, + NodeMetadataEntity::class, Packet::class, ContactSettings::class, MeshLog::class, QuickChatAction::class, ReactionEntity::class, - MetadataEntity::class, DeviceHardwareEntity::class, FirmwareReleaseEntity::class, TracerouteNodePositionEntity::class, @@ -95,15 +93,18 @@ 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), + AutoMigration(from = 39, to = 40, spec = AutoMigration39to40::class), ], - version = 38, + version = 40, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) @TypeConverters(Converters::class) @androidx.room3.DaoReturnTypeConverters(androidx.room3.paging.PagingSourceDaoReturnTypeConverter::class) abstract class MeshtasticDatabase : RoomDatabase() { - abstract fun nodeInfoDao(): NodeInfoDao + + abstract fun nodeMetadataDao(): NodeMetadataDao abstract fun packetDao(): PacketDao @@ -138,3 +139,23 @@ 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() + ) + } +} + +/** Drops legacy node tables — SDK is now the source of truth for node data. */ +@DeleteTable(tableName = "my_node") +@DeleteTable(tableName = "nodes") +@DeleteTable(tableName = "metadata") +class AutoMigration39to40 : AutoMigrationSpec diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt deleted file mode 100644 index 2966e4e49c..0000000000 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ /dev/null @@ -1,406 +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.database.dao - -import androidx.room3.Dao -import androidx.room3.MapColumn -import androidx.room3.Query -import androidx.room3.Transaction -import androidx.room3.Upsert -import kotlinx.coroutines.flow.Flow -import okio.ByteString -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.NodeWithRelations -import org.meshtastic.proto.HardwareModel - -@Suppress("TooManyFunctions") -@Dao -interface NodeInfoDao { - - companion object { - const val KEY_SIZE = 32 - - /** SQLite has a limit of ~999 bind parameters per query. */ - const val MAX_BIND_PARAMS = 999 - } - - /** - * Verifies a [NodeEntity] before an upsert operation. It handles populating the publicKey for lazy migration, - * checks for public key conflicts with new nodes, and manages updates to existing nodes, particularly in cases of - * public key mismatches to prevent potential impersonation or data corruption. - * - * @param incomingNode The node entity to be verified. - * @return A [NodeEntity] that is safe to upsert, or null if the upsert should be aborted (e.g., due to an - * impersonation attempt, though this logic is currently commented out). - */ - private suspend fun getVerifiedNodeForUpsert(incomingNode: NodeEntity): NodeEntity { - // Populate the NodeEntity.publicKey field from the User.publicKey for consistency - // and to support lazy migration. - incomingNode.publicKey = incomingNode.user.public_key - - // Populate denormalized name columns from the User protobuf for search functionality - // Only populate if the user is not a placeholder (hwModel != UNSET); otherwise keep them null - if (incomingNode.user.hw_model != HardwareModel.UNSET) { - incomingNode.longName = incomingNode.user.long_name - incomingNode.shortName = incomingNode.user.short_name - } else { - incomingNode.longName = null - incomingNode.shortName = null - } - - val existingNodeEntity = getNodeByNum(incomingNode.num)?.node - - return if (existingNodeEntity == null) { - handleNewNodeUpsertValidation(incomingNode) - } else { - handleExistingNodeUpsertValidation(existingNodeEntity, incomingNode) - } - } - - /** Validates a new node before it is inserted into the database. */ - private suspend fun handleNewNodeUpsertValidation(newNode: NodeEntity): NodeEntity { - // Check if the new node's public key (if present and not empty) - // is already claimed by another existing node. - if ((newNode.publicKey?.size ?: 0) > 0) { - val nodeWithSamePK = findNodeByPublicKey(newNode.publicKey) - if (nodeWithSamePK != null && nodeWithSamePK.num != newNode.num) { - // This is a potential impersonation attempt. - return nodeWithSamePK - } - } - // If no conflicting public key is found, or if the impersonation check is not active, - // the new node is considered safe to add. - return newNode - } - - /** - * Resolves the public key for an existing node during an update. - * - * This function implements safety checks to prevent public key conflicts (PKC) and ensure robust handling of key - * updates. - * - * @param existingNode The current state of the node in the database. - * @param incomingNode The new node data being upserted. - * @return The resolved [ByteString] for the public key: - * - [NodeEntity.ERROR_BYTE_STRING]: If there is a mismatch between a valid existing key and a new incoming key. - * - `incomingNode.publicKey`: If the incoming key is new, matches the existing one, or if recovering from an error - * state. - * - `existingNode.publicKey`: If the incoming update has no key, or if the user is licensed but already has a valid - * key (prevents wiping). - * - [ByteString.EMPTY]: If the user is licensed and didn't previously have a key (or if key is explicitly cleared). - */ - private fun resolvePublicKey(existingNode: NodeEntity, incomingNode: NodeEntity): ByteString? { - val existingKey = existingNode.publicKey ?: existingNode.user.public_key - val incomingKey = incomingNode.publicKey - - val incomingHasKey = (incomingKey?.size ?: 0) == KEY_SIZE - val existingHasKey = existingKey.size == KEY_SIZE && existingKey != NodeEntity.ERROR_BYTE_STRING - - return when { - incomingHasKey -> { - if (existingHasKey && incomingKey != existingKey) { - // Actual mismatch between two non-empty keys - NodeEntity.ERROR_BYTE_STRING - } else { - // New key, same key, or recovery from Error state - incomingKey - } - } - - existingHasKey -> existingKey - - incomingNode.user.is_licensed -> ByteString.EMPTY - - else -> existingKey - } - } - - /** - * Handles the validation logic when upserting an existing node. - * - * It distinguishes between two scenarios: - * 1. **Preservation**: If the incoming update is a placeholder (unset HW model) with a default name, and the - * existing node has full user info, we preserve the existing identity (user, keys, names, verification) while - * updating telemetry and status fields from the incoming packet. - * 2. **Update**: If it's a normal update, we validate the public key using [resolvePublicKey] to prevent conflicts - * or accidental key wiping, and then update the node. - */ - @Suppress("CyclomaticComplexMethod", "MagicNumber") - private fun handleExistingNodeUpsertValidation(existingNode: NodeEntity, incomingNode: NodeEntity): NodeEntity { - val resolvedNotes = incomingNode.notes.ifBlank { existingNode.notes } - - val isPlaceholder = incomingNode.user.hw_model == HardwareModel.UNSET - val hasExistingUser = existingNode.user.hw_model != HardwareModel.UNSET - val isDefaultName = incomingNode.user.long_name.matches(Regex("^Meshtastic [0-9a-fA-F]{4}$")) - - if (hasExistingUser && isPlaceholder && isDefaultName) { - return incomingNode.copy( - user = existingNode.user, - publicKey = existingNode.publicKey, - longName = existingNode.longName, - shortName = existingNode.shortName, - manuallyVerified = existingNode.manuallyVerified, - notes = resolvedNotes, - ) - } - - val resolvedKey = resolvePublicKey(existingNode, incomingNode) - - return incomingNode.copy( - user = incomingNode.user.copy(public_key = resolvedKey ?: ByteString.EMPTY), - publicKey = resolvedKey, - notes = resolvedNotes, - ) - } - - @Query("SELECT * FROM my_node") - fun getMyNodeInfo(): Flow - - @Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity) - - @Query("DELETE FROM my_node") - suspend fun clearMyNodeInfo() - - @Query( - """ - SELECT * FROM nodes - ORDER BY CASE - WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0 - ELSE 1 - END, - last_heard DESC - """, - ) - @Transaction - fun nodeDBbyNum(): Flow< - Map< - @MapColumn(columnName = "num") - Int, - NodeWithRelations, - >, - > - - @Query( - """ - WITH OurNode AS ( - SELECT latitude, longitude - FROM nodes - WHERE num = (SELECT myNodeNum FROM my_node LIMIT 1) - ) - SELECT * FROM nodes - WHERE (:includeUnknown = 1 OR short_name IS NOT NULL) - AND (:filter = '' - OR (long_name LIKE '%' || :filter || '%' - OR short_name LIKE '%' || :filter || '%' - OR printf('!%08x', CASE WHEN num < 0 THEN num + 4294967296 ELSE num END) LIKE '%' || :filter || '%' - OR CAST(CASE WHEN num < 0 THEN num + 4294967296 ELSE num END AS TEXT) LIKE '%' || :filter || '%')) - AND (:lastHeardMin = -1 OR last_heard >= :lastHeardMin) - AND (:hopsAwayMax = -1 OR (hops_away <= :hopsAwayMax AND hops_away >= 0) OR num = (SELECT myNodeNum FROM my_node LIMIT 1)) - ORDER BY CASE - WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0 - ELSE 1 - END, - CASE - WHEN :sort = 'last_heard' THEN last_heard * -1 - WHEN :sort = 'alpha' THEN UPPER(long_name) - WHEN :sort = 'distance' THEN - CASE - WHEN latitude IS NULL OR longitude IS NULL OR - (latitude = 0.0 AND longitude = 0.0) THEN 999999999 - ELSE - (latitude - (SELECT latitude FROM OurNode)) * - (latitude - (SELECT latitude FROM OurNode)) + - (longitude - (SELECT longitude FROM OurNode)) * - (longitude - (SELECT longitude FROM OurNode)) - END - WHEN :sort = 'hops_away' THEN - CASE - WHEN hops_away = -1 THEN 999999999 - ELSE hops_away - END - WHEN :sort = 'channel' THEN channel - WHEN :sort = 'via_mqtt' THEN via_mqtt - WHEN :sort = 'via_favorite' THEN is_favorite * -1 - ELSE 0 - END ASC, - last_heard DESC - """, - ) - @Transaction - fun getNodes( - sort: String, - filter: String, - includeUnknown: Boolean, - hopsAwayMax: Int, - lastHeardMin: Int, - ): Flow> - - @Transaction - suspend fun clearNodeInfo(preserveFavorites: Boolean) { - if (preserveFavorites) { - deleteNonFavoriteNodes() - } else { - deleteAllNodes() - } - } - - @Query("DELETE FROM nodes WHERE is_favorite = 0") - suspend fun deleteNonFavoriteNodes() - - @Query("DELETE FROM nodes") - suspend fun deleteAllNodes() - - @Query("DELETE FROM nodes WHERE num=:num") - suspend fun deleteNode(num: Int) - - @Query("DELETE FROM nodes WHERE num IN (:nodeNums)") - suspend fun deleteNodes(nodeNums: List) - - @Query("SELECT * FROM nodes WHERE last_heard < :lastHeard") - suspend fun getNodesOlderThan(lastHeard: Int): List - - @Query("SELECT * FROM nodes WHERE short_name IS NULL") - suspend fun getUnknownNodes(): List - - @Upsert suspend fun upsert(meta: MetadataEntity) - - @Query("DELETE FROM metadata WHERE num=:num") - suspend fun deleteMetadata(num: Int) - - @Query("SELECT * FROM nodes WHERE num=:num") - @Transaction - suspend fun getNodeByNum(num: Int): NodeWithRelations? - - @Query("SELECT * FROM nodes WHERE num IN (:nodeNums)") - suspend fun getNodeEntitiesByNums(nodeNums: List): List - - @Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1") - suspend fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity? - - @Query("SELECT * FROM nodes WHERE public_key IN (:publicKeys)") - suspend fun findNodesByPublicKeys(publicKeys: List): List - - @Upsert suspend fun doUpsert(node: NodeEntity) - - @Transaction - suspend fun upsert(node: NodeEntity) { - val verifiedNode = getVerifiedNodeForUpsert(node) - doUpsert(verifiedNode) - } - - @Upsert suspend fun putAll(nodes: List) - - @Query("UPDATE nodes SET notes = :notes WHERE num = :num") - suspend fun setNodeNotes(num: Int, notes: String) - - /** - * Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two - * queries instead of N individual queries, then processes each node in memory. - */ - @Suppress("NestedBlockDepth") - private suspend fun getVerifiedNodesForUpsert(incomingNodes: List): List { - // Prepare all incoming nodes (populate denormalized fields) - incomingNodes.forEach { node -> - node.publicKey = node.user.public_key - if (node.user.hw_model != HardwareModel.UNSET) { - node.longName = node.user.long_name - node.shortName = node.user.short_name - } else { - node.longName = null - node.shortName = null - } - } - - // Batch fetch all existing nodes by num (chunked for SQLite bind-param limit) - val existingNodesMap = - incomingNodes - .map { it.num } - .chunked(MAX_BIND_PARAMS) - .flatMap { getNodeEntitiesByNums(it) } - .associateBy { it.num } - - // Partition into updates vs. inserts and resolve existing nodes in-memory - val result = mutableListOf() - val newNodes = mutableListOf() - for (incoming in incomingNodes) { - val existing = existingNodesMap[incoming.num] - if (existing != null) { - result.add(handleExistingNodeUpsertValidation(existing, incoming)) - } else { - newNodes.add(incoming) - } - } - - // Batch validate new nodes' public keys (one query instead of N) - val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct() - val pkConflicts = - if (publicKeysToCheck.isNotEmpty()) { - publicKeysToCheck - .chunked(MAX_BIND_PARAMS) - .flatMap { findNodesByPublicKeys(it) } - .associateBy { it.publicKey } - } else { - emptyMap() - } - - for (newNode in newNodes) { - if ((newNode.publicKey?.size ?: 0) > 0) { - val conflicting = pkConflicts[newNode.publicKey] - if (conflicting != null && conflicting.num != newNode.num) { - result.add(conflicting) - } else { - result.add(newNode) - } - } else { - result.add(newNode) - } - } - - return result - } - - @Transaction - suspend fun installConfig(mi: MyNodeEntity, nodes: List) { - clearMyNodeInfo() - setMyNodeInfo(mi) - putAll(getVerifiedNodesForUpsert(nodes)) - } - - /** - * Backfills longName and shortName columns from the user protobuf for nodes where these columns are NULL. This - * ensures search functionality works for all nodes. Skips placeholder/default users (hwModel == UNSET). - */ - @Transaction - suspend fun backfillDenormalizedNames() { - val nodes = getAllNodesSnapshot() - val nodesToUpdate = - nodes - .filter { node -> - // Only backfill if columns are NULL AND the user is not a placeholder (hwModel != UNSET) - (node.longName == null || node.shortName == null) && node.user.hw_model != HardwareModel.UNSET - } - .map { node -> node.copy(longName = node.user.long_name, shortName = node.user.short_name) } - if (nodesToUpdate.isNotEmpty()) { - putAll(nodesToUpdate) - } - } - - @Query("SELECT * FROM nodes") - suspend fun getAllNodesSnapshot(): List -} 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 0000000000..0fdaa9946c --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt @@ -0,0 +1,91 @@ +/* + * 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.Transaction +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("INSERT OR IGNORE INTO node_metadata(num) VALUES (:num)") + suspend fun ensureExists(num: Int) + + @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) + + @Transaction + suspend fun setFavoriteEnsuringExists(num: Int, isFavorite: Boolean) { + ensureExists(num) + setFavorite(num, isFavorite) + } + + @Query("UPDATE node_metadata SET is_ignored = :isIgnored WHERE num = :num") + suspend fun setIgnored(num: Int, isIgnored: Boolean) + + @Transaction + suspend fun setIgnoredEnsuringExists(num: Int, isIgnored: Boolean) { + ensureExists(num) + setIgnored(num, isIgnored) + } + + @Query("UPDATE node_metadata SET is_muted = :isMuted WHERE num = :num") + suspend fun setMuted(num: Int, isMuted: Boolean) + + @Transaction + suspend fun setMutedEnsuringExists(num: Int, isMuted: Boolean) { + ensureExists(num) + setMuted(num, isMuted) + } + + @Query("UPDATE node_metadata SET notes = :notes WHERE num = :num") + suspend fun setNotes(num: Int, notes: String) + + @Transaction + suspend fun setNotesEnsuringExists(num: Int, notes: String) { + ensureExists(num) + setNotes(num, notes) + } + + @Query("UPDATE node_metadata SET manually_verified = :verified WHERE num = :num") + suspend fun setManuallyVerified(num: Int, verified: Boolean) + + @Transaction + suspend fun setManuallyVerifiedEnsuringExists(num: Int, verified: Boolean) { + ensureExists(num) + setManuallyVerified(num, verified) + } + + @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/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 2aef7ef6d2..0c4ebdc7eb 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -43,22 +43,22 @@ interface PacketDao { @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = :portNum ORDER BY received_time ASC """, ) - fun getAllPackets(portNum: Int): Flow> + fun getAllPackets(myNodeNum: Int, portNum: Int): Flow> @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND filtered = 0 ORDER BY received_time DESC """, ) - fun getContactKeys(): Flow< + fun getContactKeys(myNodeNum: Int): Flow< Map< @MapColumn(columnName = "contact_key") String, @@ -72,93 +72,93 @@ interface PacketDao { INNER JOIN ( SELECT contact_key, MAX(received_time) as max_time FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND filtered = 0 GROUP BY contact_key ) latest ON p.contact_key = latest.contact_key AND p.received_time = latest.max_time - WHERE (p.myNodeNum = 0 OR p.myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (p.myNodeNum = 0 OR p.myNodeNum = :myNodeNum) AND p.port_num = 1 AND p.filtered = 0 GROUP BY p.contact_key ORDER BY p.received_time DESC """, ) - fun getContactKeysPaged(): PagingSource + fun getContactKeysPaged(myNodeNum: Int): PagingSource @Query( """ SELECT COUNT(*) FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact """, ) - suspend fun getMessageCount(contact: String): Int + suspend fun getMessageCount(myNodeNum: Int, contact: String): Int @Query( """ SELECT COUNT(*) FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 """, ) - suspend fun getUnreadCount(contact: String): Int + suspend fun getUnreadCount(myNodeNum: Int, contact: String): Int @Query( """ SELECT COUNT(*) FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 """, ) - fun getUnreadCountFlow(contact: String): Flow + fun getUnreadCountFlow(myNodeNum: Int, contact: String): Flow @Query( """ SELECT uuid FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 ORDER BY received_time ASC LIMIT 1 """, ) - fun getFirstUnreadMessageUuid(contact: String): Flow + fun getFirstUnreadMessageUuid(myNodeNum: Int, contact: String): Flow @Query( """ SELECT COUNT(*) > 0 FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 """, ) - fun hasUnreadMessages(contact: String): Flow + fun hasUnreadMessages(myNodeNum: Int, contact: String): Flow @Query( """ SELECT COUNT(*) FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND read = 0 AND filtered = 0 """, ) - fun getUnreadCountTotal(): Flow + fun getUnreadCountTotal(myNodeNum: Int): Flow @Query( """ UPDATE packet SET read = 1 - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact AND read = 0 AND filtered = 0 AND received_time <= :timestamp """, ) - suspend fun clearUnreadCount(contact: String, timestamp: Long) + suspend fun clearUnreadCount(myNodeNum: Int, contact: String, timestamp: Long) @Query( """ UPDATE packet SET read = 1 - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND read = 0 AND filtered = 0 """, ) - suspend fun clearAllUnreadCounts() + suspend fun clearAllUnreadCounts(myNodeNum: Int) @Upsert suspend fun insert(packet: Packet) @@ -166,56 +166,56 @@ interface PacketDao { @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact ORDER BY received_time DESC """, ) - fun getMessagesFrom(contact: String): Flow> + fun getMessagesFrom(myNodeNum: Int, contact: String): Flow> @Transaction @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact ORDER BY received_time DESC LIMIT :limit """, ) - fun getMessagesFrom(contact: String, limit: Int): Flow> + fun getMessagesFrom(myNodeNum: Int, contact: String, limit: Int): Flow> @Transaction @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact AND (filtered = 0 OR :includeFiltered = 1) ORDER BY received_time DESC """, ) - fun getMessagesFrom(contact: String, includeFiltered: Boolean): Flow> + fun getMessagesFrom(myNodeNum: Int, contact: String, includeFiltered: Boolean): Flow> @Transaction @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact ORDER BY received_time DESC """, ) - fun getMessagesFromPaged(contact: String): PagingSource + fun getMessagesFromPaged(myNodeNum: Int, contact: String): PagingSource @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND data = :data """, ) - suspend fun findDataPacket(data: DataPacket): Packet? + suspend fun findDataPacket(myNodeNum: Int, data: DataPacket): Packet? @Query("DELETE FROM packet WHERE uuid in (:uuidList)") suspend fun deletePackets(uuidList: List) @@ -223,11 +223,11 @@ interface PacketDao { @Query( """ DELETE FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND contact_key IN (:contactList) """, ) - suspend fun deleteContacts(contactList: List) + suspend fun deleteContacts(myNodeNum: Int, contactList: List) @Query("DELETE FROM packet WHERE uuid=:uuid") suspend fun delete(uuid: Long) @@ -243,17 +243,17 @@ interface PacketDao { @Query( """ DELETE FROM reactions - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND reply_id IN (:packetIds) """, ) - suspend fun deleteReactions(packetIds: List) + suspend fun deleteReactions(myNodeNum: Int, packetIds: List) @Transaction - suspend fun deleteMessages(uuidList: List) { + suspend fun deleteMessages(myNodeNum: Int, uuidList: List) { val packetIds = getPacketIdsFrom(uuidList) if (packetIds.isNotEmpty()) { - deleteReactions(packetIds) + deleteReactions(myNodeNum, packetIds) } deletePackets(uuidList) } @@ -261,19 +261,17 @@ interface PacketDao { @Update suspend fun update(packet: Packet) @Transaction - suspend fun updateMessageStatus(data: DataPacket, m: MessageStatus) { + suspend fun updateMessageStatus(myNodeNum: Int, data: DataPacket, m: MessageStatus) { val new = data.copy(status = m) - // Match on key fields that identify the packet, rather than the entire data object - findPacketsWithId(data.id) + findPacketsWithId(myNodeNum, data.id) .find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to } ?.let { update(it.copy(data = new)) } } @Transaction - suspend fun updateMessageId(data: DataPacket, id: Int) { + suspend fun updateMessageId(myNodeNum: Int, data: DataPacket, id: Int) { val new = data.copy(id = id) - // Match on key fields that identify the packet - findPacketsWithId(data.id) + findPacketsWithId(myNodeNum, data.id) .find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to } ?.let { update(it.copy(data = new, packetId = id)) } } @@ -281,88 +279,88 @@ interface PacketDao { @Query( """ SELECT data FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) ORDER BY received_time ASC """, ) - suspend fun getDataPackets(): List + suspend fun getDataPackets(myNodeNum: Int): List @Transaction @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND packet_id = :requestId ORDER BY received_time DESC """, ) - suspend fun getPacketById(requestId: Int): Packet? + suspend fun getPacketById(myNodeNum: Int, requestId: Int): Packet? @Transaction @Query( """ SELECT * FROM packet WHERE packet_id = :packetId - AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND (myNodeNum = 0 OR myNodeNum = :myNodeNum) LIMIT 1 """, ) - suspend fun getPacketByPacketId(packetId: Int): PacketEntity? + suspend fun getPacketByPacketId(myNodeNum: Int, packetId: Int): PacketEntity? @Transaction @Query( """ SELECT * FROM packet WHERE packet_id IN (:packetIds) - AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND (myNodeNum = 0 OR myNodeNum = :myNodeNum) """, ) - suspend fun getPacketsByPacketIds(packetIds: List): List + suspend fun getPacketsByPacketIds(myNodeNum: Int, packetIds: List): List @Query( """ SELECT * FROM packet WHERE packet_id = :packetId - AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND (myNodeNum = 0 OR myNodeNum = :myNodeNum) """, ) - suspend fun findPacketsWithId(packetId: Int): List + suspend fun findPacketsWithId(myNodeNum: Int, packetId: Int): List @Transaction @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8) """, ) - suspend fun findPacketBySfppHash(hash: ByteString): Packet? + suspend fun findPacketBySfppHash(myNodeNum: Int, hash: ByteString): Packet? // Fetches all DataPackets for the current node, ordered by time. // Callers should filter by status in Kotlin (avoids SQLite json_extract dependency). @Query( """ SELECT data FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) ORDER BY received_time ASC """, ) - suspend fun getAllDataPackets(): List + suspend fun getAllDataPackets(myNodeNum: Int): List @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 8 ORDER BY received_time ASC """, ) - suspend fun getAllWaypoints(): List + suspend fun getAllWaypoints(myNodeNum: Int): List @Transaction - suspend fun deleteWaypoint(id: Int) { - val uuidList = getAllWaypoints().filter { it.data.waypoint?.id == id }.map { it.uuid } - deleteMessages(uuidList) + suspend fun deleteWaypoint(myNodeNum: Int, id: Int) { + val uuidList = getAllWaypoints(myNodeNum).filter { it.data.waypoint?.id == id }.map { it.uuid } + deleteMessages(myNodeNum, uuidList) } @Query("SELECT * FROM contact_settings") @@ -407,60 +405,60 @@ interface PacketDao { """ SELECT * FROM reactions WHERE packet_id = :packetId - AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND (myNodeNum = 0 OR myNodeNum = :myNodeNum) """, ) - suspend fun findReactionsWithId(packetId: Int): List + suspend fun findReactionsWithId(myNodeNum: Int, packetId: Int): List @Query( """ SELECT * FROM reactions WHERE packet_id = :packetId - AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + AND (myNodeNum = 0 OR myNodeNum = :myNodeNum) LIMIT 1 """, ) - suspend fun getReactionByPacketId(packetId: Int): ReactionEntity? + suspend fun getReactionByPacketId(myNodeNum: Int, packetId: Int): ReactionEntity? @Transaction @Query( """ SELECT * FROM reactions - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND substr(sfpp_hash, 1, 8) = substr(:hash, 1, 8) """, ) - suspend fun findReactionBySfppHash(hash: ByteString): ReactionEntity? + suspend fun findReactionBySfppHash(myNodeNum: Int, hash: ByteString): ReactionEntity? @Query( """ SELECT COUNT(*) FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact AND filtered = 1 """, ) - suspend fun getFilteredCount(contact: String): Int + suspend fun getFilteredCount(myNodeNum: Int, contact: String): Int @Query( """ SELECT COUNT(*) FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact AND filtered = 1 """, ) - fun getFilteredCountFlow(contact: String): Flow + fun getFilteredCountFlow(myNodeNum: Int, contact: String): Flow @Transaction @Query( """ SELECT * FROM packet - WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) + WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND port_num = 1 AND contact_key = :contact AND (filtered = 0 OR :includeFiltered = 1) ORDER BY received_time DESC """, ) - fun getMessagesFromPaged(contact: String, includeFiltered: Boolean): PagingSource + fun getMessagesFromPaged(myNodeNum: Int, contact: String, includeFiltered: Boolean): PagingSource @Query("SELECT filtering_disabled FROM contact_settings WHERE contact_key = :contact") suspend fun getContactFilteringDisabled(contact: String): Boolean? @@ -544,7 +542,7 @@ interface PacketDao { @Suppress("MaxLineLength") @Query( - "UPDATE packet SET filtered = :filtered WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node)) AND data LIKE :senderIdPattern", + "UPDATE packet SET filtered = :filtered WHERE (myNodeNum = 0 OR myNodeNum = :myNodeNum) AND data LIKE :senderIdPattern", ) - suspend fun updateFilteredBySender(senderIdPattern: String, filtered: Boolean) + suspend fun updateFilteredBySender(myNodeNum: Int, senderIdPattern: String, filtered: Boolean) } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt deleted file mode 100644 index a55e2232ff..0000000000 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt +++ /dev/null @@ -1,60 +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.database.entity - -import androidx.room3.Entity -import androidx.room3.PrimaryKey -import org.meshtastic.core.model.MyNodeInfo - -@Entity(tableName = "my_node") -@Suppress("LongParameterList") -open class MyNodeEntity( - @PrimaryKey(autoGenerate = false) val myNodeNum: Int, - val model: String?, - val firmwareVersion: String?, - val couldUpdate: Boolean, // this application contains a software load we _could_ install if you want - val shouldUpdate: Boolean, // this device has old firmware - val currentPacketId: Long, - val messageTimeoutMsec: Int, - val minAppVersion: Int, - val maxChannels: Int, - val hasWifi: Boolean, - val deviceId: String? = "unknown", - val pioEnv: String? = null, -) { - /** A human readable description of the software/hardware version */ - val firmwareString: String - get() = "$model $firmwareVersion" - - open fun toMyNodeInfo() = MyNodeInfo( - myNodeNum = myNodeNum, - hasGPS = false, - model = model, - firmwareVersion = firmwareVersion, - couldUpdate = couldUpdate, - shouldUpdate = shouldUpdate, - currentPacketId = currentPacketId, - messageTimeoutMsec = messageTimeoutMsec, - minAppVersion = minAppVersion, - maxChannels = maxChannels, - hasWifi = hasWifi, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = deviceId, - pioEnv = pioEnv, - ) -} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt deleted file mode 100644 index 16134653b3..0000000000 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ /dev/null @@ -1,259 +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.database.entity - -import androidx.room3.ColumnInfo -import androidx.room3.Embedded -import androidx.room3.Entity -import androidx.room3.Index -import androidx.room3.PrimaryKey -import androidx.room3.Relation -import okio.ByteString -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.DeviceMetrics -import org.meshtastic.core.model.EnvironmentMetrics -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.Paxcount -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.User -import org.meshtastic.proto.Position as WirePosition - -data class NodeWithRelations( - @Embedded val node: NodeEntity, - @Relation(entity = MetadataEntity::class, parentColumn = "num", entityColumn = "num") - val metadata: MetadataEntity? = null, -) { - fun toModel() = with(node) { - Node( - num = num, - metadata = metadata?.proto, - user = user, - position = position, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(), - channel = channel, - viaMqtt = viaMqtt, - hopsAway = hopsAway, - isFavorite = isFavorite, - isIgnored = isIgnored, - isMuted = isMuted, - environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(), - powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(), - paxcounter = paxcounter, - publicKey = publicKey ?: user.public_key, - notes = notes, - manuallyVerified = manuallyVerified, - nodeStatus = nodeStatus, - lastTransport = lastTransport, - ) - } - - fun toEntity() = with(node) { - NodeEntity( - num = num, - user = user, - position = position, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceTelemetry = deviceTelemetry, - channel = channel, - viaMqtt = viaMqtt, - hopsAway = hopsAway, - isFavorite = isFavorite, - isIgnored = isIgnored, - isMuted = isMuted, - environmentTelemetry = environmentTelemetry, - powerTelemetry = powerTelemetry, - paxcounter = paxcounter, - publicKey = publicKey ?: user.public_key, - notes = notes, - manuallyVerified = manuallyVerified, - nodeStatus = nodeStatus, - lastTransport = lastTransport, - ) - } -} - -@Entity(tableName = "metadata", indices = [Index(value = ["num"])]) -data class MetadataEntity( - @PrimaryKey val num: Int, - @ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: DeviceMetadata, - val timestamp: Long = nowMillis, -) - -@Suppress("MagicNumber") -@Entity( - tableName = "nodes", - indices = - [ - Index(value = ["last_heard"]), - Index(value = ["short_name"]), - Index(value = ["long_name"]), - Index(value = ["hops_away"]), - Index(value = ["is_favorite"]), - Index(value = ["last_heard", "is_favorite"]), - Index(value = ["public_key"]), - ], -) -data class NodeEntity( - @PrimaryKey(autoGenerate = false) val num: Int, // This is immutable, and used as a key - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var user: User = User(), - @ColumnInfo(name = "long_name") var longName: String? = null, - @ColumnInfo(name = "short_name") var shortName: String? = null, // used in includeUnknown filter - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var position: WirePosition = WirePosition(), - var latitude: Double = 0.0, - var longitude: Double = 0.0, - var snr: Float = Float.MAX_VALUE, - var rssi: Int = Int.MAX_VALUE, - @ColumnInfo(name = "last_heard") var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970 - @ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB) var deviceTelemetry: Telemetry = Telemetry(), - var channel: Int = 0, - @ColumnInfo(name = "via_mqtt") var viaMqtt: Boolean = false, - @ColumnInfo(name = "hops_away") var hopsAway: Int = -1, - @ColumnInfo(name = "is_favorite") var isFavorite: Boolean = false, - @ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false, - @ColumnInfo(name = "is_muted", defaultValue = "0") var isMuted: Boolean = false, - @ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB) - var environmentTelemetry: Telemetry = Telemetry(), - @ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB) var powerTelemetry: Telemetry = Telemetry(), - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var paxcounter: Paxcount = Paxcount(), - @ColumnInfo(name = "public_key") var publicKey: ByteString? = null, - @ColumnInfo(name = "notes", defaultValue = "") var notes: String = "", - @ColumnInfo(name = "manually_verified", defaultValue = "0") - var manuallyVerified: Boolean = false, // ONLY set true when scanned/imported manually - @ColumnInfo(name = "node_status") var nodeStatus: String? = null, - /** The transport mechanism this node was last heard over (see [MeshPacket.TransportMechanism]). */ - @ColumnInfo(name = "last_transport", defaultValue = "0") var lastTransport: Int = 0, -) { - val deviceMetrics: org.meshtastic.proto.DeviceMetrics? - get() = deviceTelemetry.device_metrics - - val environmentMetrics: org.meshtastic.proto.EnvironmentMetrics? - get() = environmentTelemetry.environment_metrics - - val powerMetrics: org.meshtastic.proto.PowerMetrics? - get() = powerTelemetry.power_metrics - - val isUnknownUser - get() = user.hw_model == HardwareModel.UNSET - - val hasPKC - get() = (publicKey ?: user.public_key).size > 0 - - fun setPosition(p: WirePosition, defaultTime: Int = currentTime()) { - position = p.copy(time = if (p.time != 0) p.time else defaultTime) - latitude = degD(p.latitude_i ?: 0) - longitude = degD(p.longitude_i ?: 0) - } - - /** true if the device was heard from recently */ - val isOnline: Boolean - get() { - return lastHeard > onlineTimeThreshold() - } - - companion object { - // Convert to a double representation of degrees - fun degD(i: Int) = i * 1e-7 - - fun degI(d: Double) = (d * 1e7).toInt() - - val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString() - - fun currentTime() = nowSeconds.toInt() - } - - fun toModel() = Node( - num = num, - user = user, - position = position, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceMetrics = deviceMetrics ?: org.meshtastic.proto.DeviceMetrics(), - channel = channel, - viaMqtt = viaMqtt, - hopsAway = hopsAway, - isFavorite = isFavorite, - isIgnored = isIgnored, - isMuted = isMuted, - environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(), - powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(), - paxcounter = paxcounter, - publicKey = publicKey ?: user.public_key, - notes = notes, - nodeStatus = nodeStatus, - lastTransport = lastTransport, - ) - - fun toNodeInfo() = NodeInfo( - num = num, - user = - MeshUser( - id = user.id, - longName = user.long_name, - shortName = user.short_name, - hwModel = user.hw_model, - role = user.role.value, - ) - .takeIf { user.id.isNotEmpty() }, - position = - Position( - latitude = latitude, - longitude = longitude, - altitude = position.altitude ?: 0, - time = position.time, - satellitesInView = position.sats_in_view, - groundSpeed = position.ground_speed ?: 0, - groundTrack = position.ground_track ?: 0, - precisionBits = position.precision_bits, - ) - .takeIf { it.isValid() }, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceMetrics = - DeviceMetrics( - time = deviceTelemetry.time, - batteryLevel = deviceMetrics?.battery_level ?: 0, - voltage = deviceMetrics?.voltage ?: 0f, - channelUtilization = deviceMetrics?.channel_utilization ?: 0f, - airUtilTx = deviceMetrics?.air_util_tx ?: 0f, - uptimeSeconds = deviceMetrics?.uptime_seconds ?: 0, - ), - channel = channel, - environmentMetrics = - EnvironmentMetrics.fromTelemetryProto( - environmentTelemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics(), - environmentTelemetry.time, - ), - hopsAway = hopsAway, - nodeStatus = nodeStatus, - ) -} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeMetadataEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeMetadataEntity.kt new file mode 100644 index 0000000000..5ddd5c4de2 --- /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/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt index 0a9ea4aa22..1bee413921 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -37,8 +37,8 @@ data class PacketEntity( val reactions: List = emptyList(), ) { suspend fun toMessage(getNode: suspend (userId: String?) -> Node) = with(packet) { - val node = getNode(data.from) - val isFromLocal = node.user.id == DataPacket.ID_LOCAL || (myNodeNum != 0 && node.num == myNodeNum) + val node = getNode(DataPacket.nodeNumToId(data.from)) + val isFromLocal = data.from == DataPacket.LOCAL || (myNodeNum != 0 && data.from == myNodeNum) Message( uuid = uuid, receivedTime = received_time, diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt deleted file mode 100644 index 942bce34f0..0000000000 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt +++ /dev/null @@ -1,115 +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.database.dao - -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.database.MeshtasticDatabase -import org.meshtastic.core.database.entity.MyNodeEntity -import org.meshtastic.core.database.entity.NodeEntity -import org.meshtastic.core.database.getInMemoryDatabaseBuilder -import org.meshtastic.proto.User -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -abstract class CommonNodeInfoDaoTest { - private lateinit var database: MeshtasticDatabase - private lateinit var dao: NodeInfoDao - - private val myNodeInfo: MyNodeEntity = - MyNodeEntity( - myNodeNum = 42424242, - model = "TBEAM", - firmwareVersion = "2.5.0", - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 1L, - messageTimeoutMsec = 300000, - minAppVersion = 1, - maxChannels = 8, - hasWifi = false, - ) - - suspend fun createDb() { - database = getInMemoryDatabaseBuilder().build() - dao = database.nodeInfoDao() - dao.setMyNodeInfo(myNodeInfo) - } - - @AfterTest - fun closeDb() { - database.close() - } - - @Test - fun testGetMyNodeInfo() = runTest { - val info = dao.getMyNodeInfo().first() - assertNotNull(info) - assertEquals(myNodeInfo.myNodeNum, info.myNodeNum) - } - - @Test - fun testUpsertNode() = runTest { - val node = - NodeEntity( - num = 1234, - user = User(long_name = "Test Node", id = "!test", hw_model = org.meshtastic.proto.HardwareModel.TBEAM), - lastHeard = (nowMillis / 1000).toInt(), - ) - dao.upsert(node) - val result = dao.getNodeByNum(1234) - assertNotNull(result) - assertEquals("Test Node", result.node.longName) - } - - @Test - fun testNodeDBbyNum() = runTest { - val node1 = NodeEntity(num = 1, user = User(id = "!1")) - val node2 = NodeEntity(num = 2, user = User(id = "!2")) - dao.putAll(listOf(node1, node2)) - - val nodes = dao.nodeDBbyNum().first() - assertEquals(2, nodes.size) - assertTrue(nodes.containsKey(1)) - assertTrue(nodes.containsKey(2)) - } - - @Test - fun testDeleteNode() = runTest { - val node = NodeEntity(num = 1, user = User(id = "!1")) - dao.upsert(node) - dao.deleteNode(1) - val result = dao.getNodeByNum(1) - assertEquals(null, result) - } - - @Test - fun testClearNodeInfo() = runTest { - val node1 = NodeEntity(num = 1, user = User(id = "!1"), isFavorite = true) - val node2 = NodeEntity(num = 2, user = User(id = "!2"), isFavorite = false) - dao.putAll(listOf(node1, node2)) - - dao.clearNodeInfo(preserveFavorites = true) - val nodes = dao.nodeDBbyNum().first() - assertEquals(1, nodes.size) - assertTrue(nodes.containsKey(1)) - } -} diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt index 4116cb99f8..ad9bc6ed91 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.database.MeshtasticDatabase -import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.ReactionEntity import org.meshtastic.core.database.getInMemoryDatabaseBuilder @@ -37,40 +36,24 @@ import kotlin.test.assertTrue abstract class CommonPacketDaoTest { private lateinit var database: MeshtasticDatabase - private lateinit var nodeInfoDao: NodeInfoDao private lateinit var packetDao: PacketDao - private val myNodeInfo: MyNodeEntity = - MyNodeEntity( - myNodeNum = 42424242, - model = null, - firmwareVersion = null, - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 1L, - messageTimeoutMsec = 5 * 60 * 1000, - minAppVersion = 1, - maxChannels = 8, - hasWifi = false, - ) + private val myNodeNum = 42424242 - private val myNodeNum: Int - get() = myNodeInfo.myNodeNum + private val testContactKeys = listOf("0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", "1!test1234") - private val testContactKeys = listOf("0${DataPacket.ID_BROADCAST}", "1!test1234") - - private fun generateTestPackets(myNodeNum: Int) = testContactKeys.flatMap { contactKey -> + private fun generateTestPackets(nodeNum: Int) = testContactKeys.flatMap { contactKey -> List(SAMPLE_SIZE) { Packet( uuid = 0L, - myNodeNum = myNodeNum, + myNodeNum = nodeNum, port_num = PortNum.TEXT_MESSAGE_APP.value, contact_key = contactKey, received_time = nowMillis + it, read = false, data = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = "Message $it!".encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, ), @@ -80,8 +63,6 @@ abstract class CommonPacketDaoTest { suspend fun createDb() { database = getInMemoryDatabaseBuilder().build() - nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) } - packetDao = database.packetDao().apply { generateTestPackets(42424243).forEach { insert(it) } @@ -97,7 +78,7 @@ abstract class CommonPacketDaoTest { @Test fun testGetMessagesFrom() = runTest { val contactKey = testContactKeys.first() - val messages = packetDao.getMessagesFrom(contactKey).first() + val messages = packetDao.getMessagesFrom(myNodeNum, contactKey).first() assertEquals(SAMPLE_SIZE, messages.size) assertTrue(messages.all { it.packet.myNodeNum == myNodeNum }) assertTrue(messages.all { it.packet.contact_key == contactKey }) @@ -106,42 +87,40 @@ abstract class CommonPacketDaoTest { @Test fun testGetMessageCount() = runTest { val contactKey = testContactKeys.first() - assertEquals(SAMPLE_SIZE, packetDao.getMessageCount(contactKey)) + assertEquals(SAMPLE_SIZE, packetDao.getMessageCount(myNodeNum, contactKey)) } @Test fun testGetUnreadCount() = runTest { val contactKey = testContactKeys.first() - assertEquals(SAMPLE_SIZE, packetDao.getUnreadCount(contactKey)) + assertEquals(SAMPLE_SIZE, packetDao.getUnreadCount(myNodeNum, contactKey)) } @Test fun testClearUnreadCount() = runTest { val contactKey = testContactKeys.first() - packetDao.clearUnreadCount(contactKey, nowMillis + SAMPLE_SIZE) - assertEquals(0, packetDao.getUnreadCount(contactKey)) + packetDao.clearUnreadCount(myNodeNum, contactKey, nowMillis + SAMPLE_SIZE) + assertEquals(0, packetDao.getUnreadCount(myNodeNum, contactKey)) } @Test fun testClearAllUnreadCounts() = runTest { - packetDao.clearAllUnreadCounts() - testContactKeys.forEach { assertEquals(0, packetDao.getUnreadCount(it)) } + packetDao.clearAllUnreadCounts(myNodeNum) + testContactKeys.forEach { assertEquals(0, packetDao.getUnreadCount(myNodeNum, it)) } } @Test fun testUpdateMessageStatus() = runTest { val contactKey = testContactKeys.first() - val messages = packetDao.getMessagesFrom(contactKey).first() + val messages = packetDao.getMessagesFrom(myNodeNum, contactKey).first() val packet = messages.first().packet.data - val originalStatus = packet.status - // Ensure packet has a valid ID for updating - val packetWithId = packet.copy(id = 999, from = "!$myNodeNum") + val packetWithId = packet.copy(id = 999, from = myNodeNum) val updatedRoomPacket = messages.first().packet.copy(data = packetWithId, packetId = 999) packetDao.update(updatedRoomPacket) - packetDao.updateMessageStatus(packetWithId, MessageStatus.DELIVERED) - val updatedMessages = packetDao.getMessagesFrom(contactKey).first() + packetDao.updateMessageStatus(myNodeNum, packetWithId, MessageStatus.DELIVERED) + val updatedMessages = packetDao.getMessagesFrom(myNodeNum, contactKey).first() assertEquals(MessageStatus.DELIVERED, updatedMessages.first { it.packet.data.id == 999 }.packet.data.status) } @@ -157,14 +136,14 @@ abstract class CommonPacketDaoTest { read = true, data = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = "Queued".encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, status = MessageStatus.QUEUED, ), ) packetDao.insert(queuedPacket) - val queued = packetDao.getAllDataPackets().filter { it.status == MessageStatus.QUEUED } + val queued = packetDao.getAllDataPackets(myNodeNum).filter { it.status == MessageStatus.QUEUED } assertNotNull(queued) assertEquals(1, queued.size) assertEquals("Queued", queued.first().text) @@ -173,13 +152,13 @@ abstract class CommonPacketDaoTest { @Test fun testDeleteMessages() = runTest { val contactKey = testContactKeys.first() - packetDao.deleteContacts(listOf(contactKey)) - assertEquals(0, packetDao.getMessageCount(contactKey)) + packetDao.deleteContacts(myNodeNum, listOf(contactKey)) + assertEquals(0, packetDao.getMessageCount(myNodeNum, contactKey)) } @Test fun testGetContactKeys() = runTest { - val contacts = packetDao.getContactKeys().first() + val contacts = packetDao.getContactKeys(myNodeNum).first() assertEquals(testContactKeys.size, contacts.size) testContactKeys.forEach { assertTrue(contacts.containsKey(it)) } } @@ -191,20 +170,19 @@ abstract class CommonPacketDaoTest { uuid = 0L, myNodeNum = myNodeNum, port_num = PortNum.WAYPOINT_APP.value, - contact_key = "0${DataPacket.ID_BROADCAST}", + contact_key = "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", received_time = nowMillis, read = true, data = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = "Waypoint".encodeToByteArray().toByteString(), dataType = PortNum.WAYPOINT_APP.value, ), ) packetDao.insert(waypointPacket) - val waypoints = packetDao.getAllWaypoints() + val waypoints = packetDao.getAllWaypoints(myNodeNum) assertEquals(1, waypoints.size) - // Waypoints aren't text messages, so they don't resolve a string text. } @Test @@ -221,7 +199,7 @@ abstract class CommonPacketDaoTest { val filteredMessages = listOf("Filtered 1") normalMessages.forEachIndexed { index, text -> - val packet = + packetDao.insert( Packet( uuid = 0L, myNodeNum = myNodeNum, @@ -229,19 +207,18 @@ abstract class CommonPacketDaoTest { contact_key = contactKey, received_time = nowMillis + index, read = false, - data = - DataPacket( - to = DataPacket.ID_BROADCAST, + data = DataPacket( + to = DataPacket.BROADCAST, bytes = text.encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, ), filtered = false, - ) - packetDao.insert(packet) + ), + ) } filteredMessages.forEachIndexed { index, text -> - val packet = + packetDao.insert( Packet( uuid = 0L, myNodeNum = myNodeNum, @@ -249,35 +226,31 @@ abstract class CommonPacketDaoTest { contact_key = contactKey, received_time = nowMillis + normalMessages.size + index, read = true, - data = - DataPacket( - to = DataPacket.ID_BROADCAST, + data = DataPacket( + to = DataPacket.BROADCAST, bytes = text.encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, ), filtered = true, - ) - packetDao.insert(packet) + ), + ) } - val allMessages = packetDao.getMessagesFrom(contactKey).first() + val allMessages = packetDao.getMessagesFrom(myNodeNum, contactKey).first() assertEquals(normalMessages.size + filteredMessages.size, allMessages.size) - val includingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = true).first() + val includingFiltered = packetDao.getMessagesFrom(myNodeNum, contactKey, includeFiltered = true).first() assertEquals(normalMessages.size + filteredMessages.size, includingFiltered.size) - val excludingFiltered = packetDao.getMessagesFrom(contactKey, includeFiltered = false).first() + val excludingFiltered = packetDao.getMessagesFrom(myNodeNum, contactKey, includeFiltered = false).first() assertEquals(normalMessages.size, excludingFiltered.size) assertFalse(excludingFiltered.any { it.packet.filtered }) } @Test fun testGetPacketsByPacketIdsChunked() = runTest { - // Regression test for SQLITE_MAX_VARIABLE_NUMBER (999) limit. Inserting >999 packets and - // looking them up by id must not throw; callers are expected to chunk, and each chunk - // must return the correct rows. val totalPackets = 2000 - val chunkSize = NodeInfoDao.MAX_BIND_PARAMS + val chunkSize = MAX_SQLITE_BIND_PARAMS val contactKey = "chunk-test" val baseTime = nowMillis val packetIds = (1..totalPackets).toList() @@ -291,9 +264,8 @@ abstract class CommonPacketDaoTest { contact_key = contactKey, received_time = baseTime + id, read = false, - data = - DataPacket( - to = DataPacket.ID_BROADCAST, + data = DataPacket( + to = DataPacket.BROADCAST, bytes = "Chunk $id".encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, ), @@ -302,12 +274,13 @@ abstract class CommonPacketDaoTest { ) } - val fetched = packetIds.chunked(chunkSize).flatMap { packetDao.getPacketsByPacketIds(it) } + val fetched = packetIds.chunked(chunkSize).flatMap { packetDao.getPacketsByPacketIds(myNodeNum, it) } assertEquals(totalPackets, fetched.size) assertEquals(packetIds.toSet(), fetched.map { it.packet.packetId }.toSet()) } companion object { private const val SAMPLE_SIZE = 10 + private const val MAX_SQLITE_BIND_PARAMS = 999 } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt index 7f93b09d33..d6c6a095e8 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCase.kt @@ -31,7 +31,6 @@ import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.SessionStatus import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.MeshActionHandler import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.SessionManager import kotlin.time.Duration.Companion.seconds @@ -55,7 +54,6 @@ import kotlin.time.Duration.Companion.seconds @Single open class EnsureRemoteAdminSessionUseCase( private val sessionManager: SessionManager, - private val meshActionHandler: MeshActionHandler, private val serviceRepository: ServiceRepository, @Named("ServiceScope") private val serviceScope: CoroutineScope, ) { @@ -94,7 +92,7 @@ open class EnsureRemoteAdminSessionUseCase( sessionManager.sessionRefreshFlow.filter { it == destNum }.first() } try { - meshActionHandler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) + serviceRepository.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) refreshed.await() EnsureSessionResult.Refreshed } finally { diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt index d6c48b14d5..4b40d7f7df 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt @@ -23,8 +23,8 @@ import org.meshtastic.core.repository.NodeRepository /** * Use case for performing administrative and destructive actions on mesh nodes. * - * This component provides methods for rebooting, shutting down, or resetting nodes within the mesh. It also handles - * local database synchronization when these actions are performed on the locally connected device. + * Methods suspend until the device acknowledges. On failure, they propagate + * [org.meshtastic.core.model.AdminException]. */ @Single open class AdminActionsUseCase @@ -32,66 +32,40 @@ constructor( private val radioController: RadioController, private val nodeRepository: NodeRepository, ) { - /** - * Reboots the radio. - * - * @param destNum The node number to reboot. - * @return The packet ID of the request. - */ - open suspend fun reboot(destNum: Int): Int { - val packetId = radioController.getPacketId() - radioController.reboot(destNum, packetId) - return packetId + /** Reboot the target node. */ + open suspend fun reboot(destNum: Int) { + radioController.reboot(destNum) } - /** - * Shuts down the radio. - * - * @param destNum The node number to shut down. - * @return The packet ID of the request. - */ - open suspend fun shutdown(destNum: Int): Int { - val packetId = radioController.getPacketId() - radioController.shutdown(destNum, packetId) - return packetId + /** Shut down the target node. */ + open suspend fun shutdown(destNum: Int) { + radioController.shutdown(destNum) } /** - * Factory resets the radio. + * Factory reset the target node. * * @param destNum The node number to reset. * @param isLocal Whether the reset is being performed on the locally connected node. - * @return The packet ID of the request. */ - open suspend fun factoryReset(destNum: Int, isLocal: Boolean): Int { - val packetId = radioController.getPacketId() - radioController.factoryReset(destNum, packetId) - + open suspend fun factoryReset(destNum: Int, isLocal: Boolean) { + radioController.factoryReset(destNum) if (isLocal) { - // If it's the local node, we should also clear the phone's node database as it will be out of sync. nodeRepository.clearNodeDB() } - - return packetId } /** - * Resets the NodeDB on the radio. + * Reset the NodeDB on the target node. * * @param destNum The node number to reset. * @param preserveFavorites Whether to keep favorite nodes in the database. * @param isLocal Whether the reset is being performed on the locally connected node. - * @return The packet ID of the request. */ - open suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean, isLocal: Boolean): Int { - val packetId = radioController.getPacketId() - radioController.nodedbReset(destNum, packetId, preserveFavorites) - + open suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean, isLocal: Boolean) { + radioController.nodedbReset(destNum, preserveFavorites) if (isLocal) { - // If it's the local node, we should also clear the phone's node database. nodeRepository.clearNodeDB(preserveFavorites) } - - return packetId } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt index 0ad5b47586..21d397b65c 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt @@ -58,8 +58,7 @@ constructor( nodeRepository.deleteNodes(nodeNums) for (nodeNum in nodeNums) { - val packetId = radioController.getPacketId() - radioController.removeByNodenum(packetId, nodeNum) + radioController.removeByNodenum(nodeNum) } } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt index 2f64981339..5fb4ee7ab9 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt @@ -21,8 +21,6 @@ import org.meshtastic.core.model.Position import org.meshtastic.core.model.RadioController import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceProfile -import org.meshtastic.proto.LocalConfig -import org.meshtastic.proto.LocalModuleConfig import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User @@ -32,50 +30,51 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC /** * Installs the provided [DeviceProfile] onto the radio at [destNum]. * + * Uses [RadioController.editSettings] to batch all writes inside a transactional + * `begin_edit_settings` / `commit_edit_settings` envelope. + * * @param destNum The destination node number. * @param profile The device profile to install. * @param currentUser The current user configuration of the destination node (to preserve names if not in profile). */ open suspend operator fun invoke(destNum: Int, profile: DeviceProfile, currentUser: User?) { - radioController.beginEditSettings(destNum) + radioController.editSettings(destNum) { + installOwner(profile, currentUser) + installConfig(profile.config) + installModuleConfig(profile.module_config) + } - installOwner(destNum, profile, currentUser) - installConfig(destNum, profile.config) + // Fixed position is set outside the edit block (uses a separate admin RPC) installFixedPosition(destNum, profile.fixed_position) - installModuleConfig(destNum, profile.module_config) - - radioController.commitEditSettings(destNum) } - private suspend fun installOwner(destNum: Int, profile: DeviceProfile, currentUser: User?) { + private suspend fun org.meshtastic.core.model.DeviceAdminEdit.installOwner( + profile: DeviceProfile, + currentUser: User?, + ) { if (profile.long_name != null || profile.short_name != null) { currentUser?.let { - val user = - it.copy( - long_name = profile.long_name ?: it.long_name, - short_name = profile.short_name ?: it.short_name, - ) - radioController.setOwner(destNum, user, radioController.getPacketId()) + val user = it.copy( + long_name = profile.long_name ?: it.long_name, + short_name = profile.short_name ?: it.short_name, + ) + setOwner(user) } } } - private suspend fun installConfig(destNum: Int, config: LocalConfig?) { + private suspend fun org.meshtastic.core.model.DeviceAdminEdit.installConfig( + config: org.meshtastic.proto.LocalConfig?, + ) { config?.let { lc -> - lc.device?.let { radioController.setConfig(destNum, Config(device = it), radioController.getPacketId()) } - lc.position?.let { - radioController.setConfig(destNum, Config(position = it), radioController.getPacketId()) - } - lc.power?.let { radioController.setConfig(destNum, Config(power = it), radioController.getPacketId()) } - lc.network?.let { radioController.setConfig(destNum, Config(network = it), radioController.getPacketId()) } - lc.display?.let { radioController.setConfig(destNum, Config(display = it), radioController.getPacketId()) } - lc.lora?.let { radioController.setConfig(destNum, Config(lora = it), radioController.getPacketId()) } - lc.bluetooth?.let { - radioController.setConfig(destNum, Config(bluetooth = it), radioController.getPacketId()) - } - lc.security?.let { - radioController.setConfig(destNum, Config(security = it), radioController.getPacketId()) - } + lc.device?.let { setConfig(Config(device = it)) } + lc.position?.let { setConfig(Config(position = it)) } + lc.power?.let { setConfig(Config(power = it)) } + lc.network?.let { setConfig(Config(network = it)) } + lc.display?.let { setConfig(Config(display = it)) } + lc.lora?.let { setConfig(Config(lora = it)) } + lc.bluetooth?.let { setConfig(Config(bluetooth = it)) } + lc.security?.let { setConfig(Config(security = it)) } } } @@ -85,70 +84,26 @@ open class InstallProfileUseCase constructor(private val radioController: RadioC } } - private suspend fun installModuleConfig(destNum: Int, moduleConfig: LocalModuleConfig?) { + private suspend fun org.meshtastic.core.model.DeviceAdminEdit.installModuleConfig( + moduleConfig: org.meshtastic.proto.LocalModuleConfig?, + ) { moduleConfig?.let { lmc -> - installModuleConfigPart1(destNum, lmc) - installModuleConfigPart2(destNum, lmc) - } - } - - private suspend fun installModuleConfigPart1(destNum: Int, lmc: LocalModuleConfig) { - lmc.mqtt?.let { - radioController.setModuleConfig(destNum, ModuleConfig(mqtt = it), radioController.getPacketId()) - } - lmc.serial?.let { - radioController.setModuleConfig(destNum, ModuleConfig(serial = it), radioController.getPacketId()) - } - lmc.external_notification?.let { - radioController.setModuleConfig( - destNum, - ModuleConfig(external_notification = it), - radioController.getPacketId(), - ) - } - lmc.store_forward?.let { - radioController.setModuleConfig(destNum, ModuleConfig(store_forward = it), radioController.getPacketId()) - } - lmc.range_test?.let { - radioController.setModuleConfig(destNum, ModuleConfig(range_test = it), radioController.getPacketId()) - } - lmc.telemetry?.let { - radioController.setModuleConfig(destNum, ModuleConfig(telemetry = it), radioController.getPacketId()) - } - lmc.canned_message?.let { - radioController.setModuleConfig(destNum, ModuleConfig(canned_message = it), radioController.getPacketId()) - } - lmc.audio?.let { - radioController.setModuleConfig(destNum, ModuleConfig(audio = it), radioController.getPacketId()) - } - } - - private suspend fun installModuleConfigPart2(destNum: Int, lmc: LocalModuleConfig) { - lmc.remote_hardware?.let { - radioController.setModuleConfig(destNum, ModuleConfig(remote_hardware = it), radioController.getPacketId()) - } - lmc.neighbor_info?.let { - radioController.setModuleConfig(destNum, ModuleConfig(neighbor_info = it), radioController.getPacketId()) - } - lmc.ambient_lighting?.let { - radioController.setModuleConfig(destNum, ModuleConfig(ambient_lighting = it), radioController.getPacketId()) - } - lmc.detection_sensor?.let { - radioController.setModuleConfig(destNum, ModuleConfig(detection_sensor = it), radioController.getPacketId()) - } - lmc.paxcounter?.let { - radioController.setModuleConfig(destNum, ModuleConfig(paxcounter = it), radioController.getPacketId()) - } - lmc.statusmessage?.let { - radioController.setModuleConfig(destNum, ModuleConfig(statusmessage = it), radioController.getPacketId()) - } - lmc.traffic_management?.let { - radioController.setModuleConfig( - destNum, - ModuleConfig(traffic_management = it), - radioController.getPacketId(), - ) + lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) } + lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) } + lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) } + lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) } + lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) } + lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) } + lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) } + lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) } + lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) } + lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) } + lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) } + lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) } + lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) } + lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) } + lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) } + lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) } } - lmc.tak?.let { radioController.setModuleConfig(destNum, ModuleConfig(tak = it), radioController.getPacketId()) } } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt deleted file mode 100644 index 755db4ca93..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt +++ /dev/null @@ -1,130 +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 co.touchlab.kermit.Logger -import org.koin.core.annotation.Single -import org.meshtastic.core.model.getStringResFrom -import org.meshtastic.core.resources.UiText -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.Channel -import org.meshtastic.proto.Data -import org.meshtastic.proto.DeviceConnectionStatus -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.Routing -import org.meshtastic.proto.User - -/** Sealed class representing the result of processing a radio response packet. */ -sealed class RadioResponseResult { - data class Metadata(val metadata: DeviceMetadata) : RadioResponseResult() - - data class ChannelResponse(val channel: Channel) : RadioResponseResult() - - data class Owner(val user: User) : RadioResponseResult() - - data class ConfigResponse(val config: org.meshtastic.proto.Config) : RadioResponseResult() - - data class ModuleConfigResponse(val config: org.meshtastic.proto.ModuleConfig) : RadioResponseResult() - - data class CannedMessages(val messages: String) : RadioResponseResult() - - data class Ringtone(val ringtone: String) : RadioResponseResult() - - data class ConnectionStatus(val status: DeviceConnectionStatus) : RadioResponseResult() - - data class Error(val message: UiText) : RadioResponseResult() - - data object Success : RadioResponseResult() -} - -/** Use case for processing incoming [MeshPacket]s that are responses to admin requests. */ -@Single -open class ProcessRadioResponseUseCase { - /** - * Decodes and processes the provided [packet]. - * - * @param packet The mesh packet received from the radio. - * @param destNum The node number that the response is expected from. - * @param requestIds The set of active request IDs. - * @return A [RadioResponseResult] if the packet matches a request, or null otherwise. - */ - @Suppress("CyclomaticComplexMethod", "NestedBlockDepth") - open operator fun invoke(packet: MeshPacket, destNum: Int, requestIds: Set): RadioResponseResult? { - val data = packet.decoded - if (data == null || data.request_id !in requestIds) { - return null - } - - return when (data.portnum) { - PortNum.ROUTING_APP -> processRoutingResponse(packet, data, destNum) - PortNum.ADMIN_APP -> processAdminResponse(packet, data, destNum) - else -> null - } - } - - private fun processRoutingResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult? { - val parsed = Routing.ADAPTER.decode(data.payload) - return when { - parsed.error_reason != Routing.Error.NONE -> - RadioResponseResult.Error(UiText.Resource(getStringResFrom(parsed.error_reason?.value ?: 0))) - - packet.from == destNum -> RadioResponseResult.Success - - else -> null - } - } - - private fun processAdminResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult { - if (destNum != packet.from) { - return RadioResponseResult.Error( - UiText.DynamicString("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}."), - ) - } - - val parsed = AdminMessage.ADAPTER.decode(data.payload) - return processAdminMessage(parsed) - } - - private fun processAdminMessage(parsed: AdminMessage): RadioResponseResult = when { - parsed.get_device_metadata_response != null -> - RadioResponseResult.Metadata(parsed.get_device_metadata_response!!) - - parsed.get_channel_response != null -> RadioResponseResult.ChannelResponse(parsed.get_channel_response!!) - - parsed.get_owner_response != null -> RadioResponseResult.Owner(parsed.get_owner_response!!) - - parsed.get_config_response != null -> RadioResponseResult.ConfigResponse(parsed.get_config_response!!) - - parsed.get_module_config_response != null -> - RadioResponseResult.ModuleConfigResponse(parsed.get_module_config_response!!) - - parsed.get_canned_message_module_messages_response != null -> - RadioResponseResult.CannedMessages(parsed.get_canned_message_module_messages_response!!) - - parsed.get_ringtone_response != null -> RadioResponseResult.Ringtone(parsed.get_ringtone_response!!) - - parsed.get_device_connection_status_response != null -> - RadioResponseResult.ConnectionStatus(parsed.get_device_connection_status_response!!) - - else -> { - Logger.d { "No custom processing needed for $parsed" } - RadioResponseResult.Success - } - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt index 838617b2ed..1e25ed135a 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt @@ -19,170 +19,91 @@ package org.meshtastic.core.domain.usecase.settings import org.koin.core.annotation.Single import org.meshtastic.core.model.Position import org.meshtastic.core.model.RadioController +import org.meshtastic.proto.Channel import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User -/** Use case for interacting with radio configuration components. */ +/** + * Use case for interacting with radio configuration components. + * + * Methods suspend until the device responds and return typed results directly. + * On failure, they propagate [org.meshtastic.core.model.AdminException]. + */ @Suppress("TooManyFunctions") @Single open class RadioConfigUseCase constructor(private val radioController: RadioController) { - /** - * Updates the owner information on the radio. - * - * @param destNum The node number to update. - * @param user The new user configuration. - * @return The packet ID of the request. - */ - open suspend fun setOwner(destNum: Int, user: User): Int { - val packetId = radioController.getPacketId() - radioController.setOwner(destNum, user, packetId) - return packetId - } - /** - * Requests the owner information from the radio. - * - * @param destNum The node number to query. - * @return The packet ID of the request. - */ - open suspend fun getOwner(destNum: Int): Int { - val packetId = radioController.getPacketId() - radioController.getOwner(destNum, packetId) - return packetId + /** Write the owner on the target node. */ + open suspend fun setOwner(destNum: Int, user: User) { + radioController.setOwner(destNum, user) } - /** - * Updates a configuration section on the radio. - * - * @param destNum The node number to update. - * @param config The new configuration. - * @return The packet ID of the request. - */ - open suspend fun setConfig(destNum: Int, config: Config): Int { - val packetId = radioController.getPacketId() - radioController.setConfig(destNum, config, packetId) - return packetId - } + /** Read the owner from the target node. */ + open suspend fun getOwner(destNum: Int): User = + radioController.getOwner(destNum) - /** - * Requests a configuration section from the radio. - * - * @param destNum The node number to query. - * @param configType The type of configuration to request (from [org.meshtastic.proto.AdminMessage.ConfigType]). - * @return The packet ID of the request. - */ - open suspend fun getConfig(destNum: Int, configType: Int): Int { - val packetId = radioController.getPacketId() - radioController.getConfig(destNum, configType, packetId) - return packetId + /** Write a config section on the target node. */ + open suspend fun setConfig(destNum: Int, config: Config) { + radioController.setConfig(destNum, config) } - /** - * Updates a module configuration section on the radio. - * - * @param destNum The node number to update. - * @param config The new module configuration. - * @return The packet ID of the request. - */ - open suspend fun setModuleConfig(destNum: Int, config: ModuleConfig): Int { - val packetId = radioController.getPacketId() - radioController.setModuleConfig(destNum, config, packetId) - return packetId - } + /** Read a config section from the target node. */ + open suspend fun getConfig(destNum: Int, configType: Int): Config = + radioController.getConfig(destNum, configType) - /** - * Requests a module configuration section from the radio. - * - * @param destNum The node number to query. - * @param moduleConfigType The type of module configuration to request. - * @return The packet ID of the request. - */ - open suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): Int { - val packetId = radioController.getPacketId() - radioController.getModuleConfig(destNum, moduleConfigType, packetId) - return packetId + /** Write a module config section on the target node. */ + open suspend fun setModuleConfig(destNum: Int, config: ModuleConfig) { + radioController.setModuleConfig(destNum, config) } - /** - * Requests a channel from the radio. - * - * @param destNum The node number to query. - * @param index The index of the channel to request. - * @return The packet ID of the request. - */ - open suspend fun getChannel(destNum: Int, index: Int): Int { - val packetId = radioController.getPacketId() - radioController.getChannel(destNum, index, packetId) - return packetId - } + /** Read a module config section from the target node. */ + open suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): ModuleConfig = + radioController.getModuleConfig(destNum, moduleConfigType) - /** - * Updates a channel on the radio. - * - * @param destNum The node number to update. - * @param channel The new channel configuration. - * @return The packet ID of the request. - */ - open suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel): Int { - val packetId = radioController.getPacketId() - radioController.setRemoteChannel(destNum, channel, packetId) - return packetId + /** Read a channel by index from the target node. */ + open suspend fun getChannel(destNum: Int, index: Int): Channel = + radioController.getChannel(destNum, index) + + /** Read all channels from the target node. */ + open suspend fun listChannels(destNum: Int): List = + radioController.listChannels(destNum) + + /** Write a channel on the target node. */ + open suspend fun setRemoteChannel(destNum: Int, channel: Channel) { + radioController.setRemoteChannel(destNum, channel) } - /** Updates the fixed position on the radio. */ + /** Set a fixed position on the target node. */ open suspend fun setFixedPosition(destNum: Int, position: Position) { radioController.setFixedPosition(destNum, position) } - /** Removes the fixed position on the radio. */ + /** Remove the fixed position (zero coordinates). */ open suspend fun removeFixedPosition(destNum: Int) { radioController.setFixedPosition(destNum, Position(0.0, 0.0, 0)) } - /** Sets the ringtone on the radio. */ + /** Write the ringtone on the target node. */ open suspend fun setRingtone(destNum: Int, ringtone: String) { radioController.setRingtone(destNum, ringtone) } - /** - * Requests the ringtone from the radio. - * - * @param destNum The node number to query. - * @return The packet ID of the request. - */ - open suspend fun getRingtone(destNum: Int): Int { - val packetId = radioController.getPacketId() - radioController.getRingtone(destNum, packetId) - return packetId - } + /** Read the ringtone from the target node. */ + open suspend fun getRingtone(destNum: Int): String = + radioController.getRingtone(destNum) - /** Sets the canned messages on the radio. */ + /** Write canned messages on the target node. */ open suspend fun setCannedMessages(destNum: Int, messages: String) { radioController.setCannedMessages(destNum, messages) } - /** - * Requests the canned messages from the radio. - * - * @param destNum The node number to query. - * @return The packet ID of the request. - */ - open suspend fun getCannedMessages(destNum: Int): Int { - val packetId = radioController.getPacketId() - radioController.getCannedMessages(destNum, packetId) - return packetId - } + /** Read canned messages from the target node. */ + open suspend fun getCannedMessages(destNum: Int): String = + radioController.getCannedMessages(destNum) - /** - * Requests the device connection status from the radio. - * - * @param destNum The node number to query. - * @return The packet ID of the request. - */ - open suspend fun getDeviceConnectionStatus(destNum: Int): Int { - val packetId = radioController.getPacketId() - radioController.getDeviceConnectionStatus(destNum, packetId) - return packetId - } + /** Read device connection status from the target node. */ + open suspend fun getDeviceConnectionStatus(destNum: Int): DeviceConnectionStatus = + radioController.getDeviceConnectionStatus(destNum) } 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 c72c447bce..0000000000 --- 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/ToggleAnalyticsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt deleted file mode 100644 index 2ba3064115..0000000000 --- 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 feee583935..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.HomoglyphPrefs - -/** Use case for toggling the homoglyph encoding preference. */ -@Single -open class ToggleHomoglyphEncodingUseCase constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) { - open operator fun invoke() { - homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value) - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt deleted file mode 100644 index aa4f0e2eb4..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt +++ /dev/null @@ -1,129 +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.session - -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.verifySuspend -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import okio.ByteString -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.SessionStatus -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.MeshActionHandler -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.repository.SessionManager -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.time.Clock - -@OptIn(ExperimentalCoroutinesApi::class) -class EnsureRemoteAdminSessionUseCaseTest { - - private val destNum = 0xCAFE - - private fun stubSessionManager( - initialStatus: SessionStatus = SessionStatus.NoSession, - refreshFlow: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 8), - ): SessionManager { - val mgr = mock(MockMode.autofill) - every { mgr.observeSessionStatus(any()) } returns flowOf(initialStatus) - every { mgr.sessionRefreshFlow } returns refreshFlow - every { mgr.getPasskey(any()) } returns ByteString.EMPTY - return mgr - } - - private fun connectedRepo(state: ConnectionState = ConnectionState.Connected): ServiceRepository { - val repo = mock(MockMode.autofill) - every { repo.connectionState } returns MutableStateFlow(state) - return repo - } - - @Test - fun `returns Disconnected without dispatching when not connected`() = runTest { - val sessionManager = stubSessionManager() - val handler = mock(MockMode.autofill) - val useCase = - EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(ConnectionState.Disconnected), this) - - val result = useCase(destNum) - - assertEquals(EnsureSessionResult.Disconnected, result) - } - - @Test - fun `returns AlreadyActive without dispatching when status already Active`() = runTest { - val active = SessionStatus.Active(Clock.System.now()) - val sessionManager = stubSessionManager(initialStatus = active) - val handler = mock(MockMode.autofill) - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) - - val result = useCase(destNum) - - assertEquals(EnsureSessionResult.AlreadyActive, result) - } - - @Test - fun `dispatches metadata request and returns Refreshed when refresh flow emits`() = runTest { - val refresh = MutableSharedFlow(extraBufferCapacity = 8) - val sessionManager = stubSessionManager(refreshFlow = refresh) - val handler = mock(MockMode.autofill) - // Simulate the radio responding by emitting on the refresh flow when the metadata request fires. - everySuspend { handler.onServiceAction(any()) } calls - { - refresh.tryEmit(destNum) - Unit - } - - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) - - val result = useCase(destNum) - - assertEquals(EnsureSessionResult.Refreshed, result) - verifySuspend { handler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) } - } - - @Test - fun `returns Timeout when no refresh arrives within deadline`() = runTest { - val refresh = MutableSharedFlow(extraBufferCapacity = 8) - val sessionManager = stubSessionManager(refreshFlow = refresh) - val handler = mock(MockMode.autofill) - everySuspend { handler.onServiceAction(any()) } returns Unit - - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) - - var observed: EnsureSessionResult? = null - val job = launch { observed = useCase(destNum) } - advanceTimeBy(EnsureRemoteAdminSessionUseCase.UX_TIMEOUT.inWholeMilliseconds + 100) - advanceUntilIdle() - job.join() - - assertEquals(EnsureSessionResult.Timeout, observed) - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt deleted file mode 100644 index a2bea77567..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt +++ /dev/null @@ -1,67 +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 kotlinx.coroutines.test.runTest -import org.meshtastic.core.model.Node -import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.core.testing.FakeRadioController -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class AdminActionsUseCaseTest { - - private lateinit var radioController: FakeRadioController - private lateinit var nodeRepository: FakeNodeRepository - private lateinit var useCase: AdminActionsUseCase - - @BeforeTest - fun setUp() { - radioController = FakeRadioController() - nodeRepository = FakeNodeRepository() - useCase = AdminActionsUseCase(radioController, nodeRepository) - } - - @Test - fun `reboot calls radioController`() = runTest { - val packetId = useCase.reboot(1234) - assertEquals(1, packetId) - } - - @Test - fun `shutdown calls radioController`() = runTest { - val packetId = useCase.shutdown(1234) - assertEquals(1, packetId) - } - - @Test - fun `factoryReset local node clears local NodeDB`() = runTest { - nodeRepository.upsert(Node(num = 1)) - useCase.factoryReset(1234, isLocal = true) - assertTrue(nodeRepository.nodeDBbyNum.value.isEmpty()) - } - - @Test - fun `nodedbReset local node clears local NodeDB with preserveFavorites`() = runTest { - nodeRepository.setNodes(listOf(Node(num = 1, isFavorite = true), Node(num = 2, isFavorite = false))) - useCase.nodedbReset(1234, preserveFavorites = true, isLocal = true) - assertEquals(1, nodeRepository.nodeDBbyNum.value.size) - assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(1)) - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt deleted file mode 100644 index 47013e4615..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt +++ /dev/null @@ -1,77 +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 kotlinx.coroutines.test.runTest -import org.meshtastic.core.model.Node -import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.core.testing.FakeRadioController -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.days - -class CleanNodeDatabaseUseCaseTest { - - private lateinit var nodeRepository: FakeNodeRepository - private lateinit var radioController: FakeRadioController - private lateinit var useCase: CleanNodeDatabaseUseCase - - @BeforeTest - fun setUp() { - nodeRepository = FakeNodeRepository() - radioController = FakeRadioController() - useCase = CleanNodeDatabaseUseCase(nodeRepository, radioController) - } - - @Test - fun `getNodesToClean returns nodes older than threshold`() = runTest { - val now = 1000000000L - val olderThan = now - 30.days.inWholeSeconds - val node1 = Node(num = 1, lastHeard = (olderThan - 100).toInt()) - val node2 = Node(num = 2, lastHeard = (olderThan + 100).toInt()) - nodeRepository.setNodes(listOf(node1, node2)) - - val result = useCase.getNodesToClean(30f, false, now) - - assertEquals(1, result.size) - assertEquals(1, result[0].num) - } - - @Test - fun `getNodesToClean filters out favorites and ignored`() = runTest { - val now = 1000000000L - val olderThan = now - 30.days.inWholeSeconds - val node1 = Node(num = 1, lastHeard = (olderThan - 100).toInt(), isFavorite = true) - val node2 = Node(num = 2, lastHeard = (olderThan - 100).toInt(), isIgnored = true) - nodeRepository.setNodes(listOf(node1, node2)) - - val result = useCase.getNodesToClean(30f, false, now) - - assertTrue(result.isEmpty()) - } - - @Test - fun `cleanNodes deletes from repo and controller`() = runTest { - nodeRepository.setNodes(listOf(Node(num = 1), Node(num = 2))) - useCase.cleanNodes(listOf(1)) - - assertEquals(1, nodeRepository.nodeDBbyNum.value.size) - assertTrue(nodeRepository.nodeDBbyNum.value.containsKey(2)) - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt index 2c449344a0..ad4138b220 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt @@ -60,11 +60,10 @@ class InstallProfileUseCaseTest { } @Test - fun `invoke calls begin and commit edit settings`() = runTest { + fun `invoke calls editSettings`() = runTest { useCase(1234, DeviceProfile(), User()) - assertTrue(radioController.beginEditSettingsCalled) - assertTrue(radioController.commitEditSettingsCalled) + assertTrue(radioController.editSettingsCalled) } @Test @@ -108,7 +107,6 @@ class InstallProfileUseCaseTest { useCase(1234, profile, org.meshtastic.proto.User(long_name = "Old")) - assertTrue(radioController.beginEditSettingsCalled) - assertTrue(radioController.commitEditSettingsCalled) + assertTrue(radioController.editSettingsCalled) } } diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt deleted file mode 100644 index 78e402ce2b..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt +++ /dev/null @@ -1,184 +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 app.cash.turbine.test -import dev.mokkery.MockMode -import dev.mokkery.answering.returns -import dev.mokkery.everySuspend -import dev.mokkery.matcher.any -import dev.mokkery.mock -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.DeviceHardwareRepository -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.RadioPrefs -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.User -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class IsOtaCapableUseCaseTest { - - private lateinit var nodeRepository: NodeRepository - private lateinit var radioController: RadioController - private lateinit var deviceHardwareRepository: DeviceHardwareRepository - private lateinit var radioPrefs: RadioPrefs - private lateinit var useCase: IsOtaCapableUseCase - - @BeforeTest - fun setUp() { - nodeRepository = mock(MockMode.autofill) - radioController = mock(MockMode.autofill) - deviceHardwareRepository = mock(MockMode.autofill) - radioPrefs = mock(MockMode.autofill) - - useCase = - IsOtaCapableUseCaseImpl( - nodeRepository = nodeRepository, - radioController = radioController, - radioPrefs = radioPrefs, - deviceHardwareRepository = deviceHardwareRepository, - ) - } - - @Test - fun `invoke returns true when ota capable`() = runTest { - // Arrange - val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM)) - dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node) - dev.mokkery.every { radioController.connectionState } returns - MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) - dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE - - val hw = - DeviceHardware( - activelySupported = true, - architecture = "esp32", - hwModel = HardwareModel.TBEAM.value, - requiresDfu = false, - ) - everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw) - - useCase().test { - assertTrue(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `invoke returns false when ota not capable`() = runTest { - // Arrange - val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM)) - dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node) - dev.mokkery.every { radioController.connectionState } returns - MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) - dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE - - val hw = DeviceHardware(activelySupported = false, hwModel = HardwareModel.TBEAM.value) - everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw) - - useCase().test { - assertFalse(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `invoke returns true when requires Dfu and actively supported`() = runTest { - // Arrange - val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM)) - dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node) - dev.mokkery.every { radioController.connectionState } returns - MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) - dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE - - val hw = - DeviceHardware( - activelySupported = true, - architecture = "nrf52840", - hwModel = HardwareModel.TBEAM.value, - requiresDfu = true, - ) - everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw) - - useCase().test { - assertTrue(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `invoke returns false when hardware model is UNSET`() = runTest { - // Arrange - val node = Node(num = 123, user = User(hw_model = HardwareModel.UNSET)) - dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node) - dev.mokkery.every { radioController.connectionState } returns - MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) - dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("x12345678") // x for BLE - - everySuspend { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.failure(Exception()) - - useCase().test { - assertFalse(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `invoke returns false when disconnected`() = runTest { - dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(Node(num = 123)) - dev.mokkery.every { radioController.connectionState } returns - MutableStateFlow(org.meshtastic.core.model.ConnectionState.Disconnected) - - useCase().test { - assertFalse(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `invoke returns false when node is null`() = runTest { - dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null) - dev.mokkery.every { radioController.connectionState } returns - MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) - - useCase().test { - assertFalse(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `invoke returns false when address is not ota capable`() = runTest { - val node = Node(num = 123, user = User(hw_model = HardwareModel.TBEAM)) - dev.mokkery.every { nodeRepository.ourNodeInfo } returns MutableStateFlow(node) - dev.mokkery.every { radioController.connectionState } returns - MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) - dev.mokkery.every { radioPrefs.devAddr } returns MutableStateFlow("mqtt://example.com") - - useCase().test { - assertFalse(awaitItem()) - cancelAndIgnoreRemainingEvents() - } - } -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt deleted file mode 100644 index 2ad33cad51..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt +++ /dev/null @@ -1,193 +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.proto.AdminMessage -import org.meshtastic.proto.Data -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.Routing -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class ProcessRadioResponseUseCaseTest { - - private lateinit var useCase: ProcessRadioResponseUseCase - - @BeforeTest - fun setUp() { - useCase = ProcessRadioResponseUseCase() - } - - @Test - fun `invoke with routing error returns error result`() { - // Arrange - val packet = - MeshPacket( - from = 123, - decoded = - Data( - portnum = PortNum.ROUTING_APP, - request_id = 42, - payload = Routing(error_reason = Routing.Error.NO_ROUTE).encode().toByteString(), - ), - ) - - // Act - val result = useCase(packet, 123, setOf(42)) - - // Assert - assertTrue(result is RadioResponseResult.Error) - } - - @Test - fun `invoke with metadata response returns metadata result`() { - // Arrange - val metadata = DeviceMetadata(firmware_version = "2.5.0") - val adminMsg = AdminMessage(get_device_metadata_response = metadata) - val packet = - MeshPacket( - from = 123, - decoded = Data( - portnum = PortNum.ADMIN_APP, - request_id = 42, - payload = adminMsg.encode().toByteString(), - ), - ) - - // Act - val result = useCase(packet, 123, setOf(42)) - - // Assert - assertTrue(result is RadioResponseResult.Metadata) - assertEquals("2.5.0", result.metadata.firmware_version) - } - - @Test - fun `invoke with canned messages response returns canned messages result`() { - // Arrange - val adminMsg = AdminMessage(get_canned_message_module_messages_response = "Hello World") - val packet = - MeshPacket( - from = 123, - decoded = Data( - portnum = PortNum.ADMIN_APP, - request_id = 42, - payload = adminMsg.encode().toByteString(), - ), - ) - - // Act - val result = useCase(packet, 123, setOf(42)) - - // Assert - assertTrue(result is RadioResponseResult.CannedMessages) - assertEquals("Hello World", result.messages) - } - - @Test - fun `invoke with unexpected sender returns error`() { - val adminMsg = AdminMessage() - val packet = - MeshPacket( - from = 456, - decoded = Data( - portnum = PortNum.ADMIN_APP, - request_id = 42, - payload = adminMsg.encode().toByteString(), - ), - ) - val result = useCase(packet, 123, setOf(42)) - assertTrue(result is RadioResponseResult.Error) - } - - @Test - fun `invoke with owner response returns owner result`() { - val owner = org.meshtastic.proto.User(long_name = "Owner") - val adminMsg = AdminMessage(get_owner_response = owner) - val packet = - MeshPacket( - from = 123, - decoded = Data( - portnum = PortNum.ADMIN_APP, - request_id = 42, - payload = adminMsg.encode().toByteString(), - ), - ) - val result = useCase(packet, 123, setOf(42)) - assertTrue(result is RadioResponseResult.Owner) - assertEquals("Owner", result.user.long_name) - } - - @Test - fun `invoke with config response returns config result`() { - val config = org.meshtastic.proto.Config(lora = org.meshtastic.proto.Config.LoRaConfig(use_preset = true)) - val adminMsg = AdminMessage(get_config_response = config) - val packet = - MeshPacket( - from = 123, - decoded = Data( - portnum = PortNum.ADMIN_APP, - request_id = 42, - payload = adminMsg.encode().toByteString(), - ), - ) - val result = useCase(packet, 123, setOf(42)) - assertTrue(result is RadioResponseResult.ConfigResponse) - } - - @Test - fun `invoke with module config response returns module config result`() { - val config = - org.meshtastic.proto.ModuleConfig(mqtt = org.meshtastic.proto.ModuleConfig.MQTTConfig(enabled = true)) - val adminMsg = AdminMessage(get_module_config_response = config) - val packet = - MeshPacket( - from = 123, - decoded = Data( - portnum = PortNum.ADMIN_APP, - request_id = 42, - payload = adminMsg.encode().toByteString(), - ), - ) - val result = useCase(packet, 123, setOf(42)) - assertTrue(result is RadioResponseResult.ModuleConfigResponse) - } - - @Test - fun `invoke with channel response returns channel result`() { - val channel = org.meshtastic.proto.Channel(settings = org.meshtastic.proto.ChannelSettings(name = "Main")) - val adminMsg = AdminMessage(get_channel_response = channel) - val packet = - MeshPacket( - from = 123, - decoded = Data( - portnum = PortNum.ADMIN_APP, - request_id = 42, - payload = adminMsg.encode().toByteString(), - ), - ) - val result = useCase(packet, 123, setOf(42)) - assertTrue(result is RadioResponseResult.ChannelResponse) - assertEquals("Main", result.channel.settings?.name) - } - - private fun ByteArray.toByteString() = okio.ByteString.of(*this) -} diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt index 8d83f5aee9..4b05f05955 100644 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt +++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt @@ -41,14 +41,12 @@ class RadioConfigUseCaseTest { fun `setOwner calls radioController`() = runTest { val user = User(long_name = "New Name") useCase.setOwner(1234, user) - // Verify call implicitly or by adding tracking to FakeRadioController if needed. - // FakeRadioController already has getPacketId returning 1. } @Test fun `getOwner calls radioController`() = runTest { - val packetId = useCase.getOwner(1234) - assertEquals(1, packetId) + val user = useCase.getOwner(1234) + // FakeRadioController returns a default User } @Test 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 ec52587856..0000000000 --- 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/SetMeshLogSettingsUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt deleted file mode 100644 index 20bf1a13fc..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt +++ /dev/null @@ -1,60 +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 kotlinx.coroutines.test.runTest -import org.meshtastic.core.testing.FakeMeshLogPrefs -import org.meshtastic.core.testing.FakeMeshLogRepository -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class SetMeshLogSettingsUseCaseTest { - - private lateinit var meshLogRepository: FakeMeshLogRepository - private lateinit var meshLogPrefs: FakeMeshLogPrefs - private lateinit var useCase: SetMeshLogSettingsUseCase - - @BeforeTest - fun setUp() { - meshLogRepository = FakeMeshLogRepository() - meshLogPrefs = FakeMeshLogPrefs() - useCase = SetMeshLogSettingsUseCase(meshLogRepository, meshLogPrefs) - } - - @Test - fun `setRetentionDays clamps value and deletes old logs`() = runTest { - useCase.setRetentionDays(500) // Max is 365 - assertEquals(365, meshLogPrefs.retentionDays.value) - assertEquals(365, meshLogRepository.lastDeletedOlderThan) - } - - @Test - fun `setLoggingEnabled false deletes all logs`() = runTest { - useCase.setLoggingEnabled(false) - assertEquals(false, meshLogPrefs.loggingEnabled.value) - assertEquals(true, meshLogRepository.deleteAllCalled) - } - - @Test - fun `setLoggingEnabled true deletes logs older than retention`() = runTest { - meshLogPrefs.setRetentionDays(15) - useCase.setLoggingEnabled(true) - assertEquals(true, meshLogPrefs.loggingEnabled.value) - assertEquals(15, meshLogRepository.lastDeletedOlderThan) - } -} 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 23431f816c..0000000000 --- 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 f563def741..0000000000 --- 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 c37998ae90..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.meshtastic.core.testing.FakeHomoglyphPrefs -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class ToggleHomoglyphEncodingUseCaseTest { - - private lateinit var homoglyphPrefs: FakeHomoglyphPrefs - private lateinit var useCase: ToggleHomoglyphEncodingUseCase - - @BeforeTest - fun setUp() { - homoglyphPrefs = FakeHomoglyphPrefs() - useCase = ToggleHomoglyphEncodingUseCase(homoglyphPrefs) - } - - @Test - fun `invoke toggles from false to true`() { - homoglyphPrefs.setHomoglyphEncodingEnabled(false) - useCase() - assertEquals(true, homoglyphPrefs.homoglyphEncodingEnabled.value) - } - - @Test - fun `invoke toggles from true to false`() { - homoglyphPrefs.setHomoglyphEncodingEnabled(true) - useCase() - assertEquals(false, homoglyphPrefs.homoglyphEncodingEnabled.value) - } -} diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index d115947f44..f3ac1e4c6a 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -15,6 +15,9 @@ * along with this program. If not, see . */ +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaLanguageVersion + plugins { alias(libs.plugins.meshtastic.kmp.library) alias(libs.plugins.meshtastic.kotlinx.serialization) @@ -38,6 +41,7 @@ kotlin { api(projects.core.common) api(projects.core.resources) + implementation(libs.sdk.core) api(libs.kotlinx.coroutines.core) api(libs.kotlinx.serialization.json) api(libs.kotlinx.datetime) @@ -72,3 +76,11 @@ publishing { } } } + +tasks.withType().configureEach { + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }, + ) +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt new file mode 100644 index 0000000000..0d188d84fc --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +/** + * Domain-level exception for admin (configuration) operations that fail for expected reasons. + * + * These failures are part of normal mesh operation — a remote node may be unreachable, the + * session key may have expired, or the request may time out. They are NOT thrown for catastrophic + * failures (transport gone, engine torn down) which throw standard exceptions. + */ +sealed class AdminException(message: String) : Exception(message) { + + /** The admin request timed out waiting for a device response. */ + class Timeout : AdminException("Request timed out") + + /** Client is not authorized to perform this operation on the target node. */ + class Unauthorized : AdminException("Not authorized") + + /** The destination node is unreachable (no route, NAK, or max retransmit). */ + class NodeUnreachable : AdminException("Node unreachable") + + /** Session key expired or was never established; a retry may succeed after re-seeding. */ + class SessionKeyExpired : AdminException("Session key expired") + + /** Device reported a routing error not covered by the other subtypes. */ + class RoutingError(val errorName: String) : AdminException("Routing error: $errorName") + + /** Device rate-limited the request; back off before retrying. */ + class RateLimited : AdminException("Rate limit exceeded") +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt index 8dbccf69aa..564877c034 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt @@ -17,62 +17,52 @@ package org.meshtastic.core.model import org.meshtastic.core.model.util.isDebug +import org.meshtastic.sdk.DeviceCapabilities as SdkCapabilities /** * Defines the capabilities and feature support based on the device firmware version. * * This class provides a centralized way to check if specific features are supported by the connected node's firmware. * Add new features here to ensure consistency across the app. - * - * Note: Properties are calculated once during initialization for efficiency. */ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = isDebug) { - private val version = firmwareVersion?.let { DeviceVersion(it) } + private val sdk = SdkCapabilities(firmwareVersion) - private fun atLeast(min: DeviceVersion): Boolean = forceEnableAll || (version != null && version >= min) + private fun check(sdkValue: Boolean): Boolean = forceEnableAll || sdkValue /** Ability to mute notifications from specific nodes via admin messages. */ - val canMuteNode = atLeast(V2_7_18) + val canMuteNode get() = check(sdk.canMuteNode) - /** Ability to request neighbor information from other nodes. Gated to [UNRELEASED] until working reliably. */ - val canRequestNeighborInfo = atLeast(UNRELEASED) + /** + * Ability to request neighbor information from other nodes. + * Gated to unreleased firmware until working reliably. + */ + val canRequestNeighborInfo get() = false /** Ability to send verified shared contacts. Supported since firmware v2.7.12. */ - val canSendVerifiedContacts = atLeast(V2_7_12) + val canSendVerifiedContacts get() = check(sdk.canSendVerifiedContacts) /** Ability to toggle device telemetry globally via module config. Supported since firmware v2.7.12. */ - val canToggleTelemetryEnabled = atLeast(V2_7_12) + val canToggleTelemetryEnabled get() = check(sdk.canToggleTelemetryEnabled) /** Ability to toggle the 'is_unmessageable' flag in user config. Supported since firmware v2.6.9. */ - val canToggleUnmessageable = atLeast(V2_6_9) + val canToggleUnmessageable get() = check(sdk.canToggleUnmessageable) /** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */ - val supportsQrCodeSharing = atLeast(V2_6_8) + val supportsQrCodeSharing get() = check(sdk.supportsQrCodeSharing) /** Support for Status Message module. Supported since firmware v2.8.0. */ - val supportsStatusMessage = atLeast(V2_8_0) + val supportsStatusMessage get() = check(sdk.supportsStatusMessage) /** Support for Traffic Management module. Supported since firmware v3.0.0. */ - val supportsTrafficManagementConfig = atLeast(V3_0_0) + val supportsTrafficManagementConfig get() = check(sdk.supportsTrafficManagementConfig) /** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */ - val supportsTakConfig = atLeast(V2_7_19) + val supportsTakConfig get() = check(sdk.supportsTakConfig) /** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */ - val supportsSecondaryChannelLocation = atLeast(V2_6_10) + val supportsSecondaryChannelLocation get() = check(sdk.supportsSecondaryChannelLocation) /** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */ - val supportsEsp32Ota = atLeast(V2_7_18) - - companion object { - private val V2_6_8 = DeviceVersion("2.6.8") - private val V2_6_9 = DeviceVersion("2.6.9") - private val V2_6_10 = DeviceVersion("2.6.10") - private val V2_7_12 = DeviceVersion("2.7.12") - private val V2_7_18 = DeviceVersion("2.7.18") - private val V2_7_19 = DeviceVersion("2.7.19") - private val V2_8_0 = DeviceVersion("2.8.0") - private val V3_0_0 = DeviceVersion("3.0.0") - private val UNRELEASED = DeviceVersion("9.9.9") - } + val supportsEsp32Ota get() = check(sdk.supportsEsp32Ota) } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt index da6ae71cda..0338e76b7e 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt @@ -21,16 +21,10 @@ package org.meshtastic.core.model import org.meshtastic.proto.Config.LoRaConfig import org.meshtastic.proto.Config.LoRaConfig.ModemPreset import org.meshtastic.proto.Config.LoRaConfig.RegionCode +import org.meshtastic.sdk.channelNameHashDjb2 import kotlin.math.floor -/** hash a string into an integer using the djb2 algorithm by Dan Bernstein http://www.cse.yorku.ca/~oz/hash.html */ -private fun hash(name: String): UInt { // using UInt instead of Long to match RadioInterface.cpp results - var hash = 5381u - for (c in name) { - hash += (hash shl 5) + c.code.toUInt() - } - return hash -} +private fun hash(name: String): UInt = channelNameHashDjb2(name) private val ModemPreset.bandwidth: Float get() { diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/CongestionLevel.kt similarity index 70% rename from core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/CongestionLevel.kt index ab3d26abf8..9f2afe5d0d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/CongestionLevel.kt @@ -14,12 +14,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.repository +package org.meshtastic.core.model -import org.meshtastic.proto.FromRadio - -/** Interface for dispatching non-packet [FromRadio] variants to their respective handlers. */ -interface FromRadioPacketHandler { - /** Processes a [FromRadio] message. */ - fun handleFromRadio(proto: FromRadio) -} +/** + * Domain-level typealias for SDK's CongestionLevel. + * + * This provides a single import point for feature modules without directly coupling them + * to the SDK package namespace. + */ +public typealias CongestionLevel = org.meshtastic.sdk.CongestionLevel diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionAware.kt similarity index 72% rename from feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavUtils.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionAware.kt index 93e5763ef2..5e5cb3bc3a 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavUtils.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionAware.kt @@ -14,9 +14,11 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.settings.navigation +package org.meshtastic.core.model -import org.meshtastic.core.navigation.Route +import kotlinx.coroutines.flow.StateFlow -fun getNavRouteFrom(routeName: String): Route? = - ConfigRoute.entries.find { it.name == routeName }?.route ?: ModuleRoute.entries.find { it.name == routeName }?.route +/** Provides read-only access to the app's connection state. */ +interface ConnectionAware { + val connectionState: StateFlow +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt index c8bbdadb5b..9967b6916d 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt @@ -17,15 +17,24 @@ package org.meshtastic.core.model sealed interface ConnectionState { - /** We are disconnected from the device, and we should be trying to reconnect. */ + /** Not connected; should attempt to reconnect. */ data object Disconnected : ConnectionState - /** We are currently attempting to connect to the device. */ - data object Connecting : ConnectionState + /** Transport connecting. */ + data class Connecting(val attempt: Int = 1) : ConnectionState - /** We are connected to the device and communicating normally. */ + /** Transport up, handshake in progress. */ + data class Configuring(val phase: String = "", val progress: Float = 0f) : ConnectionState + + /** Fully connected and operational. */ data object Connected : ConnectionState - /** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */ + /** Connection dropped, attempting automatic reconnect. */ + data class Reconnecting(val attempt: Int = 1) : ConnectionState + + /** Device in light sleep. */ data object DeviceSleep : ConnectionState + + /** Whether the connection is usable for sending messages. */ + val isConnected: Boolean get() = this is Connected } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt index 4214dd62ce..8ef21e45db 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt @@ -17,7 +17,17 @@ package org.meshtastic.core.model import co.touchlab.kermit.Logger +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive import okio.ByteString import okio.ByteString.Companion.toByteString import org.meshtastic.core.common.util.CommonIgnoredOnParcel @@ -25,13 +35,15 @@ import org.meshtastic.core.common.util.CommonParcel import org.meshtastic.core.common.util.CommonParcelable import org.meshtastic.core.common.util.CommonParcelize import org.meshtastic.core.common.util.CommonTypeParceler -import org.meshtastic.core.common.util.formatString import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.util.ByteStringParceler import org.meshtastic.core.model.util.ByteStringSerializer import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.Waypoint +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.fromDefaultId +import org.meshtastic.sdk.toDefaultId @CommonParcelize enum class MessageStatus : CommonParcelable { @@ -49,13 +61,15 @@ enum class MessageStatus : CommonParcelable { @Serializable @CommonParcelize data class DataPacket( - var to: String? = ID_BROADCAST, // a nodeID string, or ID_BROADCAST for broadcast + @Serializable(with = NodeNumSerializer::class) + var to: Int = BROADCAST, @Serializable(with = ByteStringSerializer::class) @CommonTypeParceler var bytes: ByteString?, // A port number for this packet var dataType: Int, - var from: String? = ID_LOCAL, // a nodeID string, or ID_LOCAL for localhost + @Serializable(with = NodeNumSerializer::class) + var from: Int = LOCAL, var time: Long = nowMillis, // msecs since 1970 var id: Int = 0, // 0 means unassigned var status: MessageStatus? = MessageStatus.UNKNOWN, @@ -78,10 +92,10 @@ data class DataPacket( ) : CommonParcelable { fun readFromParcel(parcel: CommonParcel) { - to = parcel.readString() + to = parcel.readInt() bytes = ByteStringParceler.create(parcel) dataType = parcel.readInt() - from = parcel.readString() + from = parcel.readInt() time = parcel.readLong() id = parcel.readInt() @@ -121,7 +135,7 @@ data class DataPacket( /** Syntactic sugar to make it easy to create text messages */ constructor( - to: String?, + to: Int, channel: Int, text: String, replyId: Int? = null, @@ -151,7 +165,7 @@ data class DataPacket( } constructor( - to: String?, + to: Int, channel: Int, waypoint: Waypoint, ) : this( @@ -177,23 +191,48 @@ data class DataPacket( get() = if (hopStart == 0 || (hopLimit > hopStart)) -1 else hopStart - hopLimit companion object { - // Special node IDs that can be used for sending messages - - /** the Node ID for broadcast destinations */ - const val ID_BROADCAST = "^all" - - /** The Node ID for the local node - used for from when sender doesn't know our local node ID */ - const val ID_LOCAL = "^local" - - // special broadcast address - const val NODENUM_BROADCAST = (0xffffffff).toInt() + const val BROADCAST: Int = 0xffffffff.toInt() + const val LOCAL: Int = 0 // Public-key cryptography (PKC) channel index const val PKC_CHANNEL_INDEX = 8 - fun nodeNumToDefaultId(n: Int): String = formatString("!%08x", n) + /** Format a node number as the default display ID ("!aabbccdd"). */ + fun nodeNumToDefaultId(n: Int): String = NodeId(n).toDefaultId() + + fun nodeNumToId(n: Int): String = when (n) { + BROADCAST -> "^all" + LOCAL -> "^local" + else -> nodeNumToDefaultId(n) + } + + fun parseNodeNum(id: String): Int { + val normalized = id.trim() + return when { + normalized.equals("^all", ignoreCase = true) -> BROADCAST + normalized.equals("^local", ignoreCase = true) -> LOCAL + else -> NodeId.fromDefaultId(normalized)?.raw + ?: NodeId.fromDefaultId("!$normalized")?.raw + ?: runCatching { normalized.toLong(16).toInt() }.getOrNull() + ?: throw SerializationException("Unsupported node id: $id") + } + } + } +} + +private object NodeNumSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("NodeNum", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Int) { + encoder.encodeString(DataPacket.nodeNumToId(value)) + } - @Suppress("MagicNumber") - fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull() + override fun deserialize(decoder: Decoder): Int { + if (decoder is JsonDecoder) { + val primitive = decoder.decodeJsonElement().jsonPrimitive + primitive.intOrNull?.let { return it } + return DataPacket.parseNodeNum(primitive.content) + } + return DataPacket.parseNodeNum(decoder.decodeString()) } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt similarity index 58% rename from core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt index 6e994f4efd..69564bdaaa 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt @@ -14,14 +14,14 @@ * 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 +package org.meshtastic.core.model -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) - } +/** Focused interface for requesting data from nodes. */ +interface DataRequester { + suspend fun requestPosition(destNum: Int, currentPosition: Position) + suspend fun requestUserInfo(destNum: Int) + suspend fun requestTraceroute(destNum: Int) + suspend fun requestTelemetry(destNum: Int, type: TelemetryType) + suspend fun requestNeighborInfo(destNum: Int) + suspend fun requestStoreForwardHistory(since: Int? = null, serverNodeNum: Int? = null): Boolean } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt new file mode 100644 index 0000000000..c37d30d140 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config + +/** Focused interface for local device configuration and edit sessions. */ +interface DeviceAdmin : ConnectionAware { + suspend fun setLocalConfig(config: Config) + suspend fun setLocalChannel(channel: Channel) + + /** + * Run [block] inside a `begin_edit_settings` / `commit_edit_settings` envelope so the device + * applies all writes atomically. + * + * @param destNum the target node number (local or remote) + * @param block a suspending block using [DeviceAdminEdit] receiver to queue writes + * @throws AdminException on begin/commit failure (timeout, unauthorized, etc.) + */ + suspend fun editSettings(destNum: Int, block: suspend DeviceAdminEdit.() -> Unit) +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt similarity index 50% rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt index 9ed224d3fa..88644fef66 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt @@ -14,23 +14,23 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.core.network.radio +package org.meshtastic.core.model -import org.meshtastic.core.repository.RadioTransport +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.User /** - * An intentionally inert [RadioTransport] that silently discards all operations. + * Receiver interface for batched admin writes inside an [DeviceAdmin.editSettings] block. * - * Used as a safe default when no valid device address is configured or when the requested transport type is - * unsupported. All method calls are no-ops — it never connects, never sends data, and never signals lifecycle events to - * the service layer. + * Methods queue writes without awaiting individual acknowledgements. The enclosing + * `editSettings` call handles `begin_edit_settings` / `commit_edit_settings` framing so + * the device applies all writes atomically. */ -class NopRadioTransport(val address: String) : RadioTransport { - override fun handleSendToRadio(p: ByteArray) { - // No-op - } - - override suspend fun close() { - // No-op - } +interface DeviceAdminEdit { + suspend fun setConfig(config: Config) + suspend fun setModuleConfig(config: ModuleConfig) + suspend fun setOwner(user: User) + suspend fun setChannel(channel: Channel) } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt similarity index 53% rename from core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt index 8d3018266b..7275e14f0d 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt @@ -14,17 +14,19 @@ * 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 +package org.meshtastic.core.model -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) - } +/** + * Focused interface for device lifecycle control. + * + * Methods suspend until the device acknowledges the command. On failure, they throw [AdminException]. + */ +interface DeviceControl : ConnectionAware { + suspend fun reboot(destNum: Int) + suspend fun rebootToDfu(nodeNum: Int) + suspend fun requestRebootOta(destNum: Int, mode: Int, hash: ByteArray?) + suspend fun shutdown(destNum: Int) + suspend fun factoryReset(destNum: Int) + suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean) + suspend fun removeByNodenum(nodeNum: Int) } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt index e77327d12e..08fcc1b404 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceVersion.kt @@ -16,42 +16,16 @@ */ package org.meshtastic.core.model -import co.touchlab.kermit.Logger +import org.meshtastic.sdk.DeviceVersion as SdkDeviceVersion /** Provide structured access to parse and compare device version strings */ data class DeviceVersion(val asString: String) : Comparable { + private val delegate = SdkDeviceVersion(asString) - /** The integer representation of the version (e.g., 2.7.12 -> 20712). Calculated once. */ - @Suppress("TooGenericExceptionCaught", "SwallowedException") - val asInt: Int = - try { - verStringToInt(asString) - } catch (e: Exception) { - Logger.w { "Exception while parsing version '$asString', assuming version 0" } - 0 - } + val asInt: Int + get() = delegate.asInt - /** - * Convert a version string of the form 1.23.57 to a comparable integer of the form 12357. - * - * Or throw an exception if the string can not be parsed - */ - @Suppress("TooGenericExceptionThrown", "MagicNumber") - private fun verStringToInt(s: String): Int { - // Allow 1 to two digits per match - val versionString = - if (s.split(".").size == 2) { - "$s.0" - } else { - s - } - val match = - Regex("(\\d{1,2}).(\\d{1,2}).(\\d{1,2})").find(versionString) ?: throw Exception("Can't parse version $s") - val (major, minor, build) = match.destructured - return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt() - } - - override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt) + override fun compareTo(other: DeviceVersion): Int = delegate.compareTo(other.delegate) companion object { const val MIN_FW_VERSION = "2.5.14" diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshModels.kt similarity index 67% rename from core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshModels.kt index 3a3deddd5e..8490cffa57 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshModels.kt @@ -22,8 +22,6 @@ import org.meshtastic.core.common.util.bearing import org.meshtastic.core.common.util.latLongToMeter import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.util.anonymize -import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.proto.Config import org.meshtastic.proto.HardwareModel // @@ -188,88 +186,3 @@ data class EnvironmentMetrics( } } -@CommonParcelize -data class NodeInfo( - val num: Int, // This is immutable, and used as a key - var user: MeshUser? = null, - var position: Position? = null, - var snr: Float = Float.MAX_VALUE, - var rssi: Int = Int.MAX_VALUE, - var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970 - var deviceMetrics: DeviceMetrics? = null, - var channel: Int = 0, - var environmentMetrics: EnvironmentMetrics? = null, - var hopsAway: Int = 0, - var nodeStatus: String? = null, -) : CommonParcelable { - - @Suppress("MagicNumber") - val colors: Pair - get() { // returns foreground and background @ColorInt for each 'num' - val r = (num and 0xFF0000) shr 16 - val g = (num and 0x00FF00) shr 8 - val b = num and 0x0000FF - val brightness = ((r * 0.299) + (g * 0.587) + (b * 0.114)) / 255 - val foreground = if (brightness > 0.5) 0xFF000000.toInt() else 0xFFFFFFFF.toInt() - val background = (0xFF shl 24) or (r shl 16) or (g shl 8) or b - return foreground to background - } - - val batteryLevel - get() = deviceMetrics?.batteryLevel - - val voltage - get() = deviceMetrics?.voltage - - @Suppress("ImplicitDefaultLocale") - val batteryStr - get() = if (batteryLevel in 1..100) "$batteryLevel%" else "" - - /** true if the device was heard from recently */ - val isOnline: Boolean - get() { - return lastHeard > onlineTimeThreshold() - } - - // / return the position if it is valid, else null - val validPosition: Position? - get() { - return position?.takeIf { it.isValid() } - } - - // / @return distance in meters to some other node (or null if unknown) - fun distance(o: NodeInfo?): Int? { - val p = validPosition - val op = o?.validPosition - return if (p != null && op != null) p.distance(op).toInt() else null - } - - // / @return bearing to the other position in degrees - fun bearing(o: NodeInfo?): Int? { - val p = validPosition - val op = o?.validPosition - return if (p != null && op != null) p.bearing(op).toInt() else null - } - - // / @return a nice human readable string for the distance, or null for unknown - @Suppress("MagicNumber") - fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist -> - when { - dist == 0 -> null - - // same point - prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist < 1000 -> "$dist m" - - prefUnits == Config.DisplayConfig.DisplayUnits.METRIC.value && dist >= 1000 -> - "${(dist / 100).toDouble() / 10.0} km" - - prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist < 1609 -> - "${(dist.toDouble() * 3.281).toInt()} ft" - - prefUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL.value && dist >= 1609 -> - "${(dist / 160.9).toInt() / 10.0} mi" - - else -> null - } - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt similarity index 70% rename from core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt index 58d260e32d..b89d079164 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt @@ -14,14 +14,9 @@ * 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 +package org.meshtastic.core.model -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) - } +/** Focused interface for sending messages over the mesh. */ +interface MessageSender : ConnectionAware { + suspend fun sendMessage(packet: DataPacket) } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioConfigStateProvider.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioConfigStateProvider.kt new file mode 100644 index 0000000000..aefa34dbb0 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioConfigStateProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import kotlinx.coroutines.flow.StateFlow + +/** + * Minimal interface exposing radio-config packet response state. + * Used by feature/connections to observe config-loading progress without + * depending on the full RadioConfigViewModel in feature/settings. + */ +interface RadioConfigStateProvider { + /** Current packet response state (loading/success/error/empty). */ + val packetResponseState: StateFlow> + + /** Route name associated with the pending config request (e.g. "LORA"). */ + val pendingRouteName: StateFlow + + /** Initiate a config load for the given route name. */ + fun requestConfigLoad(routeName: String) + + /** Clear the packet response, resetting to [ResponseState.Empty]. */ + fun clearPacketResponse() +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt index 84994e6288..8913df3742 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt @@ -20,26 +20,12 @@ import kotlinx.coroutines.flow.StateFlow import org.meshtastic.proto.ClientNotification /** - * Central interface for controlling the radio and mesh network. + * Composite interface for all radio operations. * - * This component provides an abstraction over the underlying communication transport (e.g., BLE, Serial, TCP) and the - * low-level mesh protocols. It allows feature modules to interact with the mesh without needing to know about - * platform-specific service details or AIDL interfaces. + * Consumers should prefer the focused sub-interfaces (for example [MessageSender] and [RemoteAdmin]) for new code. + * This super-interface remains for backward compatibility with existing injections. */ -@Suppress("TooManyFunctions") -interface RadioController { - /** - * 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 - +interface RadioController : MessageSender, DeviceAdmin, RemoteAdmin, DeviceControl, DataRequester { /** * Flow of notifications from the radio client. * @@ -47,13 +33,6 @@ interface RadioController { */ val clientNotification: StateFlow - /** - * Sends a data packet to the mesh. - * - * @param packet The [DataPacket] containing the payload and routing information. - */ - suspend fun sendMessage(packet: DataPacket) - /** Clears the current [clientNotification]. */ fun clearClientNotification() @@ -75,258 +54,6 @@ interface RadioController { */ suspend fun sendSharedContact(nodeNum: Int): Boolean - /** - * Updates the local radio configuration. - * - * @param config The new configuration [org.meshtastic.proto.Config]. - */ - suspend fun setLocalConfig(config: org.meshtastic.proto.Config) - - /** - * Updates a local radio channel. - * - * @param channel The channel configuration [org.meshtastic.proto.Channel]. - */ - suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) - - /** - * Updates the owner (user info) on a remote node. - * - * @param destNum The destination node number. - * @param user The new user info [org.meshtastic.proto.User]. - * @param packetId The request packet ID. - */ - suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) - - /** - * Updates the general configuration on a remote node. - * - * @param destNum The destination node number. - * @param config The new configuration [org.meshtastic.proto.Config]. - * @param packetId The request packet ID. - */ - suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) - - /** - * Updates a module configuration on a remote node. - * - * @param destNum The destination node number. - * @param config The new module configuration [org.meshtastic.proto.ModuleConfig]. - * @param packetId The request packet ID. - */ - suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) - - /** - * Updates a channel configuration on a remote node. - * - * @param destNum The destination node number. - * @param channel The new channel configuration [org.meshtastic.proto.Channel]. - * @param packetId The request packet ID. - */ - suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) - - /** - * Sets a fixed position on a remote node. - * - * @param destNum The destination node number. - * @param position The position to set. - */ - suspend fun setFixedPosition(destNum: Int, position: Position) - - /** - * Updates the notification ringtone on a remote node. - * - * @param destNum The destination node number. - * @param ringtone The name/ID of the ringtone. - */ - suspend fun setRingtone(destNum: Int, ringtone: String) - - /** - * Updates the canned messages configuration on a remote node. - * - * @param destNum The destination node number. - * @param messages The canned messages string. - */ - suspend fun setCannedMessages(destNum: Int, messages: String) - - /** - * Requests the current owner (user info) from a remote node. - * - * @param destNum The remote node number. - * @param packetId The request packet ID. - */ - suspend fun getOwner(destNum: Int, packetId: Int) - - /** - * Requests a specific configuration section from a remote node. - * - * @param destNum The remote node number. - * @param configType The numeric type of the configuration section. - * @param packetId The request packet ID. - */ - suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) - - /** - * Requests a module configuration section from a remote node. - * - * @param destNum The remote node number. - * @param moduleConfigType The numeric type of the module configuration section. - * @param packetId The request packet ID. - */ - suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) - - /** - * Requests a specific channel configuration from a remote node. - * - * @param destNum The remote node number. - * @param index The channel index. - * @param packetId The request packet ID. - */ - suspend fun getChannel(destNum: Int, index: Int, packetId: Int) - - /** - * Requests the current ringtone from a remote node. - * - * @param destNum The remote node number. - * @param packetId The request packet ID. - */ - suspend fun getRingtone(destNum: Int, packetId: Int) - - /** - * Requests the current canned messages from a remote node. - * - * @param destNum The remote node number. - * @param packetId The request packet ID. - */ - suspend fun getCannedMessages(destNum: Int, packetId: Int) - - /** - * Requests the hardware connection status from a remote node. - * - * @param destNum The remote node number. - * @param packetId The request packet ID. - */ - suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) - - /** - * Commands a node to reboot. - * - * @param destNum The target node number. - * @param packetId The request packet ID. - */ - suspend fun reboot(destNum: Int, packetId: Int) - - /** - * Commands a node to reboot into DFU (Device Firmware Update) mode. - * - * @param nodeNum The target node number. - */ - suspend fun rebootToDfu(nodeNum: Int) - - /** - * Initiates an Over-The-Air (OTA) reboot request. - * - * @param requestId The request ID. - * @param destNum The target node number. - * @param mode The OTA mode. - * @param hash Optional hash for verification. - */ - suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) - - /** - * Commands a node to shut down. - * - * @param destNum The target node number. - * @param packetId The request packet ID. - */ - suspend fun shutdown(destNum: Int, packetId: Int) - - /** - * Performs a factory reset on a node. - * - * @param destNum The target node number. - * @param packetId The request packet ID. - */ - suspend fun factoryReset(destNum: Int, packetId: Int) - - /** - * Resets the NodeDB on a node. - * - * @param destNum The target node number. - * @param packetId The request packet ID. - * @param preserveFavorites Whether to keep favorite nodes in the database. - */ - suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) - - /** - * Removes a node from the mesh by its node number. - * - * @param packetId The request packet ID. - * @param nodeNum The node number to remove. - */ - suspend fun removeByNodenum(packetId: Int, nodeNum: Int) - - /** - * Requests the current GPS position from a remote node. - * - * @param destNum The target node number. - * @param currentPosition Our current position to provide in the request. - */ - suspend fun requestPosition(destNum: Int, currentPosition: Position) - - /** - * Requests detailed user info from a remote node. - * - * @param destNum The target node number. - */ - suspend fun requestUserInfo(destNum: Int) - - /** - * Initiates a traceroute request to a remote node. - * - * @param requestId The request ID. - * @param destNum The destination node number. - */ - suspend fun requestTraceroute(requestId: Int, destNum: Int) - - /** - * Requests telemetry data from a remote node. - * - * @param requestId The request ID. - * @param destNum The destination node number. - * @param typeValue The numeric type of telemetry requested. - */ - suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) - - /** - * Requests neighbor information (detected nodes) from a remote node. - * - * @param requestId The request ID. - * @param destNum The destination node number. - */ - suspend fun requestNeighborInfo(requestId: Int, destNum: Int) - - /** - * Signals the start of a batch configuration session. - * - * @param destNum The target node number. - */ - suspend fun beginEditSettings(destNum: Int) - - /** - * Commits all pending configuration changes in a batch session. - * - * @param destNum The target node number. - */ - suspend fun commitEditSettings(destNum: Int) - - /** - * Generates a unique packet ID for a new request. - * - * @return A unique 32-bit integer. - */ - fun getPacketId(): Int - /** Starts providing the phone's location to the mesh. */ fun startProvideLocation() diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt new file mode 100644 index 0000000000..a743b9a94b --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.User + +/** + * Focused interface for remote node administration. + * + * Methods suspend until the device responds. On failure, they throw [AdminException]. + */ +interface RemoteAdmin { + /** Write the owner [user] on the target node. */ + suspend fun setOwner(destNum: Int, user: User) + + /** Write a [config] section on the target node. */ + suspend fun setConfig(destNum: Int, config: Config) + + /** Write a [config] module section on the target node. */ + suspend fun setModuleConfig(destNum: Int, config: ModuleConfig) + + /** Write a [channel] on the target node. */ + suspend fun setRemoteChannel(destNum: Int, channel: Channel) + + /** Set a fixed position on the target node. */ + suspend fun setFixedPosition(destNum: Int, position: Position) + + /** Set the ringtone (RTTTL) on the target node. */ + suspend fun setRingtone(destNum: Int, ringtone: String) + + /** Set canned messages on the target node. */ + suspend fun setCannedMessages(destNum: Int, messages: String) + + /** Read the owner from the target node. */ + suspend fun getOwner(destNum: Int): User + + /** Read a config section from the target node. */ + suspend fun getConfig(destNum: Int, configType: Int): Config + + /** Read a module config section from the target node. */ + suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): ModuleConfig + + /** Read a channel by [index] from the target node. */ + suspend fun getChannel(destNum: Int, index: Int): Channel + + /** Read all channels from the target node (stops at first disabled slot). */ + suspend fun listChannels(destNum: Int): List + + /** Read the ringtone from the target node. */ + suspend fun getRingtone(destNum: Int): String + + /** Read canned messages from the target node. */ + suspend fun getCannedMessages(destNum: Int): String + + /** Read device connection status from the target node. */ + suspend fun getDeviceConnectionStatus(destNum: Int): DeviceConnectionStatus +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ResponseState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ResponseState.kt similarity index 96% rename from feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ResponseState.kt rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/ResponseState.kt index d0af140392..912b8b101d 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ResponseState.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ResponseState.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.settings.radio +package org.meshtastic.core.model import org.meshtastic.core.resources.UiText diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TelemetryType.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TelemetryType.kt index 89eec6bbff..b7b532a724 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TelemetryType.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/TelemetryType.kt @@ -16,12 +16,19 @@ */ package org.meshtastic.core.model +/** + * Typed enum for telemetry request categories. + * + * Ordinal values align with SDK telemetry dispatch ordering: + * 0=Device, 1=Environment, 2=AirQuality, 3=Power, 4=LocalStats, 5=Health, 6=Host, 7=TrafficManagement. + */ enum class TelemetryType { DEVICE, ENVIRONMENT, AIR_QUALITY, POWER, LOCAL_STATS, + HEALTH, HOST, - PAX, + TRAFFIC_MANAGEMENT, } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt deleted file mode 100644 index 4df932c50f..0000000000 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@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 = nodeIdLookup.toNodeID(packet.from), - to = nodeIdLookup.toNodeID(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, - ) - } -} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt deleted file mode 100644 index f24919f02c..0000000000 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model.util - -import okio.ByteString.Companion.toByteString - -/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */ -object SfppHasher { - private const val HASH_SIZE = 16 - private const val INT_BYTES = 4 - private const val INT_COUNT = 3 - private const val SHIFT_8 = 8 - private const val SHIFT_16 = 16 - private const val SHIFT_24 = 24 - - fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray { - val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT) - encryptedPayload.copyInto(input) - var offset = encryptedPayload.size - for (value in intArrayOf(to, from, id)) { - input[offset++] = value.toByte() - input[offset++] = (value shr SHIFT_8).toByte() - input[offset++] = (value shr SHIFT_16).toByte() - input[offset++] = (value shr SHIFT_24).toByte() - } - return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE) - } -} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt deleted file mode 100644 index 917414e3d5..0000000000 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.model.util - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals - -class SfppHasherTest { - - @Test - fun outputIsAlways16Bytes() { - val hash = SfppHasher.computeMessageHash(byteArrayOf(1, 2, 3), to = 100, from = 200, id = 1) - assertEquals(16, hash.size) - } - - @Test - fun emptyPayloadProduces16Bytes() { - val hash = SfppHasher.computeMessageHash(byteArrayOf(), to = 0, from = 0, id = 0) - assertEquals(16, hash.size) - } - - @Test - fun deterministicOutput() { - val a = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3) - val b = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3) - assertEquals(a.toList(), b.toList()) - } - - @Test - fun differentPayloadsProduceDifferentHashes() { - val a = SfppHasher.computeMessageHash(byteArrayOf(1), to = 1, from = 2, id = 3) - val b = SfppHasher.computeMessageHash(byteArrayOf(2), to = 1, from = 2, id = 3) - assertNotEquals(a.toList(), b.toList()) - } - - @Test - fun differentIdsProduceDifferentHashes() { - val payload = byteArrayOf(0x10, 0x20) - val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 100) - val b = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 101) - assertNotEquals(a.toList(), b.toList()) - } - - @Test - fun differentFromProduceDifferentHashes() { - val payload = byteArrayOf(0x10, 0x20) - val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 3) - val b = SfppHasher.computeMessageHash(payload, to = 1, from = 99, id = 3) - assertNotEquals(a.toList(), b.toList()) - } - - @Test - fun maxIntValues() { - val hash = - SfppHasher.computeMessageHash( - byteArrayOf(0xFF.toByte()), - to = Int.MAX_VALUE, - from = Int.MAX_VALUE, - id = Int.MAX_VALUE, - ) - assertEquals(16, hash.size) - } - - @Test - fun littleEndianByteOrder() { - // Verify the integer 0x04030201 is encoded as [01, 02, 03, 04] (little-endian) - val hashA = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x04030201, from = 0, id = 0) - val hashB = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x01020304, from = 0, id = 0) - // Different byte orderings must produce different hashes - assertNotEquals(hashA.toList(), hashB.toList()) - } -} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt deleted file mode 100644 index cb529ad73f..0000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt +++ /dev/null @@ -1,106 +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.network.radio - -import android.content.Context -import android.hardware.usb.UsbManager -import android.provider.Settings -import org.koin.core.annotation.Single -import org.meshtastic.core.ble.BleConnectionFactory -import org.meshtastic.core.ble.BleScanner -import org.meshtastic.core.ble.BluetoothRepository -import org.meshtastic.core.common.BuildConfigProvider -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.InterfaceId -import org.meshtastic.core.network.repository.UsbRepository -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportFactory - -/** - * Android implementation of [RadioTransportFactory]. Handles pure-KMP transports (BLE) via [BaseRadioTransportFactory] - * while creating platform-specific connections (TCP, USB/Serial, Mock, NOP) directly in [createPlatformTransport]. - */ -@Single(binds = [RadioTransportFactory::class]) -@Suppress("LongParameterList") -class AndroidRadioTransportFactory( - private val context: Context, - private val buildConfigProvider: BuildConfigProvider, - private val usbRepository: UsbRepository, - private val usbManager: UsbManager, - scanner: BleScanner, - bluetoothRepository: BluetoothRepository, - connectionFactory: BleConnectionFactory, - dispatchers: CoroutineDispatchers, -) : BaseRadioTransportFactory(scanner, bluetoothRepository, connectionFactory, dispatchers) { - - override val supportedDeviceTypes: List = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB) - - override fun isMockTransport(): Boolean = - buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true" - - override fun isPlatformAddressValid(address: String): Boolean { - val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } ?: return false - val rest = address.substring(1) - return when (interfaceId) { - InterfaceId.MOCK, - InterfaceId.NOP, - InterfaceId.TCP, - -> true - - InterfaceId.SERIAL -> { - val deviceMap = usbRepository.serialDevices.value - val driver = deviceMap[rest] ?: deviceMap.values.firstOrNull() - driver != null && usbManager.hasPermission(driver.device) - } - - InterfaceId.BLUETOOTH -> true // Handled by base class - } - } - - override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport { - val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } - val rest = address.substring(1) - - return when (interfaceId) { - InterfaceId.MOCK -> MockRadioTransport(callback = service, scope = service.serviceScope, address = rest) - - InterfaceId.TCP -> - TcpRadioTransport( - callback = service, - scope = service.serviceScope, - dispatchers = dispatchers, - address = rest, - ) - - InterfaceId.SERIAL -> - SerialRadioTransport( - callback = service, - scope = service.serviceScope, - usbRepository = usbRepository, - address = rest, - ) - - InterfaceId.NOP, - null, - -> NopRadioTransport(rest) - - InterfaceId.BLUETOOTH -> error("BLE addresses should be handled by BaseRadioTransportFactory") - } - } -} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt deleted file mode 100644 index c8489efd31..0000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt +++ /dev/null @@ -1,140 +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.network.radio - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.network.repository.SerialConnection -import org.meshtastic.core.network.repository.SerialConnectionListener -import org.meshtastic.core.network.repository.UsbRepository -import org.meshtastic.core.network.transport.HeartbeatSender -import org.meshtastic.core.repository.RadioTransportCallback -import java.util.concurrent.atomic.AtomicReference - -/** An Android USB/serial [RadioTransport] implementation. */ -class SerialRadioTransport( - callback: RadioTransportCallback, - scope: CoroutineScope, - private val usbRepository: UsbRepository, - private val address: String, -) : StreamTransport(callback, scope) { - private var connRef = AtomicReference() - - private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$address]") - - override fun start() { - connect() - } - - override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) { - connRef.get()?.close(waitForStopped) - super.onDeviceDisconnect(waitForStopped, isPermanent) - } - - override fun connect() { - val deviceMap = usbRepository.serialDevices.value - val device = deviceMap[address] ?: deviceMap.values.firstOrNull() - if (device == null) { - Logger.e { "[$address] Serial device not found at address" } - } else { - val connectStart = nowMillis - Logger.i { "[$address] Opening serial device: $device" } - - var packetsReceived = 0 - var bytesReceived = 0L - var connectionStartTime = 0L - - val onConnect: () -> Unit = { - connectionStartTime = nowMillis - val connectionTime = connectionStartTime - connectStart - Logger.i { "[$address] Serial device connected in ${connectionTime}ms" } - super.connect() - } - - usbRepository - .createSerialConnection( - device, - object : SerialConnectionListener { - override fun onMissingPermission() { - Logger.e { - "[$address] Serial connection failed - missing USB permissions for device: $device" - } - } - - override fun onConnected() { - onConnect.invoke() - } - - override fun onDataReceived(bytes: ByteArray) { - packetsReceived++ - bytesReceived += bytes.size - Logger.d { - "[$address] Serial received packet #$packetsReceived - " + - "${bytes.size} byte(s) (Total RX: $bytesReceived bytes)" - } - bytes.forEach(::readChar) - } - - override fun onDisconnected(thrown: Exception?) { - val uptime = - if (connectionStartTime > 0) { - nowMillis - connectionStartTime - } else { - 0 - } - thrown?.let { e -> - // USB errors are common when unplugging; log as warning to avoid Crashlytics noise - Logger.w(e) { "[$address] Serial error after ${uptime}ms: ${e.message}" } - } - Logger.w { - "[$address] Serial device disconnected - " + - "Device: $device, " + - "Uptime: ${uptime}ms, " + - "Packets RX: $packetsReceived ($bytesReceived bytes)" - } - // USB unplug / cable error is transient — the transport will reconnect when - // the device is replugged or the OS re-enumerates the port. Only an explicit - // close() (user disconnects) should signal a permanent disconnect. - onDeviceDisconnect(waitForStopped = false, isPermanent = false) - } - }, - ) - .also { conn -> - connRef.set(conn) - conn.connect() - } - } - } - - override fun keepAlive() { - // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the serial - // link is alive and keep the local node's lastHeard timestamp current. - scope.handledLaunch { heartbeatSender.sendHeartbeat() } - } - - override fun sendBytes(p: ByteArray) { - val conn = connRef.get() - if (conn != null) { - Logger.d { "[$address] Serial sending ${p.size} bytes" } - conn.sendBytes(p) - } else { - Logger.w { "[$address] Serial connection not available, cannot send ${p.size} bytes" } - } - } -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt deleted file mode 100644 index 2b40daaa48..0000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt +++ /dev/null @@ -1,80 +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.network.radio - -import org.meshtastic.core.ble.BleConnectionFactory -import org.meshtastic.core.ble.BleScanner -import org.meshtastic.core.ble.BluetoothRepository -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.InterfaceId -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportFactory - -/** - * Common base class for platform [RadioTransportFactory] implementations. Handles KMP-friendly transports (BLE) while - * delegating platform-specific ones (like TCP, USB/Serial and Mocks) to the abstract [createPlatformTransport]. - */ -abstract class BaseRadioTransportFactory( - protected val scanner: BleScanner, - protected val bluetoothRepository: BluetoothRepository, - protected val connectionFactory: BleConnectionFactory, - protected val dispatchers: CoroutineDispatchers, -) : RadioTransportFactory { - - override fun isAddressValid(address: String?): Boolean { - val spec = address?.firstOrNull() ?: return false - return when (spec) { - InterfaceId.TCP.id, - InterfaceId.SERIAL.id, - InterfaceId.BLUETOOTH.id, - InterfaceId.MOCK.id, - '!', - -> true - - else -> isPlatformAddressValid(address) - } - } - - protected open fun isPlatformAddressValid(address: String): Boolean = false - - override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest" - - override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport { - val transport = - when { - address.startsWith(InterfaceId.BLUETOOTH.id) || address.startsWith("!") -> { - val bleAddress = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()).removePrefix("!") - BleRadioTransport( - scope = service.serviceScope, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = bleAddress, - ) - } - - else -> createPlatformTransport(address, service) - } - transport.start() - return transport - } - - /** Delegate to platform for Mock, TCP, or Serial/USB transports. */ - protected abstract fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt deleted file mode 100644 index 95512ecf47..0000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt +++ /dev/null @@ -1,505 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught") - -package org.meshtastic.core.network.radio - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.job -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull -import org.meshtastic.core.ble.BleConnection -import org.meshtastic.core.ble.BleConnectionFactory -import org.meshtastic.core.ble.BleConnectionState -import org.meshtastic.core.ble.BleDevice -import org.meshtastic.core.ble.BleScanner -import org.meshtastic.core.ble.BleWriteType -import org.meshtastic.core.ble.BluetoothRepository -import org.meshtastic.core.ble.DisconnectReason -import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID -import org.meshtastic.core.ble.MeshtasticRadioProfile -import org.meshtastic.core.ble.classifyBleException -import org.meshtastic.core.ble.retryBleOperation -import org.meshtastic.core.ble.toMeshtasticRadioProfile -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.model.RadioNotConnectedException -import org.meshtastic.core.network.transport.HeartbeatSender -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportCallback -import kotlin.concurrent.Volatile -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -private const val SCAN_RETRY_COUNT = 3 -private val SCAN_RETRY_DELAY = 1.seconds -private val CONNECTION_TIMEOUT = 15.seconds - -/** - * Delay after writing a heartbeat before re-polling FROMRADIO. - * - * The ESP32 firmware processes TORADIO writes asynchronously (NimBLE callback → FreeRTOS main task queue → - * `handleToRadio()` → `heartbeatReceived = true`). The immediate drain trigger in - * [KableMeshtasticRadioProfile.sendToRadio] fires before this completes, so the `queueStatus` response is not yet - * available. 200 ms is well above observed ESP32 task scheduling latency (~10–50 ms) while remaining imperceptible to - * the user. - */ -private val HEARTBEAT_DRAIN_DELAY = 200.milliseconds - -private val SCAN_TIMEOUT = 5.seconds -private val GATT_CLEANUP_TIMEOUT = 5.seconds - -/** - * A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable). - * - * This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including: - * - Bonding and discovery. - * - Automatic reconnection logic. - * - MTU and connection parameter monitoring. - * - Routing raw byte packets between the radio and [RadioTransportCallback]. - * - * @param scope The coroutine scope to use for launching coroutines. - * @param scanner The BLE scanner. - * @param bluetoothRepository The Bluetooth repository. - * @param connectionFactory The BLE connection factory. - * @param callback The [RadioTransportCallback] to use for handling radio events. - * @param address The BLE address of the device to connect to. - */ -class BleRadioTransport( - private val scope: CoroutineScope, - private val scanner: BleScanner, - private val bluetoothRepository: BluetoothRepository, - private val connectionFactory: BleConnectionFactory, - private val callback: RadioTransportCallback, - internal val address: String, -) : RadioTransport { - - // Detached cleanup scope for last-ditch GATT teardown from the exception handler. - // Must NOT be a child of `scope`: when an uncaught exception fires in connectionScope, - // upper layers often tear down `scope` immediately. Launching cleanup on `scope` then - // races the cancellation and may never start, leaking BluetoothGatt (status 133 on - // the next reconnect). This scope is cancelled in close() once our own disconnect - // has completed and the safety net is no longer needed. - private val cleanupScope: CoroutineScope = CoroutineScope(SupervisorJob() + scope.coroutineContext.minusKey(Job)) - - private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" } - cleanupScope.launch { - try { - bleConnection.disconnect() - } catch (e: Exception) { - Logger.w(e) { "[$address] Failed to disconnect in exception handler" } - } - } - val (isPermanent, msg) = throwable.toDisconnectReason() - callback.onDisconnect(isPermanent, errorMessage = msg) - } - - private val connectionScope: CoroutineScope = - CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + exceptionHandler) - private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address) - private val writeMutex: Mutex = Mutex() - - @Volatile private var connectionStartTime: Long = 0 - - @Volatile private var packetsReceived: Int = 0 - - @Volatile private var packetsSent: Int = 0 - - @Volatile private var bytesReceived: Long = 0 - - @Volatile private var bytesSent: Long = 0 - - @Volatile private var isFullyConnected = false - private var connectionJob: Job? = null - - // Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService) - // own the explicit-disconnect lifecycle and will close() us when the user picks a different device or - // toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s). - private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE) - - private val heartbeatSender = - HeartbeatSender( - sendToRadio = ::handleSendToRadio, - afterHeartbeat = { - delay(HEARTBEAT_DRAIN_DELAY) - radioService?.requestDrain() - }, - logTag = address, - ) - - override fun start() { - connect() - } - - // --- Connection & Discovery Logic --- - - /** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */ - private suspend fun findDevice(): BleDevice { - bluetoothRepository.state.value.bondedDevices - .firstOrNull { it.address.equals(address, ignoreCase = true) } - ?.let { - return it - } - - Logger.i { "[$address] Device not found in bonded list, scanning" } - - repeat(SCAN_RETRY_COUNT) { attempt -> - try { - val d = - withTimeoutOrNull(SCAN_TIMEOUT) { - // Pass both service UUID and address so the scanner can apply the most - // efficient platform filter. Android uses address (OS-level HW filter), - // while CoreBluetooth (macOS) needs the service UUID because it caches - // peripheral identifiers and may not re-report by address alone. - scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first { - it.address.equals(address, ignoreCase = true) - } - } - if (d != null) return d - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.v(e) { "[$address] Scan attempt failed or timed out" } - } - - if (attempt < SCAN_RETRY_COUNT - 1) { - delay(SCAN_RETRY_DELAY) - } - } - - throw RadioNotConnectedException("Device not found at address $address") - } - - private fun connect() { - connectionJob = - connectionScope.launch { - reconnectPolicy.execute( - attempt = { - try { - attemptConnection() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - val failureTime = (nowMillis - connectionStartTime).milliseconds - Logger.w(e) { "[$address] Failed to connect after $failureTime" } - BleReconnectPolicy.Outcome.Failed(e) - } - }, - onTransientDisconnect = { error -> - val msg = error?.toDisconnectReason()?.second ?: "Device unreachable" - callback.onDisconnect(isPermanent = false, errorMessage = msg) - }, - onPermanentDisconnect = { error -> - val msg = error?.toDisconnectReason()?.second ?: "Device unreachable" - callback.onDisconnect(isPermanent = true, errorMessage = msg) - }, - ) - } - } - - /** - * Performs a single BLE connect-and-wait cycle. - * - * Finds the device, bonds if needed, connects, discovers services, and waits for disconnect. Returns a - * [BleReconnectPolicy.Outcome] describing how the connection ended. - */ - @Suppress("CyclomaticComplexMethod") - private suspend fun attemptConnection(): BleReconnectPolicy.Outcome { - connectionStartTime = nowMillis - Logger.i { "[$address] BLE connection attempt started" } - - val device = findDevice() - - // Bond before connecting: firmware may require an encrypted link, - // and without a bond Android fails with status 5 or 133. - // No-op on Desktop/JVM where the OS handles pairing automatically. - if (!bluetoothRepository.isBonded(address)) { - Logger.i { "[$address] Device not bonded, initiating bonding" } - @Suppress("TooGenericExceptionCaught") - try { - bluetoothRepository.bond(device) - Logger.i { "[$address] Bonding successful" } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" } - } - } - - val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT) - - if (state !is BleConnectionState.Connected) { - throw RadioNotConnectedException("Failed to connect to device at address $address") - } - - val gattConnectedAt = nowMillis - isFullyConnected = true - onConnected() - - discoverServicesAndSetupCharacteristics() - - // Wait for the StateFlow to actually reflect Connected before watching for the next - // Disconnected. connectAndAwait returns synchronously based on the underlying Kable - // peripheral state, but our _connectionState observer runs on a separate coroutine and - // may lag. Without this gate the next .first { Disconnected } below could match the - // *previous* cycle's stale Disconnected value and fire immediately, breaking reconnect. - bleConnection.connectionState.first { it is BleConnectionState.Connected } - - // Suspend until the next Disconnected emission. We deliberately do NOT wrap this in a - // coroutineScope { launchIn(...); first(...) } pattern: launching a hot StateFlow - // collector inside coroutineScope hangs the scope after .first returns (the launched - // collector never completes naturally, and coroutineScope waits for all children). - val disconnectedState = - bleConnection.connectionState.filterIsInstance().first() - val disconnectReason = disconnectedState.reason - if (isFullyConnected) { - isFullyConnected = false - onDisconnected() - } - - Logger.i { "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" } - - val wasIntentional = disconnectReason is DisconnectReason.LocalDisconnect - val connectionUptime = (nowMillis - gattConnectedAt).milliseconds - val wasStable = connectionUptime >= reconnectPolicy.minStableConnection - - if (!wasStable && !wasIntentional) { - Logger.w { - "[$address] Connection lasted only $connectionUptime " + - "(< ${reconnectPolicy.minStableConnection}) — treating as unstable" - } - } - - return BleReconnectPolicy.Outcome.Disconnected(wasStable = wasStable, wasIntentional = wasIntentional) - } - - private suspend fun onConnected() { - try { - bleConnection.deviceFlow.first()?.let { device -> - val rssi = retryBleOperation(tag = address) { device.readRssi() } - Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" } - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Logger.w(e) { "[$address] Failed to read initial connection RSSI" } - } - } - - private fun onDisconnected() { - radioService = null - Logger.i { "[$address] BLE disconnected - ${formatSessionStats()}" } - // Signal immediately so the UI reflects the disconnect while reconnect continues. - callback.onDisconnect(isPermanent = false) - } - - private suspend fun discoverServicesAndSetupCharacteristics() { - try { - bleConnection.profile(serviceUuid = SERVICE_UUID) { service -> - val radioService = service.toMeshtasticRadioProfile() - - radioService.fromRadio - .onEach { packet -> - Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" } - dispatchPacket(packet) - } - .catch { e -> - Logger.w(e) { "[$address] Error in fromRadio flow" } - handleFailure(e) - } - .launchIn(this) - - radioService.logRadio - .onEach { packet -> - Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" } - dispatchPacket(packet) - } - .catch { e -> - Logger.w(e) { "[$address] Error in logRadio flow" } - handleFailure(e) - } - .launchIn(this) - - this@BleRadioTransport.radioService = radioService - - Logger.i { "[$address] Profile service active and characteristics subscribed" } - - // Wait for FROMNUM CCCD write before triggering the Meshtastic handshake. - radioService.awaitSubscriptionReady() - - // Log negotiated MTU for diagnostics - val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE) - Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" } - - // Ask the platform for a low-latency / high-throughput connection interval - // (~7.5 ms on Android). The Meshtastic firmware happily accepts this and it - // materially speeds up the initial config drain and any bulk fromRadio reads. - if (bleConnection.requestHighConnectionPriority()) { - Logger.d { "[$address] Requested high BLE connection priority" } - // Wait for the connection parameter update to succeed before starting the heavy traffic - // in onConnect(). Otherwise, the Android BLE stack may disconnect with GATT 147. - delay(1.seconds) - } - - this@BleRadioTransport.callback.onConnect() - } - } catch (e: CancellationException) { - // Scope was cancelled externally — still ensure GATT cleanup runs so we don't - // leak a BluetoothGatt handle and trigger GATT status 133 on the next attempt. - withContext(NonCancellable) { - try { - bleConnection.disconnect() - } catch (ignored: Exception) { - Logger.w(ignored) { "[$address] disconnect() failed during cancellation cleanup" } - } - } - throw e - } catch (e: Exception) { - Logger.w(e) { "[$address] Profile service discovery or operation failed" } - // Disconnect to let the outer reconnect loop see a clean Disconnected state. - // Do NOT call handleFailure here — the reconnect loop owns failure counting. - withContext(NonCancellable) { - try { - bleConnection.disconnect() - } catch (ignored: Exception) { - Logger.w(ignored) { "[$address] disconnect() failed after profile error" } - } - } - } - } - - @Volatile private var radioService: MeshtasticRadioProfile? = null - - // --- RadioTransport Implementation --- - - /** - * Sends a packet to the radio with retry support. - * - * @param p The packet to send. - */ - override fun handleSendToRadio(p: ByteArray) { - val currentService = radioService - if (currentService != null) { - connectionScope.launch { - writeMutex.withLock { - try { - retryBleOperation(tag = address) { currentService.sendToRadio(p) } - packetsSent++ - bytesSent += p.size - Logger.v { - "[$address] Wrote packet #$packetsSent " + - "to toRadio (${p.size} bytes, total TX: $bytesSent bytes)" - } - } catch (e: Exception) { - Logger.w(e) { - "[$address] Failed to write packet to toRadioCharacteristic after " + - "$packetsSent successful writes" - } - handleFailure(e) - } - } - } - } else { - Logger.w { "[$address] toRadio characteristic unavailable, can't send data" } - } - } - - override fun keepAlive() { - // Delegate to HeartbeatSender which sends a ToRadio heartbeat with a unique nonce - // so the firmware resets its power-saving idle timer. After sending, it schedules - // a delayed re-drain to pick up the queueStatus response. - connectionScope.launch { heartbeatSender.sendHeartbeat() } - } - - /** Closes the connection to the device. */ - override suspend fun close() { - Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" } - connectionScope.cancel() - // GATT cleanup must run under NonCancellable so a cancelled caller cannot skip it, - // which would leak BluetoothGatt and trigger status 133 on the next reconnect. - // Using withContext (not runBlocking) keeps the caller's thread free — this is - // critical when close() is invoked from the main thread during process shutdown. - withContext(NonCancellable) { - try { - withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() } - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "[$address] Failed to disconnect in close()" } - } - } - // Our own disconnect succeeded — the exception-handler safety net is no longer - // needed. Cancel the detached cleanup scope so it doesn't outlive us in tests - // or process lifetime. - cleanupScope.cancel() - } - - private fun dispatchPacket(packet: ByteArray) { - packetsReceived++ - bytesReceived += packet.size - Logger.v { - "[$address] Dispatching packet #$packetsReceived " + - "(${packet.size} bytes, total RX: $bytesReceived bytes)" - } - callback.handleFromRadio(packet) - } - - private fun handleFailure(throwable: Throwable) { - val (isPermanent, msg) = throwable.toDisconnectReason() - callback.onDisconnect(isPermanent, errorMessage = msg) - } - - /** Formats a one-line session statistics summary for logging. */ - private fun formatSessionStats(): String { - val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0 - return "Uptime: ${uptime}ms, " + - "Packets RX: $packetsReceived ($bytesReceived bytes), " + - "Packets TX: $packetsSent ($bytesSent bytes)" - } - - private fun Throwable.toDisconnectReason(): Pair { - classifyBleException()?.let { - return it.isPermanent to it.message - } - - val msg = - when (this) { - is RadioNotConnectedException -> this.message ?: "Device not found" - - is NoSuchElementException, - is IllegalArgumentException, - -> "Required characteristic missing" - - else -> this.message ?: this::class.simpleName ?: "Unknown" - } - return false to msg - } -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt deleted file mode 100644 index 38767a0ef6..0000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt +++ /dev/null @@ -1,186 +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.network.radio - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlin.coroutines.coroutineContext -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds - -/** - * Encapsulates the BLE reconnection policy with exponential backoff. - * - * The policy tracks consecutive failures and decides whether to retry or signal a transient disconnect (DeviceSleep). - * When [maxFailures] is reached the [execute] loop invokes [execute]'s `onPermanentDisconnect` callback and returns; - * set [maxFailures] to [Int.MAX_VALUE] (as [BleRadioTransport] does) to disable the give-up path entirely. - * - * @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely - * @param failureThreshold after this many consecutive failures, signal a transient disconnect - * @param settleDelay delay before each connection attempt to let the BLE stack settle - * @param minStableConnection minimum time a connection must stay up to be considered "stable" - * @param backoffStrategy computes the backoff delay for a given failure count - */ -class BleReconnectPolicy( - private val maxFailures: Int = DEFAULT_MAX_FAILURES, - private val failureThreshold: Int = DEFAULT_FAILURE_THRESHOLD, - private val settleDelay: Duration = DEFAULT_SETTLE_DELAY, - /** Minimum time a connection must stay up to be considered "stable". Exposed for callers to compare uptime. */ - val minStableConnection: Duration = DEFAULT_MIN_STABLE_CONNECTION, - private val backoffStrategy: (attempt: Int) -> Duration = ::computeReconnectBackoff, -) { - /** Outcome of a single reconnect iteration. */ - sealed interface Outcome { - /** Connection attempt succeeded and then eventually disconnected. */ - data class Disconnected(val wasStable: Boolean, val wasIntentional: Boolean) : Outcome - - /** Connection attempt failed with an exception. */ - data class Failed(val error: Throwable) : Outcome - } - - /** Action the caller should take after the policy processes an outcome. */ - sealed interface Action { - /** Retry the connection after the specified backoff delay. */ - data class Retry(val backoff: Duration) : Action - - /** Signal a transient disconnect to higher layers. */ - data class SignalTransient(val backoff: Duration) : Action - - /** Give up permanently. */ - data object GiveUp : Action - - /** Continue immediately (e.g. after an intentional disconnect). */ - data object Continue : Action - } - - internal var consecutiveFailures: Int = 0 - private set - - /** Processes the outcome of a connection attempt and returns the action the caller should take. */ - fun processOutcome(outcome: Outcome): Action = when (outcome) { - is Outcome.Disconnected -> { - if (outcome.wasIntentional) { - consecutiveFailures = 0 - Action.Continue - } else if (outcome.wasStable) { - consecutiveFailures = 0 - Action.Continue - } else { - consecutiveFailures++ - Logger.w { "Unstable connection (consecutive failures: $consecutiveFailures)" } - evaluateFailure() - } - } - - is Outcome.Failed -> { - consecutiveFailures++ - Logger.w { "Connection failed (consecutive failures: $consecutiveFailures)" } - evaluateFailure() - } - } - - private fun evaluateFailure(): Action { - if (consecutiveFailures >= maxFailures) { - return Action.GiveUp - } - val backoff = backoffStrategy(consecutiveFailures) - return if (consecutiveFailures >= failureThreshold) { - Action.SignalTransient(backoff) - } else { - Action.Retry(backoff) - } - } - - /** - * Runs the reconnect loop, calling [attempt] for each iteration. - * - * The [attempt] lambda should perform a single connect-and-wait cycle and return the [Outcome] when the connection - * drops or an error occurs. - * - * @param attempt performs a single connection attempt and returns the outcome - * @param onTransientDisconnect called when the policy decides to signal a transient disconnect - * @param onPermanentDisconnect called when the policy gives up permanently - */ - suspend fun execute( - attempt: suspend () -> Outcome, - onTransientDisconnect: suspend (Throwable?) -> Unit, - onPermanentDisconnect: suspend (Throwable?) -> Unit, - ) { - while (coroutineContext.isActive) { - delay(settleDelay) - - val outcome = attempt() - val lastError = (outcome as? Outcome.Failed)?.error - - when (val action = processOutcome(outcome)) { - is Action.Continue -> continue - - is Action.Retry -> { - Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" } - delay(action.backoff) - } - - is Action.SignalTransient -> { - onTransientDisconnect(lastError) - Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" } - delay(action.backoff) - } - - is Action.GiveUp -> { - Logger.e { "Giving up after $consecutiveFailures consecutive failures" } - onPermanentDisconnect(lastError) - return - } - } - } - } - - companion object { - const val DEFAULT_MAX_FAILURES = 10 - const val DEFAULT_FAILURE_THRESHOLD = 3 - - /** - * Delay applied before every connection attempt (including the first) so the BLE stack and the firmware-side - * GATT session have time to settle. - * - * Empirically validated against the meshtastic-client KMP SDK probes (Apr 2026): with a 1.5 s pause between - * disconnect→reconnect cycles, 3/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the - * firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose - * to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more - * mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same. - */ - val DEFAULT_SETTLE_DELAY = 3.seconds - val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds - - internal val RECONNECT_BASE_DELAY = 5.seconds - internal val RECONNECT_MAX_DELAY = 60.seconds - internal const val BACKOFF_MAX_EXPONENT = 4 - } -} - -/** - * Returns the reconnect backoff delay for a given consecutive failure count. - * - * Backoff schedule: 1 failure → 5 s, 2 failures → 10 s, 3 failures → 20 s, 4 failures → 40 s, 5+ failures → 60 s - * (capped). - */ -internal fun computeReconnectBackoff(consecutiveFailures: Int): Duration { - if (consecutiveFailures <= 0) return BleReconnectPolicy.RECONNECT_BASE_DELAY - val multiplier = 1 shl (consecutiveFailures - 1).coerceAtMost(BleReconnectPolicy.BACKOFF_MAX_EXPONENT) - return minOf(BleReconnectPolicy.RECONNECT_BASE_DELAY * multiplier, BleReconnectPolicy.RECONNECT_MAX_DELAY) -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt deleted file mode 100644 index b94eeffbfd..0000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt +++ /dev/null @@ -1,379 +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.network.radio - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import okio.ByteString.Companion.encodeUtf8 -import okio.ByteString.Companion.toByteString -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.Channel -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.util.getInitials -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportCallback -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.Config -import org.meshtastic.proto.Data -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.DeviceMetrics -import org.meshtastic.proto.FromRadio -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.Neighbor -import org.meshtastic.proto.NeighborInfo -import org.meshtastic.proto.NodeInfo -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.QueueStatus -import org.meshtastic.proto.Routing -import org.meshtastic.proto.StatusMessage -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.ToRadio -import org.meshtastic.proto.User -import kotlin.random.Random -import org.meshtastic.proto.Channel as ProtoChannel -import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo -import org.meshtastic.proto.Position as ProtoPosition - -private val defaultLoRaConfig = Config.LoRaConfig(use_preset = true, region = Config.LoRaConfig.RegionCode.TW) - -private val defaultChannel = ProtoChannel(settings = Channel.default.settings, role = ProtoChannel.Role.PRIMARY) - -/** A simulated transport that is used for testing in the simulator. */ -@Suppress("detekt:TooManyFunctions", "detekt:MagicNumber") -class MockRadioTransport( - private val callback: RadioTransportCallback, - private val scope: CoroutineScope, - val address: String, -) : RadioTransport { - - companion object { - private const val MY_NODE = 0x42424242 - - @Suppress("MagicNumber") - private val FAKE_SESSION_PASSKEY: okio.ByteString = - okio.ByteString.of(0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77) - } - - private var currentPacketId = 50 - - // an infinite sequence of ints - private val packetIdSequence = generateSequence { currentPacketId++ }.iterator() - - override fun start() { - Logger.i { "Starting the mock transport" } - callback.onConnect() // Tell clients they can use the API - } - - override fun handleSendToRadio(p: ByteArray) { - val pr = ToRadio.ADAPTER.decode(p) - - // Intercept want_config handshake — send config response only when requested, - // mirroring the behaviour of real firmware which waits for want_config_id. - val wantConfigId = pr.want_config_id ?: 0 - if (wantConfigId != 0) { - sendConfigResponse(wantConfigId) - return - } - - val packet = pr.packet - if (packet != null) { - sendQueueStatus(packet.id) - } - - val data = packet?.decoded - - when { - data != null && data.portnum == PortNum.ADMIN_APP -> - handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload)) - - packet != null && packet.want_ack == true -> sendFakeAck(pr) - - else -> Logger.i { "Ignoring data sent to mock transport $pr" } - } - } - - private fun handleAdminPacket(pr: ToRadio, d: AdminMessage) { - val packet = pr.packet ?: return - when { - d.get_config_request == AdminMessage.ConfigType.LORA_CONFIG -> - sendAdmin(packet.to, packet.from, packet.id) { - copy(get_config_response = Config(lora = defaultLoRaConfig)) - } - - (d.get_channel_request ?: 0) != 0 -> - sendAdmin(packet.to, packet.from, packet.id) { - copy( - get_channel_response = - ProtoChannel( - index = (d.get_channel_request ?: 0) - 1, // 0 based on the response - settings = if (d.get_channel_request == 1) Channel.default.settings else null, - role = - if (d.get_channel_request == 1) { - ProtoChannel.Role.PRIMARY - } else { - ProtoChannel.Role.DISABLED - }, - ), - ) - } - - d.get_module_config_request == AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG -> - sendAdmin(packet.to, packet.from, packet.id) { - copy( - get_module_config_response = - ModuleConfig( - statusmessage = - ModuleConfig.StatusMessageConfig(node_status = "Going to the farm.. to grow wheat."), - ), - ) - } - - else -> Logger.i { "Ignoring admin sent to mock transport $d" } - } - } - - override suspend fun close() { - Logger.i { "Closing the mock transport" } - } - - // / Generate a fake text message from a node - private fun makeTextMessage(numIn: Int) = FromRadio( - packet = - MeshPacket( - id = packetIdSequence.next(), - from = numIn, - to = 0xffffffff.toInt(), // broadcast - rx_time = nowSeconds.toInt(), - rx_snr = 1.5f, - decoded = - Data( - portnum = PortNum.TEXT_MESSAGE_APP, - payload = "This simulated node sends Hi!".encodeUtf8(), - ), - ), - ) - - private fun makeNeighborInfo(numIn: Int) = FromRadio( - packet = - MeshPacket( - id = packetIdSequence.next(), - from = numIn, - to = 0xffffffff.toInt(), // broadcast - rx_time = nowSeconds.toInt(), - rx_snr = 1.5f, - decoded = - Data( - portnum = PortNum.NEIGHBORINFO_APP, - payload = - NeighborInfo( - node_id = numIn, - last_sent_by_id = numIn, - node_broadcast_interval_secs = 60, - neighbors = - listOf( - Neighbor( - node_id = numIn + 1, - snr = 10.0f, - last_rx_time = nowSeconds.toInt(), - node_broadcast_interval_secs = 60, - ), - Neighbor( - node_id = numIn + 2, - snr = 12.0f, - last_rx_time = nowSeconds.toInt(), - node_broadcast_interval_secs = 60, - ), - ), - ) - .encode() - .toByteString(), - ), - ), - ) - - private fun makePosition(numIn: Int) = FromRadio( - packet = - MeshPacket( - id = packetIdSequence.next(), - from = numIn, - to = 0xffffffff.toInt(), // broadcast - rx_time = nowSeconds.toInt(), - rx_snr = 1.5f, - decoded = - Data( - portnum = PortNum.POSITION_APP, - payload = - ProtoPosition( - latitude_i = org.meshtastic.core.model.Position.degI(32.776665), - longitude_i = org.meshtastic.core.model.Position.degI(-96.796989), - altitude = 150, - time = nowSeconds.toInt(), - precision_bits = 15, - ) - .encode() - .toByteString(), - ), - ), - ) - - private fun makeTelemetry(numIn: Int) = FromRadio( - packet = - MeshPacket( - id = packetIdSequence.next(), - from = numIn, - to = 0xffffffff.toInt(), // broadcast - rx_time = nowSeconds.toInt(), - rx_snr = 1.5f, - decoded = - Data( - portnum = PortNum.TELEMETRY_APP, - payload = - Telemetry( - time = nowSeconds.toInt(), - device_metrics = - DeviceMetrics( - battery_level = 85, - voltage = 4.1f, - channel_utilization = 0.12f, - air_util_tx = 0.05f, - uptime_seconds = 123456, - ), - ) - .encode() - .toByteString(), - ), - ), - ) - - private fun makeNodeStatus(numIn: Int) = FromRadio( - packet = - MeshPacket( - id = packetIdSequence.next(), - from = numIn, - to = 0xffffffff.toInt(), // broadcast - rx_time = nowSeconds.toInt(), - rx_snr = 1.5f, - decoded = - Data( - portnum = PortNum.NODE_STATUS_APP, - payload = - StatusMessage(status = "Going to the farm.. to grow wheat.").encode().toByteString(), - ), - ), - ) - - private fun makeDataPacket(fromIn: Int, toIn: Int, data: Data) = FromRadio( - packet = - MeshPacket( - id = packetIdSequence.next(), - from = fromIn, - to = toIn, - rx_time = nowSeconds.toInt(), - rx_snr = 1.5f, - decoded = data, - ), - ) - - private fun makeAck(fromIn: Int, toIn: Int, msgId: Int) = makeDataPacket( - fromIn, - toIn, - Data(portnum = PortNum.ROUTING_APP, payload = Routing().encode().toByteString(), request_id = msgId), - ) - - private fun sendQueueStatus(msgId: Int) = callback.handleFromRadio( - FromRadio(queueStatus = QueueStatus(res = 0, free = 16, mesh_packet_id = msgId)).encode(), - ) - - private fun sendAdmin(fromIn: Int, toIn: Int, reqId: Int, initFn: AdminMessage.() -> AdminMessage) { - // Embed a deterministic 8-byte fake passkey so SessionManager can record a session refresh — mirrors what real - // firmware always attaches to admin responses (see firmware/src/modules/AdminModule.cpp:1460-1481). - val adminMsg = AdminMessage().initFn().copy(session_passkey = FAKE_SESSION_PASSKEY) - val p = - makeDataPacket( - fromIn, - toIn, - Data(portnum = PortNum.ADMIN_APP, payload = adminMsg.encode().toByteString(), request_id = reqId), - ) - callback.handleFromRadio(p.encode()) - } - - // / Send a fake ack packet back if the sender asked for want_ack - private fun sendFakeAck(pr: ToRadio) = scope.handledLaunch { - val packet = pr.packet ?: return@handledLaunch - delay(2000) - callback.handleFromRadio(makeAck(MY_NODE + 1, packet.from, packet.id).encode()) - } - - private fun sendConfigResponse(configId: Int) { - Logger.d { "Sending mock config response" } - - // / Generate a fake node info entry - @Suppress("MagicNumber") - fun makeNodeInfo(numIn: Int, lat: Double, lon: Double) = FromRadio( - node_info = - NodeInfo( - num = numIn, - user = - User( - id = DataPacket.nodeNumToDefaultId(numIn), - long_name = "Sim ${numIn.toString(16)}", - short_name = getInitials("Sim ${numIn.toString(16)}"), - hw_model = HardwareModel.ANDROID_SIM, - ), - position = - ProtoPosition( - latitude_i = org.meshtastic.core.model.Position.degI(lat), - longitude_i = org.meshtastic.core.model.Position.degI(lon), - altitude = 35, - time = nowSeconds.toInt(), - precision_bits = Random.nextInt(10, 19), - ), - ), - ) - - // Simulated network data to feed to our app - val packets = - arrayOf( - // MyNodeInfo - FromRadio(my_info = ProtoMyNodeInfo(my_node_num = MY_NODE)), - FromRadio( - metadata = DeviceMetadata(firmware_version = "9.9.9.abcdefg", hw_model = HardwareModel.ANDROID_SIM), - ), - - // Fake NodeDB - makeNodeInfo(MY_NODE, 32.776665, -96.796989), // dallas - makeNodeInfo(MY_NODE + 1, 32.960758, -96.733521), // richardson - FromRadio(config = Config(lora = defaultLoRaConfig)), - FromRadio(config = Config(lora = defaultLoRaConfig)), - FromRadio(channel = defaultChannel), - FromRadio(config_complete_id = configId), - - // Done with config response, now pretend to receive some text messages - makeTextMessage(MY_NODE + 1), - makeNeighborInfo(MY_NODE + 1), - makePosition(MY_NODE + 1), - makeTelemetry(MY_NODE + 1), - makeNodeStatus(MY_NODE + 1), - ) - - packets.forEach { p -> callback.handleFromRadio(p.encode()) } - } -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt deleted file mode 100644 index 4ac839d83a..0000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt +++ /dev/null @@ -1,80 +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.network.radio - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.network.transport.StreamFrameCodec -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportCallback - -/** - * An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP - * probably). - * - * Delegates framing logic to [StreamFrameCodec] from `core:network`. - */ -abstract class StreamTransport(protected val callback: RadioTransportCallback, protected val scope: CoroutineScope) : - RadioTransport { - - private val codec = - StreamFrameCodec(onPacketReceived = { callback.handleFromRadio(it) }, logTag = "StreamTransport") - - override suspend fun close() { - Logger.d { "Closing stream for good" } - onDeviceDisconnect(waitForStopped = true, isPermanent = true) - } - - /** - * Signals the transport callback that the device has disconnected and optionally waits for the transport to stop. - * - * @param waitForStopped if true we should wait for the transport to finish - must be false if called from inside - * transport callbacks - * @param isPermanent true only when the user has explicitly disconnected (e.g. [close] was called). USB unplug, I/O - * errors, and similar conditions are transient — the transport may recover when the device is replugged or the OS - * re-enumerates. Defaults to false so callbacks default to "may come back"; [close] passes true explicitly to - * signal a user-initiated terminal disconnect. - */ - protected open fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean = false) { - callback.onDisconnect(isPermanent = isPermanent) - } - - protected open fun connect() { - // Before connecting, send a few START1s to wake a sleeping device - sendBytes(StreamFrameCodec.WAKE_BYTES) - - // Now tell clients they can (finally use the api) - callback.onConnect() - } - - /** Writes raw bytes to the underlying stream (serial port, TCP socket, etc.). */ - abstract fun sendBytes(p: ByteArray) - - /** Flushes buffered bytes to the underlying stream. No-op by default. */ - open fun flushBytes() {} - - override fun handleSendToRadio(p: ByteArray) { - // This method is called from a continuation and it might show up late, so check for uart being null - scope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) } - } - - /** Process a single incoming byte through the stream framing state machine. */ - protected fun readChar(c: Byte) { - codec.processInputByte(c) - } -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt deleted file mode 100644 index 045d3b7ec8..0000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt +++ /dev/null @@ -1,57 +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.network.transport - -import co.touchlab.kermit.Logger -import org.meshtastic.proto.Heartbeat -import org.meshtastic.proto.ToRadio -import kotlin.concurrent.atomics.AtomicInt -import kotlin.concurrent.atomics.ExperimentalAtomicApi - -/** - * Shared heartbeat sender for Meshtastic radio transports. - * - * Constructs and sends a `ToRadio(heartbeat = Heartbeat(nonce = ...))` message to keep the firmware's idle timer from - * expiring. Each call uses a monotonically increasing nonce to prevent the firmware's per-connection duplicate-write - * filter from silently dropping it. - * - * @param sendToRadio callback to transmit the encoded heartbeat bytes to the radio - * @param afterHeartbeat optional suspend callback invoked after sending (e.g. to schedule a drain) - * @param logTag tag for log messages - */ -class HeartbeatSender( - private val sendToRadio: (ByteArray) -> Unit, - private val afterHeartbeat: (suspend () -> Unit)? = null, - private val logTag: String = "HeartbeatSender", -) { - @OptIn(ExperimentalAtomicApi::class) - private val nonce = AtomicInt(0) - - /** - * Sends a heartbeat to the radio. - * - * The firmware responds to heartbeats by queuing a `queueStatus` FromRadio packet, proving the link is alive and - * keeping the local node's lastHeard timestamp current. - */ - @OptIn(ExperimentalAtomicApi::class) - suspend fun sendHeartbeat() { - val n = nonce.fetchAndAdd(1) - Logger.v { "[$logTag] Sending ToRadio heartbeat (nonce=$n)" } - sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)).encode()) - afterHeartbeat?.invoke() - } -} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt deleted file mode 100644 index 31483cb167..0000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt +++ /dev/null @@ -1,153 +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.network.transport - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -/** - * Meshtastic stream framing codec — pure Kotlin, no platform dependencies. - * - * Implements the START1/START2 + 2-byte-length + payload framing protocol used for serial and TCP communication with - * Meshtastic radios. - * - * Shared across Android, Desktop, and iOS via `SharedRadioInterfaceService`. - */ -@Suppress("MagicNumber") -class StreamFrameCodec( - /** Called when a complete packet has been decoded from the byte stream. */ - private val onPacketReceived: (ByteArray) -> Unit, - /** Optional log tag for debug output. */ - private val logTag: String = "StreamCodec", -) { - companion object { - const val START1: Byte = 0x94.toByte() - const val START2: Byte = 0xc3.toByte() - const val MAX_TO_FROM_RADIO_SIZE = 512 - const val HEADER_SIZE = 4 - - /** Default Meshtastic TCP service port. */ - const val DEFAULT_TCP_PORT = 4403 - - /** Wake bytes to send before connecting to rouse a sleeping device. */ - val WAKE_BYTES = byteArrayOf(START1, START1, START1, START1) - } - - private val writeMutex = Mutex() - - // Framing state machine - private var ptr = 0 - private var msb = 0 - private var lsb = 0 - private var packetLen = 0 - private val rxPacket = ByteArray(MAX_TO_FROM_RADIO_SIZE) - private val debugLineBuf = StringBuilder() - - /** - * Process a single incoming byte through the stream framing state machine. - * - * Call this repeatedly with bytes from the transport (serial, TCP, etc). When a complete packet is decoded, - * [onPacketReceived] is invoked. - */ - fun processInputByte(c: Byte) { - var nextPtr = ptr + 1 - - fun lostSync() { - Logger.e { "$logTag: Lost protocol sync" } - nextPtr = 0 - } - - fun deliverPacket() { - val buf = rxPacket.copyOf(packetLen) - onPacketReceived(buf) - nextPtr = 0 - } - - when (ptr) { - 0 -> - if (c != START1) { - debugOut(c) - nextPtr = 0 - } - - 1 -> if (c != START2) lostSync() - - 2 -> msb = c.toInt() and 0xff - - 3 -> { - lsb = c.toInt() and 0xff - packetLen = (msb shl 8) or lsb - if (packetLen > MAX_TO_FROM_RADIO_SIZE) { - lostSync() - } else if (packetLen == 0) { - deliverPacket() - } - } - - else -> { - rxPacket[ptr - HEADER_SIZE] = c - if (ptr - HEADER_SIZE + 1 == packetLen) { - deliverPacket() - } - } - } - ptr = nextPtr - } - - /** - * Frames a payload into the Meshtastic stream protocol format: [START1][START2][MSB len][LSB len][payload]. - * - * Thread-safe via an internal mutex — multiple callers can call this concurrently. - */ - suspend fun frameAndSend(payload: ByteArray, sendBytes: (ByteArray) -> Unit, flush: () -> Unit = {}) { - writeMutex.withLock { - val header = ByteArray(HEADER_SIZE) - header[0] = START1 - header[1] = START2 - header[2] = (payload.size shr 8).toByte() - header[3] = (payload.size and 0xff).toByte() - - sendBytes(header) - sendBytes(payload) - flush() - } - } - - /** Resets the framing state machine. Call when reconnecting. */ - fun reset() { - ptr = 0 - msb = 0 - lsb = 0 - packetLen = 0 - debugLineBuf.clear() - } - - /** Print device serial debug output to the logger. */ - private fun debugOut(b: Byte) { - when (val c = b.toInt().toChar()) { - '\r' -> {} - - '\n' -> { - Logger.d { "$logTag DeviceLog: $debugLineBuf" } - debugLineBuf.clear() - } - - else -> debugLineBuf.append(c) - } - } -} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt deleted file mode 100644 index c1835e7881..0000000000 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt +++ /dev/null @@ -1,331 +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.network.radio - -import dev.mokkery.MockMode -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.matcher.any -import dev.mokkery.mock -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.ble.BleConnection -import org.meshtastic.core.ble.BleConnectionFactory -import org.meshtastic.core.ble.BleConnectionState -import org.meshtastic.core.ble.BleDevice -import org.meshtastic.core.ble.BleService -import org.meshtastic.core.ble.BleWriteType -import org.meshtastic.core.ble.DisconnectReason -import org.meshtastic.core.testing.FakeBleConnection -import org.meshtastic.core.testing.FakeBleConnectionFactory -import org.meshtastic.core.testing.FakeBleDevice -import org.meshtastic.core.testing.FakeBleScanner -import org.meshtastic.core.testing.FakeBluetoothRepository -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertTrue -import kotlin.time.Duration - -/** - * Tests covering the BLE reconnect crash fixes in [BleRadioTransport]: - * 1. **CancellationException / GATT 133 fix**: [discoverServicesAndSetupCharacteristics] previously had a bare `catch - * (e: Exception)` that silently swallowed [CancellationException], meaning [BleConnection.disconnect] was never - * called when the scope was cancelled. This leaked the underlying BluetoothGatt handle and caused GATT status 133 on - * every subsequent reconnect. The fix adds an explicit `if (e is CancellationException)` branch that calls - * [disconnect] under [NonCancellable] before re-throwing. - * 2. **close() calls disconnect**: Verifies that calling [BleRadioTransport.close] triggers [BleConnection.disconnect] - * exactly once so the GATT handle is always released. - * 3. **Reconnect after failure respects policy backoff**: After a configurable number of consecutive failures the - * transport signals a transient (non-permanent) disconnect to the callback. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class BleRadioTransportReconnectCrashTest { - - private val scanner = FakeBleScanner() - private val bluetoothRepository = FakeBluetoothRepository() - private val connection = FakeBleConnection() - private val connectionFactory = FakeBleConnectionFactory(connection) - private val service = mock(MockMode.autofill) - private val address = "AA:BB:CC:DD:EE:FF" - - @BeforeTest - fun setup() { - bluetoothRepository.setHasPermissions(true) - bluetoothRepository.setBluetoothEnabled(true) - } - - // ─── close() triggers disconnect ───────────────────────────────────────────────────────────── - - /** - * After [BleRadioTransport.close], [FakeBleConnection.disconnect] must be called. - * - * This validates the primary invariant introduced by the fix: GATT cleanup (disconnect) always runs — even when the - * coroutine scope is cancelled — by wrapping the call in [NonCancellable]. - */ - @Test - fun `close calls disconnect to clean up GATT handle`() = runTest { - val device = FakeBleDevice(address = address, name = "Test Radio") - bluetoothRepository.bond(device) - - val bleTransport = - BleRadioTransport( - scope = this, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = address, - ) - bleTransport.start() - - // Allow the connection loop to reach the connected state. - advanceTimeBy(4_000L) - - bleTransport.close() - - // disconnect() must be called: once by the connection loop teardown + once by close() itself. - // We only assert it was called at least once — the exact count depends on timing. - assertTrue(connection.disconnectCalls >= 1, "Expected disconnect() to be called at least once") - } - - // ─── disconnect called on connection failure ────────────────────────────────────────────────── - - /** - * When [FakeBleConnection.connectAndAwait] always returns [BleConnectionState.Disconnected], the transport must - * still eventually call [BleConnection.disconnect] to ensure the GATT handle state machine is reset before the next - * attempt. - * - * Virtual-time budget: DEFAULT_FAILURE_THRESHOLD (3) × (3 s settle + backoff) ≈ 24 s. - */ - @Test - fun `disconnect is called on connection failure`() = runTest { - val device = FakeBleDevice(address = address, name = "Test Radio") - bluetoothRepository.bond(device) - - // Make every connection attempt fail. - connection.failNextN = Int.MAX_VALUE - - val bleTransport = - BleRadioTransport( - scope = this, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = address, - ) - bleTransport.start() - - advanceTimeBy(30_000L) - - bleTransport.close() - - // Each failed connectAndAwait round-trips through the reconnect loop; close() always disconnects. - assertTrue(connection.disconnectCalls >= 1, "disconnect() not called after connection failure") - } - - // ─── transient onDisconnect after failure threshold ────────────────────────────────────────── - - /** - * Mirrors [BleRadioTransportTest.`onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`] but - * focuses specifically on the *reconnect* scenario introduced by the fix: after enough consecutive failures, the - * callback receives `isPermanent = false` — the transport keeps retrying rather than giving up permanently. - * - * Virtual time: 3 failures × (3 s settle + backoff starting at 5 s) ≈ 24 s. - */ - @Test - fun `transient onDisconnect is signalled after failure threshold without giving up`() = runTest { - val device = FakeBleDevice(address = address, name = "Test Radio") - bluetoothRepository.bond(device) - - connection.connectException = org.meshtastic.core.model.RadioNotConnectedException("simulated GATT failure") - - every { service.onDisconnect(any(), any()) } returns Unit - - val bleTransport = - BleRadioTransport( - scope = this, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = address, - ) - bleTransport.start() - - advanceTimeBy(24_001L) - - // Transient disconnect must have been signalled. - dev.mokkery.verify { service.onDisconnect(isPermanent = false, errorMessage = any()) } - // Permanent disconnect must NEVER be called by the transport on its own. - dev.mokkery.verify(mode = dev.mokkery.verify.VerifyMode.not) { - service.onDisconnect(isPermanent = true, errorMessage = any()) - } - - bleTransport.close() - } - - // ─── CancellationException is not silently swallowed ───────────────────────────────────────── - - /** - * [BleRadioTransport.close] cancels the [connectionScope]. The cancellation propagates as a [CancellationException] - * through the active coroutines in [discoverServicesAndSetupCharacteristics]. - * - * Before the fix, `catch (e: Exception)` swallowed the [CancellationException] and the `disconnect()` call was - * skipped. After the fix, [disconnect] is called under [NonCancellable]. - * - * This test uses a dedicated fake that throws [CancellationException] from [BleConnection.profile] to simulate the - * scope-cancellation path without races. - */ - @Test - fun `disconnect is called when profile setup throws CancellationException`() = runTest { - val throwingConnection = CancellingProfileBleConnection() - val throwingFactory = - object : BleConnectionFactory { - override fun create(scope: CoroutineScope, tag: String): BleConnection = throwingConnection - } - val device = FakeBleDevice(address = address, name = "Test Radio") - bluetoothRepository.bond(device) - - val bleTransport = - BleRadioTransport( - scope = this, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = throwingFactory, - callback = service, - address = address, - ) - bleTransport.start() - - // Allow one connection attempt to reach profile() and be cancelled. - advanceTimeBy(4_000L) - - bleTransport.close() - - assertTrue( - throwingConnection.disconnectCalls >= 1, - "disconnect() must be called after CancellationException in profile() — GATT leak fix", - ) - } - - // ─── Reconnect after a stable connection drops ─────────────────────────────────────────────── - - /** - * Regression test for the BLE reconnect hang. - * - * Symptom: after a stable connection (uptime > minStableConnection) was terminated by a remote disconnect (e.g. - * node power-cycle), the transport's reconnect loop never iterated — `attemptConnection` ran exactly once, the GATT - * disconnect callback fired, and then nothing. - * - * Root cause: `attemptConnection` wrapped its disconnect-watcher in a `coroutineScope { - * connectionState.onEach{...}.launchIn(this); connectionState.first { Disconnected } }` block. `coroutineScope` - * waits for ALL launched children before returning, but the `.launchIn` collector on a hot `StateFlow` (or - * `SharedFlow(replay=1)`) never completes naturally. After `.first` returned, the scope hung forever, blocking - * `BleReconnectPolicy.execute` from issuing the next attempt. - * - * This test exercises the full happy-path reconnect cycle: connect → stable uptime → external disconnect → expect a - * second `connectAndAwait` call. With the bug present, only one `connectAndAwait` call ever happens. - */ - @Test - fun `transport reconnects after a stable connection is dropped remotely`() = runTest { - val device = FakeBleDevice(address = address, name = "Test Radio") - bluetoothRepository.bond(device) - - val bleTransport = - BleRadioTransport( - scope = this, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = address, - ) - bleTransport.start() - - // Settle delay (3 s) + connect + handshake. - advanceTimeBy(4_000L) - assertTrue(connection.connectAndAwaitCalls == 1, "First connect must happen during initial start window") - - // Stay connected long enough to be considered stable (> minStableConnection = 5 s). - advanceTimeBy(10_000L) - - // Simulate the firmware dying mid-session — the same path a node power-cycle takes. - connection.simulateRemoteDisconnect(reason = DisconnectReason.Timeout) - - // Settle delay (3 s) before the next attempt + re-connect window. Generous to absorb - // the policy retry backoff (5 s on first failure) plus another 3 s settle delay. - advanceTimeBy(30_000L) - - assertTrue( - connection.connectAndAwaitCalls >= 2, - "Reconnect loop must call connectAndAwait again after a remote disconnect " + - "(actual calls: ${connection.connectAndAwaitCalls})", - ) - - bleTransport.close() - } -} - -// ─── Test doubles ──────────────────────────────────────────────────────────────────────────────── - -/** - * A [BleConnection] that succeeds at [connectAndAwait] but throws [CancellationException] from [profile]. This - * simulates what happens when the owning coroutine scope is cancelled while GATT service discovery is in progress. - */ -private class CancellingProfileBleConnection : BleConnection { - - private val _deviceFlow = MutableStateFlow(null) - override val deviceFlow: StateFlow = _deviceFlow.asStateFlow() - - private val _connectionState = MutableStateFlow(BleConnectionState.Disconnected()) - override val connectionState: StateFlow = _connectionState.asStateFlow() - - override val device: BleDevice? = null - - var disconnectCalls = 0 - - override suspend fun connect(device: BleDevice) { - _deviceFlow.value = device - _connectionState.value = BleConnectionState.Connected - } - - override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState { - connect(device) - return BleConnectionState.Connected - } - - override suspend fun disconnect() { - disconnectCalls++ - _connectionState.value = BleConnectionState.Disconnected() - _deviceFlow.value = null - } - - override suspend fun profile( - serviceUuid: kotlin.uuid.Uuid, - timeout: Duration, - setup: suspend CoroutineScope.(BleService) -> T, - ): T = throw CancellationException("Simulated scope cancellation during service discovery") - - override fun maximumWriteValueLength(writeType: BleWriteType): Int? = null -} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt deleted file mode 100644 index 09e5ede0a8..0000000000 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt +++ /dev/null @@ -1,199 +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.network.radio - -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.verify.VerifyMode -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID -import org.meshtastic.core.model.RadioNotConnectedException -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.testing.FakeBleConnection -import org.meshtastic.core.testing.FakeBleConnectionFactory -import org.meshtastic.core.testing.FakeBleDevice -import org.meshtastic.core.testing.FakeBleScanner -import org.meshtastic.core.testing.FakeBluetoothRepository -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -@OptIn(ExperimentalCoroutinesApi::class) -class BleRadioTransportTest { - - private val testScope = TestScope() - private val scanner = FakeBleScanner() - private val bluetoothRepository = FakeBluetoothRepository() - private val connection = FakeBleConnection() - private val connectionFactory = FakeBleConnectionFactory(connection) - private val service: RadioInterfaceService = mock(MockMode.autofill) - private val address = "00:11:22:33:44:55" - - @BeforeTest - fun setup() { - bluetoothRepository.setHasPermissions(true) - bluetoothRepository.setBluetoothEnabled(true) - } - - @Test - fun `connect attempts to scan and connect via start`() = runTest { - val device = FakeBleDevice(address = address, name = "Test Device") - scanner.emitDevice(device) - - val bleTransport = - BleRadioTransport( - scope = testScope, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = address, - ) - bleTransport.start() - - // start() begins connect() which is async - // In a real test we'd verify the connection state, - // but for now this confirms it works with the fakes. - assertEquals(address, bleTransport.address) - } - - @Test - fun `address returns correct value`() { - val bleTransport = - BleRadioTransport( - scope = testScope, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = address, - ) - assertEquals(address, bleTransport.address) - } - - /** - * After [BleReconnectPolicy.DEFAULT_FAILURE_THRESHOLD] consecutive connection failures, - * [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep - * timeout in [MeshConnectionManagerImpl]). - * - * Virtual-time breakdown (DEFAULT_FAILURE_THRESHOLD = 3, DEFAULT_SETTLE_DELAY = 3 s): t = 3 000 ms — iteration 1 - * settle delay elapses, connectAndAwait throws, backoff 5 s starts t = 8 000 ms — backoff ends t = 11 000 ms — - * iteration 2 settle delay elapses, connectAndAwait throws, backoff 10 s starts t = 21 000 ms — backoff ends t = 24 - * 000 ms — iteration 3 settle delay elapses, connectAndAwait throws → onDisconnect called - */ - @Test - fun `onDisconnect is called after DEFAULT_FAILURE_THRESHOLD consecutive failures`() = runTest { - val device = FakeBleDevice(address = address, name = "Test Device") - bluetoothRepository.bond(device) // skip BLE scan — device is already bonded - - // Make every connectAndAwait call throw so each iteration counts as one failure. - connection.connectException = RadioNotConnectedException("simulated failure") - - val bleTransport = - BleRadioTransport( - scope = this, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = address, - ) - bleTransport.start() - - // Advance through exactly 3 failure iterations (≈24 001 ms virtual time). - // The 4th iteration's backoff hasn't elapsed yet, so the coroutine is suspended - // and advanceTimeBy returns cleanly. - advanceTimeBy(24_001L) - - verify { service.onDisconnect(any(), any()) } - - // Cancel the reconnect loop so runTest can complete. - bleTransport.close() - } - - /** - * Reconnect policy must NEVER give up on its own. The transport is only ever instantiated for the user-selected - * device, and explicit-disconnect is owned by the service layer (close()). Even after a sustained failure storm — - * well beyond the legacy [BleReconnectPolicy.DEFAULT_MAX_FAILURES] — the transport must keep retrying and must - * never call `onDisconnect(isPermanent = true)` from the give-up path. - * - * Time budget for 15 failures with bonded device (no scan): each iteration ≈ 3 s settle + immediate throw + - * backoff. Backoffs cap at 60 s after failure 5: 5+10+20+40+60+60+60+60+60+60+60+60+60+60+60 = 735 s, plus 15×3 s - * settle = 45 s, total ≈ 780 s. Use 800_000 ms to cover variance. - */ - @Test - fun `reconnect loop never gives up - no permanent disconnect from policy`() = runTest { - val device = FakeBleDevice(address = address, name = "Test Device") - bluetoothRepository.bond(device) - - connection.connectException = RadioNotConnectedException("simulated failure") - every { service.onDisconnect(any(), any()) } returns Unit - - val bleTransport = - BleRadioTransport( - scope = this, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = address, - ) - bleTransport.start() - - // Run well past where the legacy policy (maxFailures = 10) would have given up. - advanceTimeBy(800_001L) - - // Transient disconnects (isPermanent = false) are expected once the failure threshold is hit; - // the policy must NEVER signal a permanent disconnect on its own. Only explicit close() - // (verified separately by the service layer) may emit isPermanent = true. - verify(mode = VerifyMode.not) { service.onDisconnect(isPermanent = true, errorMessage = any()) } - - bleTransport.close() - } - - @Test - fun `findDevice scans with both service UUID and address`() = runTest { - val device = FakeBleDevice(address = address, name = "Test Device") - scanner.emitDevice(device) - - val bleTransport = - BleRadioTransport( - scope = this, - scanner = scanner, - bluetoothRepository = bluetoothRepository, - connectionFactory = connectionFactory, - callback = service, - address = address, - ) - bleTransport.start() - advanceTimeBy(3_001) - - assertNotNull(scanner.lastScanServiceUuid, "scan must include serviceUuid") - assertEquals(SERVICE_UUID, scanner.lastScanServiceUuid) - assertEquals(address, scanner.lastScanAddress) - - bleTransport.close() - } -} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt deleted file mode 100644 index a6a7aa82cb..0000000000 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt +++ /dev/null @@ -1,277 +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.network.radio - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class BleReconnectPolicyTest { - - @Test - fun `stable disconnect resets failures and returns Continue`() { - val policy = BleReconnectPolicy() - // Simulate one prior failure - policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertEquals(1, policy.consecutiveFailures) - - // Now a stable disconnect should reset - val action = - policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false)) - assertEquals(BleReconnectPolicy.Action.Continue, action) - assertEquals(0, policy.consecutiveFailures) - } - - @Test - fun `intentional disconnect resets failures and returns Continue`() { - val policy = BleReconnectPolicy() - policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - - val action = - policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = true)) - assertEquals(BleReconnectPolicy.Action.Continue, action) - assertEquals(0, policy.consecutiveFailures) - } - - @Test - fun `unstable disconnect increments failures`() { - val policy = BleReconnectPolicy() - val action = - policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = false)) - assertEquals(1, policy.consecutiveFailures) - assertTrue(action is BleReconnectPolicy.Action.Retry) - } - - @Test - fun `failure at threshold signals transient disconnect`() { - val policy = BleReconnectPolicy(failureThreshold = 3) - // Accumulate failures up to threshold - repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertEquals(3, policy.consecutiveFailures) - assertTrue(action is BleReconnectPolicy.Action.SignalTransient) - } - - @Test - fun `failure at max gives up permanently`() { - val policy = BleReconnectPolicy(maxFailures = 3) - repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertEquals(BleReconnectPolicy.Action.GiveUp, action) - } - - @Test - fun `backoff increases with consecutive failures`() { - val policy = BleReconnectPolicy() - val backoffs = - (1..5).map { i -> - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - when (action) { - is BleReconnectPolicy.Action.Retry -> action.backoff - is BleReconnectPolicy.Action.SignalTransient -> action.backoff - else -> error("Unexpected action: $action") - } - } - // Verify backoffs are non-decreasing - for (i in 0 until backoffs.size - 1) { - assertTrue(backoffs[i] <= backoffs[i + 1], "Expected ${backoffs[i]} <= ${backoffs[i + 1]}") - } - } - - @Test - fun `custom backoff strategy is used`() { - val customBackoff = 42.seconds - val policy = BleReconnectPolicy(backoffStrategy = { customBackoff }) - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertTrue(action is BleReconnectPolicy.Action.Retry) - assertEquals(customBackoff, action.backoff) - } - - @Test - fun `maxFailures equal to failureThreshold gives up without signalling transient`() { - val policy = BleReconnectPolicy(maxFailures = 3, failureThreshold = 3) - repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } - val action = policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - // GiveUp takes priority over SignalTransient when both thresholds are the same - assertEquals(BleReconnectPolicy.Action.GiveUp, action) - } - - @Test - fun `failure count resets after stable disconnect then re-increments`() { - val policy = BleReconnectPolicy() - // Accumulate two failures - repeat(2) { policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) } - assertEquals(2, policy.consecutiveFailures) - - // Stable disconnect resets - policy.processOutcome(BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false)) - assertEquals(0, policy.consecutiveFailures) - - // New failure starts from 1 - policy.processOutcome(BleReconnectPolicy.Outcome.Failed(RuntimeException("test"))) - assertEquals(1, policy.consecutiveFailures) - } - - // region execute() loop tests - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute gives up after maxFailures and calls onPermanentDisconnect`() = runTest { - val policy = - BleReconnectPolicy(maxFailures = 3, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds }) - var permanentError: Throwable? = null - var permanentCalled = false - var transientCalled = false - - policy.execute( - attempt = { BleReconnectPolicy.Outcome.Failed(RuntimeException("connection failed")) }, - onTransientDisconnect = { transientCalled = true }, - onPermanentDisconnect = { error -> - permanentCalled = true - permanentError = error - }, - ) - - assertTrue(permanentCalled, "onPermanentDisconnect should have been called") - assertNotNull(permanentError, "error should be passed to onPermanentDisconnect") - assertEquals("connection failed", permanentError?.message) - assertEquals(3, policy.consecutiveFailures) - // failureThreshold defaults to 3, same as maxFailures here, so GiveUp takes priority - assertTrue(!transientCalled, "onTransientDisconnect should not be called when GiveUp fires first") - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute calls onTransientDisconnect at threshold then continues retrying`() = runTest { - var attemptCount = 0 - val policy = - BleReconnectPolicy( - maxFailures = 5, - failureThreshold = 2, - settleDelay = 1.milliseconds, - backoffStrategy = { 1.milliseconds }, - ) - var transientCount = 0 - - policy.execute( - attempt = { - attemptCount++ - BleReconnectPolicy.Outcome.Failed(RuntimeException("fail #$attemptCount")) - }, - onTransientDisconnect = { transientCount++ }, - onPermanentDisconnect = {}, - ) - - assertEquals(5, attemptCount, "should attempt exactly maxFailures times") - // Transient is signalled for failures 2, 3, 4 (at or above threshold, below maxFailures) - assertEquals(3, transientCount, "should signal transient for each failure at or above threshold") - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute continues immediately after stable disconnect`() = runTest { - var attemptCount = 0 - val policy = - BleReconnectPolicy(maxFailures = 5, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds }) - - policy.execute( - attempt = { - attemptCount++ - if (attemptCount <= 2) { - // First two attempts connect briefly and disconnect stably - BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false) - } else { - // Then fail until maxFailures - BleReconnectPolicy.Outcome.Failed(RuntimeException("fail")) - } - }, - onTransientDisconnect = {}, - onPermanentDisconnect = {}, - ) - - // 2 stable disconnects + 5 failures (counter resets after each stable, so needs 5 more to hit max) - assertEquals(7, attemptCount) - assertEquals(5, policy.consecutiveFailures) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute passes null error for unstable disconnect at threshold`() = runTest { - val policy = - BleReconnectPolicy( - maxFailures = 5, - failureThreshold = 2, - settleDelay = 1.milliseconds, - backoffStrategy = { 1.milliseconds }, - ) - val transientErrors = mutableListOf() - var attemptCount = 0 - - policy.execute( - attempt = { - attemptCount++ - // Use unstable disconnects (not Failed) so lastError is null - BleReconnectPolicy.Outcome.Disconnected(wasStable = false, wasIntentional = false) - }, - onTransientDisconnect = { error -> transientErrors.add(error) }, - onPermanentDisconnect = {}, - ) - - // Disconnected outcomes don't have errors, so all transient callbacks get null - assertTrue(transientErrors.all { it == null }, "Disconnected outcomes should pass null error") - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `execute stops when coroutine is cancelled`() = runTest { - var attemptCount = 0 - val policy = - BleReconnectPolicy(maxFailures = 100, settleDelay = 1.milliseconds, backoffStrategy = { 1.milliseconds }) - - val job = - backgroundScope.launch { - policy.execute( - attempt = { - attemptCount++ - // Always succeed stably — loop should run until cancelled - BleReconnectPolicy.Outcome.Disconnected(wasStable = true, wasIntentional = false) - }, - onTransientDisconnect = {}, - onPermanentDisconnect = {}, - ) - } - - // Let a few iterations run, then cancel - advanceTimeBy(50) - job.cancel() - advanceUntilIdle() - - // Should have made some attempts but not reached maxFailures - assertTrue(attemptCount > 0, "should have attempted at least once") - assertTrue(attemptCount < 100, "should not have exhausted all failures — was cancelled") - } - - // endregion -} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt deleted file mode 100644 index f3514c752a..0000000000 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt +++ /dev/null @@ -1,75 +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.network.radio - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import kotlin.time.Duration.Companion.seconds - -/** - * Tests the exponential backoff schedule used by [BleRadioTransport] when consecutive connection attempts fail. The - * schedule is: failure #1 → 5 s failure #2 → 10 s failure #3 → 20 s failure #4 → 40 s failure #5+ → 60 s (capped) - */ -class ReconnectBackoffTest { - - @Test - fun `zero failures yields base delay`() { - assertEquals(5.seconds, computeReconnectBackoff(0)) - } - - @Test - fun `first failure yields 5s`() { - assertEquals(5.seconds, computeReconnectBackoff(1)) - } - - @Test - fun `second failure yields 10s`() { - assertEquals(10.seconds, computeReconnectBackoff(2)) - } - - @Test - fun `third failure yields 20s`() { - assertEquals(20.seconds, computeReconnectBackoff(3)) - } - - @Test - fun `fourth failure yields 40s`() { - assertEquals(40.seconds, computeReconnectBackoff(4)) - } - - @Test - fun `fifth failure is capped at 60s`() { - assertEquals(60.seconds, computeReconnectBackoff(5)) - } - - @Test - fun `large failure count stays capped at 60s`() { - assertEquals(60.seconds, computeReconnectBackoff(100)) - } - - @Test - fun `backoff is strictly increasing up to the cap`() { - val values = (1..5).map { computeReconnectBackoff(it) } - for (i in 0 until values.size - 1) { - assertTrue( - values[i] < values[i + 1], - "Expected backoff[${i + 1}] (${values[i]}) < backoff[${i + 2}] (${values[i + 1]})", - ) - } - } -} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt deleted file mode 100644 index 6faa692173..0000000000 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt +++ /dev/null @@ -1,87 +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.network.radio - -import dev.mokkery.MockMode -import dev.mokkery.mock -import dev.mokkery.verify -import io.kotest.property.Arb -import io.kotest.property.arbitrary.byte -import io.kotest.property.arbitrary.byteArray -import io.kotest.property.arbitrary.int -import io.kotest.property.checkAll -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.network.transport.StreamFrameCodec -import org.meshtastic.core.repository.RadioTransportCallback -import kotlin.test.Test -import kotlin.test.assertTrue - -class StreamTransportTest { - - private val callback: RadioTransportCallback = mock(MockMode.autofill) - private lateinit var fakeStream: FakeStreamTransport - - class FakeStreamTransport(callback: RadioTransportCallback, scope: TestScope) : StreamTransport(callback, scope) { - val sentBytes = mutableListOf() - - override fun sendBytes(p: ByteArray) { - sentBytes.add(p) - } - - override fun flushBytes() { - /* no-op */ - } - - override fun keepAlive() { - /* no-op */ - } - - fun feed(b: Byte) = readChar(b) - - public override fun connect() = super.connect() - } - - private val testScope = TestScope() - - @Test - fun `handleSendToRadio property test`() = runTest { - fakeStream = FakeStreamTransport(callback, testScope) - - checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> fakeStream.handleSendToRadio(payload) } - } - - @Test - fun `readChar property test`() = runTest { - fakeStream = FakeStreamTransport(callback, testScope) - - checkAll(Arb.byteArray(Arb.int(0, 100), Arb.byte())) { data -> - data.forEach { fakeStream.feed(it) } - // Ensure no crash - } - } - - @Test - fun `connect sends wake bytes`() { - fakeStream = FakeStreamTransport(callback, testScope) - fakeStream.connect() - - assertTrue(fakeStream.sentBytes.isNotEmpty()) - assertTrue(fakeStream.sentBytes[0].contentEquals(StreamFrameCodec.WAKE_BYTES)) - verify { callback.onConnect() } - } -} diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt deleted file mode 100644 index 5313fd17a0..0000000000 --- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt +++ /dev/null @@ -1,187 +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.network.transport - -import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe -import io.kotest.property.Arb -import io.kotest.property.arbitrary.byte -import io.kotest.property.arbitrary.byteArray -import io.kotest.property.arbitrary.int -import io.kotest.property.checkAll -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class StreamFrameCodecTest { - - private val receivedPackets = mutableListOf() - private val codec = StreamFrameCodec(onPacketReceived = { receivedPackets.add(it) }, logTag = "Test") - - @Test - fun `processInputByte delivers a 1-byte packet`() { - val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x42) - - packet.forEach { codec.processInputByte(it) } - - assertEquals(1, receivedPackets.size) - assertEquals(listOf(0x42.toByte()), receivedPackets[0].toList()) - } - - @Test - fun `processInputByte handles zero length packet`() { - val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x00) - - packet.forEach { codec.processInputByte(it) } - - assertEquals(1, receivedPackets.size) - assertTrue(receivedPackets[0].isEmpty()) - } - - @Test - fun `processInputByte loses sync on invalid START2`() { - // START1, wrong START2, START1, START2, LenMSB=0, LenLSB=1, payload - val data = byteArrayOf(0x94.toByte(), 0x00, 0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x55) - - data.forEach { codec.processInputByte(it) } - - assertEquals(1, receivedPackets.size) - assertEquals(listOf(0x55.toByte()), receivedPackets[0].toList()) - } - - @Test - fun `frameAndSend and processInputByte are inverse`() = runTest { - checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> - var received: ByteArray? = null - val codec = StreamFrameCodec(onPacketReceived = { received = it }) - - val bytes = mutableListOf() - codec.frameAndSend(payload, sendBytes = { bytes.add(it) }) - - bytes.forEach { arr -> arr.forEach { codec.processInputByte(it) } } - - received.shouldNotBeNull() - received.shouldBe(payload) - } - } - - @Test - fun `processInputByte is robust against random noise`() = runTest { - checkAll(Arb.byteArray(Arb.int(0, 1000), Arb.byte())) { noise -> - val codec = StreamFrameCodec(onPacketReceived = { /* ignore */ }) - noise.forEach { codec.processInputByte(it) } - // Should not crash - } - } - - @Test - fun `processInputByte handles multiple packets sequentially`() { - val packet1 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x11) - val packet2 = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0x22) - - packet1.forEach { codec.processInputByte(it) } - packet2.forEach { codec.processInputByte(it) } - - assertEquals(2, receivedPackets.size) - assertEquals(listOf(0x11.toByte()), receivedPackets[0].toList()) - assertEquals(listOf(0x22.toByte()), receivedPackets[1].toList()) - } - - @Test - fun `processInputByte handles large packet up to MAX_TO_FROM_RADIO_SIZE`() { - val size = 512 - val payload = ByteArray(size) { it.toByte() } - val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), (size shr 8).toByte(), (size and 0xff).toByte()) - - header.forEach { codec.processInputByte(it) } - payload.forEach { codec.processInputByte(it) } - - assertEquals(1, receivedPackets.size) - assertEquals(payload.toList(), receivedPackets[0].toList()) - } - - @Test - fun `processInputByte loses sync on overly large packet length`() { - // 513 bytes is > 512 - val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x02, 0x01) - - header.forEach { codec.processInputByte(it) } - - assertTrue(receivedPackets.isEmpty()) - } - - @Test - fun `processInputByte handles multi-byte payload`() { - val payload = byteArrayOf(0x01, 0x02, 0x03, 0x04, 0x05) - val header = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x05) - - header.forEach { codec.processInputByte(it) } - payload.forEach { codec.processInputByte(it) } - - assertEquals(1, receivedPackets.size) - assertEquals(payload.toList(), receivedPackets[0].toList()) - } - - @Test - fun `reset clears framing state`() { - // Feed partial header - codec.processInputByte(0x94.toByte()) - codec.processInputByte(0xc3.toByte()) - - // Reset mid-stream - codec.reset() - - // Now feed a complete packet — should work from scratch - val packet = byteArrayOf(0x94.toByte(), 0xc3.toByte(), 0x00, 0x01, 0xAA.toByte()) - packet.forEach { codec.processInputByte(it) } - - assertEquals(1, receivedPackets.size) - assertEquals(listOf(0xAA.toByte()), receivedPackets[0].toList()) - } - - @Test - fun `frameAndSend produces correct header for 1-byte payload`() = runTest { - val payload = byteArrayOf(0x42.toByte()) - val sentBytes = mutableListOf() - - codec.frameAndSend(payload, sendBytes = { sentBytes.add(it) }) - - // First sent bytes are the 4-byte header, second is the payload - assertEquals(2, sentBytes.size) - val header = sentBytes[0] - assertEquals(4, header.size) - assertEquals(0x94.toByte(), header[0]) - assertEquals(0xc3.toByte(), header[1]) - assertEquals(0x00.toByte(), header[2]) - assertEquals(0x01.toByte(), header[3]) - - val sentPayload = sentBytes[1] - assertEquals(payload.toList(), sentPayload.toList()) - } - - @Test - fun `WAKE_BYTES is four START1 bytes`() { - assertEquals(4, StreamFrameCodec.WAKE_BYTES.size) - StreamFrameCodec.WAKE_BYTES.forEach { assertEquals(0x94.toByte(), it) } - } - - @Test - fun `DEFAULT_TCP_PORT is 4403`() { - assertEquals(4403, StreamFrameCodec.DEFAULT_TCP_PORT) - } -} diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt deleted file mode 100644 index 9a0bd278e8..0000000000 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt +++ /dev/null @@ -1,96 +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.network.radio - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.network.transport.StreamFrameCodec -import org.meshtastic.core.network.transport.TcpTransport -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportCallback -import kotlin.concurrent.Volatile - -/** - * TCP radio transport — thin adapter over the shared [TcpTransport] from `core:network`. - * - * Implements [RadioTransport] directly via composition over [TcpTransport], delegating send/receive to the transport - * and calling [RadioTransportCallback] for lifecycle events. This avoids the previous inheritance from - * [StreamTransport] which created a dead [StreamFrameCodec] and required overriding `sendBytes` as a no-op. - */ -open class TcpRadioTransport( - private val callback: RadioTransportCallback, - private val scope: CoroutineScope, - private val dispatchers: CoroutineDispatchers, - private val address: String, -) : RadioTransport { - - companion object { - const val SERVICE_PORT = StreamFrameCodec.DEFAULT_TCP_PORT - } - - /** Guards against a double [RadioTransportCallback.onDisconnect] when [close] triggers [TcpTransport.stop]. */ - @Volatile private var closing = false - - private val transport = - TcpTransport( - dispatchers = dispatchers, - scope = scope, - listener = - object : TcpTransport.Listener { - override fun onConnected() { - callback.onConnect() - } - - override fun onDisconnected() { - if (closing) return // close() will fire the permanent disconnect itself - // TCP disconnects are transient (not permanent) — the transport will auto-reconnect. - callback.onDisconnect(isPermanent = false) - } - - override fun onPacketReceived(bytes: ByteArray) { - callback.handleFromRadio(bytes) - } - }, - logTag = "TcpRadioTransport[$address]", - ) - - override fun start() { - transport.start(address) - } - - override suspend fun close() { - Logger.d { "[$address] Closing TCP transport" } - closing = true - transport.stop() - // Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the - // service layer's responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting - // it from close() caused a double-disconnect and prevented the auto-reconnect loop from - // owning its own lifecycle. The `closing` guard above suppresses the listener's transient - // disconnect during teardown. - } - - override fun keepAlive() { - Logger.d { "[$address] TCP keepAlive" } - scope.handledLaunch { transport.sendHeartbeat() } - } - - override fun handleSendToRadio(p: ByteArray) { - scope.handledLaunch { transport.sendPacket(p) } - } -} diff --git a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt b/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt deleted file mode 100644 index ea32c74748..0000000000 --- a/core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt +++ /dev/null @@ -1,333 +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.network.transport - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.proto.ToRadio -import java.io.BufferedInputStream -import java.io.BufferedOutputStream -import java.io.IOException -import java.io.OutputStream -import java.net.InetAddress -import java.net.Socket -import java.net.SocketTimeoutException -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger - -/** - * Shared JVM TCP transport for Meshtastic radios. - * - * Manages the TCP socket lifecycle (connect, read loop, reconnect with backoff) and uses [StreamFrameCodec] for the - * START1/START2 stream framing protocol. [sendHeartbeat] sends a heartbeat with a monotonically-increasing nonce so the - * firmware's per-connection duplicate-write filter does not silently drop it. - * - * Used by Android and Desktop via the shared `SharedRadioInterfaceService`. - */ -@Suppress("TooManyFunctions", "MagicNumber") -class TcpTransport( - private val dispatchers: CoroutineDispatchers, - private val scope: CoroutineScope, - private val listener: Listener, - private val logTag: String = "TcpTransport", -) { - - /** Callbacks from the transport to the owning radio interface. */ - interface Listener { - /** Called when the TCP connection is established and wake bytes have been sent. */ - fun onConnected() - - /** Called when the TCP connection is lost. */ - fun onDisconnected() - - /** Called when a decoded Meshtastic packet arrives. */ - fun onPacketReceived(bytes: ByteArray) - } - - companion object { - /** - * Maximum reconnect retries. Set to [Int.MAX_VALUE] to retry indefinitely — the caller ([TcpTransport.stop]) - * owns the cancellation lifecycle. - */ - const val MAX_RECONNECT_RETRIES = Int.MAX_VALUE - const val MIN_BACKOFF_MILLIS = 1_000L - const val MAX_BACKOFF_MILLIS = 5 * 60 * 1_000L - const val SOCKET_TIMEOUT_MS = 5_000 - const val SOCKET_RETRIES = 18 // 18 * 5s = 90s inactivity before disconnect - const val TIMEOUT_LOG_INTERVAL = 5 - private const val MILLIS_PER_SECOND = 1_000L - } - - private val codec = - StreamFrameCodec( - onPacketReceived = { - packetsReceived++ - listener.onPacketReceived(it) - }, - logTag = logTag, - ) - - // TCP socket state - @Volatile private var socket: Socket? = null - - @Volatile private var outStream: OutputStream? = null - - @Volatile private var connectionJob: Job? = null - - @Volatile private var currentAddress: String? = null - - // Metrics - @Volatile private var connectionStartTime: Long = 0 - - @Volatile private var packetsReceived: Int = 0 - - @Volatile private var packetsSent: Int = 0 - - @Volatile private var bytesReceived: Long = 0 - - @Volatile private var bytesSent: Long = 0 - - @Volatile private var timeoutEvents: Int = 0 - - private val heartbeatNonce = AtomicInteger(0) - - /** Whether the transport is currently connected. */ - val isConnected: Boolean - get() { - val s = socket ?: return false - return s.isConnected && !s.isClosed - } - - /** - * Start a TCP connection to the given address with automatic reconnect. - * - * @param address host or host:port string - */ - fun start(address: String) { - stop() - currentAddress = address - connectionJob = scope.handledLaunch { connectWithRetry(address) } - } - - /** Stop the transport and close the socket. */ - fun stop() { - connectionJob?.cancel() - connectionJob = null - disconnectSocket() - currentAddress = null - } - - /** - * Send a raw framed Meshtastic packet. - * - * The payload is wrapped with the START1/START2 header by the codec. - */ - suspend fun sendPacket(payload: ByteArray) { - codec.frameAndSend(payload = payload, sendBytes = ::sendBytesRaw, flush = ::flushBytes) - packetsSent++ - bytesSent += payload.size - } - - /** Send a heartbeat packet with a monotonically-increasing nonce to keep the connection alive. */ - suspend fun sendHeartbeat() { - val nonce = heartbeatNonce.getAndIncrement() - val heartbeat = ToRadio(heartbeat = org.meshtastic.proto.Heartbeat(nonce = nonce)) - sendPacket(heartbeat.encode()) - } - - // region Connection lifecycle - - @Suppress("NestedBlockDepth") - private suspend fun connectWithRetry(address: String) { - var retryCount = 1 - var backoff = MIN_BACKOFF_MILLIS - - while (retryCount <= MAX_RECONNECT_RETRIES) { - val hadData = - try { - connectAndRead(address) - } catch (ex: IOException) { - Logger.w { "$logTag: [$address] TCP connection error" } - disconnectSocket() - false - } catch (@Suppress("TooGenericExceptionCaught") ex: Throwable) { - Logger.e(ex) { "$logTag: [$address] TCP exception" } - disconnectSocket() - false - } - - // Reset backoff after a connection that successfully exchanged data, - // so transient firmware-side disconnects recover quickly. - if (hadData) { - Logger.d { "$logTag: [$address] Resetting backoff after successful data exchange" } - retryCount = 1 - backoff = MIN_BACKOFF_MILLIS - } - - val delaySec = backoff / MILLIS_PER_SECOND - Logger.i { "$logTag: [$address] Reconnect #$retryCount in ${delaySec}s" } - delay(backoff) - retryCount++ - backoff = minOf(backoff * 2, MAX_BACKOFF_MILLIS) - } - } - - /** - * Connect to the given address, read data until the connection is lost, and return whether any bytes were - * successfully received (used by [connectWithRetry] to decide whether to reset backoff). - */ - @Suppress("NestedBlockDepth") - private suspend fun connectAndRead(address: String): Boolean = withContext(dispatchers.io) { - val parts = address.split(":", limit = 2) - val host = parts[0] - val port = parts.getOrNull(1)?.toIntOrNull() ?: StreamFrameCodec.DEFAULT_TCP_PORT - - Logger.i { "$logTag: [$address] Connecting to $host:$port" } - val attemptStart = nowMillis - - Socket(InetAddress.getByName(host), port).use { sock -> - sock.tcpNoDelay = true - sock.keepAlive = true - sock.soTimeout = SOCKET_TIMEOUT_MS - socket = sock - - val connectTime = nowMillis - attemptStart - connectionStartTime = nowMillis - resetMetrics() - codec.reset() - - Logger.i { "$logTag: [$address] Socket connected in ${connectTime}ms" } - - BufferedOutputStream(sock.getOutputStream()).use { output -> - outStream = output - - BufferedInputStream(sock.getInputStream()).use { input -> - // Send wake bytes and signal connected - sendBytesRaw(StreamFrameCodec.WAKE_BYTES) - listener.onConnected() - - // Read loop - var timeoutCount = 0 - while (timeoutCount < SOCKET_RETRIES) { - try { - val c = input.read() - if (c == -1) { - Logger.i { "$logTag: [$address] EOF after $packetsReceived packets" } - break - } - timeoutCount = 0 - bytesReceived++ - codec.processInputByte(c.toByte()) - } catch (_: SocketTimeoutException) { - timeoutCount++ - timeoutEvents++ - if (timeoutCount % TIMEOUT_LOG_INTERVAL == 0) { - Logger.d { "$logTag: [$address] Timeout $timeoutCount/$SOCKET_RETRIES" } - } - } - } - - if (timeoutCount >= SOCKET_RETRIES) { - Logger.w { "$logTag: [$address] Closing after $SOCKET_RETRIES consecutive timeouts" } - } - } - } - val hadData = bytesReceived > 0 - disconnectSocket() - hadData - } - } - - // Guards against recursive disconnects triggered by listener callbacks. - private val isDisconnecting = AtomicBoolean(false) - - private fun disconnectSocket() { - if (!isDisconnecting.compareAndSet(false, true)) return - - try { - val s = socket - val hadConnection = s != null || outStream != null - if (s != null) { - val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0 - Logger.i { - "$logTag: [$currentAddress] Disconnecting - Uptime: ${uptime}ms, " + - "RX: $packetsReceived ($bytesReceived bytes), " + - "TX: $packetsSent ($bytesSent bytes)" - } - try { - s.close() - } catch (_: IOException) { - // Ignore close errors - } - } - - socket = null - outStream = null - - if (hadConnection) { - listener.onDisconnected() - } - } finally { - isDisconnecting.set(false) - } - } - - // endregion - - // region Byte I/O - - private fun sendBytesRaw(p: ByteArray) { - val stream = - outStream - ?: run { - Logger.w { "$logTag: [$currentAddress] Cannot send ${p.size} bytes: not connected" } - return - } - try { - stream.write(p) - } catch (ex: IOException) { - Logger.w(ex) { "$logTag: [$currentAddress] TCP write error" } - disconnectSocket() - } - } - - private fun flushBytes() { - val stream = outStream ?: return - try { - stream.flush() - } catch (ex: IOException) { - Logger.w(ex) { "$logTag: [$currentAddress] TCP flush error" } - disconnectSocket() - } - } - - // endregion - - private fun resetMetrics() { - packetsReceived = 0 - packetsSent = 0 - bytesReceived = 0 - bytesSent = 0 - timeoutEvents = 0 - } -} diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt deleted file mode 100644 index 45ba70eb73..0000000000 --- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt +++ /dev/null @@ -1,242 +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.network - -import co.touchlab.kermit.Logger -import com.fazecast.jSerialComm.SerialPort -import com.fazecast.jSerialComm.SerialPortTimeoutException -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.network.radio.StreamTransport -import org.meshtastic.core.network.transport.HeartbeatSender -import org.meshtastic.core.repository.RadioTransportCallback -import java.io.File - -/** - * JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamTransport] for START1/START2 packet - * framing. - * - * Use the [open] factory method instead of the constructor directly to ensure the serial port is opened and the read - * loop is started. - */ -class SerialTransport -private constructor( - private val portName: String, - private val baudRate: Int = DEFAULT_BAUD_RATE, - callback: RadioTransportCallback, - scope: CoroutineScope, - private val dispatchers: CoroutineDispatchers, -) : StreamTransport(callback, scope) { - private var serialPort: SerialPort? = null - private var readJob: Job? = null - - private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$portName]") - - /** Attempts to open the serial port and starts the read loop. Returns true if successful, false otherwise. */ - private fun startConnection(): Boolean { - return try { - val port = SerialPort.getCommPort(portName) ?: return false - port.setComPortParameters(baudRate, DATA_BITS, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY) - port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, READ_TIMEOUT_MS, 0) - if (port.openPort()) { - serialPort = port - port.setDTR() - port.setRTS() - Logger.i { "[$portName] Serial port opened (baud=$baudRate)" } - super.connect() // Sends WAKE_BYTES and signals callback.onConnect() - startReadLoop(port) - true - } else { - Logger.w { "[$portName] Serial port openPort() returned false" } - false - } - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.w(e) { "[$portName] Serial connection failed" } - false - } - } - - @Suppress("CyclomaticComplexMethod") - private fun startReadLoop(port: SerialPort) { - Logger.d { "[$portName] Starting serial read loop" } - readJob = - scope.launch(dispatchers.io) { - val input = port.inputStream - val buffer = ByteArray(READ_BUFFER_SIZE) - try { - var reading = true - while (isActive && port.isOpen && reading) { - try { - val numRead = input.read(buffer) - if (numRead == -1) { - reading = false - } else if (numRead > 0) { - for (i in 0 until numRead) { - readChar(buffer[i]) - } - } - } catch (_: SerialPortTimeoutException) { - // Expected timeout when no data is available - } catch (e: CancellationException) { - throw e - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - if (isActive) { - Logger.w(e) { "[$portName] Serial read error" } - } else { - Logger.d { "[$portName] Serial read interrupted by cancellation" } - } - reading = false - } - } - } catch (e: CancellationException) { - throw e - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - if (isActive) { - Logger.w(e) { "[$portName] Serial read loop outer error" } - } else { - Logger.d { "[$portName] Serial read loop interrupted by cancellation" } - } - } finally { - Logger.d { "[$portName] Serial read loop exiting" } - try { - input.close() - } catch (_: Exception) { - // Ignore errors during input stream close - } - try { - if (port.isOpen) { - port.closePort() - } - } catch (_: Exception) { - // Ignore errors during port close - } - if (isActive) { - // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as - // transient — the user did not explicitly disconnect, and the port may come - // back when the device is replugged or the OS re-enumerates it. - onDeviceDisconnect(waitForStopped = true, isPermanent = false) - } - } - } - } - - override fun sendBytes(p: ByteArray) { - serialPort?.takeIf { it.isOpen }?.outputStream?.write(p) - } - - override fun flushBytes() { - serialPort?.takeIf { it.isOpen }?.outputStream?.flush() - } - - override fun keepAlive() { - // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the - // serial link is alive. - scope.launch { heartbeatSender.sendHeartbeat() } - } - - private fun closePortResources() { - serialPort?.takeIf { it.isOpen }?.closePort() - serialPort = null - } - - override suspend fun close() { - Logger.d { "[$portName] Closing serial transport" } - readJob?.cancel() - readJob = null - closePortResources() - super.close() - } - - companion object { - private const val DEFAULT_BAUD_RATE = 115200 - private const val DATA_BITS = 8 - private const val READ_BUFFER_SIZE = 1024 - private const val READ_TIMEOUT_MS = 100 - - /** - * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient - * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as - * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the - * user grants permission); only an explicit close should signal a permanent disconnect. - */ - fun open( - portName: String, - baudRate: Int = DEFAULT_BAUD_RATE, - callback: RadioTransportCallback, - scope: CoroutineScope, - dispatchers: CoroutineDispatchers, - ): SerialTransport { - val transport = SerialTransport(portName, baudRate, callback, scope, dispatchers) - if (!transport.startConnection()) { - val errorMessage = diagnoseOpenFailure(portName) - Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" } - callback.onDisconnect(isPermanent = false, errorMessage = errorMessage) - } - return transport - } - - /** - * Discovers and returns a list of available serial ports. Returns a list of the system port names (e.g., - * "COM3", "/dev/ttyUSB0"). - */ - fun getAvailablePorts(): List = SerialPort.getCommPorts().map { it.systemPortName } - - /** - * Diagnoses why a serial port could not be opened and returns a user-facing error message. On Linux, checks - * file permissions and suggests the appropriate group fix. - */ - @Suppress("ReturnCount") - private fun diagnoseOpenFailure(portName: String): String { - val osName = System.getProperty("os.name", "").lowercase() - if (!osName.contains("linux")) { - return "Could not open serial port: $portName" - } - - // jSerialComm resolves bare names like "ttyUSB0" to "/dev/ttyUSB0" - val devPath = if (portName.startsWith("/")) portName else "/dev/$portName" - val portFile = File(devPath) - if (!portFile.exists()) { - return "Serial port $portName not found. Is the device still connected?" - } - if (!portFile.canRead() || !portFile.canWrite()) { - val group = detectSerialGroup(devPath) - val user = System.getProperty("user.name", "your_user") - return "Permission denied for $devPath. " + - "Run: sudo usermod -aG $group $user — then log out and back in." - } - return "Could not open serial port: $portName" - } - - /** - * Attempts to detect the group that owns the serial device file. Falls back to "dialout" (Debian/Ubuntu - * default) if detection fails. - */ - @Suppress("SwallowedException", "TooGenericExceptionCaught") - private fun detectSerialGroup(devPath: String): String = try { - val process = ProcessBuilder("stat", "-c", "%G", devPath).redirectErrorStream(true).start() - val group = process.inputStream.bufferedReader().readText().trim() - process.waitFor() - group.ifEmpty { "dialout" } - } catch (e: Exception) { - "dialout" - } - } -} diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index ec4dc0b205..5eed33898d 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -20,6 +20,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.atomicfu.atomic @@ -118,6 +119,21 @@ class UiPrefsImpl( scope.launch { dataStore.edit { it[KEY_EXCLUDE_MQTT] = value } } } + override val maxDistanceKm: StateFlow = + dataStore.data.map { it[KEY_MAX_DISTANCE_KM] }.stateIn(scope, SharingStarted.Lazily, null) + + override fun setMaxDistanceKm(value: Float?) { + scope.launch { + dataStore.edit { + if (value == null) { + it.remove(KEY_MAX_DISTANCE_KM) + } else { + it[KEY_MAX_DISTANCE_KM] = value + } + } + } + } + override val hasShownNotPairedWarning: StateFlow = dataStore.data .map { it[KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF] ?: false } @@ -195,6 +211,7 @@ class UiPrefsImpl( val KEY_ONLY_DIRECT = booleanPreferencesKey("only-direct") val KEY_SHOW_IGNORED = booleanPreferencesKey("show-ignored") val KEY_EXCLUDE_MQTT = booleanPreferencesKey("exclude-mqtt") + val KEY_MAX_DISTANCE_KM = floatPreferencesKey("max-distance-km") val KEY_BLE_AUTO_SCAN = booleanPreferencesKey("ble-auto-scan") val KEY_NETWORK_AUTO_SCAN = booleanPreferencesKey("network-auto-scan") val KEY_SHOW_BLE_TRANSPORT = booleanPreferencesKey("show-ble-transport") diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts index b090517008..d142dcc957 100644 --- a/core/repository/build.gradle.kts +++ b/core/repository/build.gradle.kts @@ -30,6 +30,7 @@ kotlin { commonMain.dependencies { api(projects.core.model) api(projects.core.proto) + api(libs.sdk.core) implementation(projects.core.common) implementation(projects.core.database) 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 8b8249adf8..0000000000 --- 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 0000000000..e053a2ebc6 --- /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/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index bcbaad63b2..7ec0224c3d 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -112,6 +112,10 @@ interface UiPrefs { fun setExcludeMqtt(value: Boolean) + val maxDistanceKm: StateFlow + + fun setMaxDistanceKm(value: Float?) + val hasShownNotPairedWarning: StateFlow fun setHasShownNotPairedWarning(shown: Boolean) 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 a6b58bb485..0000000000 --- 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/HandshakeConstants.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt index f4a0d1fb32..f6455a8d3f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HandshakeConstants.kt @@ -22,7 +22,7 @@ package org.meshtastic.core.repository * Stage 1 (`CONFIG_NONCE`): requests device config, module config, and channels. Stage 2 (`NODE_INFO_NONCE`): requests * the full node database. * - * Both [MeshConfigFlowManager] (consumer) and [MeshConnectionManager] (sender) reference these. + * Both the SDK state bridge (consumer) and handshake initiator (sender) reference these. */ object HandshakeConstants { /** Nonce sent in `want_config_id` to request config-only (Stage 1). */ diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt deleted file mode 100644 index 0087dde970..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.repository - -import org.meshtastic.proto.ModuleConfig - -/** Interface for managing store-and-forward history replay requests. */ -interface HistoryManager { - /** - * Requests a history replay from the radio. - * - * @param trigger A string identifying the trigger for the request (for logging). - * @param myNodeNum The local node number. - * @param storeForwardConfig The store-and-forward module configuration. - * @param transport The transport method being used (for logging). - */ - fun requestHistoryReplay( - trigger: String, - myNodeNum: Int?, - storeForwardConfig: ModuleConfig.StoreForwardConfig?, - transport: String, - ) - - /** - * Updates the last requested history marker. - * - * @param source A string identifying the source of the update (for logging). - * @param lastRequest The timestamp or sequence number of the last received history message. - * @param transport The transport method being used (for logging). - */ - fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) -} 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 873e1c76bd..0000000000 --- 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 bd2f8c6121..0000000000 --- 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/MeshConfigHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt deleted file mode 100644 index dabd463dcd..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.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.repository - -import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.proto.Channel -import org.meshtastic.proto.Config -import org.meshtastic.proto.DeviceUIConfig -import org.meshtastic.proto.LocalConfig -import org.meshtastic.proto.LocalModuleConfig -import org.meshtastic.proto.ModuleConfig - -/** Interface for handling device and module configuration updates. */ -interface MeshConfigHandler { - /** Reactive local configuration. */ - val localConfig: StateFlow - - /** Reactive local module configuration. */ - val moduleConfig: StateFlow - - /** Handles a received device configuration. */ - fun handleDeviceConfig(config: Config) - - /** Handles a received module configuration. */ - fun handleModuleConfig(config: ModuleConfig) - - /** Handles a received channel configuration. */ - fun handleChannel(channel: Channel) - - /** - * Handles the [DeviceUIConfig] received during the config handshake (STATE_SEND_UIDATA). This arrives as the 2nd - * packet in every handshake, immediately after my_info. - */ - fun handleDeviceUIConfig(config: DeviceUIConfig) -} 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 9d898a3333..0000000000 --- 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/MeshDataHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt deleted file mode 100644 index 4879d757e4..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.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.repository - -import kotlinx.coroutines.Job -import org.meshtastic.core.model.DataPacket -import org.meshtastic.proto.MeshPacket - -/** Interface for handling incoming mesh data packets and routing them to the appropriate handlers. */ -interface MeshDataHandler { - /** - * Processes a received mesh packet. - * - * @param packet The received mesh packet. - * @param myNodeNum The local node number. - * @param logUuid Optional UUID for logging purposes. - * @param logInsertJob Optional job that tracks the insertion of the packet into the log. - */ - fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) - - /** - * Persists a data packet in the history and triggers notifications if necessary. - * - * @param dataPacket The data packet to remember. - * @param myNodeNum The local node number. - * @param updateNotification Whether to trigger a notification for this packet. - */ - fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean = true) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt deleted file mode 100644 index 4b98e19ac0..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.repository - -import org.meshtastic.proto.MeshPacket - -/** Interface for processing incoming radio messages and mesh packets. */ -interface MeshMessageProcessor { - /** Handles a raw message received from the radio. */ - fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) - - /** Handles a received mesh packet. */ - fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) - - /** Clears the buffer of early received packets. */ - fun clearEarlyPackets() -} 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 490f507255..0000000000 --- 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/NeighborInfoHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt deleted file mode 100644 index b924d510a9..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt +++ /dev/null @@ -1,36 +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 -import org.meshtastic.proto.NeighborInfo - -/** Interface for handling neighbor info responses from the mesh. */ -interface NeighborInfoHandler { - /** Records the start time for a neighbor info request. */ - fun recordStartTime(requestId: Int) - - /** The latest neighbor info received from the connected radio. */ - var lastNeighborInfo: NeighborInfo? - - /** - * Processes a neighbor info packet. - * - * @param packet The received mesh packet. - */ - fun handleNeighborInfo(packet: MeshPacket) -} 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 80c1c5e538..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ /dev/null @@ -1,110 +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.NodeInfo -import org.meshtastic.core.model.util.NodeIdLookup -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.Paxcount -import org.meshtastic.proto.StatusMessage -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) - - /** Whether node database writes are allowed. */ - val allowNodeDbWrites: StateFlow - - /** Sets whether node database writes are allowed. */ - fun setAllowNodeDbWrites(allowed: 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?) - - /** Loads the cached node database from the repository. */ - fun loadCachedNodeDB() - - /** 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 - - /** Returns a list of all known nodes. */ - fun getNodes(): List - - /** Processes a received user packet. */ - fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) - - /** 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) - - /** Processes a received paxcounter packet. */ - fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) - - /** Processes a received node status message. */ - fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) - - /** Updates the status string for a node. */ - fun updateNodeStatus(nodeNum: Int, status: String?) - - /** Updates a node using a transformation function. */ - fun updateNode(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, transform: (Node) -> Node) - - /** 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) - - /** Inserts hardware metadata for a node. */ - fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) -} 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 ee47db5b0b..2fc11ccae9 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,20 +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.proto.DeviceMetadata +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 @@ -167,11 +172,56 @@ interface NodeRepository { */ suspend fun installConfig(mi: MyNodeInfo, nodes: List) - /** - * Persists hardware metadata for a node. - * - * @param nodeNum The node number. - * @param metadata The [DeviceMetadata] to save. - */ - suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) + // ── 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/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt index 491c3e193f..88de646292 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt @@ -120,6 +120,11 @@ interface PacketRepository { /** Updates the transmission status of a packet. */ suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) + /** Updates the transmission status of a packet by its mesh packet ID. */ + suspend fun updateMessageStatus(packetId: Int, status: MessageStatus) { + getPacketByPacketId(packetId)?.let { updateMessageStatus(it, status) } + } + /** Updates the identifier of a persisted packet. */ suspend fun updateMessageId(d: DataPacket, id: Int) diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt index 51d8554944..4162a9e799 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt @@ -16,103 +16,38 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId -import org.meshtastic.core.model.MeshActivity /** - * Interface for the low-level radio interface that handles raw byte communication. + * Thin interface exposing device-address and connection-management operations to feature modules. * - * This is the **transport layer** — it manages the raw hardware connection (BLE, TCP, Serial, USB) to a Meshtastic - * radio. Its [connectionState] reflects whether the physical link is up or down, **before** any handshake or - * config-loading logic is applied. - * - * **Important:** UI and feature modules should **never** observe [connectionState] directly. Instead, they should use - * [ServiceRepository.connectionState], which is the canonical app-level connection state that accounts for handshake - * progress, light-sleep policy, and other higher-level concerns. The only legitimate consumer of this transport-level - * flow is [MeshConnectionManager], which bridges transport state changes into the app-level - * [ServiceRepository.connectionState]. - * - * @see ServiceRepository.connectionState + * The SDK now owns the raw transport (BLE, TCP, Serial). This interface retains only the device-selection + * surface that Scanner and connection UIs require. */ -interface RadioInterfaceService : RadioTransportCallback { +interface RadioInterfaceService { /** The device types supported by this platform's radio interface. */ val supportedDeviceTypes: List - /** - * Transport-level connection state of the radio hardware. - * - * This flow reflects the raw state of the physical link (BLE, TCP, Serial, USB): - * - [ConnectionState.Connected] — the transport link is established - * - [ConnectionState.Disconnected] — the transport link is down (permanent) - * - [ConnectionState.DeviceSleep] — the transport link is down (transient, device sleeping) - * - * **This is NOT the canonical app-level connection state.** The transport may report [ConnectionState.Connected] - * while the app is still performing the mesh handshake (config + node-info exchange), during which the app-level - * state remains [ConnectionState.Connecting]. - * - * Only [MeshConnectionManager] should observe this flow. All other consumers (ViewModels, feature modules, UI) must - * use [ServiceRepository.connectionState]. - * - * @see ServiceRepository.connectionState - */ - val connectionState: StateFlow - - /** Flow of the current device address. */ + /** Flow of the current device address (e.g. "x0123456789AB" for BLE, "tTCP:192.168.1.1" for TCP). */ val currentDeviceAddressFlow: StateFlow /** Whether we are currently using a mock transport. */ fun isMockTransport(): Boolean - /** - * Flow of raw data received from the radio. - * - * Emissions preserve the order in which bytes arrived from the hardware — this is required because the firmware - * handshake (initial config packet ordering) depends on strict FIFO delivery. Implementations MUST guarantee - * ordering; do not swap in a [SharedFlow] without preserving order. - */ - val receivedData: Flow - - /** Flow of radio activity events. */ - val meshActivity: Flow - - /** - * Drains any bytes currently buffered in [receivedData] without emitting them to collectors. - * - * Callers invoke this before attaching a fresh collector after a stop/start cycle so stale bytes buffered while no - * collector was attached do not get replayed ahead of the next session's handshake. - */ - fun resetReceivedBuffer() - - /** Sends a raw byte array to the radio. */ - fun sendToRadio(bytes: ByteArray) - - /** Initiates the connection to the radio. */ - fun connect() - - /** - * Explicitly tears down the active transport, sending a polite `ToRadio(disconnect = true)` goodbye frame first - * when a transport is live. Safe to call when nothing is connected — implementations must no-op in that case. - * Suspends until the teardown completes. - */ - suspend fun disconnect() - /** Returns the current device address. */ fun getDeviceAddress(): String? - /** Sets the device address to connect to. */ + /** Sets the device address to connect to. Returns true if the address changed. */ fun setDeviceAddress(deviceAddr: String?): Boolean /** Constructs a full radio address for the specific interface type. */ fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String - /** Flow of user-facing connection error messages (e.g. permission failures). */ - val connectionError: Flow + /** Initiates connection to the radio at the current device address. */ + fun connect() - /** The scope in which interface-related coroutines should run. */ - val serviceScope: CoroutineScope + /** Disconnects from the radio. */ + suspend fun disconnect() } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt deleted file mode 100644 index c0572f83f2..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.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.repository - -/** - * Interface for hardware transports (BLE, Serial, TCP, etc.) that handles raw byte communication. This is the - * KMP-compatible replacement for the legacy Android-specific IRadioInterface. - */ -interface RadioTransport { - /** Sends a raw byte array to the radio hardware. */ - fun handleSendToRadio(p: ByteArray) - - /** - * Initializes the transport after construction. Called by the factory once the transport has been fully created. - * - * This separates construction from side effects (connecting, launching coroutines), making transports easier to - * test and reason about. - */ - fun start() {} - - /** - * If we think we are connected, but we don't hear anything from the device, we might be in a zombie state. This - * function can be implemented by transports to see if we are really connected. - */ - fun keepAlive() {} - - /** - * Closes the connection to the device. - * - * Implementations that perform potentially-blocking teardown (e.g. BLE GATT disconnect) MUST run that work inside - * `withContext(NonCancellable)` so a cancelled caller cannot skip cleanup, leaving the underlying resource leaked. - * Callers must invoke this from a coroutine — it must never be called from a blocking context (no `runBlocking`). - */ - suspend fun close() -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt deleted file mode 100644 index 9771062a5f..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt +++ /dev/null @@ -1,41 +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 - -/** - * Narrow callback interface for transport → service communication. - * - * Transport implementations ([RadioTransport]) need only these three methods to report lifecycle events and deliver - * data. This replaces the previous pattern of passing the full [RadioInterfaceService] to transport constructors, - * decoupling transports from the service layer. - */ -interface RadioTransportCallback { - /** Called when the transport has successfully established a connection. */ - fun onConnect() - - /** - * Called when the transport has disconnected. - * - * @param isPermanent true if the device is definitely gone (e.g. USB unplugged, max retries exhausted), false if it - * may come back (e.g. BLE range, TCP transient). - * @param errorMessage optional user-facing error message describing the disconnect reason. - */ - fun onDisconnect(isPermanent: Boolean, errorMessage: String? = null) - - /** Called when the transport has received raw data from the radio. */ - fun handleFromRadio(bytes: ByteArray) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt deleted file mode 100644 index c3d2abff12..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.repository - -import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.InterfaceId - -/** - * Creates [RadioTransport] instances for specific device addresses. - * - * Implemented per-platform to provide the correct hardware transport (BLE, Serial, TCP). - */ -interface RadioTransportFactory { - /** The device types supported by this factory. */ - val supportedDeviceTypes: List - - /** Whether we are currently forced into using a mock transport (e.g., Firebase Test Lab). */ - fun isMockTransport(): Boolean - - /** Creates a transport for the given [address], or a NOP implementation if invalid/unsupported. */ - fun createTransport(address: String, service: RadioInterfaceService): RadioTransport - - /** Checks if the given [address] represents a valid, supported transport type. */ - fun isAddressValid(address: String?): Boolean - - /** Constructs a full radio address for the specific [interfaceId] and [rest] identifier. */ - fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String -} 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 5cd61b6714..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.repository - -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Node - -/** Interface for broadcasting service-level events to the application. */ -interface ServiceBroadcasts { - /** Subscribes a receiver to mesh broadcasts. */ - fun subscribeReceiver(receiverName: String, packageName: String) - - /** Broadcasts received data to the application. */ - fun broadcastReceivedData(dataPacket: DataPacket) - - /** Broadcasts that the radio connection state has changed. */ - fun broadcastConnection() - - /** Broadcasts that node information has changed. */ - fun broadcastNodeChange(node: Node) - - /** Broadcasts that the status of a message has changed. */ - fun broadcastMessageStatus(packetId: Int, status: MessageStatus) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index 2a09e95c8b..48fffac773 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt @@ -20,10 +20,12 @@ import co.touchlab.kermit.Severity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.MeshPacket +import org.meshtastic.core.model.CongestionLevel /** * Interface for managing background service state, connection status, and mesh events. @@ -32,12 +34,8 @@ import org.meshtastic.proto.MeshPacket * maintains reactive flows for connection status, error messages, and incoming mesh traffic. * * **Connection state contract:** [connectionState] is the **canonical, app-level** connection state that all UI, - * feature modules, and ViewModels should observe. It incorporates handshake progress, light-sleep policy, and transport - * reconciliation — unlike [RadioInterfaceService.connectionState], which only reflects the raw hardware link status. - * The [MeshConnectionManager] is the sole writer of this state; it bridges [RadioInterfaceService.connectionState] - * changes into app-level transitions via [setConnectionState]. - * - * @see RadioInterfaceService.connectionState + * feature modules, and ViewModels should observe. The SDK's [SdkStateBridge] is the sole writer of this state; + * it maps SDK connection events into app-level transitions via [setConnectionState]. */ @Suppress("TooManyFunctions") interface ServiceRepository { @@ -45,29 +43,34 @@ interface ServiceRepository { * Canonical app-level connection state. * * This is the **single source of truth** for connection status across the entire application. All UI components, - * feature modules, and ViewModels should observe this flow — never [RadioInterfaceService.connectionState]. + * feature modules, and ViewModels should observe this flow. * - * State transitions are managed exclusively by [MeshConnectionManager], which reconciles transport-level events - * with handshake progress and device sleep policy: + * State transitions are managed by [SdkStateBridge], which maps SDK connection events into app-level transitions: * - [ConnectionState.Disconnected] — no active connection to a radio - * - [ConnectionState.Connecting] — transport is up, mesh handshake (config + node-info) in progress + * - [ConnectionState.Connecting] — transport establishment is in progress + * - [ConnectionState.Configuring] — transport is up and mesh handshake/config sync is in progress * - [ConnectionState.Connected] — handshake complete, radio fully operational + * - [ConnectionState.Reconnecting] — connection dropped and automatic retry is in progress * - [ConnectionState.DeviceSleep] — radio entered light-sleep (transient disconnect) - * - * @see RadioInterfaceService.connectionState */ val connectionState: StateFlow + /** Current mesh congestion level, null when unknown or disconnected. */ + val congestionLevel: StateFlow + /** * Updates the canonical app-level connection state. * - * **This should only be called by [MeshConnectionManager].** Direct mutation from other components would bypass the - * transport-to-app reconciliation logic and create state inconsistencies. + * **This should only be called by [SdkStateBridge].** Direct mutation from other components would bypass the + * SDK-to-app state mapping logic and create state inconsistencies. * * @param connectionState The new [ConnectionState]. */ fun setConnectionState(connectionState: ConnectionState) + /** Sets the current mesh congestion level. */ + fun setCongestionLevel(level: CongestionLevel?) + /** * Reactive flow of high-level client notifications. * @@ -85,6 +88,12 @@ interface ServiceRepository { /** Clears the current client notification. */ fun clearClientNotification() + /** Node numbers for known Store-and-Forward servers discovered by the SDK. */ + val storeForwardServers: StateFlow> + + /** Updates the known Store-and-Forward server list. */ + fun setStoreForwardServers(servers: List) + /** * Reactive flow of human-readable error messages. * @@ -135,6 +144,18 @@ interface ServiceRepository { */ suspend fun emitMeshPacket(packet: MeshPacket) + /** + * Flow of mesh network send/receive activity events. + * + * Emits [MeshActivity.Receive] when packets arrive from the mesh, + * and [MeshActivity.Send] when packets are sent to the radio. + * Used to drive the connection-icon animation in the nav bar. + */ + val meshActivityFlow: Flow + + /** Emits a mesh activity event (Send or Receive). */ + fun emitMeshActivity(activity: MeshActivity) + /** Reactive flow of the most recent traceroute result. */ val tracerouteResponse: StateFlow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt deleted file mode 100644 index e884a8d3c3..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.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.proto.MeshPacket - -/** Interface for handling Store & Forward (legacy) and SF++ packets. */ -interface StoreForwardPacketHandler { - /** - * Handles a legacy Store & Forward packet. - * - * @param packet The received mesh packet. - * @param dataPacket The decoded data packet. - * @param myNodeNum The local node number. - */ - fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) - - /** - * Handles a Store Forward++ packet. - * - * @param packet The received mesh packet. - */ - fun handleStoreForwardPlusPlus(packet: MeshPacket) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt deleted file mode 100644 index 31ec30db8d..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt +++ /dev/null @@ -1,32 +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.proto.MeshPacket - -/** Interface for handling telemetry packets from the mesh, including battery notifications. */ -interface TelemetryPacketHandler { - /** - * Processes a telemetry packet. - * - * @param packet The received mesh packet. - * @param dataPacket The decoded data packet. - * @param myNodeNum The local node number. - */ - fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt deleted file mode 100644 index c0cc42aa21..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt +++ /dev/null @@ -1,35 +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.Job -import org.meshtastic.proto.MeshPacket - -/** Interface for handling traceroute responses from the mesh. */ -interface TracerouteHandler { - /** Records the start time for a traceroute request. */ - fun recordStartTime(requestId: Int) - - /** - * Processes a traceroute packet. - * - * @param packet The received mesh packet. - * @param logUuid Optional UUID for the associated log entry. - * @param logInsertJob Optional job for the log entry insertion, to ensure ordering. - */ - fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: Job?) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt index 7fc17c2a9e..0b13c67f0f 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCase.kt @@ -44,7 +44,11 @@ import kotlin.random.Random * This implementation is platform-agnostic and relies on injected repositories and controllers. */ interface SendMessageUseCase { - suspend operator fun invoke(text: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) + suspend operator fun invoke( + text: String, + contactKey: String = "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", + replyId: Int? = null, + ) } @Suppress("TooGenericExceptionCaught") @@ -69,15 +73,16 @@ class SendMessageUseCaseImpl( val dest = if (channel != null) contactKey.substring(1) else contactKey val ourNode = nodeRepository.ourNodeInfo.value - val fromId = ourNode?.user?.id ?: DataPacket.ID_LOCAL + val fromNodeNum = ourNode?.num ?: DataPacket.LOCAL // Direct message side-effects: share the contact's public key (PKI) or // favorite the node (legacy) before sending the first message. PKI DMs use // channel == PKC_CHANNEL_INDEX (8); legacy DMs have no channel prefix // (channel == null). Both formats target a specific node. val isDirectMessage = channel == null || channel == DataPacket.PKC_CHANNEL_INDEX + val destNode = if (isDirectMessage) nodeRepository.getNode(dest) else null + val destNodeNum = destNode?.num ?: DataPacket.parseNodeNum(dest) if (isDirectMessage) { - val destNode = nodeRepository.getNode(dest) val fwVersion = ourNode?.metadata?.firmware_version val isClientBase = ourNode?.user?.role == Config.DeviceConfig.Role.CLIENT_BASE val capabilities = Capabilities(fwVersion) @@ -86,10 +91,10 @@ class SendMessageUseCaseImpl( // Best-effort: inform firmware of the destination's public key // for its NodeDB cache. The MeshPacket itself carries the key // directly, so the message can be encrypted regardless. - sendSharedContact(destNode) + sendSharedContact(destNode!!) } else if (channel == null) { // Legacy favoriting only applies to old-style DMs without PKI - if (!destNode.isFavorite && !isClientBase) { + if (!destNode!!.isFavorite && !isClientBase) { favoriteNode(destNode) } } @@ -106,8 +111,8 @@ class SendMessageUseCaseImpl( val packetId = Random.nextInt(1, Int.MAX_VALUE) val packet = - DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply { - from = fromId + DataPacket(destNodeNum, channel ?: 0, finalMessageText, replyId).apply { + from = fromNodeNum id = packetId status = MessageStatus.QUEUED } diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt deleted file mode 100644 index 303b8a4ad7..0000000000 --- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.repository - -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertTrue - -class RadioTransportTest { - - @Test - fun `RadioTransport can be implemented`() = runTest { - var sentData: ByteArray? = null - var closed = false - var keepAliveCalled = false - - val transport = - object : RadioTransport { - override fun handleSendToRadio(p: ByteArray) { - sentData = p - } - - override fun keepAlive() { - keepAliveCalled = true - } - - override suspend fun close() { - closed = true - } - } - - val testData = byteArrayOf(1, 2, 3) - transport.handleSendToRadio(testData) - transport.keepAlive() - transport.close() - - assertTrue(sentData!!.contentEquals(testData)) - assertTrue(keepAliveCalled) - assertTrue(closed) - } -} diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt index c65812c016..fc5a858e35 100644 --- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt +++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/usecase/SendMessageUseCaseTest.kt @@ -68,7 +68,7 @@ class SendMessageUseCaseTest { appPreferences.homoglyph.setHomoglyphEncodingEnabled(false) // Act - useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null) + useCase("Hello broadcast", "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", null) // Assert radioController.favoritedNodes.size shouldBe 0 @@ -133,7 +133,7 @@ class SendMessageUseCaseTest { val originalText = "\u0410pple" // Cyrillic A // Act - useCase(originalText, "0${DataPacket.ID_BROADCAST}", null) + useCase(originalText, "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", null) // Assert // Verified by observing that no exception is thrown and coverage is hit. diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 0f3cf4e28a..9444ffbcbf 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -779,6 +779,10 @@ Exclude MQTT You are viewing ignored nodes,\nPress to return to the node list. Include unknown + Max distance + Max distance: %1$s + %1$s km + Unlimited Only show direct nodes Hide offline nodes Filter @@ -1144,6 +1148,8 @@ Store & Forward Store & Forward Config Store & Forward enabled + Store & Forward server + Channel: %1$s Subred Super deep sleep duration Supported diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt deleted file mode 100644 index 4dd27dbf4e..0000000000 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.service - -import org.junit.runner.RunWith -import org.meshtastic.core.service.testing.FakeIMeshService -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -/** Test to verify that the AIDL contract is correctly implemented by our test harness. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class IMeshServiceContractTest { - - @Test - fun `verify fake implementation matches aidl contract`() { - val service: IMeshService = FakeIMeshService() - - // Basic verification that we can call methods and get expected results - assertEquals("fake_id", service.myId) - assertEquals(1234, service.packetId) - assertEquals("CONNECTED", service.connectionState()) - assertNotNull(service.nodes) - } -} diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt index 8a43a2a3da..fc1c713b91 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/SendMessageWorkerTest.kt @@ -64,9 +64,9 @@ class SendMessageWorkerTest { fun `doWork returns success when packet is sent successfully`() = runTest { // Arrange val packetId = 12345 - val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0) + val dataPacket = DataPacket(to = 1234, bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0) everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket - everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit + everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit val worker = TestListenableWorkerBuilder(context) @@ -96,7 +96,7 @@ class SendMessageWorkerTest { fun `doWork returns retry when radio is disconnected`() = runTest { // Arrange val packetId = 12345 - val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0) + val dataPacket = DataPacket(to = 1234, bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0) everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket radioController.setConnectionState(ConnectionState.Disconnected) @@ -121,7 +121,7 @@ class SendMessageWorkerTest { // Assert assertEquals(ListenableWorker.Result.retry(), result) assertEquals(emptyList(), radioController.sentPackets) - verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.updateMessageStatus(any(), any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.updateMessageStatus(any(), any()) } } @Test @@ -143,15 +143,15 @@ class SendMessageWorkerTest { val result = worker.doWork() assertEquals(ListenableWorker.Result.failure(), result) - verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.getPacketByPacketId(any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.getPacketByPacketId(any()) } } @Test fun `doWork returns retry and marks queued when send throws`() = runTest { val packetId = 12345 - val dataPacket = DataPacket(to = "dest", bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0) + val dataPacket = DataPacket(to = 1234, bytes = "Hello".encodeToByteArray().toByteString(), dataType = 0) everySuspend { packetRepository.getPacketByPacketId(packetId) } returns dataPacket - everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit + everySuspend { packetRepository.updateMessageStatus(any(), any()) } returns Unit radioController.throwOnSend = true val worker = 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 16a9a000c1..0000000000 --- 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/AndroidRadioControllerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt deleted file mode 100644 index af7cb85c20..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ /dev/null @@ -1,223 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.service - -import android.content.Context -import android.content.Intent -import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.StateFlow -import org.koin.core.annotation.Single -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.proto.Channel -import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.SharedContact -import org.meshtastic.proto.User - -/** - * Android [RadioController] implementation that delegates to the bound [MeshService] via AIDL. - * - * All radio commands are forwarded through [AndroidServiceRepository.meshService]. If the service is not yet bound, - * commands are silently dropped with a warning log. - */ -@Single -@Suppress("TooManyFunctions") -class AndroidRadioControllerImpl( - private val context: Context, - private val serviceRepository: AndroidServiceRepository, - private val nodeRepository: NodeRepository, -) : RadioController { - - /** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */ - override val connectionState: StateFlow - get() = serviceRepository.connectionState - - override val clientNotification: StateFlow - get() = serviceRepository.clientNotification - - override suspend fun sendMessage(packet: DataPacket) { - val svc = serviceRepository.meshService - if (svc == null) { - Logger.w { "sendMessage: meshService is null, dropping packet" } - return - } - svc.send(packet) - } - - override fun clearClientNotification() { - serviceRepository.clearClientNotification() - } - - override suspend fun favoriteNode(nodeNum: Int) { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef)) - } - - override suspend fun sendSharedContact(nodeNum: Int): Boolean { - val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum)) - val contact = - SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified) - val action = ServiceAction.SendContact(contact) - serviceRepository.onServiceAction(action) - return action.result.await() - } - - override suspend fun setLocalConfig(config: Config) { - serviceRepository.meshService?.setConfig(config.encode()) - } - - override suspend fun setLocalChannel(channel: Channel) { - serviceRepository.meshService?.setChannel(channel.encode()) - } - - override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { - serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode()) - } - - override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { - serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode()) - } - - override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { - serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode()) - } - - override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { - serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode()) - } - - override suspend fun setFixedPosition(destNum: Int, position: Position) { - serviceRepository.meshService?.setFixedPosition(destNum, position) - } - - override suspend fun setRingtone(destNum: Int, ringtone: String) { - serviceRepository.meshService?.setRingtone(destNum, ringtone) - } - - override suspend fun setCannedMessages(destNum: Int, messages: String) { - serviceRepository.meshService?.setCannedMessages(destNum, messages) - } - - override suspend fun getOwner(destNum: Int, packetId: Int) { - serviceRepository.meshService?.getRemoteOwner(packetId, destNum) - } - - override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { - serviceRepository.meshService?.getRemoteConfig(packetId, destNum, configType) - } - - override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) { - serviceRepository.meshService?.getModuleConfig(packetId, destNum, moduleConfigType) - } - - override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) { - serviceRepository.meshService?.getRemoteChannel(packetId, destNum, index) - } - - override suspend fun getRingtone(destNum: Int, packetId: Int) { - serviceRepository.meshService?.getRingtone(packetId, destNum) - } - - override suspend fun getCannedMessages(destNum: Int, packetId: Int) { - serviceRepository.meshService?.getCannedMessages(packetId, destNum) - } - - override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { - serviceRepository.meshService?.getDeviceConnectionStatus(packetId, destNum) - } - - override suspend fun reboot(destNum: Int, packetId: Int) { - serviceRepository.meshService?.requestReboot(packetId, destNum) - } - - override suspend fun rebootToDfu(nodeNum: Int) { - serviceRepository.meshService?.rebootToDfu(nodeNum) - } - - override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { - serviceRepository.meshService?.requestRebootOta(requestId, destNum, mode, hash) - } - - override suspend fun shutdown(destNum: Int, packetId: Int) { - serviceRepository.meshService?.requestShutdown(packetId, destNum) - } - - override suspend fun factoryReset(destNum: Int, packetId: Int) { - serviceRepository.meshService?.requestFactoryReset(packetId, destNum) - } - - override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) { - serviceRepository.meshService?.requestNodedbReset(packetId, destNum, preserveFavorites) - } - - override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) { - serviceRepository.meshService?.removeByNodenum(packetId, nodeNum) - } - - override suspend fun requestPosition(destNum: Int, currentPosition: Position) { - serviceRepository.meshService?.requestPosition(destNum, currentPosition) - } - - override suspend fun requestUserInfo(destNum: Int) { - serviceRepository.meshService?.requestUserInfo(destNum) - } - - override suspend fun requestTraceroute(requestId: Int, destNum: Int) { - serviceRepository.meshService?.requestTraceroute(requestId, destNum) - } - - override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { - serviceRepository.meshService?.requestTelemetry(requestId, destNum, typeValue) - } - - override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) { - serviceRepository.meshService?.requestNeighborInfo(requestId, destNum) - } - - override suspend fun beginEditSettings(destNum: Int) { - serviceRepository.meshService?.beginEditSettings(destNum) - } - - override suspend fun commitEditSettings(destNum: Int) { - serviceRepository.meshService?.commitEditSettings(destNum) - } - - override fun getPacketId(): Int = - serviceRepository.meshService?.getPacketId() ?: error("Cannot generate packet ID: meshService is not bound") - - override fun startProvideLocation() { - serviceRepository.meshService?.startProvideLocation() - } - - override fun stopProvideLocation() { - serviceRepository.meshService?.stopProvideLocation() - } - - override fun setDeviceAddress(address: String) { - @Suppress("DEPRECATION") // Internal use: routes address change through AIDL binder - serviceRepository.meshService?.setDeviceAddress(address) - // Ensure service is running/restarted to handle the new address - val intent = Intent().apply { setClassName("com.geeksville.mesh", "org.meshtastic.core.service.MeshService") } - context.startForegroundService(intent) - } -} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt index dca0fb415f..a7e904133a 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidServiceRepository.kt @@ -20,19 +20,10 @@ import org.koin.core.annotation.Single import org.meshtastic.core.repository.ServiceRepository /** - * Android-specific [ServiceRepository] that extends [ServiceRepositoryImpl] with AIDL service binding. + * Android-specific [ServiceRepository] that extends [ServiceRepositoryImpl]. * - * The base class provides all reactive state management (connection state, error messages, mesh packets, etc.) in pure - * KMP code. This subclass adds the [IMeshService] reference needed by [AndroidRadioControllerImpl] and the AIDL binder - * in `MeshService`. + * With the SDK hard-cutover, the AIDL [IMeshService] reference is no longer needed — all radio communication flows + * through [SdkRadioControllerImpl] and [SdkStateBridge]. This subclass exists only to preserve the Android DI binding. */ @Single(binds = [ServiceRepository::class, AndroidServiceRepository::class]) -@Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding -class AndroidServiceRepository : ServiceRepositoryImpl() { - var meshService: IMeshService? = null - private set - - fun setMeshService(service: IMeshService?) { - meshService = service - } -} +class AndroidServiceRepository : ServiceRepositoryImpl() diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt deleted file mode 100644 index 425b19fe2b..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.service - -import org.meshtastic.core.api.MeshtasticIntent - -const val PREFIX = "com.geeksville.mesh" - -const val ACTION_NODE_CHANGE = MeshtasticIntent.ACTION_NODE_CHANGE -const val ACTION_MESH_CONNECTED = MeshtasticIntent.ACTION_MESH_CONNECTED -const val ACTION_MESH_DISCONNECTED = MeshtasticIntent.ACTION_MESH_DISCONNECTED - -@Suppress("DEPRECATION") // Intentionally re-exported for backward-compat broadcast in ServiceBroadcasts -const val ACTION_CONNECTION_CHANGED = MeshtasticIntent.ACTION_CONNECTION_CHANGED -const val ACTION_MESSAGE_STATUS = MeshtasticIntent.ACTION_MESSAGE_STATUS - -fun actionReceived(portNum: String) = "$PREFIX.RECEIVED.$portNum" - -// Standard EXTRA bundle definitions -const val EXTRA_CONNECTED = MeshtasticIntent.EXTRA_CONNECTED - -const val EXTRA_PAYLOAD = MeshtasticIntent.EXTRA_PAYLOAD -const val EXTRA_NODEINFO = MeshtasticIntent.EXTRA_NODEINFO -const val EXTRA_PACKET_ID = MeshtasticIntent.EXTRA_PACKET_ID -const val EXTRA_STATUS = MeshtasticIntent.EXTRA_STATUS diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 0f4bc60b7d..f39c792254 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 @@ -28,86 +28,46 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import org.koin.android.ext.android.inject import org.meshtastic.core.common.hasLocationPermission -import org.meshtastic.core.common.util.toRemoteExceptions import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceVersion -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.RadioNotConnectedException -import org.meshtastic.core.model.util.anonymize -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshConnectionManager -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshRouter import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.SERVICE_NOTIFY_ID -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.PortNum /** * Android foreground service that hosts the Meshtastic mesh radio connection. * * Acts as the lifecycle anchor for the [MeshServiceOrchestrator], which manages all manager initialization and - * connection state. Exposes an AIDL binder for external client integration via [core:api]. + * connection state. With the SDK hard-cutover, this service no longer exposes an AIDL binder — all communication + * flows through the SDK's RadioClient via [SdkRadioControllerImpl] and [SdkStateBridge]. */ -// IMeshService is deprecated but still required for AIDL binding -@Suppress("TooManyFunctions", "LargeClass", "DEPRECATION") class MeshService : Service() { - private val radioInterfaceService: RadioInterfaceService by inject() - - private val serviceRepository: ServiceRepository by inject() - - private val serviceBroadcasts: ServiceBroadcasts by inject() - - private val nodeManager: NodeManager by inject() - - private val commandSender: CommandSender by inject() - - private val locationManager: MeshLocationManager by inject() - - private val connectionManager: MeshConnectionManager by inject() + private val radioPrefs: RadioPrefs 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 private val orchestrator: MeshServiceOrchestrator by inject() - private val router: MeshRouter by inject() - private val dispatchers: CoroutineDispatchers by inject() + private val sdkClientLifecycle: SdkClientLifecycle by inject() + private val serviceJob = Job() private val serviceScope by lazy { CoroutineScope(dispatchers.io + serviceJob) } private var isServiceInitialized = false - private val myNodeNum: Int - get() = nodeManager.myNodeNum.value ?: throw RadioNotConnectedException() - companion object { - fun actionReceived(portNum: Int): String { - val portType = PortNum.fromValue(portNum) - val portStr = portType?.toString() ?: portNum.toString() - return actionReceived(portStr) - } - fun createIntent(context: Context) = Intent(context, MeshService::class.java) - fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) { - service.setDeviceAddress(address) - startService(context) - } - val minDeviceVersion = DeviceVersion(DeviceVersion.MIN_FW_VERSION) val absoluteMinDeviceVersion = DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION) } @@ -142,10 +102,10 @@ class MeshService : Service() { return START_NOT_STICKY } - val a = radioInterfaceService.getDeviceAddress() + val a = radioPrefs.devAddr.value val wantForeground = a != null && a != "n" - connectionManager.updateStatusNotification() + notifications.updateServiceStateNotification(serviceRepository.connectionState.value, null) val notification = androidNotifications.getServiceNotification() val foregroundServiceType = @@ -210,7 +170,7 @@ class MeshService : Service() { Logger.i { "Mesh service: onTaskRemoved" } } - override fun onBind(intent: Intent?): IBinder = binder + override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { Logger.i { "Destroying mesh service" } @@ -218,188 +178,8 @@ class MeshService : Service() { if (isServiceInitialized) { orchestrator.stop() } + sdkClientLifecycle.disconnect() serviceJob.cancel() super.onDestroy() } - - private val binder = - object : IMeshService.Stub() { - @Suppress("OVERRIDE_DEPRECATION") - override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { - Logger.d { "Passing through device change to radio service: ${deviceAddr?.anonymize}" } - router.actionHandler.handleUpdateLastAddress(deviceAddr) - radioInterfaceService.setDeviceAddress(deviceAddr) - } - - override fun subscribeReceiver(packageName: String, receiverName: String) { - serviceBroadcasts.subscribeReceiver(receiverName, packageName) - } - - @Suppress("OVERRIDE_DEPRECATION") - override fun getUpdateStatus(): Int = -4 - - @Suppress("OVERRIDE_DEPRECATION") - override fun startFirmwareUpdate() { - // No-op: firmware update is handled by the in-app OTA system. - } - - override fun getMyNodeInfo(): MyNodeInfo? = nodeManager.getMyNodeInfo() - - override fun getMyId(): String = nodeManager.getMyId() - - override fun getPacketId(): Int = commandSender.generatePacketId() - - override fun setOwner(u: MeshUser) = toRemoteExceptions { - router.actionHandler.handleSetOwner(u, myNodeNum) - } - - override fun setRemoteOwner(id: Int, destNum: Int, payload: ByteArray) = toRemoteExceptions { - router.actionHandler.handleSetRemoteOwner(id, destNum, payload) - } - - override fun getRemoteOwner(id: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleGetRemoteOwner(id, destNum) - } - - override fun send(p: DataPacket) = toRemoteExceptions { router.actionHandler.handleSend(p, myNodeNum) } - - override fun getConfig(): ByteArray = toRemoteExceptions { commandSender.getCachedLocalConfig().encode() } - - override fun setConfig(payload: ByteArray) = toRemoteExceptions { - router.actionHandler.handleSetConfig(payload, myNodeNum) - } - - override fun setRemoteConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { - router.actionHandler.handleSetRemoteConfig(id, num, payload) - } - - override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { - router.actionHandler.handleGetRemoteConfig(id, destNum, config) - } - - override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { - router.actionHandler.handleSetModuleConfig(id, num, payload) - } - - override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { - router.actionHandler.handleGetModuleConfig(id, destNum, config) - } - - override fun setRingtone(destNum: Int, ringtone: String) = toRemoteExceptions { - router.actionHandler.handleSetRingtone(destNum, ringtone) - } - - override fun getRingtone(id: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleGetRingtone(id, destNum) - } - - override fun setCannedMessages(destNum: Int, messages: String) = toRemoteExceptions { - router.actionHandler.handleSetCannedMessages(destNum, messages) - } - - override fun getCannedMessages(id: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleGetCannedMessages(id, destNum) - } - - override fun setChannel(payload: ByteArray?) = toRemoteExceptions { - router.actionHandler.handleSetChannel(payload, myNodeNum) - } - - override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions { - router.actionHandler.handleSetRemoteChannel(id, num, payload) - } - - override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions { - router.actionHandler.handleGetRemoteChannel(id, destNum, index) - } - - override fun beginEditSettings(destNum: Int) = toRemoteExceptions { - router.actionHandler.handleBeginEditSettings(destNum) - } - - override fun commitEditSettings(destNum: Int) = toRemoteExceptions { - router.actionHandler.handleCommitEditSettings(destNum) - } - - override fun getChannelSet(): ByteArray = toRemoteExceptions { - commandSender.getCachedChannelSet().encode() - } - - override fun getNodes(): List = nodeManager.getNodes() - - override fun connectionState(): String = serviceRepository.connectionState.value.toString() - - override fun startProvideLocation() { - locationManager.start(serviceScope) { commandSender.sendPosition(it) } - } - - override fun stopProvideLocation() { - locationManager.stop() - } - - override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions { - val myNodeNum = nodeManager.myNodeNum.value - if (myNodeNum != null) { - router.actionHandler.handleRemoveByNodenum(nodeNum, requestId, myNodeNum) - } else { - nodeManager.removeByNodenum(nodeNum) - } - } - - override fun requestUserInfo(destNum: Int) = toRemoteExceptions { - if (destNum != myNodeNum) { - commandSender.requestUserInfo(destNum) - } - } - - override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { - router.actionHandler.handleRequestPosition(destNum, position, myNodeNum) - } - - override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions { - commandSender.setFixedPosition(destNum, position) - } - - override fun requestTraceroute(requestId: Int, destNum: Int) = toRemoteExceptions { - commandSender.requestTraceroute(requestId, destNum) - } - - override fun requestNeighborInfo(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRequestNeighborInfo(requestId, destNum) - } - - override fun requestShutdown(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRequestShutdown(requestId, destNum) - } - - override fun requestReboot(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRequestReboot(requestId, destNum) - } - - override fun rebootToDfu(destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRebootToDfu(destNum) - } - - override fun requestFactoryReset(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleRequestFactoryReset(requestId, destNum) - } - - override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) = - toRemoteExceptions { - router.actionHandler.handleRequestNodedbReset(requestId, destNum, preserveFavorites) - } - - override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) = toRemoteExceptions { - router.actionHandler.handleGetDeviceConnectionStatus(requestId, destNum) - } - - override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) = toRemoteExceptions { - router.actionHandler.handleRequestTelemetry(requestId, destNum, type) - } - - override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) = - toRemoteExceptions { - router.actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash) - } - } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt index 4bb322ad70..cb5f27dd23 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceClient.kt @@ -17,89 +17,46 @@ package org.meshtastic.core.service import android.content.Context -import android.content.Context.BIND_ABOVE_CLIENT -import android.content.Context.BIND_AUTO_CREATE import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import co.touchlab.kermit.Logger import kotlinx.coroutines.launch import org.koin.core.annotation.Factory -import org.meshtastic.core.common.util.SequentialJob -/** A Activity-lifecycle-aware [ServiceClient] that binds [MeshService] once the Activity is started. */ +/** + * Activity-lifecycle-aware component that starts [MeshService] when the Activity becomes visible. + * + * With the SDK hard-cutover, AIDL binding is no longer used. This simply ensures the foreground service is running + * while the UI is active. + */ @Factory -@Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding class MeshServiceClient( private val context: Context, - private val serviceRepository: AndroidServiceRepository, - private val serviceSetupJob: SequentialJob, -) : ServiceClient(IMeshService.Stub::asInterface), - DefaultLifecycleObserver { +) : DefaultLifecycleObserver { private val lifecycleOwner: LifecycleOwner = context as LifecycleOwner init { - Logger.d { "Adding self as LifecycleObserver for $lifecycleOwner" } + Logger.d { "Adding MeshServiceClient as LifecycleObserver for $lifecycleOwner" } lifecycleOwner.lifecycle.addObserver(this) } - // region ServiceClient overrides - - override fun onConnected(service: IMeshService) { - serviceSetupJob.launch(lifecycleOwner.lifecycleScope) { - serviceRepository.setMeshService(service) - Logger.d { "connected to mesh service, connectionState=${serviceRepository.connectionState.value}" } - } - } - - override fun onDisconnected() { - serviceSetupJob.cancel() - serviceRepository.setMeshService(null) - } - - // endregion - - // region DefaultLifecycleObserver overrides - override fun onStart(owner: LifecycleOwner) { super.onStart(owner) - Logger.d { "Lifecycle: ON_START" } - + Logger.d { "Lifecycle: ON_START — starting MeshService" } owner.lifecycleScope.launch { try { - bindMeshService() - } catch (ex: BindFailedException) { - Logger.e { "Bind of MeshService failed: ${ex.message}" } + MeshService.startService(context) + } catch (ex: Exception) { + Logger.e { "Failed to start MeshService: ${ex.message}" } } } } - override fun onStop(owner: LifecycleOwner) { - super.onStop(owner) - Logger.d { "Lifecycle: ON_STOP" } - close() - } - override fun onDestroy(owner: LifecycleOwner) { super.onDestroy(owner) - Logger.d { "Lifecycle: ON_DESTROY" } - owner.lifecycle.removeObserver(this) - Logger.d { "Removed self as LifecycleObserver to $lifecycleOwner" } - } - - // endregion - - @Suppress("TooGenericExceptionCaught") - private suspend fun bindMeshService() { - Logger.d { "Binding to mesh service!" } - try { - MeshService.startService(context) - } catch (ex: Exception) { - Logger.e { "Failed to start service from activity - but ignoring because bind will work: ${ex.message}" } - } - - connect(context, MeshService.createIntent(context), BIND_AUTO_CREATE or BIND_ABOVE_CLIENT) + Logger.d { "Removed MeshServiceClient as LifecycleObserver" } } } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index 3bddd12912..7e074c90d9 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -317,7 +317,9 @@ class MeshServiceNotificationsImpl( is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping) - is ConnectionState.Connecting -> getString(Res.string.connecting) + is ConnectionState.Connecting, + is ConnectionState.Configuring, + is ConnectionState.Reconnecting -> getString(Res.string.connecting) } // Update caches if telemetry is provided @@ -420,7 +422,7 @@ class MeshServiceNotificationsImpl( val history = packetRepository.value .getMessagesFrom(contactKey, includeFiltered = false) { nodeId -> - if (nodeId == DataPacket.ID_LOCAL) { + if (nodeId == DataPacket.nodeNumToId(DataPacket.LOCAL)) { ourNode ?: nodeRepository.value.getNode(nodeId) } else { nodeRepository.value.getNode(nodeId.orEmpty()) @@ -461,7 +463,7 @@ class MeshServiceNotificationsImpl( val me = Person.Builder() .setName(meName) - .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL) + .setKey(ourNode?.user?.id ?: DataPacket.nodeNumToId(DataPacket.LOCAL)) .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } } .build() @@ -573,7 +575,7 @@ class MeshServiceNotificationsImpl( val me = Person.Builder() .setName(meName) - .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL) + .setKey(ourNode?.user?.id ?: DataPacket.nodeNumToId(DataPacket.LOCAL)) .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } } .build() diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt index c7f57eba20..44ef74ff43 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt @@ -77,7 +77,7 @@ class ReplyReceiver : // contactKey: unique contact key filter (channel)+(nodeId) val channel = contactKey.getOrNull(0)?.digitToIntOrNull() val dest = if (channel != null) contactKey.substring(1) else contactKey - val p = DataPacket(dest, channel ?: 0, str) + val p = DataPacket(DataPacket.parseNodeNum(dest), channel ?: 0, str) radioController.sendMessage(p) } } 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 d63c5f2edb..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.service - -import android.content.Context -import android.content.Intent -import android.os.Parcelable -import co.touchlab.kermit.Logger -import org.koin.core.annotation.Single -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.util.toPIIString -import org.meshtastic.core.repository.ServiceRepository -import java.util.Locale -import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcasts - -@Single -class ServiceBroadcasts(private val context: Context, private val serviceRepository: ServiceRepository) : - SharedServiceBroadcasts { - // A mapping of receiver class name to package name - used for explicit broadcasts. - // ConcurrentHashMap because subscribeReceiver() is called from AIDL binder threads - // while explicitBroadcast() iterates from coroutine contexts. - private val clientPackages = java.util.concurrent.ConcurrentHashMap() - - override fun subscribeReceiver(receiverName: String, packageName: String) { - clientPackages[receiverName] = packageName - } - - /** Broadcast some received data Payload will be a DataPacket */ - override fun broadcastReceivedData(dataPacket: DataPacket) { - val action = MeshService.actionReceived(dataPacket.dataType) - explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, dataPacket)) - - // Also broadcast with the numeric port number for backwards compatibility with some apps - val numericAction = actionReceived(dataPacket.dataType.toString()) - if (numericAction != action) { - explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, dataPacket)) - } - } - - override fun broadcastNodeChange(node: Node) { - Logger.d { "Broadcasting node change ${node.user.toPIIString()}" } - val legacy = node.toLegacy() - val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, legacy) - explicitBroadcast(intent) - } - - private fun Node.toLegacy(): NodeInfo = NodeInfo( - num = num, - user = - org.meshtastic.core.model.MeshUser( - id = user.id, - longName = user.long_name, - shortName = user.short_name, - hwModel = user.hw_model, - role = user.role.value, - ), - position = - org.meshtastic.core.model - .Position( - latitude = latitude, - longitude = longitude, - altitude = position.altitude ?: 0, - time = position.time, - satellitesInView = position.sats_in_view, - groundSpeed = position.ground_speed ?: 0, - groundTrack = position.ground_track ?: 0, - precisionBits = position.precision_bits, - ) - .takeIf { latitude != 0.0 || longitude != 0.0 }, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceMetrics = - org.meshtastic.core.model.DeviceMetrics( - batteryLevel = deviceMetrics.battery_level ?: 0, - voltage = deviceMetrics.voltage ?: 0f, - channelUtilization = deviceMetrics.channel_utilization ?: 0f, - airUtilTx = deviceMetrics.air_util_tx ?: 0f, - uptimeSeconds = deviceMetrics.uptime_seconds ?: 0, - ), - channel = channel, - environmentMetrics = org.meshtastic.core.model.EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0), - hopsAway = hopsAway, - nodeStatus = nodeStatus, - ) - - fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN) - - override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) { - if (packetId == 0) { - Logger.d { "Ignoring anonymous packet status" } - } else { - // Do not log, contains PII possibly - // MeshService.Logger.d { "Broadcasting message status $p" } - val intent = - Intent(ACTION_MESSAGE_STATUS).apply { - putExtra(EXTRA_PACKET_ID, packetId) - putExtra(EXTRA_STATUS, status as Parcelable) - } - explicitBroadcast(intent) - } - } - - /** Broadcast our current connection status */ - override fun broadcastConnection() { - val connectionState = serviceRepository.connectionState.value - // ATAK expects a String: "CONNECTED" or "DISCONNECTED" - // It uses equalsIgnoreCase, but we'll use uppercase to be specific. - val stateStr = connectionState.toString().uppercase(Locale.ROOT) - - val intent = Intent(ACTION_MESH_CONNECTED).apply { putExtra(EXTRA_CONNECTED, stateStr) } - explicitBroadcast(intent) - - if (connectionState == ConnectionState.Disconnected) { - explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED)) - } - - // Restore legacy action for other consumers (e.g. ATAK plugins) - val legacyIntent = - Intent(ACTION_CONNECTION_CHANGED).apply { - putExtra(EXTRA_CONNECTED, stateStr) - // Legacy boolean extra often expected by older implementations - putExtra("connected", connectionState == ConnectionState.Connected) - } - explicitBroadcast(legacyIntent) - } - - /** - * See com.geeksville.mesh broadcast intents. - * - * RECEIVED_OPAQUE for data received from other nodes - * NODE_CHANGE for new IDs appearing or disappearing - * ACTION_MESH_CONNECTED for losing/gaining connection to the packet radio - * Note: this is not the same as RadioInterfaceService.RADIO_CONNECTED_ACTION, - * because it implies we have assembled a valid node db. - */ - private fun explicitBroadcast(intent: Intent) { - context.sendBroadcast( - intent, - ) // We also do a regular (not explicit broadcast) so any context-registered receivers will work - clientPackages.forEach { - intent.setClassName(it.value, it.key) - context.sendBroadcast(intent) - } - } -} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceClient.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceClient.kt deleted file mode 100644 index c7c1e01f49..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceClient.kt +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.service - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.os.IInterface -import co.touchlab.kermit.Logger -import kotlinx.coroutines.delay -import org.meshtastic.core.common.util.exceptionReporter -import java.io.Closeable -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -class BindFailedException : Exception("bindService failed") - -/** - * A generic helper for binding to an Android Service via AIDL. Handles connection lifecycle, thread safety for initial - * binding, and automatic retry for common race conditions. - * - * @param T The type of the AIDL interface. - * @param stubFactory A factory function to convert an [IBinder] to the interface type. - */ -open class ServiceClient(private val stubFactory: (IBinder) -> T) : Closeable { - - private companion object { - const val BIND_RETRY_DELAY_MS = 500L - } - - /** The currently bound service instance, or null if not connected. */ - var serviceP: T? = null - - /** - * Returns the bound service instance. If not currently connected, this will block the current thread until the - * connection is established. - * - * @throws IllegalStateException If [connect] has not been called. - * @throws IllegalStateException If the service is not bound after waiting. - */ - val service: T - get() { - waitConnect() - return checkNotNull(serviceP) { "Service not bound" } - } - - private var context: Context? = null - private var isClosed = true - - private val lock = ReentrantLock() - private val condition = lock.newCondition() - - /** - * Blocks the current thread until the service is connected. - * - * @throws IllegalStateException If [connect] has not been called. - */ - fun waitConnect() { - lock.withLock { - check(context != null) { "Connect must be called before waitConnect" } - - if (serviceP == null) { - condition.await() - } - } - } - - /** - * Initiates a binding to the service. - * - * @param c The context to use for binding. - * @param intent The intent used to identify the service. - * @param flags Binding flags (e.g., [Context.BIND_AUTO_CREATE]). - * @throws BindFailedException If the initial bind call fails twice. - */ - suspend fun connect(c: Context, intent: Intent, flags: Int) { - context = c - if (isClosed) { - isClosed = false - if (!c.bindService(intent, connection, flags)) { - // Handle potential race condition on quick re-bind - Logger.w { "Initial bind failed, retrying after delay..." } - delay(BIND_RETRY_DELAY_MS) - if (!c.bindService(intent, connection, flags)) { - throw BindFailedException() - } - } - } else { - Logger.w { "Ignoring rebind attempt for already active service connection" } - } - } - - override fun close() { - isClosed = true - try { - context?.unbindService(connection) - } catch (ex: IllegalArgumentException) { - Logger.w(ex) { "Ignoring error during unbind: service might have already been cleaned up" } - } - serviceP = null - context = null - } - - /** Called on the main thread when the service is connected. */ - open fun onConnected(service: T) {} - - /** Called on the main thread when the service connection is lost. */ - open fun onDisconnected() {} - - private val connection = - object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, binder: IBinder) = exceptionReporter { - if (!isClosed) { - val s = stubFactory(binder) - serviceP = s - onConnected(s) - - lock.withLock { condition.signalAll() } - } else { - Logger.w { "Service connected after close was called; ignoring stale connection" } - } - } - - override fun onServiceDisconnected(name: ComponentName?) = exceptionReporter { - serviceP = null - onDisconnected() - } - } -} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt deleted file mode 100644 index 3549aff6e1..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -@file:Suppress("DEPRECATION") // IMeshService is deprecated but still required for AIDL binding - -package org.meshtastic.core.service.testing - -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.NodeInfo -import org.meshtastic.core.model.Position -import org.meshtastic.core.service.IMeshService - -/** - * A fake implementation of [IMeshService] for testing purposes. This also serves as a contract verification: if the - * AIDL changes, this class will fail to compile. - * - * Developers can use this to mock the MeshService in their unit tests. - */ -@Suppress("TooManyFunctions", "EmptyFunctionBlock") -open class FakeIMeshService : IMeshService.Stub() { - override fun subscribeReceiver(packageName: String?, receiverName: String?) {} - - override fun setOwner(user: MeshUser?) {} - - override fun setRemoteOwner(requestId: Int, destNum: Int, payload: ByteArray?) {} - - override fun getRemoteOwner(requestId: Int, destNum: Int) {} - - override fun getMyId(): String = "fake_id" - - override fun getPacketId(): Int = 1234 - - override fun send(packet: DataPacket?) {} - - override fun getNodes(): List = emptyList() - - override fun getConfig(): ByteArray = byteArrayOf() - - override fun setConfig(payload: ByteArray?) {} - - override fun setRemoteConfig(requestId: Int, destNum: Int, payload: ByteArray?) {} - - override fun getRemoteConfig(requestId: Int, destNum: Int, configTypeValue: Int) {} - - override fun setModuleConfig(requestId: Int, destNum: Int, payload: ByteArray?) {} - - override fun getModuleConfig(requestId: Int, destNum: Int, moduleConfigTypeValue: Int) {} - - override fun setRingtone(destNum: Int, ringtone: String?) {} - - override fun getRingtone(requestId: Int, destNum: Int) {} - - override fun setCannedMessages(destNum: Int, messages: String?) {} - - override fun getCannedMessages(requestId: Int, destNum: Int) {} - - override fun setChannel(payload: ByteArray?) {} - - override fun setRemoteChannel(requestId: Int, destNum: Int, payload: ByteArray?) {} - - override fun getRemoteChannel(requestId: Int, destNum: Int, channelIndex: Int) {} - - override fun beginEditSettings(destNum: Int) {} - - override fun commitEditSettings(destNum: Int) {} - - override fun removeByNodenum(requestID: Int, nodeNum: Int) {} - - override fun requestPosition(destNum: Int, position: Position?) {} - - override fun setFixedPosition(destNum: Int, position: Position?) {} - - override fun requestTraceroute(requestId: Int, destNum: Int) {} - - override fun requestNeighborInfo(requestId: Int, destNum: Int) {} - - override fun requestShutdown(requestId: Int, destNum: Int) {} - - override fun requestReboot(requestId: Int, destNum: Int) {} - - override fun requestFactoryReset(requestId: Int, destNum: Int) {} - - override fun rebootToDfu(destNum: Int) {} - - override fun requestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {} - - override fun getChannelSet(): ByteArray = byteArrayOf() - - override fun connectionState(): String = "CONNECTED" - - @Suppress("OVERRIDE_DEPRECATION") - override fun setDeviceAddress(deviceAddr: String?): Boolean = true - - override fun getMyNodeInfo(): MyNodeInfo? = null - - @Suppress("OVERRIDE_DEPRECATION") - override fun startFirmwareUpdate() {} - - @Suppress("OVERRIDE_DEPRECATION") - override fun getUpdateStatus(): Int = 0 - - override fun startProvideLocation() {} - - override fun stopProvideLocation() {} - - override fun requestUserInfo(destNum: Int) {} - - override fun getDeviceConnectionStatus(requestId: Int, destNum: Int) {} - - override fun requestTelemetry(requestId: Int, destNum: Int, type: Int) {} - - override fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {} -} diff --git a/core/service/src/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 a4c95d8cd5..0000000000 --- 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 ab107e18b3..c4f877d02d 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 @@ -17,24 +17,19 @@ package org.meshtastic.core.service import co.touchlab.kermit.Logger -import co.touchlab.kermit.Severity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch 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.MeshMessageProcessor -import org.meshtastic.core.repository.MeshRouter +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.RadioPrefs import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.takserver.TAKMeshIntegration @@ -51,17 +46,14 @@ import org.meshtastic.core.takserver.TAKServerManager @Suppress("LongParameterList") @Single class MeshServiceOrchestrator( - private val radioInterfaceService: RadioInterfaceService, - private val serviceRepository: ServiceRepository, - private val nodeManager: NodeManager, - private val messageProcessor: MeshMessageProcessor, - private val router: MeshRouter, + private val radioPrefs: RadioPrefs, private val serviceNotifications: MeshServiceNotifications, private val takServerManager: TAKServerManager, 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 @@ -75,8 +67,13 @@ class MeshServiceOrchestrator( /** * Starts the mesh service components and wires up data flows. * - * This is the KMP equivalent of `MeshService.onCreate()`. It connects to the radio and wires incoming radio data to - * the message processor and service actions to the router's action handler. + * With the SDK hard-cutover, the RadioClient (via RadioClientProvider) owns transport and + * packet handling. This orchestrator retains responsibility for: + * - Per-device database initialization + * - TAK server integration lifecycle + * - Service notification channels + * + * ServiceAction dispatch and radio data flows are handled by [SdkStateBridge]. */ fun start() { if (isRunning) { @@ -84,17 +81,26 @@ class MeshServiceOrchestrator( return } - Logger.i { "Starting mesh service orchestrator" } + Logger.i { "Starting mesh service orchestrator (SDK mode)" } val newScope = CoroutineScope(SupervisorJob() + dispatchers.default) scope = newScope - // Drop any bytes that piled up in the service's receivedData channel since the last stop(). The channel - // outlives the orchestrator's per-start scope, so without this drain a stop/start cycle would replay stale - // packets ahead of the fresh session's firmware handshake. - radioInterfaceService.resetReceivedBuffer() - 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 @@ -110,54 +116,22 @@ class MeshServiceOrchestrator( .launchIn(newScope) newScope.handledLaunch { - // Ensure the per-device database is active before the radio connects. - // On Android this is handled by MeshUtilApplication.init(); on Desktop (and any - // future KMP host) the orchestrator is the first entry point, so it must initialize - // the database here. Without this, DatabaseManager._currentDb stays null and all - // Room writes via withDb() are silently dropped — causing ourNodeInfo to remain null - // after the handshake completes. - databaseManager.switchActiveDatabase(radioInterfaceService.getDeviceAddress()) - Logger.i { "Per-device database initialized, connecting radio" } - radioInterfaceService.connect() + // Ensure the per-device database is active before SDK connects. + databaseManager.switchActiveDatabase(radioPrefs.devAddr.value) + Logger.i { "Per-device database initialized" } } - - radioInterfaceService.receivedData - .onEach { bytes -> messageProcessor.handleFromRadio(bytes, nodeManager.myNodeNum.value) } - .launchIn(newScope) - - radioInterfaceService.connectionError - .onEach { errorMessage -> serviceRepository.setErrorMessage(errorMessage, Severity.Warn) } - .launchIn(newScope) - - // Each action is dispatched in its own supervised coroutine so that a failure in one - // action (e.g. a timeout in sendAdminAwait) cannot terminate the collector and silently - // drop all subsequent service actions for the rest of the session. - serviceRepository.serviceAction - .onEach { action -> newScope.handledLaunch { router.actionHandler.onServiceAction(action) } } - .launchIn(newScope) - - nodeManager.loadCachedNodeDB() } /** * Stops the mesh service components and cancels the coroutine scope. * - * This is the KMP equivalent of `MeshService.onDestroy()`. + * Radio disconnect is handled by [RadioClientProvider.disconnect] / SDK client teardown. */ fun stop() { Logger.i { "Stopping mesh service orchestrator" } - // Guard stop() so we don't emit a spurious "stopped" log when TAK was never started if (takServerManager.isRunning.value) { takMeshIntegration.stop() } - // Best-effort polite goodbye on service teardown (onDestroy / process shutdown). We launch - // on a fresh detached scope — not the orchestrator's per-start scope — so the subsequent - // scope.cancel() below doesn't interrupt the short drain delay inside disconnect(). The - // coroutine is fire-and-forget; typical runtime is ~100-150ms which comfortably fits - // inside Android's onDestroy() grace window. - CoroutineScope(SupervisorJob() + dispatchers.default).launch { - runCatching { radioInterfaceService.disconnect() } - } scope?.cancel() scope = null } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SdkClientLifecycle.kt similarity index 57% rename from core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt rename to core/service/src/commonMain/kotlin/org/meshtastic/core/service/SdkClientLifecycle.kt index cc3a1a37ea..a8727cb2c5 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SdkClientLifecycle.kt @@ -14,14 +14,16 @@ * 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 +package org.meshtastic.core.service -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) - } +/** + * Minimal interface allowing [MeshService] to tear down the SDK client on service destroy + * without creating a reverse module dependency on `app`. + * + * Implemented by `RadioClientProvider` in the `app` module and registered in Koin as a + * `@Single(binds = [SdkClientLifecycle::class])`. [MeshService] injects it via `by inject()`. + */ +interface SdkClientLifecycle { + /** Gracefully disconnect and release the active SDK radio client. */ + fun disconnect() } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt index 5ad5c2d003..76485e61fa 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt @@ -24,13 +24,16 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.MeshActivity 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.meshtastic.core.model.CongestionLevel /** * Platform-agnostic implementation of [ServiceRepository]. @@ -42,13 +45,23 @@ import org.meshtastic.proto.MeshPacket @Suppress("TooManyFunctions") open class ServiceRepositoryImpl : ServiceRepository { - // Canonical app-level connection state — written exclusively by MeshConnectionManager. + // Canonical app-level connection state — written exclusively by SdkStateBridge. private val _connectionState: MutableStateFlow = MutableStateFlow(ConnectionState.Disconnected) override val connectionState: StateFlow get() = _connectionState override fun setConnectionState(connectionState: ConnectionState) { _connectionState.value = connectionState + if (connectionState == ConnectionState.Disconnected) { + setCongestionLevel(null) + } + } + + private val _congestionLevel = MutableStateFlow(null) + override val congestionLevel: StateFlow = _congestionLevel.asStateFlow() + + override fun setCongestionLevel(level: CongestionLevel?) { + _congestionLevel.value = level } private val _clientNotification = MutableStateFlow(null) @@ -64,6 +77,14 @@ open class ServiceRepositoryImpl : ServiceRepository { _clientNotification.value = null } + private val _storeForwardServers = MutableStateFlow>(emptyList()) + override val storeForwardServers: StateFlow> + get() = _storeForwardServers + + override fun setStoreForwardServers(servers: List) { + _storeForwardServers.value = servers + } + private val _errorMessage = MutableStateFlow(null) override val errorMessage: StateFlow get() = _errorMessage @@ -93,6 +114,15 @@ open class ServiceRepositoryImpl : ServiceRepository { override suspend fun emitMeshPacket(packet: MeshPacket) { _meshPacketFlow.emit(packet) + _meshActivityFlow.tryEmit(MeshActivity.Receive) + } + + private val _meshActivityFlow = MutableSharedFlow(extraBufferCapacity = 64) + override val meshActivityFlow: Flow + get() = _meshActivityFlow.asFlow() + + override fun emitMeshActivity(activity: MeshActivity) { + _meshActivityFlow.tryEmit(activity) } private val _tracerouteResponse = MutableStateFlow(null) diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt deleted file mode 100644 index 68a3573e9c..0000000000 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt +++ /dev/null @@ -1,417 +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 androidx.lifecycle.Lifecycle -import androidx.lifecycle.coroutineScope -import co.touchlab.kermit.Logger -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.receiveAsFlow -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.ble.BluetoothRepository -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.di.CoroutineDispatchers -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.InterfaceId -import org.meshtastic.core.model.MeshActivity -import org.meshtastic.core.model.util.anonymize -import org.meshtastic.core.network.repository.NetworkRepository -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.RadioPrefs -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportFactory -import org.meshtastic.proto.ToRadio -import kotlin.concurrent.Volatile - -/** - * Shared multiplatform connection orchestrator for Meshtastic radios. - * - * Manages the connection lifecycle (connect, active, disconnect, reconnect loop), device address state flows, and - * hardware state observability (BLE/Network toggles). Delegates the actual raw byte transport mapping to a - * platform-specific [RadioTransportFactory]. - */ -@Suppress("LongParameterList", "TooManyFunctions") -@Single -class SharedRadioInterfaceService( - private val dispatchers: CoroutineDispatchers, - private val bluetoothRepository: BluetoothRepository, - private val networkRepository: NetworkRepository, - @Named("ProcessLifecycle") private val processLifecycle: Lifecycle, - private val radioPrefs: RadioPrefs, - private val transportFactory: RadioTransportFactory, - private val analytics: PlatformAnalytics, -) : RadioInterfaceService { - - override val supportedDeviceTypes: List - get() = transportFactory.supportedDeviceTypes - - /** - * Transport-level connection state reflecting the raw hardware link status. - * - * Updated directly by [onConnect] and [onDisconnect] when the physical transport (BLE, TCP, Serial) connects or - * disconnects. This is consumed exclusively by - * [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager], which reconciles it into the - * canonical app-level - * [ServiceRepository.connectionState][org.meshtastic.core.repository.ServiceRepository.connectionState]. - */ - private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) - override val connectionState: StateFlow = _connectionState.asStateFlow() - - private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value) - override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow.asStateFlow() - - // Unbounded Channel preserves strict FIFO delivery of incoming radio bytes, which the - // firmware handshake depends on (initial config packet ordering). A SharedFlow with - // `launch { emit() }` per packet reorders under concurrent dispatch and breaks config load. - // trySend on an UNLIMITED channel never suspends and never drops, so handleFromRadio can - // remain a non-suspend synchronous callback. - private val _receivedData = Channel(Channel.UNLIMITED) - override val receivedData: Flow = _receivedData.receiveAsFlow() - - private val _meshActivity = - MutableSharedFlow(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST) - override val meshActivity: Flow = _meshActivity.asFlow() - - private val _connectionError = MutableSharedFlow(extraBufferCapacity = 64) - override val connectionError: Flow = _connectionError.asFlow() - - override val serviceScope: CoroutineScope - get() = _serviceScope - - private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) - private var radioTransport: RadioTransport? = null - private var runningTransportId: InterfaceId? = null - private var isStarted = false - - /** - * Set while [stopTransportLocked] is draining the polite disconnect frame. [sendToRadio] checks this so any late - * traffic submitted after we've announced disconnection is dropped rather than racing in front of the firmware-side - * link teardown. - */ - @Volatile private var isStopping = false - - private val listenersInitialized = atomic(false) - private var heartbeatJob: Job? = null - private var lastHeartbeatMillis = 0L - - @Volatile private var lastDataReceivedMillis = 0L - - companion object { - private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L - - // If we haven't received any data from the radio within this window after sending a - // heartbeat while the connection is nominally "Connected", the connection is likely a - // zombie (BLE stack didn't report disconnect). Two missed heartbeat intervals gives - // the firmware a reasonable window to respond or send telemetry. - private const val LIVENESS_TIMEOUT_MILLIS = HEARTBEAT_INTERVAL_MILLIS * 2 - - /** - * Upper bound on how long we wait for the polite `ToRadio(disconnect = true)` frame to flush before tearing the - * transport down. 500ms gives BLE's write-retry path (`BleRetry` backs off 500ms) room for one attempt on a - * flaky GATT connection. Serial and TCP typically flush well under this window. - */ - private const val POLITE_DISCONNECT_DRAIN_MS = 500L - } - - private val initLock = Mutex() - private val transportMutex = Mutex() - - private fun initStateListeners() { - if (listenersInitialized.value) return - processLifecycle.coroutineScope.launch { - initLock.withLock { - if (listenersInitialized.value) return@withLock - listenersInitialized.value = true - - radioPrefs.devAddr - .onEach { addr -> - transportMutex.withLock { - if (_currentDeviceAddressFlow.value != addr) { - _currentDeviceAddressFlow.value = addr - startTransportLocked() - } - } - } - .catch { Logger.e(it) { "devAddr flow crashed" } } - .launchIn(processLifecycle.coroutineScope) - - bluetoothRepository.state - .onEach { state -> - transportMutex.withLock { - if (state.enabled) { - startTransportLocked() - } else if (runningTransportId == InterfaceId.BLUETOOTH) { - stopTransportLocked() - } - } - } - .catch { Logger.e(it) { "bluetoothRepository.state flow crashed" } } - .launchIn(processLifecycle.coroutineScope) - - networkRepository.networkAvailable - .onEach { state -> - transportMutex.withLock { - if (state) { - startTransportLocked() - } else if (runningTransportId == InterfaceId.TCP) { - stopTransportLocked() - } - } - } - .catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed" } } - .launchIn(processLifecycle.coroutineScope) - } - } - } - - override fun connect() { - processLifecycle.coroutineScope.launch { transportMutex.withLock { startTransportLocked() } } - initStateListeners() - } - - override suspend fun disconnect() { - transportMutex.withLock { ignoreExceptionSuspend { stopTransportLocked() } } - } - - override fun isMockTransport(): Boolean = transportFactory.isMockTransport() - - override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = - transportFactory.toInterfaceAddress(interfaceId, rest) - - override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value - - private fun getBondedDeviceAddress(): String? { - val address = getDeviceAddress() - return if (transportFactory.isAddressValid(address)) { - address - } else { - null - } - } - - override fun setDeviceAddress(deviceAddr: String?): Boolean { - val sanitized = if (deviceAddr == "n" || deviceAddr.isNullOrBlank()) null else deviceAddr - - if (getBondedDeviceAddress() == sanitized && isStarted && _connectionState.value == ConnectionState.Connected) { - Logger.w { "Ignoring setBondedDevice ${sanitized?.anonymize}, already using that device" } - return false - } - - analytics.track("mesh_bond") - - Logger.d { "Setting bonded device to ${sanitized?.anonymize}" } - radioPrefs.setDevAddr(sanitized) - _currentDeviceAddressFlow.value = sanitized - - processLifecycle.coroutineScope.launch { - transportMutex.withLock { - ignoreExceptionSuspend { stopTransportLocked() } - startTransportLocked() - } - } - return true - } - - /** Must be called under [transportMutex]. */ - private fun startTransportLocked() { - if (radioTransport != null) return - - // Never autoconnect to the simulated node. The mock transport may be offered in the - // device-picker UI on debug builds, but it must only connect when the user explicitly - // selects it (i.e. its address is stored in radioPrefs). - val address = getBondedDeviceAddress() - - if (address == null) { - Logger.d { "No valid address to connect to" } - return - } - - Logger.i { "Starting radio transport for ${address.anonymize}" } - isStarted = true - runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } - radioTransport = transportFactory.createTransport(address, this) - startHeartbeat() - } - - /** Must be called under [transportMutex]. */ - private suspend fun stopTransportLocked() { - val currentTransport = radioTransport - Logger.i { "Stopping transport $currentTransport" } - // Best-effort polite goodbye: tell the firmware we're disconnecting on purpose so it can - // tear down its side of the link cleanly instead of relying on timeouts / hardware events. - // Flip isStopping before sending so any concurrent sendToRadio() drops incoming traffic — - // we don't want normal packets racing behind the disconnect frame. Skip only when already - // Disconnected; firmware can still consume the goodbye while handshaking or sleeping, so - // it's worth sending in every other state. The send is fire-and-forget through the - // transport's own scope; the drain delay gives async transports a window to flush before - // close() cancels their write scope. BLE's retry path backs off 500ms, so this window - // also covers one retry on flaky GATT links. - if (currentTransport != null && _connectionState.value != ConnectionState.Disconnected) { - isStopping = true - ignoreExceptionSuspend { - currentTransport.handleSendToRadio(ToRadio(disconnect = true).encode()) - delay(POLITE_DISCONNECT_DRAIN_MS) - } - } - isStarted = false - radioTransport = null - runningTransportId = null - isStopping = false - currentTransport?.close() - - _serviceScope.cancel("stopping transport") - _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob()) - - if (currentTransport != null) { - onDisconnect(isPermanent = true) - } - } - - private fun startHeartbeat() { - heartbeatJob?.cancel() - lastDataReceivedMillis = nowMillis - heartbeatJob = - serviceScope.launch { - while (true) { - delay(HEARTBEAT_INTERVAL_MILLIS) - keepAlive() - checkLiveness() - } - } - } - - /** - * Detects zombie connections where the BLE stack didn't report a disconnect. - * - * If we believe we're connected but haven't received any data from the radio within [LIVENESS_TIMEOUT_MILLIS], the - * connection is likely dead. Signal a non-permanent disconnect so the reconnect machinery can take over. - */ - private fun checkLiveness() { - if (_connectionState.value != ConnectionState.Connected) return - - val silenceMs = nowMillis - lastDataReceivedMillis - if (silenceMs > LIVENESS_TIMEOUT_MILLIS) { - Logger.w { - "Liveness check failed: no data received for ${silenceMs}ms " + - "(threshold: ${LIVENESS_TIMEOUT_MILLIS}ms). Treating as disconnect." - } - onDisconnect(isPermanent = false, errorMessage = "Connection timeout — no data received") - } - } - - fun keepAlive(now: Long = nowMillis) { - if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) { - radioTransport?.keepAlive() - lastHeartbeatMillis = now - } - } - - override fun sendToRadio(bytes: ByteArray) { - if (isStopping) { - Logger.d { "sendToRadio: transport stopping, dropping ${bytes.size} bytes" } - return - } - // Snapshot the transport to avoid calling handleSendToRadio on a null reference. - // There is still a benign race: stopTransportLocked() may cancel _serviceScope - // between the null-check and the launch, causing the coroutine to be silently - // dropped. This is acceptable — if the transport is shutting down, dropping the - // send is the correct behavior. - val currentTransport = - radioTransport - ?: run { - Logger.w { "sendToRadio: no active radio transport, dropping ${bytes.size} bytes" } - return - } - _serviceScope.handledLaunch { - currentTransport.handleSendToRadio(bytes) - _meshActivity.tryEmit(MeshActivity.Send) - } - } - - @Suppress("TooGenericExceptionCaught") - override fun handleFromRadio(bytes: ByteArray) { - try { - lastDataReceivedMillis = nowMillis - // trySend synchronously onto the unbounded Channel so packet order matches arrival - // order. The previous `launch { emit() }` pattern dispatched each packet onto a - // fresh coroutine, letting the scheduler reorder them — which broke the firmware - // config handshake (see PhoneAPI.cpp initial-handshake sequence). - val result = _receivedData.trySend(bytes) - if (result.isFailure) { - Logger.e(result.exceptionOrNull()) { "Failed to enqueue ${bytes.size} received bytes; dropping packet" } - } - _meshActivity.tryEmit(MeshActivity.Receive) - } catch (t: Throwable) { - Logger.e(t) { "handleFromRadio failed while emitting data" } - } - } - - override fun resetReceivedBuffer() { - // Drain any bytes buffered while no collector was attached. Without this, a stop/start cycle - // would replay stale bytes ahead of the next session's firmware handshake, since the channel - // outlives the orchestrator's per-start scope. - @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody") - while (_receivedData.tryReceive().isSuccess) Unit - } - - override fun onConnect() { - // MutableStateFlow.value is thread-safe (backed by atomics) — assign directly rather than - // launching a coroutine. The async launch pattern introduced a window where a concurrent - // onDisconnect launch could execute AFTER an onConnect launch, leaving the service stuck - // in Connected while the transport was actually disconnected. - lastDataReceivedMillis = nowMillis - if (_connectionState.value != ConnectionState.Connected) { - Logger.d { "Broadcasting connection state change to Connected" } - _connectionState.value = ConnectionState.Connected - } - } - - override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) { - if (errorMessage != null) { - processLifecycle.coroutineScope.launch(dispatchers.default) { _connectionError.emit(errorMessage) } - } - val newTargetState = if (isPermanent) ConnectionState.Disconnected else ConnectionState.DeviceSleep - if (_connectionState.value != newTargetState) { - Logger.d { "Broadcasting connection state change to $newTargetState" } - _connectionState.value = newTargetState - } - } -} 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 87109be1ec..e83b367ff4 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 @@ -16,14 +16,12 @@ */ package org.meshtastic.core.service -import co.touchlab.kermit.Severity 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.verify.VerifyMode.Companion.atLeast import dev.mokkery.verify.VerifyMode.Companion.exactly import dev.mokkery.verifySuspend import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,18 +30,13 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshActionHandler -import org.meshtastic.core.repository.MeshConfigHandler -import org.meshtastic.core.repository.MeshConnectionManager -import org.meshtastic.core.repository.MeshMessageProcessor -import org.meshtastic.core.repository.MeshRouter +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.RadioInterfaceService +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.takserver.TAKMeshIntegration @@ -56,21 +49,19 @@ import kotlin.test.assertTrue class MeshServiceOrchestratorTest { - private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) - private val serviceRepository: ServiceRepository = mock(MockMode.autofill) - private val nodeManager: NodeManager = mock(MockMode.autofill) - private val nodeRepository: NodeRepository = mock(MockMode.autofill) - private val messageProcessor: MeshMessageProcessor = mock(MockMode.autofill) - private val commandSender: CommandSender = mock(MockMode.autofill) - private val router: MeshRouter = mock(MockMode.autofill) - private val actionHandler: MeshActionHandler = mock(MockMode.autofill) - private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill) + private val radioPrefs: RadioPrefs = mock(MockMode.autofill) private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill) private val takServerManager: TAKServerManager = mock(MockMode.autofill) private val takPrefs: TakPrefs = mock(MockMode.autofill) - private val cotHandler: CoTHandler = 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) + private val nodeRepository: NodeRepository = mock(MockMode.autofill) + private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) + private val cotHandler: CoTHandler = mock(MockMode.autofill) @OptIn(ExperimentalCoroutinesApi::class) private val testDispatcher = UnconfinedTestDispatcher() @@ -78,47 +69,36 @@ class MeshServiceOrchestratorTest { @OptIn(ExperimentalCoroutinesApi::class) private val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher) - /** Stubs the shared flow dependencies used by every test and returns an orchestrator. */ private fun createOrchestrator( - receivedData: MutableSharedFlow = MutableSharedFlow(), - connectionError: MutableSharedFlow = MutableSharedFlow(), - serviceAction: MutableSharedFlow = MutableSharedFlow(), takEnabledFlow: MutableStateFlow = MutableStateFlow(false), takRunningFlow: MutableStateFlow = MutableStateFlow(false), ): MeshServiceOrchestrator { - every { radioInterfaceService.receivedData } returns receivedData - every { radioInterfaceService.connectionError } returns connectionError - every { serviceRepository.serviceAction } returns serviceAction - every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() - every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig()) every { takPrefs.isTakServerEnabled } returns takEnabledFlow every { takServerManager.isRunning } returns takRunningFlow every { takServerManager.inboundMessages } returns MutableSharedFlow() + every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig()) every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) - every { router.actionHandler } returns actionHandler + every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() + every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) - val takMeshIntegration = - TAKMeshIntegration( - takServerManager = takServerManager, - commandSender = commandSender, - nodeRepository = nodeRepository, - serviceRepository = serviceRepository, - meshConfigHandler = meshConfigHandler, - cotHandler = cotHandler, - ) + val takMeshIntegration = TAKMeshIntegration( + takServerManager = takServerManager, + radioController = radioController, + nodeRepository = nodeRepository, + serviceRepository = serviceRepository, + radioConfigRepository = radioConfigRepository, + cotHandler = cotHandler, + ) return MeshServiceOrchestrator( - radioInterfaceService = radioInterfaceService, - serviceRepository = serviceRepository, - nodeManager = nodeManager, - messageProcessor = messageProcessor, - router = router, + radioPrefs = radioPrefs, serviceNotifications = serviceNotifications, takServerManager = takServerManager, takMeshIntegration = takMeshIntegration, takPrefs = takPrefs, databaseManager = databaseManager, - connectionManager = connectionManager, + serviceRepository = serviceRepository, + appWidgetUpdater = appWidgetUpdater, dispatchers = dispatchers, ) } @@ -132,7 +112,6 @@ class MeshServiceOrchestratorTest { assertTrue(orchestrator.isRunning) verify { serviceNotifications.initChannels() } - verify { nodeManager.loadCachedNodeDB() } orchestrator.stop() assertFalse(orchestrator.isRunning) @@ -163,43 +142,12 @@ class MeshServiceOrchestratorTest { @Test fun testStartCallsSwitchActiveDatabase() { - every { radioInterfaceService.getDeviceAddress() } returns "tcp:192.168.1.100" + every { radioPrefs.devAddr } returns MutableStateFlow("tcp:192.168.1.100") val orchestrator = createOrchestrator() orchestrator.start() verifySuspend { databaseManager.switchActiveDatabase("tcp:192.168.1.100") } - verify { radioInterfaceService.connect() } - - orchestrator.stop() - } - - @Test - fun testConnectionErrorForwardedToServiceRepository() { - val connectionError = MutableSharedFlow(extraBufferCapacity = 1) - - val orchestrator = createOrchestrator(connectionError = connectionError) - orchestrator.start() - - // Emit an error into the radio interface's connectionError flow - connectionError.tryEmit("BLE connection lost") - - verify { serviceRepository.setErrorMessage("BLE connection lost", Severity.Warn) } - - orchestrator.stop() - } - - @Test - fun testServiceActionDispatchedToActionHandler() { - val serviceAction = MutableSharedFlow(extraBufferCapacity = 1) - - val orchestrator = createOrchestrator(serviceAction = serviceAction) - orchestrator.start() - - val action = ServiceAction.Favorite(Node(num = 42)) - serviceAction.tryEmit(action) - - verifySuspend { actionHandler.onServiceAction(action) } orchestrator.stop() } @@ -217,84 +165,8 @@ class MeshServiceOrchestratorTest { // Components should only be initialized once verify(exactly(1)) { serviceNotifications.initChannels() } - verify(exactly(1)) { nodeManager.loadCachedNodeDB() } orchestrator.stop() assertFalse(orchestrator.isRunning) } - - /** - * Regression test for a bug where `stop()` did not actually tear down the FromRadio collectors. Collectors were - * attached to an injected process-wide ServiceScope rather than a per-start scope, so `start() -> stop() -> - * start()` caused duplicate collectors and every FromRadio packet was handled 2x (then 3x, etc.). - */ - @Test - fun testFromRadioCollectorsTornDownOnStopAndRestartedCleanlyOnStart() { - val receivedData = MutableSharedFlow(extraBufferCapacity = 8) - val orchestrator = createOrchestrator(receivedData = receivedData) - every { nodeManager.myNodeNum } returns MutableStateFlow(null) - - orchestrator.start() - val packet1 = byteArrayOf(1, 2, 3) - receivedData.tryEmit(packet1) - verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet1, null) } - - orchestrator.stop() - val packet2 = byteArrayOf(4, 5, 6) - receivedData.tryEmit(packet2) - // After stop(), the collector must be gone - the handler should not be invoked for packet2. - verifySuspend(exactly(0)) { messageProcessor.handleFromRadio(packet2, null) } - - orchestrator.start() - val packet3 = byteArrayOf(7, 8, 9) - receivedData.tryEmit(packet3) - // After restart, a single fresh collector must process packet3 exactly once (not twice). - verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet3, null) } - - orchestrator.stop() - } - - /** - * Regression test for a channel-buffer-replay bug: the production [RadioInterfaceService] buffers inbound bytes in - * a process-lifetime `Channel(UNLIMITED)`. Between `stop()` and the next `start()`, any bytes that arrive sit in - * the channel and would be replayed to the fresh collector — prepending stale packets to the next session's - * firmware handshake. `start()` must call [RadioInterfaceService.resetReceivedBuffer] before attaching the - * collector. - */ - @Test - fun testStartDrainsReceivedBufferBeforeAttachingCollector() { - val orchestrator = createOrchestrator() - every { nodeManager.myNodeNum } returns MutableStateFlow(null) - - orchestrator.start() - orchestrator.stop() - orchestrator.start() - - // resetReceivedBuffer must be invoked at least once per start() (twice total for two starts). - verify(atLeast(2)) { radioInterfaceService.resetReceivedBuffer() } - - orchestrator.stop() - } - - /** Additional regression: after many start/stop cycles, collectors must not accumulate. */ - @Test - fun testRepeatedStartStopDoesNotAccumulateCollectors() { - val receivedData = MutableSharedFlow(extraBufferCapacity = 8) - val orchestrator = createOrchestrator(receivedData = receivedData) - every { nodeManager.myNodeNum } returns MutableStateFlow(null) - - repeat(5) { - orchestrator.start() - orchestrator.stop() - } - - orchestrator.start() - val packet = byteArrayOf(42) - receivedData.tryEmit(packet) - - // Despite six total start() calls, only the most recent collector is live. - verifySuspend(exactly(1)) { messageProcessor.handleFromRadio(packet, null) } - - orchestrator.stop() - } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt index 4f30014275..dffa2c46c5 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt @@ -28,9 +28,9 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.takserver.TAKPacketConversion.toCoTMessage import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket @@ -44,10 +44,10 @@ import kotlin.concurrent.Volatile class TAKMeshIntegration( private val takServerManager: TAKServerManager, - private val commandSender: CommandSender, + private val radioController: RadioController, private val nodeRepository: NodeRepository, private val serviceRepository: ServiceRepository, - private val meshConfigHandler: MeshConfigHandler, + private val radioConfigRepository: RadioConfigRepository, private val cotHandler: CoTHandler, ) { @Volatile private var isRunning = false @@ -92,7 +92,7 @@ class TAKMeshIntegration( .collect {} }, scope.launch { - meshConfigHandler.moduleConfig + radioConfigRepository.moduleConfigFlow .map { it.tak } .distinctUntilChanged() .collect { takConfig -> @@ -130,12 +130,12 @@ class TAKMeshIntegration( val dataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = payload.toByteString(), dataType = PortNum.ATAK_PLUGIN.value, ) - commandSender.sendData(dataPacket) + radioController.sendMessage(dataPacket) Logger.d { "Forwarded CoT to mesh as TAKPacket: ${cotMessage.type}" } } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt index 66fa34a93c..eccf19b478 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt @@ -19,9 +19,9 @@ package org.meshtastic.core.takserver.di import org.koin.core.annotation.Module import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshConfigHandler +import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServer @@ -37,23 +37,23 @@ class CoreTakServerModule { @Single fun provideTAKServerManager(takServer: TAKServer): TAKServerManager = TAKServerManagerImpl(takServer) @Single - fun provideGenericCoTHandler(commandSender: CommandSender, takServerManager: TAKServerManager): CoTHandler = - GenericCoTHandler(commandSender, takServerManager) + fun provideGenericCoTHandler(radioController: RadioController, takServerManager: TAKServerManager): CoTHandler = + GenericCoTHandler(radioController, takServerManager) @Single fun provideTAKMeshIntegration( takServerManager: TAKServerManager, - commandSender: CommandSender, + radioController: RadioController, nodeRepository: NodeRepository, serviceRepository: ServiceRepository, - meshConfigHandler: MeshConfigHandler, + radioConfigRepository: RadioConfigRepository, cotHandler: CoTHandler, ): TAKMeshIntegration = TAKMeshIntegration( takServerManager, - commandSender, + radioController, nodeRepository, serviceRepository, - meshConfigHandler, + radioConfigRepository, cotHandler, ) } diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt index c6bfb5f1eb..ec135a5017 100644 --- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt +++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/fountain/GenericCoTHandler.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.repository.CommandSender +import org.meshtastic.core.model.RadioController import org.meshtastic.core.takserver.CoTMessage import org.meshtastic.core.takserver.CoTXmlParser import org.meshtastic.core.takserver.TAKServerManager @@ -30,7 +30,7 @@ import org.meshtastic.core.takserver.toXml import org.meshtastic.proto.PortNum import kotlin.time.Clock -class GenericCoTHandler(private val commandSender: CommandSender, private val takServerManager: TAKServerManager) : +class GenericCoTHandler(private val radioController: RadioController, private val takServerManager: TAKServerManager) : CoTHandler { companion object { private const val INTER_PACKET_DELAY_MS = 100L @@ -90,14 +90,14 @@ class GenericCoTHandler(private val commandSender: CommandSender, private val ta } } - private fun sendDirect(payload: ByteArray) { + private suspend fun sendDirect(payload: ByteArray) { val dataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = payload.toByteString(), dataType = PortNum.ATAK_FORWARDER.value, ) - commandSender.sendData(dataPacket) + radioController.sendMessage(dataPacket) Logger.i { "Sent generic CoT directly: ${payload.size} bytes on port 257" } } @@ -115,11 +115,11 @@ class GenericCoTHandler(private val commandSender: CommandSender, private val ta for ((index, packetData) in packets.withIndex()) { val dataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = packetData.toByteString(), dataType = PortNum.ATAK_FORWARDER.value, ) - commandSender.sendData(dataPacket) + radioController.sendMessage(dataPacket) if (index < packets.size - 1) { delay(INTER_PACKET_DELAY_MS) // Inter-packet delay @@ -179,7 +179,7 @@ class GenericCoTHandler(private val commandSender: CommandSender, private val ta } } - private fun sendFountainAck(transferId: Int, hash: ByteArray, toNodeNum: Int) { + private suspend fun sendFountainAck(transferId: Int, hash: ByteArray, toNodeNum: Int) { val ackPacket = fountainCodec.buildAck( transferId, @@ -191,11 +191,11 @@ class GenericCoTHandler(private val commandSender: CommandSender, private val ta val dataPacket = DataPacket( - to = toNodeNum.toString(), + to = toNodeNum, bytes = ackPacket.toByteString(), dataType = PortNum.ATAK_FORWARDER.value, ) - commandSender.sendData(dataPacket) + radioController.sendMessage(dataPacket) Logger.d { "Sent fountain ACK for transfer $transferId" } } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 5cb6f5ce1d..094eec7a6c 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -132,6 +132,12 @@ class FakeUiPrefs : UiPrefs { excludeMqtt.value = value } + override val maxDistanceKm = MutableStateFlow(null) + + override fun setMaxDistanceKm(value: Float?) { + maxDistanceKm.value = value + } + override val hasShownNotPairedWarning = MutableStateFlow(false) override fun setHasShownNotPairedWarning(shown: Boolean) { diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt index cfdc64f4f2..65f487fb9e 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshService.kt @@ -26,13 +26,9 @@ class FakeMeshService { val nodeRepository = FakeNodeRepository() val serviceRepository = FakeServiceRepository() val radioController = FakeRadioController() - val radioInterfaceService = FakeRadioInterfaceService() val notifications = FakeMeshServiceNotifications() - val transport = FakeRadioTransport() val logRepository = FakeMeshLogRepository() val packetRepository = FakePacketRepository() val contactRepository = FakeContactRepository() val locationRepository = FakeLocationRepository() - - // Add more as they are implemented } 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 281c248b83..e9220ed8ad 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,9 +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.DeviceMetadata +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. @@ -164,11 +167,73 @@ class FakeNodeRepository : _nodeDBbyNum.value = nodes.associateBy { it.num } } - override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { - val node = _nodeDBbyNum.value[nodeNum] ?: return - _nodeDBbyNum.value = _nodeDBbyNum.value + (nodeNum to node.copy(metadata = metadata)) + // ── 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/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 4c5092080b..c2e689c5b1 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt @@ -19,11 +19,14 @@ package org.meshtastic.core.testing import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.DeviceAdminEdit import org.meshtastic.core.model.Position import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.TelemetryType import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User @@ -47,8 +50,8 @@ class FakeRadioController : val sentSharedContacts = mutableListOf() var throwOnSend: Boolean = false var lastSetDeviceAddress: String? = null - var beginEditSettingsCalled = false - var commitEditSettingsCalled = false + var lastStoreForwardHistoryRequest: Pair? = null + var editSettingsCalled = false var startProvideLocationCalled = false var stopProvideLocationCalled = false @@ -59,8 +62,8 @@ class FakeRadioController : sentSharedContacts.clear() throwOnSend = false lastSetDeviceAddress = null - beginEditSettingsCalled = false - commitEditSettingsCalled = false + lastStoreForwardHistoryRequest = null + editSettingsCalled = false startProvideLocationCalled = false stopProvideLocationCalled = false } @@ -88,13 +91,13 @@ class FakeRadioController : override suspend fun setLocalChannel(channel: Channel) {} - override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {} + override suspend fun setOwner(destNum: Int, user: User) {} - override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {} + override suspend fun setConfig(destNum: Int, config: Config) {} - override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {} + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig) {} - override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {} + override suspend fun setRemoteChannel(destNum: Int, channel: Channel) {} override suspend fun setFixedPosition(destNum: Int, position: Position) {} @@ -102,54 +105,63 @@ class FakeRadioController : override suspend fun setCannedMessages(destNum: Int, messages: String) {} - override suspend fun getOwner(destNum: Int, packetId: Int) {} + override suspend fun getOwner(destNum: Int): User = User() - override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {} + override suspend fun getConfig(destNum: Int, configType: Int): Config = Config() - override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {} + override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): ModuleConfig = ModuleConfig() - override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {} + override suspend fun getChannel(destNum: Int, index: Int): Channel = Channel() - override suspend fun getRingtone(destNum: Int, packetId: Int) {} + override suspend fun listChannels(destNum: Int): List = emptyList() - override suspend fun getCannedMessages(destNum: Int, packetId: Int) {} + override suspend fun getRingtone(destNum: Int): String = "" - override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {} + override suspend fun getCannedMessages(destNum: Int): String = "" - override suspend fun reboot(destNum: Int, packetId: Int) {} + override suspend fun getDeviceConnectionStatus(destNum: Int): DeviceConnectionStatus = + DeviceConnectionStatus() + + override suspend fun reboot(destNum: Int) {} override suspend fun rebootToDfu(nodeNum: Int) {} - override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {} + override suspend fun requestRebootOta(destNum: Int, mode: Int, hash: ByteArray?) {} - override suspend fun shutdown(destNum: Int, packetId: Int) {} + override suspend fun shutdown(destNum: Int) {} - override suspend fun factoryReset(destNum: Int, packetId: Int) {} + override suspend fun factoryReset(destNum: Int) {} - override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {} + override suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean) {} - override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {} + override suspend fun removeByNodenum(nodeNum: Int) {} override suspend fun requestPosition(destNum: Int, currentPosition: Position) {} override suspend fun requestUserInfo(destNum: Int) {} - override suspend fun requestTraceroute(requestId: Int, destNum: Int) {} + override suspend fun requestTraceroute(destNum: Int) {} - override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {} + override suspend fun requestTelemetry(destNum: Int, type: TelemetryType) {} - override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {} + override suspend fun requestNeighborInfo(destNum: Int) {} - override suspend fun beginEditSettings(destNum: Int) { - beginEditSettingsCalled = true + override suspend fun requestStoreForwardHistory(since: Int?, serverNodeNum: Int?): Boolean { + lastStoreForwardHistoryRequest = since to serverNodeNum + return true } - override suspend fun commitEditSettings(destNum: Int) { - commitEditSettingsCalled = true + override suspend fun editSettings(destNum: Int, block: suspend DeviceAdminEdit.() -> Unit) { + editSettingsCalled = true + val edit = object : DeviceAdminEdit { + override suspend fun setConfig(config: Config) {} + override suspend fun setModuleConfig(config: ModuleConfig) {} + override suspend fun setOwner(user: User) {} + override suspend fun setChannel(channel: Channel) {} + } + block(edit) } - override fun getPacketId(): Int = 1 - override fun startProvideLocation() { startProvideLocationCalled = true } diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt deleted file mode 100644 index f7837e4364..0000000000 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt +++ /dev/null @@ -1,117 +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.testing - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.receiveAsFlow -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.InterfaceId -import org.meshtastic.core.model.MeshActivity -import org.meshtastic.core.repository.RadioInterfaceService - -/** - * A test double for [RadioInterfaceService] that provides an in-memory implementation. - * - * The [connectionState] here mirrors the transport-level semantics of the real implementation. In production, only - * [MeshConnectionManager][org.meshtastic.core.repository.MeshConnectionManager] observes this flow; tests should verify - * that bridging behavior rather than consuming it directly from UI/feature test code (use - * [FakeServiceRepository.connectionState] instead). - */ -@Suppress("TooManyFunctions") -class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = MainScope()) : RadioInterfaceService { - - override val supportedDeviceTypes: List = emptyList() - - /** Transport-level connection state (raw hardware link status). */ - private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) - override val connectionState: StateFlow = _connectionState - - private val _currentDeviceAddressFlow = MutableStateFlow(null) - override val currentDeviceAddressFlow: StateFlow = _currentDeviceAddressFlow - - // Use an unbounded Channel to mirror SharedRadioInterfaceService semantics. A MutableSharedFlow would - // hide the stop/start backlog bug that motivated the resetReceivedBuffer() API. - private val _receivedData = Channel(Channel.UNLIMITED) - override val receivedData: Flow = _receivedData.receiveAsFlow() - - private val _meshActivity = MutableSharedFlow() - override val meshActivity: Flow = _meshActivity.asFlow() - - private val _connectionError = MutableSharedFlow() - override val connectionError: Flow = _connectionError.asFlow() - - val sentToRadio = mutableListOf() - var connectCalled = false - - override fun isMockTransport(): Boolean = true - - override fun sendToRadio(bytes: ByteArray) { - sentToRadio.add(bytes) - } - - override fun connect() { - connectCalled = true - } - - override suspend fun disconnect() { - connectCalled = false - } - - override fun getDeviceAddress(): String? = _currentDeviceAddressFlow.value - - override fun setDeviceAddress(deviceAddr: String?): Boolean { - _currentDeviceAddressFlow.value = deviceAddr - return true - } - - override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "$interfaceId:$rest" - - override fun onConnect() { - _connectionState.value = ConnectionState.Connected - } - - override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) { - _connectionState.value = ConnectionState.Disconnected - } - - override fun handleFromRadio(bytes: ByteArray) { - _receivedData.trySend(bytes) - } - - override fun resetReceivedBuffer() { - @Suppress("EmptyWhileBlock", "ControlFlowWithEmptyBody") - while (_receivedData.tryReceive().isSuccess) Unit - } - - // --- Helper methods for testing --- - - fun emitFromRadio(bytes: ByteArray) { - _receivedData.trySend(bytes) - } - - fun setConnectionState(state: ConnectionState) { - _connectionState.value = state - } -} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt deleted file mode 100644 index 4928024260..0000000000 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt +++ /dev/null @@ -1,38 +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.testing - -import org.meshtastic.core.repository.RadioTransport - -/** A test double for [RadioTransport] that tracks sent data. */ -class FakeRadioTransport : RadioTransport { - val sentData = mutableListOf() - var closeCalled = false - var keepAliveCalled = false - - override fun handleSendToRadio(p: ByteArray) { - sentData.add(p) - } - - override fun keepAlive() { - keepAliveCalled = true - } - - override suspend fun close() { - closeCalled = true - } -} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt index 494586e08c..cb48a055c4 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt @@ -22,12 +22,15 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.asStateFlow import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.MeshActivity 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.meshtastic.core.model.CongestionLevel @Suppress("TooManyFunctions") class FakeServiceRepository : ServiceRepository { @@ -37,6 +40,16 @@ class FakeServiceRepository : ServiceRepository { override fun setConnectionState(connectionState: ConnectionState) { _connectionState.value = connectionState + if (connectionState == ConnectionState.Disconnected) { + setCongestionLevel(null) + } + } + + private val _congestionLevel = MutableStateFlow(null) + override val congestionLevel: StateFlow = _congestionLevel.asStateFlow() + + override fun setCongestionLevel(level: CongestionLevel?) { + _congestionLevel.value = level } private val _clientNotification = MutableStateFlow(null) @@ -50,6 +63,13 @@ class FakeServiceRepository : ServiceRepository { _clientNotification.value = null } + private val _storeForwardServers = MutableStateFlow>(emptyList()) + override val storeForwardServers: StateFlow> = _storeForwardServers + + override fun setStoreForwardServers(servers: List) { + _storeForwardServers.value = servers + } + private val _errorMessage = MutableStateFlow(null) override val errorMessage: StateFlow = _errorMessage @@ -75,6 +95,13 @@ class FakeServiceRepository : ServiceRepository { _meshPacketFlow.emit(packet) } + private val _meshActivityFlow = MutableSharedFlow(extraBufferCapacity = 64) + override val meshActivityFlow: Flow = _meshActivityFlow.asFlow() + + override fun emitMeshActivity(activity: MeshActivity) { + _meshActivityFlow.tryEmit(activity) + } + private val _tracerouteResponse = MutableStateFlow(null) override val tracerouteResponse: StateFlow = _tracerouteResponse diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeNodeRepositoryTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeNodeRepositoryTest.kt index b12c54f8f9..822666f84e 100644 --- a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeNodeRepositoryTest.kt +++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeNodeRepositoryTest.kt @@ -84,17 +84,6 @@ class FakeNodeRepositoryTest { } } - @Test - fun `insertMetadata updates node metadata`() = runTest { - val nodeNum = 1234 - repository.upsert(Node(num = nodeNum)) - val metadata = org.meshtastic.proto.DeviceMetadata(firmware_version = "2.5.0") - repository.insertMetadata(nodeNum, metadata) - - val node = repository.nodeDBbyNum.value[nodeNum] - assertEquals("2.5.0", node?.metadata?.firmware_version) - } - @Test fun `deleteNodes removes multiple nodes`() = runTest { repository.setNodes(listOf(Node(num = 1), Node(num = 2), Node(num = 3))) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt index 5aed67880e..3fbb11a096 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ConnectionsNavIcon.kt @@ -75,7 +75,9 @@ fun ConnectionsNavIcon( @Composable private fun getTint(connectionState: ConnectionState): Color = when (connectionState) { - ConnectionState.Connecting -> colorScheme.StatusOrange + is ConnectionState.Connecting, + is ConnectionState.Configuring, + is ConnectionState.Reconnecting -> colorScheme.StatusOrange ConnectionState.Disconnected -> colorScheme.StatusRed ConnectionState.DeviceSleep -> colorScheme.StatusYellow else -> colorScheme.StatusGreen @@ -88,7 +90,9 @@ fun getIconPair(connectionState: ConnectionState, deviceType: DeviceType? = null ConnectionState.DeviceSleep -> MeshtasticIcons.Device to MeshtasticIcons.DeviceSleep - ConnectionState.Connecting -> MeshtasticIcons.Device to MeshtasticIcons.Reconnecting + is ConnectionState.Connecting, + is ConnectionState.Configuring, + is ConnectionState.Reconnecting -> MeshtasticIcons.Device to MeshtasticIcons.Reconnecting else -> MeshtasticIcons.Device to diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt index 7504c048a0..0cc03c97c9 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/IndoorAirQuality.kt @@ -232,10 +232,6 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil } } -// Assuming Iaq is an enum class with color and description properties -// and that it conforms to CaseIterable. -// Replace with your actual implementation - @Composable fun IAQScale(modifier: Modifier = Modifier) { Column(modifier = modifier.padding(16.dp), horizontalAlignment = Alignment.Start) { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt index f23f082b5c..caf72b4c33 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticNavigationSuite.kt @@ -190,7 +190,9 @@ private fun NavigationIconContent( if (isConnectionsRoute) { when (connectionState) { ConnectionState.Connected -> stringResource(Res.string.connected) - ConnectionState.Connecting -> stringResource(Res.string.connecting) + is ConnectionState.Connecting, + is ConnectionState.Configuring, + is ConnectionState.Reconnecting -> stringResource(Res.string.connecting) ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping) ConnectionState.Disconnected -> stringResource(Res.string.disconnected) } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PacketResponseStateDialog.kt similarity index 97% rename from feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt rename to core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PacketResponseStateDialog.kt index c0e536e89f..2e772f46f8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/PacketResponseStateDialog.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PacketResponseStateDialog.kt @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.feature.settings.radio.component +package org.meshtastic.core.ui.component import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement @@ -36,17 +36,16 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.model.ResponseState import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel import org.meshtastic.core.resources.close import org.meshtastic.core.resources.delivery_confirmed import org.meshtastic.core.resources.delivery_confirmed_reboot_warning import org.meshtastic.core.resources.error -import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.icon.Error import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Success -import org.meshtastic.feature.settings.radio.ResponseState private const val AUTO_DISMISS_DELAY_MS = 1500L diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt index 5198bd3a1a..2df0a6c2b8 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModel.kt @@ -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()) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt index f4d15d3d9c..d72a30d74a 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModel.kt @@ -104,7 +104,9 @@ class ConnectionsViewModel( is ConnectionState.Connected -> if (unset) ConnectionStatus.MUST_SET_REGION else ConnectionStatus.CONNECTED - ConnectionState.Connecting -> ConnectionStatus.CONNECTING + is ConnectionState.Connecting, + is ConnectionState.Configuring, + is ConnectionState.Reconnecting -> ConnectionStatus.CONNECTING ConnectionState.Disconnected -> ConnectionStatus.NOT_CONNECTED diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt index e0d895226b..3f6a2d09b0 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt @@ -55,7 +55,7 @@ import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.resources.Res @@ -81,7 +81,7 @@ class UIViewModel( private val nodeDB: NodeRepository, protected val serviceRepository: ServiceRepository, private val radioController: RadioController, - radioInterfaceService: RadioInterfaceService, + private val radioPrefs: RadioPrefs, meshLogRepository: MeshLogRepository, firmwareReleaseRepository: FirmwareReleaseRepository, private val uiPrefs: UiPrefs, @@ -137,9 +137,9 @@ class UIViewModel( } /** Emits events for mesh network send/receive activity. */ - val meshActivity: Flow = radioInterfaceService.meshActivity + val meshActivity: Flow = serviceRepository.meshActivityFlow - val currentDeviceAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow + val currentDeviceAddressFlow: StateFlow = radioPrefs.devAddr private val _scrollToTopEventFlow = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModelTest.kt new file mode 100644 index 0000000000..bac51bc752 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModelTest.kt @@ -0,0 +1,141 @@ +/* + * 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.ui.qr + +import app.cash.turbine.test +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceAdmin +import org.meshtastic.core.model.DeviceAdminEdit +import org.meshtastic.core.testing.FakeRadioConfigRepository +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ScannedQrCodeViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var radioConfigRepository: FakeRadioConfigRepository + private lateinit var deviceAdmin: TestDeviceAdmin + private lateinit var viewModel: ScannedQrCodeViewModel + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + radioConfigRepository = FakeRadioConfigRepository() + deviceAdmin = TestDeviceAdmin() + viewModel = ScannedQrCodeViewModel(radioConfigRepository, deviceAdmin) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun channels_emitsFromRadioConfigRepository() = runTest { + val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Primary"))) + radioConfigRepository.setChannelSet(channelSet) + viewModel = ScannedQrCodeViewModel(radioConfigRepository, deviceAdmin) + + viewModel.channels.test { + advanceUntilIdle() + assertEquals(channelSet, expectMostRecentItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun setChannels_updatesChannelsViaRadioController() = runTest { + val first = ChannelSettings(name = "Primary") + val second = ChannelSettings(name = "Secondary") + val channelSet = ChannelSet(settings = listOf(first, second)) + + viewModel.setChannels(channelSet) + advanceUntilIdle() + + assertEquals( + listOf( + Channel(role = Channel.Role.PRIMARY, index = 0, settings = first), + Channel(role = Channel.Role.SECONDARY, index = 1, settings = second), + ), + deviceAdmin.setLocalChannelCalls, + ) + } + + @Test + fun setChannels_updatesLoraConfig_whenDifferent() = runTest { + val loraConfig = Config.LoRaConfig(hop_limit = 5) + val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Primary")), lora_config = loraConfig) + + viewModel.setChannels(channelSet) + advanceUntilIdle() + + assertEquals(listOf(Config(lora = loraConfig)), deviceAdmin.setLocalConfigCalls) + } + + @Test + fun setChannels_skipsLoraConfig_whenSame() = runTest { + val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "Primary"))) + + viewModel.setChannels(channelSet) + advanceUntilIdle() + + assertTrue(deviceAdmin.setLocalConfigCalls.isEmpty()) + } + + @Test + fun setChannels_replacesAllSettings() = runTest { + val settings = listOf(ChannelSettings(name = "Primary"), ChannelSettings(name = "Secondary")) + + viewModel.setChannels(ChannelSet(settings = settings)) + advanceUntilIdle() + + assertEquals(settings, radioConfigRepository.currentChannelSet.settings) + } + + private class TestDeviceAdmin : DeviceAdmin { + override val connectionState = MutableStateFlow(ConnectionState.Connected) + val setLocalChannelCalls = mutableListOf() + val setLocalConfigCalls = mutableListOf() + + override suspend fun setLocalConfig(config: Config) { + setLocalConfigCalls += config + } + + override suspend fun setLocalChannel(channel: Channel) { + setLocalChannelCalls += channel + } + + override suspend fun editSettings(destNum: Int, block: suspend DeviceAdminEdit.() -> Unit) = Unit + } +} diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelTest.kt new file mode 100644 index 0000000000..32cef2f34e --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelTest.kt @@ -0,0 +1,285 @@ +/* + * 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.ui.viewmodel + +import app.cash.turbine.test +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.Node +import org.meshtastic.core.model.TracerouteMapAvailability +import org.meshtastic.core.model.util.getSharedContactUrl +import org.meshtastic.core.navigation.ContactsRoute +import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI +import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioPrefs +import org.meshtastic.core.repository.UiPrefs +import org.meshtastic.core.testing.FakeFirmwareReleaseRepository +import org.meshtastic.core.testing.FakeMeshLogRepository +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeRadioController +import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.core.testing.TestDataFactory +import org.meshtastic.core.ui.util.AlertManager +import org.meshtastic.core.ui.util.SnackbarManager +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Position +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class UIViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private lateinit var viewModel: UIViewModel + private lateinit var nodeRepository: FakeNodeRepository + private lateinit var serviceRepository: FakeServiceRepository + private lateinit var radioController: FakeRadioController + private lateinit var meshLogRepository: FakeMeshLogRepository + private lateinit var firmwareReleaseRepository: FakeFirmwareReleaseRepository + private lateinit var radioPrefs: RadioPrefs + private lateinit var uiPrefs: UiPrefs + private lateinit var notificationManager: NotificationManager + private lateinit var packetRepository: PacketRepository + + private lateinit var devAddrFlow: MutableStateFlow + private lateinit var themeFlow: MutableStateFlow + private lateinit var appIntroCompletedFlow: MutableStateFlow + private lateinit var unreadMessageCountFlow: MutableStateFlow + + @BeforeTest + fun setUp() { + Dispatchers.setMain(testDispatcher) + + nodeRepository = FakeNodeRepository() + serviceRepository = FakeServiceRepository() + radioController = FakeRadioController() + meshLogRepository = FakeMeshLogRepository() + firmwareReleaseRepository = FakeFirmwareReleaseRepository() + radioPrefs = mock(MockMode.autofill) + uiPrefs = mock(MockMode.autofill) + notificationManager = mock(MockMode.autofill) + packetRepository = mock(MockMode.autofill) + + devAddrFlow = MutableStateFlow(null) + themeFlow = MutableStateFlow(1) + appIntroCompletedFlow = MutableStateFlow(false) + unreadMessageCountFlow = MutableStateFlow(0) + + every { radioPrefs.devAddr } returns devAddrFlow + every { uiPrefs.theme } returns themeFlow + every { uiPrefs.appIntroCompleted } returns appIntroCompletedFlow + every { uiPrefs.setAppIntroCompleted(any()) } returns Unit + every { notificationManager.cancel(any()) } returns Unit + every { packetRepository.getUnreadCountTotal() } returns unreadMessageCountFlow + + viewModel = createViewModel() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun handleDeepLink_triesDeepLinkRouterFirst() = runTest { + viewModel.navigationDeepLink.test { + viewModel.handleDeepLink(CommonUri.parse("$DEEP_LINK_BASE_URI/messages/contact1")) + + assertEquals( + listOf(ContactsRoute.ContactsGraph, ContactsRoute.Messages(contactKey = "contact1", message = "")), + awaitItem(), + ) + assertNull(viewModel.sharedContactRequested.value) + assertNull(viewModel.requestChannelSet.value) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun handleDeepLink_fallsBackToDispatchMeshtasticUri() { + val sharedContact = SharedContact(user = User(long_name = "Suzume", short_name = "SZ"), node_num = 12345) + + viewModel.handleDeepLink(sharedContact.getSharedContactUrl()) + + assertEquals(sharedContact, viewModel.sharedContactRequested.value) + assertNull(viewModel.requestChannelSet.value) + } + + @Test + fun sharedContactRequested_setAndClear() = runTest { + val sharedContact = SharedContact(user = User(long_name = "Suzume"), node_num = 12345) + + viewModel.sharedContactRequested.test { + assertNull(awaitItem()) + + viewModel.setSharedContactRequested(sharedContact) + assertEquals(sharedContact, awaitItem()) + + viewModel.clearSharedContactRequested() + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun requestChannelSet_setAndClear() = runTest { + val channelSet = ChannelSet(settings = listOf(ChannelSettings(name = "LongFast"))) + + viewModel.requestChannelSet.test { + assertNull(awaitItem()) + + viewModel.setRequestChannelSet(channelSet) + assertEquals(channelSet, awaitItem()) + + viewModel.clearRequestChannelUrl() + assertNull(awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun connectionState_delegatesToServiceRepository() = runTest { + viewModel.connectionState.test { + assertEquals(ConnectionState.Disconnected, awaitItem()) + + serviceRepository.setConnectionState(ConnectionState.Connected) + assertEquals(ConnectionState.Connected, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun unreadMessageCount_coercesToZero() = runTest { + unreadMessageCountFlow.value = -1 + + viewModel.unreadMessageCount.test { + advanceUntilIdle() + assertEquals(0, expectMostRecentItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun unreadMessageCount_emitsPositiveValues() = runTest { + unreadMessageCountFlow.value = 5 + + viewModel.unreadMessageCount.test { + advanceUntilIdle() + assertEquals(5, expectMostRecentItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun appIntroCompleted_readsFromPrefs() { + appIntroCompletedFlow.value = true + viewModel = createViewModel() + + assertTrue(viewModel.appIntroCompleted.value) + } + + @Test + fun onAppIntroCompleted_setsPrefs() { + viewModel.onAppIntroCompleted() + + verify { uiPrefs.setAppIntroCompleted(true) } + } + + @Test + fun clearClientNotification_clearsServiceRepoAndCancelsNotification() { + val notification = ClientNotification(message = "Check me") + serviceRepository.setClientNotification(notification) + + viewModel.clearClientNotification(notification) + + assertNull(serviceRepository.clientNotification.value) + verify { notificationManager.cancel(notification.toString().hashCode()) } + } + + @Test + fun myNodeInfo_delegatesToNodeDB() { + val myNodeInfo = TestDataFactory.createMyNodeInfo(myNodeNum = 42) + nodeRepository.setMyNodeInfo(myNodeInfo) + + assertEquals(myNodeInfo, viewModel.myNodeInfo.value) + } + + @Test + fun theme_delegatesToUiPrefs() { + themeFlow.value = 2 + viewModel = createViewModel() + + assertEquals(2, viewModel.theme.value) + + themeFlow.value = 4 + assertEquals(4, viewModel.theme.value) + } + + @Test + fun tracerouteMapAvailability_correctlyEvaluatesForwardAndReturnRoutes() { + nodeRepository.setNodes( + listOf( + Node(num = 1, position = Position(latitude_i = 100000000, longitude_i = 200000000)), + Node(num = 2), + Node(num = 3, position = Position(latitude_i = 300000000, longitude_i = 400000000)), + ), + ) + + val result = viewModel.tracerouteMapAvailability(forwardRoute = listOf(1, 2, 3), returnRoute = listOf(3, 2, 1)) + + assertEquals(TracerouteMapAvailability.Ok, result) + } + + private fun createViewModel() = + UIViewModel( + nodeDB = nodeRepository, + serviceRepository = serviceRepository, + radioController = radioController, + radioPrefs = radioPrefs, + meshLogRepository = meshLogRepository, + firmwareReleaseRepository = firmwareReleaseRepository, + uiPrefs = uiPrefs, + notificationManager = notificationManager, + packetRepository = packetRepository, + alertManager = AlertManager(), + snackbarManager = SnackbarManager(), + ) +} diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 4fa09179f4..e3d4dd0898 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 59f468f825..d7a70f4ef9 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 @@ -53,10 +53,8 @@ import org.meshtastic.core.repository.MeshWorkerManager import org.meshtastic.core.repository.MessageQueue import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioTransportFactory -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.service.DirectRadioControllerImpl +import org.meshtastic.core.service.SdkClientLifecycle import org.meshtastic.core.service.ServiceRepositoryImpl import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.desktop.DesktopNotificationManager @@ -67,7 +65,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.DesktopRadioTransportFactory +import org.meshtastic.desktop.radio.DesktopRadioClientProvider import org.meshtastic.desktop.stub.NoopAppWidgetUpdater import org.meshtastic.desktop.stub.NoopCompassHeadingProvider import org.meshtastic.desktop.stub.NoopLocationRepository @@ -77,7 +75,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 @@ -151,25 +148,10 @@ fun desktopModule() = module { @Suppress("LongMethod") private fun desktopPlatformStubsModule() = module { single { ServiceRepositoryImpl() } - single { - DesktopRadioTransportFactory( - dispatchers = get(), - scanner = get(), - bluetoothRepository = get(), - 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 +163,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/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index d7581cc9c5..ccdba56b64 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -19,6 +19,7 @@ package org.meshtastic.desktop.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey +import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.core.navigation.MultiBackstack import org.meshtastic.core.navigation.TopLevelDestination import org.meshtastic.core.ui.viewmodel.UIViewModel @@ -28,6 +29,7 @@ import org.meshtastic.feature.map.navigation.mapGraph import org.meshtastic.feature.messaging.navigation.contactsGraph import org.meshtastic.feature.node.navigation.nodesGraph import org.meshtastic.feature.settings.navigation.settingsGraph +import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.radio.channel.channelsGraph import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph @@ -53,6 +55,6 @@ fun EntryProviderScope.desktopNavGraph( firmwareGraph(backStack) settingsGraph(backStack) channelsGraph(backStack) - connectionsGraph(backStack) + connectionsGraph(backStack) { koinViewModel() } wifiProvisionGraph(backStack) } 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 3888b0af3e..a7e58f2657 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 0000000000..0b90275478 --- /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/radio/DesktopRadioTransportFactory.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt deleted file mode 100644 index 9d05d99050..0000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt +++ /dev/null @@ -1,71 +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.desktop.radio - -import org.meshtastic.core.ble.BleConnectionFactory -import org.meshtastic.core.ble.BleScanner -import org.meshtastic.core.ble.BluetoothRepository -import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.InterfaceId -import org.meshtastic.core.network.SerialTransport -import org.meshtastic.core.network.radio.BaseRadioTransportFactory -import org.meshtastic.core.network.radio.TcpRadioTransport -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.RadioTransport -import org.meshtastic.core.repository.RadioTransportFactory - -/** - * Desktop implementation of [RadioTransportFactory] delegating multiplatform transports (BLE, TCP) and providing - * platform-specific transports (USB/Serial) via jSerialComm. - * - * Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid double-registration with - * the @ComponentScan("org.meshtastic.desktop") in DesktopDiModule. - */ -class DesktopRadioTransportFactory( - scanner: BleScanner, - bluetoothRepository: BluetoothRepository, - connectionFactory: BleConnectionFactory, - dispatchers: CoroutineDispatchers, -) : BaseRadioTransportFactory(scanner, bluetoothRepository, connectionFactory, dispatchers) { - - override val supportedDeviceTypes: List = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB) - - override fun isMockTransport(): Boolean = false - - override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when { - address.startsWith(InterfaceId.TCP.id) -> { - TcpRadioTransport( - callback = service, - scope = service.serviceScope, - dispatchers = dispatchers, - address = address.removePrefix(InterfaceId.TCP.id.toString()), - ) - } - - address.startsWith(InterfaceId.SERIAL.id) -> { - SerialTransport.open( - portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), - callback = service, - scope = service.serviceScope, - dispatchers = dispatchers, - ) - } - - else -> error("Unsupported transport for address: $address") - } -} 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 081735e259..8eeec2dabc 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -20,20 +20,9 @@ package org.meshtastic.desktop.stub import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow 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 @@ -42,8 +31,6 @@ import org.meshtastic.core.repository.LocationRepository 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 @@ -64,54 +51,6 @@ private fun logWarn(message: String) { Logger.w(tag = TAG) { message } } -// region Transport / Radio Stubs (Android BLE/USB — no commonMain impl) - -class NoopRadioInterfaceService : RadioInterfaceService { - override val supportedDeviceTypes: List = emptyList() - - override val connectionState = MutableStateFlow(ConnectionState.Disconnected) - override val currentDeviceAddressFlow = MutableStateFlow(null) - - override fun isMockTransport(): Boolean = false - - override val receivedData = MutableSharedFlow() - override val meshActivity: Flow = MutableSharedFlow().asFlow() - override val connectionError: Flow = MutableSharedFlow().asFlow() - - override fun sendToRadio(bytes: ByteArray) { - logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)") - } - - override fun resetReceivedBuffer() { - // No-op: this stub never buffers bytes. - } - - override fun connect() { - logWarn("NoopRadioInterfaceService.connect()") - } - - override suspend fun disconnect() { - logWarn("NoopRadioInterfaceService.disconnect()") - } - - override fun getDeviceAddress(): String? = null - - override fun setDeviceAddress(deviceAddr: String?): Boolean = false - - override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "" - - override fun onConnect() {} - - override fun onDisconnect(isPermanent: Boolean, errorMessage: String?) {} - - override fun handleFromRadio(bytes: ByteArray) {} - - @Suppress("InjectDispatcher") - override val serviceScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) -} - -// endregion - // region Notification / Platform Stubs (Android-only) class NoopPlatformAnalytics : PlatformAnalytics { @@ -122,18 +61,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/connections/build.gradle.kts b/feature/connections/build.gradle.kts index 18d7673a94..99779604cb 100644 --- a/feature/connections/build.gradle.kts +++ b/feature/connections/build.gradle.kts @@ -29,7 +29,6 @@ kotlin { implementation(libs.compose.multiplatform.foundation) implementation(projects.core.common) implementation(projects.core.data) - implementation(projects.core.database) implementation(projects.core.datastore) implementation(projects.core.di) implementation(projects.core.domain) @@ -38,15 +37,15 @@ kotlin { implementation(projects.core.prefs) implementation(projects.core.proto) implementation(projects.core.resources) - implementation(projects.core.service) implementation(projects.core.ui) implementation(projects.core.ble) implementation(projects.core.network) - implementation(projects.feature.settings) implementation(libs.jetbrains.navigation3.ui) } androidMain.dependencies { implementation(libs.usb.serial.android) } + + jvmMain.dependencies { implementation(libs.sdk.transport.serial) } } } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DeviceListEntry.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DeviceListEntry.kt index bb5bdbb37a..f3e281dac6 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DeviceListEntry.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DeviceListEntry.kt @@ -25,6 +25,7 @@ import org.meshtastic.core.model.util.anonymize interface UsbDeviceData /** A sealed class representing the different types of devices that can be displayed in the connections list. */ +@androidx.compose.runtime.Immutable sealed class DeviceListEntry( open val name: String, open val fullAddress: String, diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt index 3a6053bf5b..3ba50ed48e 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/model/DiscoveredDevices.kt @@ -19,6 +19,7 @@ package org.meshtastic.feature.connections.model import kotlinx.coroutines.flow.Flow import org.meshtastic.core.network.repository.DiscoveredService +@androidx.compose.runtime.Immutable data class DiscoveredDevices( val bleDevices: List = emptyList(), val usbDevices: List = emptyList(), diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt index 7b91f09754..6b045afef6 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt @@ -16,22 +16,26 @@ */ package org.meshtastic.feature.connections.navigation +import androidx.compose.runtime.Composable import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import org.koin.compose.viewmodel.koinViewModel +import org.meshtastic.core.model.RadioConfigStateProvider import org.meshtastic.core.navigation.ConnectionsRoute import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.feature.connections.ScannerViewModel import org.meshtastic.feature.connections.ui.ConnectionsScreen -import org.meshtastic.feature.settings.radio.RadioConfigViewModel /** Navigation graph for for the top level ConnectionsScreen - [ConnectionsRoute.Connections]. */ -fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) { +fun EntryProviderScope.connectionsGraph( + backStack: NavBackStack, + radioConfigStateProvider: @Composable () -> RadioConfigStateProvider, +) { entry { ConnectionsScreen( scanModel = koinViewModel(), - radioConfigViewModel = koinViewModel(), + radioConfigStateProvider = radioConfigStateProvider(), onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onConfigNavigate = { route -> backStack.add(route) }, @@ -41,7 +45,7 @@ fun EntryProviderScope.connectionsGraph(backStack: NavBackStack) entry { ConnectionsScreen( scanModel = koinViewModel(), - radioConfigViewModel = koinViewModel(), + radioConfigStateProvider = radioConfigStateProvider(), onClickNodeChip = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onNavigateToNodeDetails = { id -> backStack.add(NodesRoute.NodeDetail(id)) }, onConfigNavigate = { route -> backStack.add(route) }, diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt index b52d5013d3..fc862ac28f 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/ConnectionsScreen.kt @@ -78,10 +78,8 @@ import org.meshtastic.feature.connections.ui.components.ConnectingDeviceInfo import org.meshtastic.feature.connections.ui.components.CurrentlyConnectedInfo import org.meshtastic.feature.connections.ui.components.DeviceList import org.meshtastic.feature.connections.ui.components.TransportFilterChips -import org.meshtastic.feature.settings.navigation.ConfigRoute -import org.meshtastic.feature.settings.navigation.getNavRouteFrom -import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog +import org.meshtastic.core.model.RadioConfigStateProvider +import org.meshtastic.core.ui.component.PacketResponseStateDialog import kotlin.uuid.ExperimentalUuidApi /** @@ -98,12 +96,13 @@ private val CardMinHeight = 100.dp fun ConnectionsScreen( connectionsViewModel: ConnectionsViewModel = koinViewModel(), scanModel: ScannerViewModel = koinViewModel(), - radioConfigViewModel: RadioConfigViewModel = koinViewModel(), + radioConfigStateProvider: RadioConfigStateProvider, onClickNodeChip: (Int) -> Unit, onNavigateToNodeDetails: (Int) -> Unit, onConfigNavigate: (Route) -> Unit, ) { - val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle() + val responseState by radioConfigStateProvider.packetResponseState.collectAsStateWithLifecycle() + val pendingRoute by radioConfigStateProvider.pendingRouteName.collectAsStateWithLifecycle() val connectionProgress by scanModel.connectionProgressText.collectAsStateWithLifecycle() val connectionStatus by connectionsViewModel.connectionStatus.collectAsStateWithLifecycle() val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle() @@ -153,18 +152,16 @@ fun ConnectionsScreen( var isWaiting by remember { mutableStateOf(false) } if (isWaiting) { PacketResponseStateDialog( - state = radioConfigState.responseState, + state = responseState, onDismiss = { isWaiting = false - radioConfigViewModel.clearPacketResponse() + radioConfigStateProvider.clearPacketResponse() }, onComplete = { - getNavRouteFrom(radioConfigState.route)?.let { route -> + if (pendingRoute == "LORA") { isWaiting = false - radioConfigViewModel.clearPacketResponse() - if (route == SettingsRoute.LoRa) { - onConfigNavigate(SettingsRoute.LoRa) - } + radioConfigStateProvider.clearPacketResponse() + onConfigNavigate(SettingsRoute.LoRa) } }, ) @@ -198,7 +195,9 @@ fun ConnectionsScreen( ConnectionUiState.CONNECTED_WITH_NODE connectionState is ConnectionState.Connected || - connectionState == ConnectionState.Connecting || + connectionState is ConnectionState.Connecting || + connectionState is ConnectionState.Configuring || + connectionState is ConnectionState.Reconnecting || selectedDevice != NO_DEVICE_SELECTED -> ConnectionUiState.CONNECTING else -> ConnectionUiState.NO_DEVICE @@ -262,7 +261,7 @@ fun ConnectionsScreen( text = stringResource(Res.string.set_your_region), onClick = { isWaiting = true - radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA) + radioConfigStateProvider.requestConfigLoad("LORA") }, ) } diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt index cebc40724d..60aa4d8b59 100644 --- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt +++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ui/components/DeviceListItem.kt @@ -89,12 +89,17 @@ fun DeviceListItem( } } + val isConnecting = + connectionState is ConnectionState.Connecting || + connectionState is ConnectionState.Configuring || + connectionState is ConnectionState.Reconnecting + val icon = when (device) { is DeviceListEntry.Ble -> if (connectionState is ConnectionState.Connected) { MeshtasticIcons.BluetoothConnected - } else if (connectionState is ConnectionState.Connecting) { + } else if (isConnecting) { MeshtasticIcons.BluetoothSearching } else { MeshtasticIcons.Bluetooth @@ -155,7 +160,7 @@ fun DeviceListItem( Rssi(rssi = displayedRssi) } - if (connectionState is ConnectionState.Connecting) { + if (isConnecting) { CircularProgressIndicator(modifier = Modifier.size(32.dp)) } else { RadioButton(selected = connectionState is ConnectionState.Connected, onClick = null) diff --git a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmUsbScanner.kt b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmUsbScanner.kt index 9601ff1b29..d63f5baa1e 100644 --- a/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmUsbScanner.kt +++ b/feature/connections/src/jvmMain/kotlin/org/meshtastic/feature/connections/domain/usecase/JvmUsbScanner.kt @@ -22,9 +22,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.isActive import org.koin.core.annotation.Single -import org.meshtastic.core.network.SerialTransport import org.meshtastic.feature.connections.model.DeviceListEntry import org.meshtastic.feature.connections.model.JvmUsbDeviceData +import org.meshtastic.sdk.transport.serial.JvmSerialPorts import kotlin.coroutines.coroutineContext @Single @@ -32,12 +32,12 @@ class JvmUsbScanner : UsbScanner { override fun scanUsbDevices(): Flow> = flow { while (coroutineContext.isActive) { val ports = - SerialTransport.getAvailablePorts().map { portName -> + JvmSerialPorts.list().map { portInfo -> DeviceListEntry.Usb( - usbData = JvmUsbDeviceData(portName), - name = portName, - fullAddress = "s$portName", - bonded = true, // Desktop serial ports don't need Android USB permission bonding + usbData = JvmUsbDeviceData(portInfo.name), + name = portInfo.description ?: portInfo.name, + fullAddress = "s${portInfo.name}", + bonded = true, node = null, ) } diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index 9c83bee0e8..6eaa5e6288 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -41,7 +41,6 @@ kotlin { implementation(projects.core.network) implementation(projects.core.prefs) implementation(projects.core.proto) - implementation(projects.core.service) implementation(projects.core.resources) implementation(projects.core.ui) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt index 6342aa5fcd..94b6e39bad 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/Esp32OtaUpdateHandler.kt @@ -190,7 +190,7 @@ class Esp32OtaUpdateHandler( val myInfo = nodeRepository.myNodeInfo.value ?: return val myNodeNum = myInfo.myNodeNum Logger.i { "ESP32 OTA: Triggering reboot OTA mode $mode with hash" } - radioController.requestRebootOta(radioController.getPacketId(), myNodeNum, mode, hash) + radioController.requestRebootOta(myNodeNum, mode, hash) } /** diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index 42506ea334..82488f6be5 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -30,14 +30,12 @@ kotlin { commonMain.dependencies { implementation(libs.jetbrains.navigation3.ui) implementation(libs.kotlinx.collections.immutable) - implementation(projects.core.data) - implementation(projects.core.database) - implementation(projects.core.datastore) + implementation(projects.core.common) implementation(projects.core.model) implementation(projects.core.navigation) implementation(projects.core.prefs) implementation(projects.core.proto) - implementation(projects.core.service) + implementation(projects.core.repository) implementation(projects.core.resources) implementation(projects.core.ui) implementation(projects.core.di) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index fdfd3f05a0..07b1c1cb32 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -28,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 @@ -142,19 +142,19 @@ open class BaseMapViewModel( } open fun getUser(userId: String?) = - nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST) + nodeRepository.getUser(userId ?: DataPacket.nodeNumToId(DataPacket.BROADCAST)) fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum) fun deleteWaypoint(id: Int) = safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) } - fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") { + fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}") { // contactKey: unique contact key filter (channel)+(nodeId) val channel = contactKey[0].digitToIntOrNull() val dest = if (channel != null) contactKey.substring(1) else contactKey - val p = DataPacket(dest, channel ?: 0, wpt) + val p = DataPacket(DataPacket.parseNodeNum(dest), channel ?: 0, wpt) if (wpt.id != 0) sendDataPacket(p) } @@ -162,7 +162,8 @@ open class BaseMapViewModel( safeLaunch(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) } } - fun generatePacketId(): Int = radioController.getPacketId() + /** Generate a unique ID for a new waypoint. */ + fun generatePacketId(): Int = kotlin.random.Random.nextInt(1, Int.MAX_VALUE) data class MapFilterState( val onlyFavorites: Boolean, diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt index bcebdabf62..6d9017f6b4 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt @@ -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) diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt index e88a73077c..a7a6bd9aae 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt @@ -162,7 +162,7 @@ fun MessageScreen( val title = remember(nodeId, channelName, viewModel) { when (nodeId) { - DataPacket.ID_BROADCAST -> channelName + DataPacket.nodeNumToId(DataPacket.BROADCAST) -> channelName else -> viewModel.getUser(nodeId).long_name } } @@ -401,7 +401,7 @@ fun MessageScreen( onSendMessage = { text, key -> viewModel.sendMessage(text, key) }, onReply = { message -> replyingToPacketId = message?.packetId }, ), - quickEmojis = viewModel.frequentEmojis, + quickEmojis = viewModel.frequentEmojis.collectAsStateWithLifecycle().value, ) // Show FAB if we can scroll towards the newest messages (index 0). if (listState.canScrollBackward) { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt index 3f92f3cbf7..e9e4f45d9f 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt @@ -344,7 +344,7 @@ private fun RenderPagedChatMessageRow( message.emojis.any { reaction -> ( reaction.user.id == ourNode.user.id || - reaction.user.id == org.meshtastic.core.model.DataPacket.ID_LOCAL + reaction.user.id == org.meshtastic.core.model.DataPacket.nodeNumToId(org.meshtastic.core.model.DataPacket.LOCAL) ) && reaction.emoji == emoji } if (!hasReacted) { diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index ca29b38421..f17aeca5a3 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.ioDispatcher @@ -105,17 +106,21 @@ class MessageViewModel( } .cachedIn(viewModelScope) - val frequentEmojis: List - get() = - customEmojiPrefs.customEmojiFrequency.value - ?.split(",") - ?.associate { entry -> - entry.split("=", limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].toInt() } ?: ("" to 0) - } - ?.toList() - ?.sortedByDescending { it.second } - ?.map { it.first } - ?.take(6) ?: listOf("👍", "👎", "😂", "🔥", "❤️", "😮") + private val defaultEmojis = listOf("👍", "👎", "😂", "🔥", "❤️", "😮") + + val frequentEmojis: StateFlow> = + customEmojiPrefs.customEmojiFrequency + .map { raw -> + raw?.split(",") + ?.associate { entry -> + entry.split("=", limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1].toInt() } ?: ("" to 0) + } + ?.toList() + ?.sortedByDescending { it.second } + ?.map { it.first } + ?.take(6) ?: defaultEmojis + } + .stateInWhileSubscribed(defaultEmojis) val homoglyphEncodingEnabled = homoglyphEncodingPrefs.homoglyphEncodingEnabled @@ -195,9 +200,9 @@ class MessageViewModel( } } - fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) + fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.nodeNumToId(DataPacket.BROADCAST)) - fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) + fun getUser(userId: String?) = nodeRepository.getUser(userId ?: DataPacket.nodeNumToId(DataPacket.BROADCAST)) /** * Sends a message to a contact or channel. @@ -212,7 +217,7 @@ class MessageViewModel( * broadcasting on channel 0. * @param replyId The ID of the message this is a reply to, if any. */ - fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}", replyId: Int? = null) { + fun sendMessage(str: String, contactKey: String = "0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", replyId: Int? = null) { safeLaunch(tag = "sendMessage") { sendMessageUseCase.invoke(str, contactKey, replyId) } } diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt index c08298e297..b3c7b8c569 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt @@ -145,7 +145,8 @@ internal fun ReactionRow( items(emojiGroups.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value - val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } + val localReaction = + reactions.find { it.user.id == DataPacket.nodeNumToId(DataPacket.LOCAL) || it.user.id == myId } ReactionItem( emoji = emoji, emojiCount = reactions.size, @@ -231,7 +232,8 @@ internal fun ReactionDialog( items(groupedEmojis.entries.toList(), key = { it.key }) { entry -> val emoji = entry.key val reactions = entry.value - val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL || it.user.id == myId } + val localReaction = + reactions.find { it.user.id == DataPacket.nodeNumToId(DataPacket.LOCAL) || it.user.id == myId } val isSending = localReaction?.status == MessageStatus.QUEUED || localReaction?.status == MessageStatus.ENROUTE Text( @@ -263,7 +265,8 @@ internal fun ReactionDialog( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - val isLocal = reaction.user.id == myId || reaction.user.id == DataPacket.ID_LOCAL + val isLocal = + reaction.user.id == myId || reaction.user.id == DataPacket.nodeNumToId(DataPacket.LOCAL) val displayName = if (isLocal) { "${reaction.user.long_name} (${stringResource(Res.string.you)})" diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt index d846ba2609..9de72aac13 100644 --- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt +++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt @@ -75,12 +75,12 @@ class ContactsViewModel( channelSet, settings, -> - val (myNodeInfo, myId) = identity + val (myNodeInfo, _) = identity val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList() // Add empty channel placeholders (always show Broadcast contacts, even when empty) val placeholder = (0 until channelSet.settings.size).associate { ch -> - val contactKey = "$ch${DataPacket.ID_BROADCAST}" + val contactKey = "$ch${DataPacket.nodeNumToId(DataPacket.BROADCAST)}" val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch) contactKey to data } @@ -89,14 +89,13 @@ class ContactsViewModel( val contactKey = entry.key val packetData = entry.value // Determine if this is my message (originated on this device) - val fromLocal = - (packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId)) - val toBroadcast = packetData.to == DataPacket.ID_BROADCAST + val fromLocal = packetData.from == DataPacket.LOCAL || packetData.from == myNodeNum + val toBroadcast = packetData.to == DataPacket.BROADCAST // grab usernames from NodeInfo - val userId = if (fromLocal) packetData.to else packetData.from - val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) - val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) + val userId = DataPacket.nodeNumToId(if (fromLocal) packetData.to else packetData.from) + val user = nodeRepository.getUser(userId) + val node = nodeRepository.getNode(userId) val shortName = user.short_name val longName = @@ -129,31 +128,30 @@ class ContactsViewModel( val contactListPaged: Flow> = combine(identityFlow, channels, packetRepository.getContactSettings()) { identity, channelSet, settings -> - val (myNodeInfo, myId) = identity - ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings, myId) + val (myNodeInfo, _) = identity + ContactsPagedParams(myNodeInfo?.myNodeNum, channelSet, settings) } .flatMapLatest { params -> val channelSet = params.channelSet val settings = params.settings - val myId = params.myId + val myNodeNum = params.myNodeNum packetRepository.getContactsPaged().map { pagingData -> pagingData.map { packetData: DataPacket -> // Determine if this is my message (originated on this device) - val fromLocal = - (packetData.from == DataPacket.ID_LOCAL || (myId != null && packetData.from == myId)) - val toBroadcast = packetData.to == DataPacket.ID_BROADCAST + val fromLocal = packetData.from == DataPacket.LOCAL || packetData.from == myNodeNum + val toBroadcast = packetData.to == DataPacket.BROADCAST // Reconstruct contactKey exactly as rememberDataPacket() computes it: // For outgoing or broadcast: use the "to" field (recipient / ^all) // For incoming DMs: use the "from" field (the other party) val contactId = if (fromLocal || toBroadcast) packetData.to else packetData.from - val contactKey = "${packetData.channel}$contactId" + val contactKey = "${packetData.channel}${DataPacket.nodeNumToId(contactId)}" // grab usernames from NodeInfo - val userId = if (fromLocal) packetData.to else packetData.from - val user = nodeRepository.getUser(userId ?: DataPacket.ID_BROADCAST) - val node = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) + val userId = DataPacket.nodeNumToId(if (fromLocal) packetData.to else packetData.from) + val user = nodeRepository.getUser(userId) + val node = nodeRepository.getNode(userId) val shortName = user.short_name val longName = @@ -185,7 +183,7 @@ class ContactsViewModel( } .cachedIn(viewModelScope) - fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST) + fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.nodeNumToId(DataPacket.BROADCAST)) fun deleteContacts(contacts: List) = safeLaunch(context = ioDispatcher, tag = "deleteContacts") { packetRepository.deleteContacts(contacts) } @@ -218,6 +216,5 @@ class ContactsViewModel( val myNodeNum: Int?, val channelSet: ChannelSet, val settings: Map, - val myId: String?, ) } diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt index 80877834b6..14a3e082f6 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/MessageViewModelTest.kt @@ -172,8 +172,7 @@ class MessageViewModelTest { fun testFrequentEmojis() = runTest { customEmojiFrequencyFlow.value = "👍=10,👎=5,😂=20" - // frequentEmojis is a property, not a flow. - val emojis = viewModel.frequentEmojis + val emojis = viewModel.frequentEmojis.value assertEquals(listOf("😂", "👍", "👎"), emojis) } diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index c909082806..f17e2795fd 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -33,17 +33,14 @@ kotlin { implementation(projects.core.common) implementation(projects.core.data) implementation(projects.core.database) - implementation(projects.core.datastore) implementation(projects.core.domain) implementation(projects.core.model) implementation(projects.core.navigation) implementation(projects.core.proto) implementation(projects.core.repository) implementation(projects.core.resources) - implementation(projects.core.service) implementation(projects.core.ui) implementation(projects.core.di) - implementation(projects.feature.map) implementation(libs.jetbrains.navigation3.ui) implementation(libs.markdown.renderer) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt index 88423aab10..b229e84bc5 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeFilterTextField.kt @@ -18,6 +18,7 @@ package org.meshtastic.feature.node.component import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -26,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -39,9 +41,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MenuDefaults import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -61,8 +65,12 @@ import org.meshtastic.core.resources.clear import org.meshtastic.core.resources.desc_node_filter_clear import org.meshtastic.core.resources.node_filter_exclude_infrastructure import org.meshtastic.core.resources.node_filter_exclude_mqtt +import org.meshtastic.core.resources.node_filter_distance_km +import org.meshtastic.core.resources.node_filter_distance_unlimited import org.meshtastic.core.resources.node_filter_ignored import org.meshtastic.core.resources.node_filter_include_unknown +import org.meshtastic.core.resources.node_filter_max_distance +import org.meshtastic.core.resources.node_filter_max_distance_value import org.meshtastic.core.resources.node_filter_only_direct import org.meshtastic.core.resources.node_filter_only_online import org.meshtastic.core.resources.node_filter_placeholder @@ -74,6 +82,8 @@ import org.meshtastic.core.ui.icon.Close import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Search import org.meshtastic.core.ui.icon.Sort +import kotlin.math.abs +import kotlin.math.roundToInt @Suppress("LongParameterList") @Composable @@ -96,6 +106,8 @@ fun NodeFilterTextField( ignoredNodeCount: Int, excludeMqtt: Boolean, onToggleExcludeMqtt: () -> Unit, + maxDistanceKm: Float?, + onMaxDistanceKmChange: (Float?) -> Unit, ) { Column(modifier = modifier.background(MaterialTheme.colorScheme.background)) { Row { @@ -120,6 +132,8 @@ fun NodeFilterTextField( ignoredNodeCount = ignoredNodeCount, excludeMqtt = excludeMqtt, onToggleExcludeMqtt = onToggleExcludeMqtt, + maxDistanceKm = maxDistanceKm, + onMaxDistanceKmChange = onMaxDistanceKmChange, ), ) } @@ -157,6 +171,8 @@ data class NodeFilterToggles( val ignoredNodeCount: Int, val excludeMqtt: Boolean, val onToggleExcludeMqtt: () -> Unit, + val maxDistanceKm: Float?, + val onMaxDistanceKmChange: (Float?) -> Unit, ) @Composable @@ -288,9 +304,70 @@ private fun NodeSortButton( checked = toggles.excludeMqtt, onClick = toggles.onToggleExcludeMqtt, ) + + HorizontalDivider(modifier = Modifier.padding(MenuDefaults.DropdownMenuItemContentPadding)) + + DistanceFilterDropdownSection( + maxDistanceKm = toggles.maxDistanceKm, + onMaxDistanceKmChange = toggles.onMaxDistanceKmChange, + ) + } +} + +@Composable +private fun DistanceFilterDropdownSection(maxDistanceKm: Float?, onMaxDistanceKmChange: (Float?) -> Unit) { + val distanceOptions = remember { listOf(null, 1f, 5f, 10f, 50f) } + val selectedIndex = + remember(maxDistanceKm) { + distanceOptions.indexOf(maxDistanceKm).takeIf { it >= 0 } + ?: distanceOptions.indices + .filter { distanceOptions[it] != null } + .minByOrNull { index -> abs(distanceOptions[index]!! - (maxDistanceKm ?: 0f)) } + ?: 0 + } + var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) } + val distanceLabel = + maxDistanceKm?.let { stringResource(Res.string.node_filter_distance_km, formatDistanceKm(it)) } + ?: stringResource(Res.string.node_filter_distance_unlimited) + + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).widthIn(min = 240.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = stringResource(Res.string.node_filter_max_distance), style = MaterialTheme.typography.titleSmall) + Text( + text = stringResource(Res.string.node_filter_max_distance_value, distanceLabel), + style = MaterialTheme.typography.labelLarge, + ) + Slider( + value = sliderPosition, + onValueChange = { sliderPosition = it }, + onValueChangeFinished = { + val newIndex = sliderPosition.roundToInt().coerceIn(0, distanceOptions.lastIndex) + onMaxDistanceKmChange(distanceOptions[newIndex]) + }, + valueRange = 0f..distanceOptions.lastIndex.toFloat(), + steps = distanceOptions.size - 2, + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + distanceOptions.forEach { option -> + Text( + text = + option?.let { stringResource(Res.string.node_filter_distance_km, formatDistanceKm(it)) } + ?: stringResource(Res.string.node_filter_distance_unlimited), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } } } +private fun formatDistanceKm(value: Float): String = + value.toInt().takeIf { it.toFloat() == value }?.toString() ?: value.toString() + @Composable private fun DropdownMenuTitle(text: String) { Text( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 84dd70d1f7..daff4bdef6 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -90,6 +90,7 @@ import org.meshtastic.core.ui.icon.ChannelUtilization import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Notes import org.meshtastic.proto.Config +import org.meshtastic.core.model.CongestionLevel private const val ACTIVE_ALPHA = 0.5f private const val INACTIVE_ALPHA = 0.2f @@ -102,12 +103,14 @@ fun NodeItem( thatNode: Node, distanceUnits: Int, tempInFahrenheit: Boolean, + congestionLevel: CongestionLevel? = null, modifier: Modifier = Modifier, onClick: () -> Unit = {}, onLongClick: (() -> Unit)? = null, connectionState: ConnectionState, deviceType: DeviceType? = null, isActive: Boolean = false, + isStoreForwardServer: Boolean = false, ) { val originalLongName = thatNode.user.long_name.ifEmpty { stringResource(Res.string.unknown_username) } val isMuted = remember(thatNode) { thatNode.isMuted } @@ -167,7 +170,9 @@ fun NodeItem( isMuted = isMuted, isUnmessageable = unmessageable, connectionState = connectionState, + congestionLevel = congestionLevel, deviceType = deviceType, + isStoreForwardServer = isStoreForwardServer, contentColor = contentColor, ) @@ -395,7 +400,9 @@ private fun NodeItemHeader( isMuted: Boolean, isUnmessageable: Boolean, connectionState: ConnectionState, + congestionLevel: CongestionLevel?, deviceType: DeviceType?, + isStoreForwardServer: Boolean, contentColor: Color, ) { Row( @@ -441,7 +448,9 @@ private fun NodeItemHeader( isMuted = isMuted, isUnmessageable = isUnmessageable, connectionState = connectionState, + congestionLevel = congestionLevel, deviceType = deviceType, + isStoreForwardServer = isStoreForwardServer, contentColor = contentColor, ) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt index 6151c7d36a..12d991814b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt @@ -39,20 +39,28 @@ import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DeviceType import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.congestion_level import org.meshtastic.core.resources.connected import org.meshtastic.core.resources.connecting import org.meshtastic.core.resources.device_sleeping import org.meshtastic.core.resources.disconnected import org.meshtastic.core.resources.favorite import org.meshtastic.core.resources.mute_always +import org.meshtastic.core.resources.store_forward_server import org.meshtastic.core.resources.unmessageable import org.meshtastic.core.resources.unmonitored_or_infrastructure import org.meshtastic.core.ui.component.ConnectionsNavIcon +import org.meshtastic.core.ui.icon.CloudDownload import org.meshtastic.core.ui.icon.Favorite import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Unmessageable import org.meshtastic.core.ui.icon.VolumeOff +import org.meshtastic.core.ui.icon.Warning +import org.meshtastic.core.ui.theme.StatusColors.StatusBlue +import org.meshtastic.core.ui.theme.StatusColors.StatusOrange +import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow +import org.meshtastic.core.model.CongestionLevel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,14 +70,19 @@ fun NodeStatusIcons( isFavorite: Boolean, isMuted: Boolean, connectionState: ConnectionState, + congestionLevel: CongestionLevel? = null, modifier: Modifier = Modifier, deviceType: DeviceType? = null, + isStoreForwardServer: Boolean = false, contentColor: Color = LocalContentColor.current, ) { Row(modifier = modifier.padding(4.dp)) { if (isThisNode) { ThisNodeStatusBadge(connectionState = connectionState, deviceType = deviceType) } + if (isThisNode && congestionLevel != null && congestionLevel != CongestionLevel.LOW) { + CongestionBadge(congestionLevel) + } if (isUnmessageable) { StatusBadge( @@ -79,6 +92,9 @@ fun NodeStatusIcons( tint = contentColor, ) } + if (isStoreForwardServer) { + StoreForwardBadge() + } if (isMuted && !isThisNode) { StatusBadge( imageVector = MeshtasticIcons.VolumeOff, @@ -109,7 +125,9 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: De stringResource( when (connectionState) { ConnectionState.Connected -> Res.string.connected - ConnectionState.Connecting -> Res.string.connecting + is ConnectionState.Connecting, + is ConnectionState.Configuring, + is ConnectionState.Reconnecting -> Res.string.connecting ConnectionState.Disconnected -> Res.string.disconnected ConnectionState.DeviceSleep -> Res.string.device_sleeping }, @@ -123,6 +141,49 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: De } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StoreForwardBadge() { + val text = stringResource(Res.string.store_forward_server) + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text(text) } }, + state = rememberTooltipState(), + ) { + Icon( + imageVector = MeshtasticIcons.CloudDownload, + contentDescription = text, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.StatusBlue, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CongestionBadge(level: CongestionLevel) { + val color = + when (level) { + CongestionLevel.MEDIUM -> MaterialTheme.colorScheme.StatusYellow + CongestionLevel.HIGH -> MaterialTheme.colorScheme.StatusOrange + CongestionLevel.CRITICAL -> MaterialTheme.colorScheme.StatusRed + else -> return + } + val tooltipText = stringResource(Res.string.congestion_level, level.name) + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text(tooltipText) } }, + state = rememberTooltipState(), + ) { + Icon( + imageVector = MeshtasticIcons.Warning, + contentDescription = tooltipText, + modifier = Modifier.size(24.dp), + tint = color, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StatusBadge( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt index 2ff709b759..e1c2f7b0c8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt @@ -197,7 +197,7 @@ private fun rememberTelemetricFeatures( TelemetricFeature( titleRes = LogsType.PAX.titleRes, icon = LogsType.PAX.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) }, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) }, logsType = LogsType.PAX, ), TelemetricFeature( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt index 1ea4636857..f32ee3b5d1 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt @@ -26,8 +26,8 @@ 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.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 +48,7 @@ import org.meshtastic.core.ui.util.SnackbarManager @Single(binds = [NodeRequestActions::class]) class CommonNodeRequestActions constructor( - private val radioController: RadioController, + private val dataRequester: DataRequester, private val snackbarManager: SnackbarManager, ) : NodeRequestActions { @@ -64,58 +64,66 @@ constructor( override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(ioDispatcher) { - Logger.i { "Requesting UserInfo for '$destNum'" } - radioController.requestUserInfo(destNum) - showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName)) + runCatching { + Logger.i { "Requesting UserInfo for '$destNum'" } + dataRequester.requestUserInfo(destNum) + showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName)) + }.onFailure { e -> Logger.e(e) { "requestUserInfo failed" } } } } override fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(ioDispatcher) { - Logger.i { "Requesting NeighborInfo for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestNeighborInfo(packetId, destNum) - _lastRequestNeighborTimes.update { it + (destNum to nowMillis) } - showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName)) + runCatching { + Logger.i { "Requesting NeighborInfo for '$destNum'" } + dataRequester.requestNeighborInfo(destNum) + _lastRequestNeighborTimes.update { it + (destNum to nowMillis) } + showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName)) + }.onFailure { e -> Logger.e(e) { "requestNeighborInfo failed" } } } } override fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) { scope.launch(ioDispatcher) { - Logger.i { "Requesting position for '$destNum'" } - radioController.requestPosition(destNum, position) - showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName)) + runCatching { + Logger.i { "Requesting position for '$destNum'" } + dataRequester.requestPosition(destNum, position) + showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName)) + }.onFailure { e -> Logger.e(e) { "requestPosition failed" } } } } 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) + runCatching { + Logger.i { "Requesting telemetry for '$destNum'" } + dataRequester.requestTelemetry(destNum, type) - val typeRes = - when (type) { - TelemetryType.DEVICE -> Res.string.request_device_metrics - TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics - TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics - TelemetryType.POWER -> Res.string.request_power_metrics - TelemetryType.LOCAL_STATS -> Res.string.signal_quality - TelemetryType.HOST -> Res.string.request_host_metrics - TelemetryType.PAX -> Res.string.request_pax_metrics - } + val typeRes = + when (type) { + TelemetryType.DEVICE -> Res.string.request_device_metrics + TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics + TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics + TelemetryType.POWER -> Res.string.request_power_metrics + TelemetryType.LOCAL_STATS -> Res.string.signal_quality + TelemetryType.HEALTH -> Res.string.request_device_metrics + TelemetryType.HOST -> Res.string.request_host_metrics + TelemetryType.TRAFFIC_MANAGEMENT -> Res.string.request_device_metrics + } - showFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)) + showFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)) + }.onFailure { e -> Logger.e(e) { "requestTelemetry failed" } } } } override fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(ioDispatcher) { - Logger.i { "Requesting traceroute for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestTraceroute(packetId, destNum) - _lastTracerouteTime.value = nowMillis - showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName)) + runCatching { + Logger.i { "Requesting traceroute for '$destNum'" } + dataRequester.requestTraceroute(destNum) + _lastTracerouteTime.value = nowMillis + showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName)) + }.onFailure { e -> Logger.e(e) { "requestTraceroute failed" } } } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index 17046f3a7c..5eef80e91c 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -22,8 +22,8 @@ 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.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 +47,7 @@ open class NodeManagementActions constructor( private val nodeRepository: NodeRepository, private val serviceRepository: ServiceRepository, - private val radioController: RadioController, + private val deviceControl: DeviceControl, private val alertManager: AlertManager, ) { open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) { @@ -64,8 +64,7 @@ constructor( open fun removeNode(scope: CoroutineScope, nodeNum: Int) { scope.launch(ioDispatcher) { Logger.i { "Removing node '$nodeNum'" } - val packetId = radioController.getPacketId() - radioController.removeByNodenum(packetId, nodeNum) + deviceControl.removeByNodenum(nodeNum) nodeRepository.deleteNode(nodeNum) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt index 26fdce5987..e815f22812 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCase.kt @@ -17,8 +17,10 @@ package org.meshtastic.feature.node.domain.usecase import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.distanceKm import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.repository.NodeRepository @@ -29,15 +31,19 @@ import org.meshtastic.proto.Config @Single open class GetFilteredNodesUseCase constructor(private val nodeRepository: NodeRepository) { @Suppress("CyclomaticComplexMethod", "LongMethod") - open operator fun invoke(filter: NodeFilterState, sort: NodeSortOption): Flow> = nodeRepository - .getNodes( - sort = sort, - filter = filter.filterText, - includeUnknown = filter.includeUnknown, - onlyOnline = filter.onlyOnline, - onlyDirect = filter.onlyDirect, - ) - .map { list -> + open operator fun invoke(filter: NodeFilterState, sort: NodeSortOption): Flow> = + combine( + nodeRepository.getNodes( + sort = sort, + filter = filter.filterText, + includeUnknown = filter.includeUnknown, + onlyOnline = filter.onlyOnline, + onlyDirect = filter.onlyDirect, + ), + nodeRepository.ourNodeInfo, + ) { list, ourNode -> + list to ourNode + }.map { (list, ourNode) -> list .filter { node -> node.isIgnored == filter.showIgnored } .filter { node -> @@ -58,5 +64,21 @@ open class GetFilteredNodesUseCase constructor(private val nodeRepository: NodeR } } .filter { node -> if (filter.excludeMqtt) !node.viaMqtt else true } + .filter { node -> + val maxDistanceKm = filter.maxDistanceKm + val ourDistanceAnchor = ourNode?.takeIf { it.validPosition != null } + val nodePosition = node.validPosition + + if (maxDistanceKm == null || ourDistanceAnchor == null || nodePosition == null) { + true + } else { + distanceKm( + latitudeA = ourDistanceAnchor.latitude, + longitudeA = ourDistanceAnchor.longitude, + latitudeB = node.latitude, + longitudeB = node.longitude, + ) <= maxDistanceKm + } + } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt index 648d6b1d84..2d5cc1da6f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt @@ -29,6 +29,7 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) { open val onlyDirect = uiPrefs.onlyDirect open val showIgnored = uiPrefs.showIgnored open val excludeMqtt = uiPrefs.excludeMqtt + open val maxDistanceKm = uiPrefs.maxDistanceKm open val nodeSortOption = uiPrefs.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } } @@ -60,4 +61,8 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) { open fun toggleExcludeMqtt() { uiPrefs.setExcludeMqtt(!excludeMqtt.value) } + + open fun setMaxDistanceKm(value: Float?) { + uiPrefs.setMaxDistanceKm(value) + } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index fbb81101a3..8aee8cc424 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -98,6 +98,8 @@ fun NodeListScreen( val nodes by viewModel.nodeList.collectAsStateWithLifecycle() val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() + val congestionLevel by viewModel.congestionLevel.collectAsStateWithLifecycle() + val storeForwardServers by viewModel.storeForwardServers.collectAsStateWithLifecycle() val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0) val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0) val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle() @@ -181,6 +183,8 @@ fun NodeListScreen( ignoredNodeCount = ignoredNodeCount, excludeMqtt = state.filter.excludeMqtt, onToggleExcludeMqtt = { viewModel.nodeFilterPreferences.toggleExcludeMqtt() }, + maxDistanceKm = state.filter.maxDistanceKm, + onMaxDistanceKmChange = viewModel.nodeFilterPreferences::setMaxDistanceKm, ) } @@ -203,11 +207,13 @@ fun NodeListScreen( thatNode = node, distanceUnits = state.distanceUnits, tempInFahrenheit = state.tempInFahrenheit, + congestionLevel = congestionLevel, onClick = { navigateToNodeDetails(node.num) }, onLongClick = longClick, connectionState = connectionState, deviceType = deviceType, isActive = isActive, + isStoreForwardServer = node.num in storeForwardServers, ) val isThisNode = remember(node) { ourNode?.num == node.num } if (!isThisNode) { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index d557318180..d6c250612a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt @@ -29,16 +29,17 @@ 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.RadioInterfaceService +import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.node.detail.NodeManagementActions import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Config +import org.meshtastic.core.model.CongestionLevel @Suppress("LongParameterList") @KoinViewModel @@ -47,8 +48,8 @@ class NodeListViewModel( private val nodeRepository: NodeRepository, private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, - private val radioController: RadioController, - private val radioInterfaceService: RadioInterfaceService, + private val radioController: DeviceAdmin, + private val radioPrefs: RadioPrefs, val nodeManagementActions: NodeManagementActions, private val getFilteredNodesUseCase: GetFilteredNodesUseCase, val nodeFilterPreferences: NodeFilterPreferences, @@ -62,8 +63,12 @@ class NodeListViewModel( val connectionState = serviceRepository.connectionState + val congestionLevel: StateFlow = serviceRepository.congestionLevel + + val storeForwardServers: StateFlow> = serviceRepository.storeForwardServers + val deviceType: StateFlow = - radioInterfaceService.currentDeviceAddressFlow + radioPrefs.devAddr .map { address -> address?.let { DeviceType.fromAddress(it) } } .stateInWhileSubscribed(initialValue = null) @@ -89,10 +94,11 @@ class NodeListViewModel( } private val nodeFilter: Flow = - combine(_nodeFilterText, filterToggles, nodeFilterPreferences.excludeMqtt) { + combine(_nodeFilterText, filterToggles, nodeFilterPreferences.excludeMqtt, nodeFilterPreferences.maxDistanceKm) { filterText, filterToggles, excludeMqtt, + maxDistanceKm, -> NodeFilterState( filterText = filterText, @@ -102,6 +108,7 @@ class NodeListViewModel( onlyDirect = filterToggles.onlyDirect, showIgnored = filterToggles.showIgnored, excludeMqtt = excludeMqtt, + maxDistanceKm = maxDistanceKm, ) } val nodesUiState: StateFlow = @@ -154,6 +161,7 @@ class NodeListViewModel( } } +@androidx.compose.runtime.Immutable data class NodesUiState( val sort: NodeSortOption = NodeSortOption.LAST_HEARD, val filter: NodeFilterState = NodeFilterState(), @@ -161,6 +169,7 @@ data class NodesUiState( val tempInFahrenheit: Boolean = false, ) +@androidx.compose.runtime.Immutable data class NodeFilterState( val filterText: String = "", val includeUnknown: Boolean = false, @@ -169,10 +178,11 @@ data class NodeFilterState( val onlyDirect: Boolean = false, val showIgnored: Boolean = false, val excludeMqtt: Boolean = false, + val maxDistanceKm: Float? = null, ) { /** True if any user-applied filter is narrowing the visible node set. */ val isActive: Boolean - get() = filterText.isNotEmpty() || excludeInfrastructure || onlyOnline || onlyDirect || excludeMqtt + get() = filterText.isNotEmpty() || excludeInfrastructure || onlyOnline || onlyDirect || excludeMqtt || maxDistanceKm != null } data class NodeFilterToggles( diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index 492685b185..61367756aa 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -191,12 +191,12 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni BaseMetricScreen( onNavigateUp = onNavigateUp, - telemetryType = TelemetryType.PAX, + telemetryType = TelemetryType.DEVICE, titleRes = Res.string.pax_metrics_log, nodeName = state.node?.user?.long_name ?: "", data = paxMetrics, timeProvider = { (it.first.received_date / MS_PER_SEC).toDouble() }, - onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }, + onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.DEVICE) }, controlPart = { TimeFrameSelector( selectedTimeFrame = timeFrame, diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index 4e65cf2907..bcc451b077 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -46,7 +46,7 @@ class NodeManagementActionsTest { NodeManagementActions( nodeRepository = nodeRepository, serviceRepository = serviceRepository, - radioController = radioController, + deviceControl = radioController, alertManager = alertManager, ) @@ -78,7 +78,7 @@ class NodeManagementActionsTest { NodeManagementActions( nodeRepository = nodeRepository, serviceRepository = serviceRepository, - radioController = radioController, + deviceControl = radioController, alertManager = realAlertManager, ) val node = Node(num = 123, user = User(long_name = "Test Node")) diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt index 5cf782707c..385e9ac685 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/domain/usecase/GetFilteredNodesUseCaseTest.kt @@ -20,6 +20,7 @@ import dev.mokkery.answering.returns import dev.mokkery.every import dev.mokkery.matcher.any import dev.mokkery.mock +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -28,6 +29,7 @@ import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.repository.NodeRepository import org.meshtastic.feature.node.list.NodeFilterState import org.meshtastic.proto.Config +import org.meshtastic.proto.Position import org.meshtastic.proto.User import kotlin.test.BeforeTest import kotlin.test.Test @@ -36,11 +38,14 @@ import kotlin.test.assertEquals class GetFilteredNodesUseCaseTest { private lateinit var nodeRepository: NodeRepository + private lateinit var ourNodeInfo: MutableStateFlow private lateinit var useCase: GetFilteredNodesUseCase @BeforeTest fun setUp() { nodeRepository = mock() + ourNodeInfo = MutableStateFlow(null) + every { nodeRepository.ourNodeInfo } returns ourNodeInfo useCase = GetFilteredNodesUseCase(nodeRepository) } @@ -50,9 +55,17 @@ class GetFilteredNodesUseCaseTest { ignored: Boolean = false, name: String = "Node$num", viaMqtt: Boolean = false, + latitudeI: Int = 0, + longitudeI: Int = 0, ): Node { val user = User(id = "!$num", long_name = name, short_name = "N$num", role = role) - return Node(num = num, user = user, isIgnored = ignored, viaMqtt = viaMqtt) + return Node( + num = num, + user = user, + position = Position(latitude_i = latitudeI, longitude_i = longitudeI), + isIgnored = ignored, + viaMqtt = viaMqtt, + ) } @Test @@ -152,4 +165,21 @@ class GetFilteredNodesUseCaseTest { // Assert assertEquals(2, result.size) } + + @Test + fun `invoke filters out nodes beyond max distance and keeps nodes without position`() = runTest { + val ourNode = createNode(0, latitudeI = 10000000, longitudeI = 10000000) + val nearbyNode = createNode(1, latitudeI = 10000000, longitudeI = 10100000) + val farNode = createNode(2, latitudeI = 10000000, longitudeI = 12000000) + val noPositionNode = createNode(3) + val filter = NodeFilterState(maxDistanceKm = 5f) + + ourNodeInfo.value = ourNode + every { nodeRepository.getNodes(any(), any(), any(), any(), any()) } returns + flowOf(listOf(nearbyNode, farNode, noPositionNode)) + + val result = useCase(filter, NodeSortOption.LAST_HEARD).first() + + assertEquals(listOf(1, 3), result.map(Node::num)) + } } diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt index 231ca30e1a..4aabf2ce67 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/list/NodeListViewModelTest.kt @@ -29,10 +29,11 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeRadioController -import org.meshtastic.core.testing.FakeRadioInterfaceService +import org.meshtastic.core.testing.FakeAppPreferences import org.meshtastic.core.testing.TestDataFactory import org.meshtastic.feature.node.detail.NodeManagementActions import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase @@ -46,7 +47,7 @@ class NodeListViewModelTest { private lateinit var viewModel: NodeListViewModel private lateinit var nodeRepository: FakeNodeRepository private lateinit var radioController: FakeRadioController - private lateinit var radioInterfaceService: FakeRadioInterfaceService + private val radioPrefs: RadioPrefs = FakeAppPreferences().radio private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) private val nodeFilterPreferences: NodeFilterPreferences = mock(MockMode.autofill) @@ -57,7 +58,6 @@ class NodeListViewModelTest { fun setUp() { nodeRepository = FakeNodeRepository() radioController = FakeRadioController() - radioInterfaceService = FakeRadioInterfaceService() every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig()) every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(org.meshtastic.proto.DeviceProfile()) @@ -70,6 +70,7 @@ class NodeListViewModelTest { every { nodeFilterPreferences.onlyDirect } returns MutableStateFlow(false) every { nodeFilterPreferences.showIgnored } returns MutableStateFlow(false) every { nodeFilterPreferences.excludeMqtt } returns MutableStateFlow(false) + every { nodeFilterPreferences.maxDistanceKm } returns MutableStateFlow(null) every { getFilteredNodesUseCase(any(), any()) } returns MutableStateFlow(emptyList()) @@ -82,7 +83,7 @@ class NodeListViewModelTest { radioConfigRepository = radioConfigRepository, serviceRepository = serviceRepository, radioController = radioController, - radioInterfaceService = radioInterfaceService, + radioPrefs = radioPrefs, nodeManagementActions = nodeManagementActions, getFilteredNodesUseCase = getFilteredNodesUseCase, nodeFilterPreferences = nodeFilterPreferences, diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 3725dd2933..8b9881ddc1 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -39,7 +39,6 @@ kotlin { implementation(projects.core.network) implementation(projects.core.proto) implementation(projects.core.repository) - implementation(projects.core.service) implementation(projects.core.resources) implementation(projects.core.ui) implementation(projects.core.di) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt index 12943e3469..077b2b9a21 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt @@ -51,9 +51,9 @@ import org.meshtastic.feature.settings.component.ExpressiveSection import org.meshtastic.feature.settings.radio.AdminRoute import org.meshtastic.feature.settings.radio.RadioConfigState import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.ResponseState +import org.meshtastic.core.model.ResponseState import org.meshtastic.feature.settings.radio.component.LoadingOverlay -import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog +import org.meshtastic.core.ui.component.PacketResponseStateDialog import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialog import org.meshtastic.feature.settings.radio.component.WarningDialog 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 be5ca8c790..7dd0387adf 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/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt index 136131241e..5c8369cfec 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt @@ -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() { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/MqttProbeCoordinator.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/MqttProbeCoordinator.kt new file mode 100644 index 0000000000..92aad19edf --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/MqttProbeCoordinator.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.meshtastic.core.common.util.safeCatching +import org.meshtastic.core.model.MqttConnectionState +import org.meshtastic.core.model.MqttProbeStatus +import org.meshtastic.core.repository.MqttManager + +/** + * Encapsulates MQTT broker reachability probing logic. + * Injected into [RadioConfigViewModel] to keep probe state and cancellation self-contained. + */ +class MqttProbeCoordinator( + private val mqttManager: MqttManager, + private val scope: CoroutineScope, +) { + /** MQTT proxy connection state for the settings UI. */ + val mqttConnectionState: StateFlow = mqttManager.mqttConnectionState + + private val _probeStatus = MutableStateFlow(null) + + /** Latest result from a [probe] call, or `null` if no probe has been run. */ + val probeStatus: StateFlow = _probeStatus.asStateFlow() + + private var probeJob: Job? = null + + /** + * Run a one-shot reachability/credentials probe against an MQTT broker. + * Cancels any in-flight probe before starting a new one. + */ + fun probe(address: String, tlsEnabled: Boolean, username: String?, password: String?) { + probeJob?.cancel() + _probeStatus.value = MqttProbeStatus.Probing + probeJob = scope.launch { + val result = + safeCatching { mqttManager.probe(address, tlsEnabled, username, password) } + .getOrElse { e -> + Logger.w(e) { "MQTT probe threw" } + MqttProbeStatus.Other(message = e.message) + } + _probeStatus.value = result + } + } + + /** Clear the latest probe result (e.g. when the user edits the address). */ + fun clearProbeStatus() { + probeJob?.cancel() + _probeStatus.value = null + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ProfileCoordinator.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ProfileCoordinator.kt new file mode 100644 index 0000000000..0ff97aaaf1 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ProfileCoordinator.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.radio + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.meshtastic.core.common.util.CommonUri +import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase +import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase +import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase +import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase +import org.meshtastic.core.repository.FileService +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceProfile +import org.meshtastic.proto.User + +/** + * Encapsulates device-profile import/export/install operations. + * Injected into [RadioConfigViewModel] to keep file I/O logic self-contained. + */ +class ProfileCoordinator( + private val fileService: FileService, + private val importProfileUseCase: ImportProfileUseCase, + private val exportProfileUseCase: ExportProfileUseCase, + private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase, + private val installProfileUseCase: InstallProfileUseCase, + private val scope: CoroutineScope, +) { + fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) { + scope.launch { + try { + var profile: DeviceProfile? = null + fileService.read(uri) { source -> + importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } + } + profile?.let { onResult(it) } + } catch (e: Exception) { + Logger.e(e) { "[importProfile] failed" } + } + } + } + + fun exportProfile(uri: CommonUri, profile: DeviceProfile) { + scope.launch { + try { + fileService.write(uri) { sink -> + exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } + } + } catch (e: Exception) { + Logger.e(e) { "[exportProfile] failed" } + } + } + } + + fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) { + scope.launch { + try { + fileService.write(uri) { sink -> + exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } + } + } catch (e: Exception) { + Logger.e(e) { "[exportSecurityConfig] failed" } + } + } + } + + fun installProfile(destNum: Int, protobuf: DeviceProfile, user: User?) { + scope.launch { + try { + installProfileUseCase(destNum, protobuf, user) + } catch (e: Exception) { + Logger.e(e) { "[installProfile] failed" } + } + } + } +} 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 fd923a1336..0a76ba7579 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 @@ -21,37 +21,35 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.common.util.CommonUri -import org.meshtastic.core.common.util.safeCatching import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase -import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase -import org.meshtastic.core.domain.usecase.settings.RadioResponseResult -import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase -import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase +import org.meshtastic.core.model.AdminException import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MqttConnectionState import org.meshtastic.core.model.MqttProbeStatus import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.Position +import org.meshtastic.core.model.RadioConfigStateProvider import org.meshtastic.core.repository.AnalyticsPrefs import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.HomoglyphPrefs @@ -72,7 +70,6 @@ import org.meshtastic.core.ui.viewmodel.safeLaunch import org.meshtastic.feature.settings.navigation.ConfigRoute import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceConnectionStatus @@ -83,16 +80,16 @@ import org.meshtastic.proto.FileInfo import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig -import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User -import kotlin.time.Duration.Companion.seconds +import org.meshtastic.core.model.ResponseState /** Data class that represents the current RadioConfig state. */ +@androidx.compose.runtime.Immutable data class RadioConfigState( val isLocal: Boolean = false, val connected: Boolean = false, - val route: String = "", + val route: Enum<*>? = null, val metadata: DeviceMetadata? = null, val userConfig: User = User(), val channelList: List = emptyList(), @@ -121,64 +118,47 @@ 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, private val installProfileUseCase: InstallProfileUseCase, private val radioConfigUseCase: RadioConfigUseCase, private val adminActionsUseCase: AdminActionsUseCase, - private val processRadioResponseUseCase: ProcessRadioResponseUseCase, private val locationService: LocationService, private val fileService: FileService, private val mqttManager: MqttManager, -) : ViewModel() { +) : ViewModel(), RadioConfigStateProvider { 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. */ - val mqttConnectionState: StateFlow = mqttManager.mqttConnectionState + private val mqttProbeCoordinator = MqttProbeCoordinator(mqttManager, viewModelScope) - private val _mqttProbeStatus = MutableStateFlow(null) + /** MQTT proxy connection state for the settings UI. */ + val mqttConnectionState: StateFlow = mqttProbeCoordinator.mqttConnectionState /** Latest result from a [probeMqttConnection] call, or `null` if no probe has been run. */ - val mqttProbeStatus: StateFlow = _mqttProbeStatus.asStateFlow() - - private var probeJob: Job? = null + val mqttProbeStatus: StateFlow = mqttProbeCoordinator.probeStatus /** * Run a one-shot reachability/credentials probe against an MQTT broker. Cancels any in-flight probe before starting * a new one. Result is exposed via [mqttProbeStatus]. */ fun probeMqttConnection(address: String, tlsEnabled: Boolean, username: String?, password: String?) { - probeJob?.cancel() - _mqttProbeStatus.value = MqttProbeStatus.Probing - probeJob = - viewModelScope.launch { - val result = - safeCatching { mqttManager.probe(address, tlsEnabled, username, password) } - .getOrElse { e -> - Logger.w(e) { "MQTT probe threw" } - MqttProbeStatus.Other(message = e.message) - } - _mqttProbeStatus.value = result - } + mqttProbeCoordinator.probe(address, tlsEnabled, username, password) } /** Clear the latest probe result (e.g. when the user edits the address). */ fun clearMqttProbeStatus() { - probeJob?.cancel() - _mqttProbeStatus.value = null + mqttProbeCoordinator.clearProbeStatus() } private val destNumFlow = MutableStateFlow(savedStateHandle.get("destNum")) @@ -193,10 +173,11 @@ open class RadioConfigViewModel( val destNode: StateFlow get() = _destNode - private val requestIds = MutableStateFlow(hashSetOf()) private val _radioConfigState = MutableStateFlow(RadioConfigState()) val radioConfigState: StateFlow = _radioConfigState + private var loadJob: Job? = null + fun setPreserveFavorites(preserveFavorites: Boolean) { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } @@ -243,11 +224,10 @@ open class RadioConfigViewModel( .onEach { manifest -> _radioConfigState.update { it.copy(fileManifest = manifest) } } .launchIn(viewModelScope) - serviceRepository.meshPacketFlow.onEach(::processPacketResponse).launchIn(viewModelScope) - - combine(serviceRepository.connectionState, radioConfigState) { connState, _ -> - _radioConfigState.update { it.copy(connected = connState == ConnectionState.Connected) } - } + serviceRepository.connectionState + .onEach { connState -> + _radioConfigState.update { it.copy(connected = connState == ConnectionState.Connected) } + } .launchIn(viewModelScope) combine(nodeRepository.myNodeInfo, destNumFlow) { ni, id -> @@ -285,20 +265,14 @@ open class RadioConfigViewModel( fun setOwner(user: User) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "setOwner") { - _radioConfigState.update { it.copy(userConfig = user) } - val packetId = radioConfigUseCase.setOwner(destNum, user) - registerRequestId(packetId) - } + _radioConfigState.update { it.copy(userConfig = user) } + writeAction("setOwner") { radioConfigUseCase.setOwner(destNum, user) } } fun updateChannels(new: List, old: List) { val destNum = destNode.value?.num ?: return getChannelList(new, old).forEach { channel -> - safeLaunch(tag = "setRemoteChannel") { - val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel) - registerRequestId(packetId) - } + writeAction("setRemoteChannel") { radioConfigUseCase.setRemoteChannel(destNum, channel) } } if (destNum == myNodeNum) { @@ -312,255 +286,278 @@ open class RadioConfigViewModel( fun setConfig(config: Config) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "setConfig") { - _radioConfigState.update { state -> - state.copy( - radioConfig = - state.radioConfig.copy( - device = config.device ?: state.radioConfig.device, - position = config.position ?: state.radioConfig.position, - power = config.power ?: state.radioConfig.power, - network = config.network ?: state.radioConfig.network, - display = config.display ?: state.radioConfig.display, - lora = config.lora ?: state.radioConfig.lora, - bluetooth = config.bluetooth ?: state.radioConfig.bluetooth, - security = config.security ?: state.radioConfig.security, - ), - ) - } - val packetId = radioConfigUseCase.setConfig(destNum, config) - registerRequestId(packetId) + _radioConfigState.update { state -> + state.copy( + radioConfig = + state.radioConfig.copy( + device = config.device ?: state.radioConfig.device, + position = config.position ?: state.radioConfig.position, + power = config.power ?: state.radioConfig.power, + network = config.network ?: state.radioConfig.network, + display = config.display ?: state.radioConfig.display, + lora = config.lora ?: state.radioConfig.lora, + bluetooth = config.bluetooth ?: state.radioConfig.bluetooth, + security = config.security ?: state.radioConfig.security, + ), + ) } + writeAction("setConfig") { radioConfigUseCase.setConfig(destNum, config) } } @Suppress("CyclomaticComplexMethod") fun setModuleConfig(config: ModuleConfig) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "setModuleConfig") { - _radioConfigState.update { state -> - state.copy( - moduleConfig = - state.moduleConfig.copy( - mqtt = config.mqtt ?: state.moduleConfig.mqtt, - serial = config.serial ?: state.moduleConfig.serial, - external_notification = - config.external_notification ?: state.moduleConfig.external_notification, - store_forward = config.store_forward ?: state.moduleConfig.store_forward, - range_test = config.range_test ?: state.moduleConfig.range_test, - telemetry = config.telemetry ?: state.moduleConfig.telemetry, - canned_message = config.canned_message ?: state.moduleConfig.canned_message, - audio = config.audio ?: state.moduleConfig.audio, - remote_hardware = config.remote_hardware ?: state.moduleConfig.remote_hardware, - neighbor_info = config.neighbor_info ?: state.moduleConfig.neighbor_info, - ambient_lighting = config.ambient_lighting ?: state.moduleConfig.ambient_lighting, - detection_sensor = config.detection_sensor ?: state.moduleConfig.detection_sensor, - paxcounter = config.paxcounter ?: state.moduleConfig.paxcounter, - statusmessage = config.statusmessage ?: state.moduleConfig.statusmessage, - traffic_management = config.traffic_management ?: state.moduleConfig.traffic_management, - tak = config.tak ?: state.moduleConfig.tak, - ), - ) - } - val packetId = radioConfigUseCase.setModuleConfig(destNum, config) - registerRequestId(packetId) + _radioConfigState.update { state -> + state.copy( + moduleConfig = + state.moduleConfig.copy( + mqtt = config.mqtt ?: state.moduleConfig.mqtt, + serial = config.serial ?: state.moduleConfig.serial, + external_notification = + config.external_notification ?: state.moduleConfig.external_notification, + store_forward = config.store_forward ?: state.moduleConfig.store_forward, + range_test = config.range_test ?: state.moduleConfig.range_test, + telemetry = config.telemetry ?: state.moduleConfig.telemetry, + canned_message = config.canned_message ?: state.moduleConfig.canned_message, + audio = config.audio ?: state.moduleConfig.audio, + remote_hardware = config.remote_hardware ?: state.moduleConfig.remote_hardware, + neighbor_info = config.neighbor_info ?: state.moduleConfig.neighbor_info, + ambient_lighting = config.ambient_lighting ?: state.moduleConfig.ambient_lighting, + detection_sensor = config.detection_sensor ?: state.moduleConfig.detection_sensor, + paxcounter = config.paxcounter ?: state.moduleConfig.paxcounter, + statusmessage = config.statusmessage ?: state.moduleConfig.statusmessage, + traffic_management = config.traffic_management ?: state.moduleConfig.traffic_management, + tak = config.tak ?: state.moduleConfig.tak, + ), + ) } + writeAction("setModuleConfig") { radioConfigUseCase.setModuleConfig(destNum, config) } } fun setRingtone(ringtone: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(ringtone = ringtone) } - safeLaunch(tag = "setRingtone") { radioConfigUseCase.setRingtone(destNum, ringtone) } + writeAction("setRingtone") { radioConfigUseCase.setRingtone(destNum, ringtone) } } fun setCannedMessages(messages: String) { val destNum = destNode.value?.num ?: return _radioConfigState.update { it.copy(cannedMessageMessages = messages) } - safeLaunch(tag = "setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) } - } - - private fun sendAdminRequest(destNum: Int) { - val route = radioConfigState.value.route - _radioConfigState.update { it.copy(route = "") } // setter (response is PortNum.ROUTING_APP) - - val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites - - when (route) { - AdminRoute.REBOOT.name -> - safeLaunch(tag = "reboot") { - val packetId = adminActionsUseCase.reboot(destNum) - registerRequestId(packetId) - } - - AdminRoute.SHUTDOWN.name -> - with(radioConfigState.value) { - if (metadata?.canShutdown != true) { - sendError(Res.string.cant_shutdown) - } else { - safeLaunch(tag = "shutdown") { - val packetId = adminActionsUseCase.shutdown(destNum) - registerRequestId(packetId) - } - } - } - - AdminRoute.FACTORY_RESET.name -> - safeLaunch(tag = "factoryReset") { - val isLocal = (destNum == myNodeNum) - val packetId = adminActionsUseCase.factoryReset(destNum, isLocal) - registerRequestId(packetId) - } - - AdminRoute.NODEDB_RESET.name -> - safeLaunch(tag = "nodedbReset") { - val isLocal = (destNum == myNodeNum) - val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal) - registerRequestId(packetId) - } - } + writeAction("setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) } } fun setFixedPosition(position: Position) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) } + writeAction("setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) } } fun removeFixedPosition() { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) } + writeAction("removeFixedPosition") { radioConfigUseCase.removeFixedPosition(destNum) } } + private val profileCoordinator = ProfileCoordinator( + fileService, importProfileUseCase, exportProfileUseCase, + exportSecurityConfigUseCase, installProfileUseCase, viewModelScope, + ) + fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) { - safeLaunch(tag = "importProfile") { - var profile: DeviceProfile? = null - fileService.read(uri) { source -> - importProfileUseCase(source).onSuccess { profile = it }.onFailure { throw it } - } - profile?.let { onResult(it) } - } + profileCoordinator.importProfile(uri, onResult) } fun exportProfile(uri: CommonUri, profile: DeviceProfile) { - safeLaunch(tag = "exportProfile") { - fileService.write(uri) { sink -> - exportProfileUseCase(sink, profile).onSuccess { /* Success */ }.onFailure { throw it } - } - } + profileCoordinator.exportProfile(uri, profile) } fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) { - safeLaunch(tag = "exportSecurityConfig") { - fileService.write(uri) { sink -> - exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it } - } - } + profileCoordinator.exportSecurityConfig(uri, securityConfig) } fun installProfile(protobuf: DeviceProfile) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) } + profileCoordinator.installProfile(destNum, protobuf, destNode.value?.user) + } + + // region RadioConfigStateProvider implementation + + override val packetResponseState: StateFlow> = + _radioConfigState.map { it.responseState } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ResponseState.Empty) + + override val pendingRouteName: StateFlow = + _radioConfigState.map { it.route?.name.orEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "") + + override fun requestConfigLoad(routeName: String) { + val route = ConfigRoute.entries.find { it.name == routeName } + ?: ModuleRoute.entries.find { it.name == routeName } + ?: return + setResponseStateLoading(route) } - fun clearPacketResponse() { - requestIds.value = hashSetOf() + override fun clearPacketResponse() { _radioConfigState.update { it.copy(responseState = ResponseState.Empty) } } + // endregion + fun setResponseStateLoading(route: Enum<*>) { val destNum = destNumFlow.value ?: destNode.value?.num ?: return - _radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) } + _radioConfigState.update { it.copy(route = route, responseState = ResponseState.Loading()) } - when (route) { - ConfigRoute.USER -> - safeLaunch(tag = "getOwner") { - val packetId = radioConfigUseCase.getOwner(destNum) - registerRequestId(packetId) - } + loadJob?.cancel() + loadJob = viewModelScope.launch { + try { + when (route) { + ConfigRoute.USER -> { + val user = radioConfigUseCase.getOwner(destNum) + _radioConfigState.update { it.copy(userConfig = user) } + } - ConfigRoute.CHANNELS -> { - safeLaunch(tag = "getChannel0") { - val packetId = radioConfigUseCase.getChannel(destNum, 0) - registerRequestId(packetId) - } - safeLaunch(tag = "getLoraConfig") { - val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value) - registerRequestId(packetId) - } - // channel editor is synchronous, so we don't use requestIds as total - setResponseStateTotal(maxChannels + 1) - } + ConfigRoute.CHANNELS -> { + val channels = radioConfigUseCase.listChannels(destNum) + val loraConfig = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value) + _radioConfigState.update { state -> + state.copy( + channelList = channels.mapNotNull { it.settings }, + radioConfig = state.radioConfig.copy(lora = loraConfig.lora ?: state.radioConfig.lora), + ) + } + } - is AdminRoute -> { - safeLaunch(tag = "getSessionKeyConfig") { - val packetId = - radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) - registerRequestId(packetId) - } - setResponseStateTotal(2) - } + is AdminRoute -> { + executeAdminAction(destNum, route) + return@launch + } - is ConfigRoute -> { - if (route == ConfigRoute.LORA) { - safeLaunch(tag = "getChannel0ForLora") { - val packetId = radioConfigUseCase.getChannel(destNum, 0) - registerRequestId(packetId) + is ConfigRoute -> { + val config = radioConfigUseCase.getConfig(destNum, route.type) + _radioConfigState.update { state -> + state.copy( + radioConfig = state.radioConfig.copy( + device = config.device ?: state.radioConfig.device, + position = config.position ?: state.radioConfig.position, + power = config.power ?: state.radioConfig.power, + network = config.network ?: state.radioConfig.network, + display = config.display ?: state.radioConfig.display, + lora = config.lora ?: state.radioConfig.lora, + bluetooth = config.bluetooth ?: state.radioConfig.bluetooth, + security = config.security ?: state.radioConfig.security, + ), + ) + } + if (route == ConfigRoute.LORA) { + val channels = radioConfigUseCase.listChannels(destNum) + _radioConfigState.update { it.copy(channelList = channels.mapNotNull { ch -> ch.settings }) } + } + if (route == ConfigRoute.NETWORK) { + val status = radioConfigUseCase.getDeviceConnectionStatus(destNum) + _radioConfigState.update { it.copy(deviceConnectionStatus = status) } + } } - } - if (route == ConfigRoute.NETWORK) { - safeLaunch(tag = "getConnectionStatus") { - val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum) - registerRequestId(packetId) + + is ModuleRoute -> { + val moduleConfig = radioConfigUseCase.getModuleConfig(destNum, route.type) + _radioConfigState.update { state -> + state.copy( + moduleConfig = state.moduleConfig.copy( + mqtt = moduleConfig.mqtt ?: state.moduleConfig.mqtt, + serial = moduleConfig.serial ?: state.moduleConfig.serial, + external_notification = + moduleConfig.external_notification ?: state.moduleConfig.external_notification, + store_forward = moduleConfig.store_forward ?: state.moduleConfig.store_forward, + range_test = moduleConfig.range_test ?: state.moduleConfig.range_test, + telemetry = moduleConfig.telemetry ?: state.moduleConfig.telemetry, + canned_message = moduleConfig.canned_message ?: state.moduleConfig.canned_message, + audio = moduleConfig.audio ?: state.moduleConfig.audio, + remote_hardware = + moduleConfig.remote_hardware ?: state.moduleConfig.remote_hardware, + neighbor_info = moduleConfig.neighbor_info ?: state.moduleConfig.neighbor_info, + ambient_lighting = + moduleConfig.ambient_lighting ?: state.moduleConfig.ambient_lighting, + detection_sensor = + moduleConfig.detection_sensor ?: state.moduleConfig.detection_sensor, + paxcounter = moduleConfig.paxcounter ?: state.moduleConfig.paxcounter, + statusmessage = moduleConfig.statusmessage ?: state.moduleConfig.statusmessage, + traffic_management = + moduleConfig.traffic_management ?: state.moduleConfig.traffic_management, + tak = moduleConfig.tak ?: state.moduleConfig.tak, + ), + ) + } + if (route == ModuleRoute.CANNED_MESSAGE) { + val messages = radioConfigUseCase.getCannedMessages(destNum) + _radioConfigState.update { it.copy(cannedMessageMessages = messages) } + } + if (route == ModuleRoute.EXT_NOTIFICATION) { + val ringtone = radioConfigUseCase.getRingtone(destNum) + _radioConfigState.update { it.copy(ringtone = ringtone) } + } } } - safeLaunch(tag = "getConfig") { - val packetId = radioConfigUseCase.getConfig(destNum, route.type) - registerRequestId(packetId) - } + setResponseStateSuccess() + } catch (e: AdminException) { + sendError(e.toUiText()) } + } + } - is ModuleRoute -> { - if (route == ModuleRoute.CANNED_MESSAGE) { - safeLaunch(tag = "getCannedMessages") { - val packetId = radioConfigUseCase.getCannedMessages(destNum) - registerRequestId(packetId) + private suspend fun executeAdminAction(destNum: Int, route: AdminRoute) { + try { + val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites + when (route) { + AdminRoute.REBOOT -> adminActionsUseCase.reboot(destNum) + AdminRoute.SHUTDOWN -> { + if (radioConfigState.value.metadata?.canShutdown != true) { + sendError(Res.string.cant_shutdown) + return } + adminActionsUseCase.shutdown(destNum) } - if (route == ModuleRoute.EXT_NOTIFICATION) { - safeLaunch(tag = "getRingtone") { - val packetId = radioConfigUseCase.getRingtone(destNum) - registerRequestId(packetId) - } + AdminRoute.FACTORY_RESET -> { + val isLocal = (destNum == myNodeNum) + adminActionsUseCase.factoryReset(destNum, isLocal) } - safeLaunch(tag = "getModuleConfig") { - val packetId = radioConfigUseCase.getModuleConfig(destNum, route.type) - registerRequestId(packetId) + AdminRoute.NODEDB_RESET -> { + val isLocal = (destNum == myNodeNum) + adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal) } } + setResponseStateSuccess() + } catch (e: AdminException) { + sendError(e.toUiText()) } } - fun shouldReportLocation(nodeNum: Int?) = mapConsentPrefs.shouldReportLocation(nodeNum) - - fun setShouldReportLocation(nodeNum: Int?, shouldReportLocation: Boolean) { - mapConsentPrefs.setShouldReportLocation(nodeNum, shouldReportLocation) + private fun AdminException.toUiText(): UiText = when (this) { + is AdminException.Timeout -> UiText.Resource(Res.string.timeout) + else -> UiText.DynamicString(message ?: "Admin request failed") } - private fun setResponseStateTotal(total: Int) { - _radioConfigState.update { state -> - if (state.responseState is ResponseState.Loading) { - state.copy(responseState = state.responseState.copy(total = total)) - } else { - state // Return the unchanged state for other response states + /** Launch a write operation that reports [AdminException] to the UI as an error state. */ + private fun writeAction(tag: String, block: suspend () -> Unit) { + safeLaunch(tag = tag) { + try { + block() + } catch (e: AdminException) { + sendError(e.toUiText()) } } } + fun shouldReportLocation(nodeNum: Int?) = mapConsentPrefs.shouldReportLocation(nodeNum) + + fun setShouldReportLocation(nodeNum: Int?, shouldReportLocation: Boolean) { + mapConsentPrefs.setShouldReportLocation(nodeNum, shouldReportLocation) + } + protected fun setResponseStateSuccess() { _radioConfigState.update { state -> if (state.responseState is ResponseState.Loading) { state.copy(responseState = ResponseState.Success(true)) } else { - state // Return the unchanged state for other response states + state } } } @@ -574,195 +571,4 @@ open class RadioConfigViewModel( private fun setResponseStateError(error: UiText) { _radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) } } - - private fun incrementCompleted() { - _radioConfigState.update { state -> - if (state.responseState is ResponseState.Loading) { - val increment = state.responseState.completed + 1 - state.copy(responseState = state.responseState.copy(completed = increment)) - } else { - state // Return the unchanged state for other response states - } - } - } - - private fun registerRequestId(packetId: Int) { - requestIds.update { it.apply { add(packetId) } } - _radioConfigState.update { state -> - if (state.responseState is ResponseState.Loading) { - val total = maxOf(requestIds.value.size, state.responseState.total) - state.copy(responseState = state.responseState.copy(total = total)) - } else { - state.copy( - route = "", // setter (response is PortNum.ROUTING_APP) - responseState = ResponseState.Loading(), - ) - } - } - - val requestTimeout = 30.seconds - safeLaunch(tag = "requestTimeout") { - delay(requestTimeout) - if (requestIds.value.contains(packetId)) { - requestIds.update { it.apply { remove(packetId) } } - if (requestIds.value.isEmpty()) { - sendError(Res.string.timeout) - } - } - } - } - - private fun processPacketResponse(packet: MeshPacket) { - val destNum = destNode.value?.num ?: return - val result = processRadioResponseUseCase(packet, destNum, requestIds.value) ?: return - val route = radioConfigState.value.route - - when (result) { - is RadioResponseResult.Error -> { - sendError(result.message) - // Abort the AdminRoute flow — do not fire the destructive action - // (reboot/shutdown/factory_reset) if the metadata preflight failed. - return - } - - is RadioResponseResult.Success -> { - if (route.isEmpty()) { - val data = packet.decoded!! - requestIds.update { it.apply { remove(data.request_id) } } - if (requestIds.value.isEmpty()) { - setResponseStateSuccess() - } else { - incrementCompleted() - } - } - } - - is RadioResponseResult.Metadata -> { - _radioConfigState.update { it.copy(metadata = result.metadata) } - incrementCompleted() - } - - is RadioResponseResult.ChannelResponse -> { - val response = result.channel - // Stop once we get to the first disabled entry - if (response.role != Channel.Role.DISABLED) { - _radioConfigState.update { state -> - state.copy( - channelList = - state.channelList.toMutableList().apply { - val index = response.index - val settings = response.settings ?: ChannelSettings() - // Make sure list is large enough - while (size <= index) add(ChannelSettings()) - set(index, settings) - }, - ) - } - incrementCompleted() - val index = response.index - if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) { - // Not done yet, request next channel - safeLaunch(tag = "getNextChannel") { - val packetId = radioConfigUseCase.getChannel(destNum, index + 1) - registerRequestId(packetId) - } - } - } else { - // Received last channel, update total and start channel editor - setResponseStateTotal(response.index + 1) - } - } - - is RadioResponseResult.Owner -> { - _radioConfigState.update { it.copy(userConfig = result.user) } - incrementCompleted() - } - - is RadioResponseResult.ConfigResponse -> { - val response = result.config - _radioConfigState.update { state -> - state.copy( - radioConfig = - state.radioConfig.copy( - device = response.device ?: state.radioConfig.device, - position = response.position ?: state.radioConfig.position, - power = response.power ?: state.radioConfig.power, - network = response.network ?: state.radioConfig.network, - display = response.display ?: state.radioConfig.display, - lora = response.lora ?: state.radioConfig.lora, - bluetooth = response.bluetooth ?: state.radioConfig.bluetooth, - security = response.security ?: state.radioConfig.security, - ), - ) - } - incrementCompleted() - } - - is RadioResponseResult.ModuleConfigResponse -> { - val response = result.config - _radioConfigState.update { state -> - state.copy( - moduleConfig = - state.moduleConfig.copy( - mqtt = response.mqtt ?: state.moduleConfig.mqtt, - serial = response.serial ?: state.moduleConfig.serial, - external_notification = - response.external_notification ?: state.moduleConfig.external_notification, - store_forward = response.store_forward ?: state.moduleConfig.store_forward, - range_test = response.range_test ?: state.moduleConfig.range_test, - telemetry = response.telemetry ?: state.moduleConfig.telemetry, - canned_message = response.canned_message ?: state.moduleConfig.canned_message, - audio = response.audio ?: state.moduleConfig.audio, - remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware, - neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info, - ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting, - detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor, - paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter, - statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage, - traffic_management = - response.traffic_management ?: state.moduleConfig.traffic_management, - tak = response.tak ?: state.moduleConfig.tak, - ), - ) - } - incrementCompleted() - } - - is RadioResponseResult.CannedMessages -> { - _radioConfigState.update { it.copy(cannedMessageMessages = result.messages) } - incrementCompleted() - } - - is RadioResponseResult.Ringtone -> { - _radioConfigState.update { it.copy(ringtone = result.ringtone) } - incrementCompleted() - } - - is RadioResponseResult.ConnectionStatus -> { - _radioConfigState.update { it.copy(deviceConnectionStatus = result.status) } - incrementCompleted() - } - } - - // Routing ACKs (Success) share the same request_id as the upcoming ADMIN_APP response. - // Removing the id here would cause the actual admin response to be silently dropped, - // because processRadioResponseUseCase checks `request_id in requestIds`. - // The Success branch already handles its own id removal when route is empty (set flow). - if (result is RadioResponseResult.Success) return - - if (AdminRoute.entries.any { it.name == route }) { - sendAdminRequest(destNum) - } - - val requestId = packet.decoded?.request_id ?: return - requestIds.update { it.apply { remove(requestId) } } - - if (requestIds.value.isEmpty()) { - if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) { - clearPacketResponse() - } else if (route.isEmpty()) { - setResponseStateSuccess() - } - } - } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index efdc699b1a..7b43fc87b4 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -65,14 +65,14 @@ import org.meshtastic.core.ui.component.rememberDragDropState import org.meshtastic.core.ui.icon.Add import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.ResponseState +import org.meshtastic.core.model.ResponseState import org.meshtastic.feature.settings.radio.channel.component.ChannelCard import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog import org.meshtastic.feature.settings.radio.component.LoadingOverlay -import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog +import org.meshtastic.core.ui.component.PacketResponseStateDialog import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt index 97352c2b3d..243b80348b 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt @@ -93,9 +93,9 @@ import org.meshtastic.core.ui.util.rememberQrCodePainter import org.meshtastic.core.ui.util.rememberShowToastResource import org.meshtastic.feature.settings.channel.ChannelViewModel import org.meshtastic.feature.settings.navigation.ConfigRoute -import org.meshtastic.feature.settings.navigation.getNavRouteFrom +import org.meshtastic.feature.settings.navigation.ModuleRoute import org.meshtastic.feature.settings.radio.RadioConfigViewModel -import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog +import org.meshtastic.core.ui.component.PacketResponseStateDialog import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config @@ -140,10 +140,12 @@ fun ChannelScreen( radioConfigViewModel.clearPacketResponse() }, onComplete = { - getNavRouteFrom(radioConfigState.route)?.let { route -> + val navRoute = (radioConfigState.route as? ConfigRoute)?.route + ?: (radioConfigState.route as? ModuleRoute)?.route + if (navRoute != null) { isWaiting = false radioConfigViewModel.clearPacketResponse() - onNavigate(route) + onNavigate(navRoute) } }, ) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt index 2f26953319..cb79b1990c 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoadingOverlay.kt @@ -40,7 +40,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.meshtastic.core.common.util.MetricFormatter -import org.meshtastic.feature.settings.radio.ResponseState +import org.meshtastic.core.model.ResponseState private const val LOADING_OVERLAY_ALPHA = 0.8f private const val PERCENTAGE_FACTOR = 100 diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt index d5a956cabf..c93b6c2aaa 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/RadioConfigScreenList.kt @@ -40,8 +40,9 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.discard_changes import org.meshtastic.core.resources.save_changes import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.component.PacketResponseStateDialog import org.meshtastic.core.ui.component.PreferenceFooter -import org.meshtastic.feature.settings.radio.ResponseState +import org.meshtastic.core.model.ResponseState @Suppress("LongMethod") @Composable 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 95e02f05be..10433e1183 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 c1b7d8a9ee..89c42a5879 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 @@ -20,6 +20,7 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import dev.mokkery.MockMode import dev.mokkery.answering.returns +import dev.mokkery.answering.throws import dev.mokkery.every import dev.mokkery.everySuspend import dev.mokkery.matcher.any @@ -28,10 +29,8 @@ import dev.mokkery.verify import dev.mokkery.verifySuspend import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -41,12 +40,10 @@ import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase -import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase -import org.meshtastic.core.domain.usecase.settings.RadioResponseResult -import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase -import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase +import org.meshtastic.core.model.AdminException import org.meshtastic.core.model.Node +import org.meshtastic.core.model.ResponseState import org.meshtastic.core.repository.AnalyticsPrefs import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.HomoglyphPrefs @@ -57,9 +54,9 @@ import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.feature.settings.navigation.ConfigRoute +import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings import org.meshtastic.proto.Config @@ -67,7 +64,6 @@ import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceProfile import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig -import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.User import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -89,19 +85,15 @@ 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) private val installProfileUseCase: InstallProfileUseCase = mock(MockMode.autofill) private val radioConfigUseCase: RadioConfigUseCase = mock(MockMode.autofill) private val adminActionsUseCase: AdminActionsUseCase = mock(MockMode.autofill) - private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mock(MockMode.autofill) private val locationService: LocationService = mock(MockMode.autofill) private val fileService: FileService = mock(MockMode.autofill) private val mqttManager: MqttManager = mock(MockMode.autofill) - private val uiPrefs: UiPrefs = mock(MockMode.autofill) private lateinit var viewModel: RadioConfigViewModel @@ -119,15 +111,12 @@ class RadioConfigViewModelTest { every { analyticsPrefs.analyticsAllowed } returns MutableStateFlow(false) every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns MutableStateFlow(false) - every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() every { serviceRepository.connectionState } returns MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected) every { mqttManager.mqttConnectionState } returns MutableStateFlow(org.meshtastic.core.model.MqttConnectionState.Inactive) - every { uiPrefs.showQuickChat } returns MutableStateFlow(false) - viewModel = createViewModel() } @@ -146,15 +135,12 @@ class RadioConfigViewModelTest { mapConsentPrefs = mapConsentPrefs, analyticsPrefs = analyticsPrefs, homoglyphEncodingPrefs = homoglyphEncodingPrefs, - toggleAnalyticsUseCase = toggleAnalyticsUseCase, - toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase, importProfileUseCase = importProfileUseCase, exportProfileUseCase = exportProfileUseCase, exportSecurityConfigUseCase = exportSecurityConfigUseCase, installProfileUseCase = installProfileUseCase, radioConfigUseCase = radioConfigUseCase, adminActionsUseCase = adminActionsUseCase, - processRadioResponseUseCase = processRadioResponseUseCase, locationService = locationService, fileService = fileService, mqttManager = mqttManager, @@ -167,7 +153,7 @@ class RadioConfigViewModelTest { viewModel = createViewModel() val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) - everySuspend { radioConfigUseCase.setConfig(any(), any()) } returns 42 + everySuspend { radioConfigUseCase.setConfig(any(), any()) } returns Unit viewModel.setConfig(config) @@ -181,105 +167,116 @@ 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 - fun `processPacketResponse updates state on metadata result`() = runTest { + fun `updateChannels calls useCase for each changed channel`() = runTest { val node = Node(num = 123, user = User(id = "!123")) nodeRepository.setNodes(listOf(node)) + viewModel = createViewModel() - val packet = MeshPacket() - val metadata = DeviceMetadata(firmware_version = "3.0.0") - val packetFlow = MutableSharedFlow() - - every { serviceRepository.meshPacketFlow } returns packetFlow - every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Metadata(metadata) + val old = listOf(ChannelSettings(name = "Old")) + val new = listOf(ChannelSettings(name = "New")) - viewModel = createViewModel() + everySuspend { radioConfigUseCase.setRemoteChannel(any(), any()) } returns Unit - packetFlow.emit(packet) + viewModel.updateChannels(new, old) - viewModel.radioConfigState.test { - val state = awaitItem() - assertEquals("3.0.0", state.metadata?.firmware_version) - cancelAndIgnoreRemainingEvents() - } + verifySuspend { radioConfigUseCase.setRemoteChannel(123, any()) } + assertEquals(new, viewModel.radioConfigState.value.channelList) } @Test - fun `updateChannels calls useCase for each changed channel`() = runTest { + fun `setResponseStateLoading for USER fetches owner directly`() = runTest { val node = Node(num = 123, user = User(id = "!123")) nodeRepository.setNodes(listOf(node)) viewModel = createViewModel() - val old = listOf(ChannelSettings(name = "Old")) - val new = listOf(ChannelSettings(name = "New")) + val user = User(long_name = "Fetched User") + everySuspend { radioConfigUseCase.getOwner(any()) } returns user - everySuspend { radioConfigUseCase.setRemoteChannel(any(), any()) } returns 42 - - viewModel.updateChannels(new, old) + viewModel.setResponseStateLoading(ConfigRoute.USER) + runCurrent() - verifySuspend { radioConfigUseCase.setRemoteChannel(123, any()) } - assertEquals(new, viewModel.radioConfigState.value.channelList) + assertEquals("Fetched User", viewModel.radioConfigState.value.userConfig.long_name) + assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success) } @Test - fun `setResponseStateLoading for REBOOT calls useCase after config response`() = runTest { + fun `setResponseStateLoading for CHANNELS fetches channels and lora config`() = runTest { val node = Node(num = 123, user = User(id = "!123")) nodeRepository.setNodes(listOf(node)) + viewModel = createViewModel() - val packetFlow = MutableSharedFlow() - every { serviceRepository.meshPacketFlow } returns packetFlow - // AdminRoute first sends a session key config request; the admin action fires - // only after the actual ConfigResponse (not a routing ACK / Success). - every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.ConfigResponse(Config()) + val channels = listOf( + Channel(index = 0, settings = ChannelSettings(name = "Primary")), + Channel(index = 1, settings = ChannelSettings(name = "Secondary")), + ) + val loraConfig = Config(lora = Config.LoRaConfig(hop_limit = 5)) + everySuspend { radioConfigUseCase.listChannels(any()) } returns channels + everySuspend { radioConfigUseCase.getConfig(any(), any()) } returns loraConfig + viewModel.setResponseStateLoading(ConfigRoute.CHANNELS) + runCurrent() + + assertEquals(2, viewModel.radioConfigState.value.channelList.size) + assertEquals("Primary", viewModel.radioConfigState.value.channelList[0].name) + assertEquals(5, viewModel.radioConfigState.value.radioConfig.lora?.hop_limit) + assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success) + } + + @Test + fun `setResponseStateLoading for REBOOT calls admin action directly`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + nodeRepository.setNodes(listOf(node)) viewModel = createViewModel() - everySuspend { adminActionsUseCase.reboot(any()) } returns 42 + everySuspend { adminActionsUseCase.reboot(any()) } returns Unit viewModel.setResponseStateLoading(AdminRoute.REBOOT) - - // Emit a config response packet to trigger processPacketResponse -> sendAdminRequest - packetFlow.emit(MeshPacket()) + runCurrent() verifySuspend { adminActionsUseCase.reboot(123) } + assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success) } @Test - fun `setResponseStateLoading for FACTORY_RESET calls useCase after config response`() = runTest { - val node = Node(num = 123, user = User(id = "!123")) + fun `setResponseStateLoading for SHUTDOWN blocked when canShutdown is false`() = runTest { + val node = Node(num = 123, user = User(id = "!123"), metadata = DeviceMetadata(canShutdown = false)) nodeRepository.setNodes(listOf(node)) + viewModel = createViewModel() + + viewModel.setResponseStateLoading(AdminRoute.SHUTDOWN) + runCurrent() - val packetFlow = MutableSharedFlow() - every { serviceRepository.meshPacketFlow } returns packetFlow - // AdminRoute first sends a session key config request; the admin action fires - // only after the actual ConfigResponse (not a routing ACK / Success). - every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.ConfigResponse(Config()) + assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error) + } + @Test + fun `setResponseStateLoading for FACTORY_RESET calls admin action`() = runTest { + val node = Node(num = 123, user = User(id = "!123")) + nodeRepository.setNodes(listOf(node)) viewModel = createViewModel() - everySuspend { adminActionsUseCase.factoryReset(any(), any()) } returns 42 + everySuspend { adminActionsUseCase.factoryReset(any(), any()) } returns Unit viewModel.setResponseStateLoading(AdminRoute.FACTORY_RESET) - - // Emit a config response packet to trigger processPacketResponse -> sendAdminRequest - packetFlow.emit(MeshPacket()) + runCurrent() verifySuspend { adminActionsUseCase.factoryReset(123, any()) } } @@ -301,7 +298,7 @@ class RadioConfigViewModelTest { viewModel = createViewModel() val user = User(long_name = "Test User") - everySuspend { radioConfigUseCase.setOwner(any(), any()) } returns 42 + everySuspend { radioConfigUseCase.setOwner(any(), any()) } returns Unit viewModel.setOwner(user) @@ -340,15 +337,12 @@ class RadioConfigViewModelTest { fun `initDestNum updates value correctly including null`() = runTest { viewModel = createViewModel() - // Initial setup should take the flow value, but let's just force update it viewModel.initDestNum(123) assertEquals( 123, viewModel.destNode.value?.num ?: 123, - ) // the flow combine might need yielding, but we can just check it doesn't crash + ) - // The bug was that null was ignored. Here we test we can pass null - // Since we can't easily read destNumFlow directly, we can just call it to ensure no crashes viewModel.initDestNum(null) } @@ -360,7 +354,7 @@ class RadioConfigViewModelTest { val config = org.meshtastic.proto.ModuleConfig(mqtt = org.meshtastic.proto.ModuleConfig.MQTTConfig(enabled = true)) - everySuspend { radioConfigUseCase.setModuleConfig(any(), any()) } returns 42 + everySuspend { radioConfigUseCase.setModuleConfig(any(), any()) } returns Unit viewModel.setModuleConfig(config) @@ -410,125 +404,32 @@ class RadioConfigViewModelTest { } @Test - fun `processPacketResponse updates state on various results`() = runTest { - val node = Node(num = 123, user = User(id = "!123")) - nodeRepository.setNodes(listOf(node)) - val packetFlow = MutableSharedFlow() - every { serviceRepository.meshPacketFlow } returns packetFlow - - viewModel = createViewModel() - - // ConfigResponse - val configResponse = Config(lora = Config.LoRaConfig(hop_limit = 5)) - every { processRadioResponseUseCase(any(), 123, any()) } returns - RadioResponseResult.ConfigResponse(configResponse) - packetFlow.emit(MeshPacket()) - assertEquals(5, viewModel.radioConfigState.value.radioConfig.lora?.hop_limit) - - // ModuleConfigResponse - val moduleResponse = - org.meshtastic.proto.ModuleConfig( - telemetry = org.meshtastic.proto.ModuleConfig.TelemetryConfig(device_update_interval = 300), - ) - every { processRadioResponseUseCase(any(), 123, any()) } returns - RadioResponseResult.ModuleConfigResponse(moduleResponse) - packetFlow.emit(MeshPacket()) - assertEquals(300, viewModel.radioConfigState.value.moduleConfig.telemetry?.device_update_interval) - - // Owner - val user = User(long_name = "New Name") - every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Owner(user) - packetFlow.emit(MeshPacket()) - assertEquals("New Name", viewModel.radioConfigState.value.userConfig.long_name) - - // Ringtone - every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Ringtone("bell.mp3") - packetFlow.emit(MeshPacket()) - assertEquals("bell.mp3", viewModel.radioConfigState.value.ringtone) - - // Error - every { processRadioResponseUseCase(any(), 123, any()) } returns - RadioResponseResult.Error(org.meshtastic.core.resources.UiText.DynamicString("Fail")) - packetFlow.emit(MeshPacket()) - assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error) - } - - @Test - fun `Admin actions call correct useCases`() = runTest { + fun `setResponseStateLoading shows error on AdminException timeout`() = runTest { val node = Node(num = 123, user = User(id = "!123")) nodeRepository.setNodes(listOf(node)) - val packetFlow = MutableSharedFlow() - every { serviceRepository.meshPacketFlow } returns packetFlow - viewModel = createViewModel() - // SHUTDOWN - everySuspend { adminActionsUseCase.shutdown(any()) } returns 42 - // Set metadata to allow shutdown - every { processRadioResponseUseCase(any(), 123, any()) } returns - RadioResponseResult.Metadata(DeviceMetadata(canShutdown = true)) - packetFlow.emit(MeshPacket()) - - viewModel.setResponseStateLoading(AdminRoute.SHUTDOWN) - // AdminRoute fires sendAdminRequest after receiving ConfigResponse (session key), - // not after a routing ACK (Success). - every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.ConfigResponse(Config()) - packetFlow.emit(MeshPacket()) - verifySuspend { adminActionsUseCase.shutdown(123) } - - // NODEDB_RESET - everySuspend { adminActionsUseCase.nodedbReset(any(), any(), any()) } returns 42 - viewModel.setResponseStateLoading(AdminRoute.NODEDB_RESET) - every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.ConfigResponse(Config()) - packetFlow.emit(MeshPacket()) - verifySuspend { adminActionsUseCase.nodedbReset(123, any(), any()) } - } - - @Test - fun `setResponseStateLoading for various routes calls correct useCases`() = runTest { - val node = Node(num = 123, user = User(id = "!123")) - nodeRepository.setNodes(listOf(node)) - viewModel = createViewModel() + everySuspend { radioConfigUseCase.getOwner(any()) } throws AdminException.Timeout() - // USER - everySuspend { radioConfigUseCase.getOwner(any()) } returns 42 viewModel.setResponseStateLoading(ConfigRoute.USER) - verifySuspend { radioConfigUseCase.getOwner(123) } - - // CHANNELS - everySuspend { radioConfigUseCase.getChannel(any(), any()) } returns 42 - everySuspend { radioConfigUseCase.getConfig(any(), any()) } returns 42 - viewModel.setResponseStateLoading(ConfigRoute.CHANNELS) - verifySuspend { radioConfigUseCase.getChannel(123, 0) } - verifySuspend { - radioConfigUseCase.getConfig(123, org.meshtastic.proto.AdminMessage.ConfigType.LORA_CONFIG.value) - } + runCurrent() - // LORA - viewModel.setResponseStateLoading(ConfigRoute.LORA) - verifySuspend { radioConfigUseCase.getConfig(123, ConfigRoute.LORA.type) } + assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error) } @Test - fun `registerRequestId timeout clears request and sets error`() = runTest { + fun `setResponseStateLoading for ConfigRoute fetches config`() = runTest { val node = Node(num = 123, user = User(id = "!123")) nodeRepository.setNodes(listOf(node)) viewModel = createViewModel() - everySuspend { radioConfigUseCase.getOwner(any()) } returns 42 - - viewModel.setResponseStateLoading(ConfigRoute.USER) - - // state should be loading - assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Loading) + val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) + everySuspend { radioConfigUseCase.getConfig(any(), any()) } returns config - // advance time past 30 seconds - advanceTimeBy(31_000) + viewModel.setResponseStateLoading(ConfigRoute.DEVICE) runCurrent() - // after timeout, the request ID should be removed, and if empty, sendError is called. - // It's hard to assert sendError directly without a mock on a channel, but we can verify it doesn't stay loading - // actually sendError updates the state? No, sendError sends an event. - // But the requestIds gets cleared. + assertEquals(Config.DeviceConfig.Role.ROUTER, viewModel.radioConfigState.value.radioConfig.device?.role) + assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success) } } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt index d37194bd1f..0436053d32 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidget.kt @@ -304,7 +304,9 @@ class LocalStatsWidget : val statusText = when (state.connectionState) { is ConnectionState.Disconnected -> stringResource(Res.string.disconnected) - is ConnectionState.Connecting -> stringResource(Res.string.connecting) + is ConnectionState.Connecting, + is ConnectionState.Configuring, + is ConnectionState.Reconnecting -> stringResource(Res.string.connecting) is ConnectionState.DeviceSleep -> stringResource(Res.string.device_sleeping) is ConnectionState.Connected -> "" } diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt index f86acb8c10..a19f822ebf 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/LocalStatsWidgetState.kt @@ -126,7 +126,10 @@ class LocalStatsWidgetStateProvider(nodeRepository: NodeRepository, serviceRepos return LocalStatsWidgetUiState( connectionState = connectionState, - isConnecting = connectionState is ConnectionState.Connecting, + isConnecting = + connectionState is ConnectionState.Connecting || + connectionState is ConnectionState.Configuring || + connectionState is ConnectionState.Reconnecting, showContent = connectionState is ConnectionState.Connected, nodeShortName = localNode?.user?.short_name, nodeColors = localNode?.colors, 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 0bc269eaf4..bcf0f42e5d 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 @@ -23,25 +23,25 @@ import androidx.glance.appwidget.action.ActionCallback import co.touchlab.kermit.Logger 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.CommandSender -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository class RefreshLocalStatsAction : ActionCallback, KoinComponent { - private val commandSender: CommandSender by inject() - private val nodeManager: NodeManager by inject() + private val radioController: RadioController 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 } - commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal) - commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal) + radioController.requestTelemetry(myNodeNum, TelemetryType.LOCAL_STATS) + radioController.requestTelemetry(myNodeNum, TelemetryType.DEVICE) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30052aed9a..99efa0baf3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,9 @@ room = "3.0.0-alpha04" koin = "4.2.1" koin-plugin = "1.0.0-RC2" +# meshtastic-sdk +meshtastic-sdk = "0.1.0-SNAPSHOT" + # Kotlin kotlin = "2.3.21" kotlinx-coroutines-android = "1.11.0-rc02" @@ -266,6 +269,15 @@ aboutlibraries-gradlePlugin = { module = "com.mikepenz.aboutlibraries.plugin:abo jmdns = { module = "org.jmdns:jmdns", version.ref = "jmdns" } jna = { module = "net.java.dev.jna:jna", version = "5.18.1" } +# Meshtastic SDK (sourced via composite build — no version needed here) +sdk-core = { module = "org.meshtastic:sdk-core", version.ref = "meshtastic-sdk" } +sdk-proto = { module = "org.meshtastic:sdk-proto", version.ref = "meshtastic-sdk" } +sdk-transport-ble = { module = "org.meshtastic:sdk-transport-ble", version.ref = "meshtastic-sdk" } +sdk-transport-tcp = { module = "org.meshtastic:sdk-transport-tcp", version.ref = "meshtastic-sdk" } +sdk-transport-serial = { module = "org.meshtastic:sdk-transport-serial", version.ref = "meshtastic-sdk" } +sdk-storage-sqldelight = { module = "org.meshtastic:sdk-storage-sqldelight", version.ref = "meshtastic-sdk" } +sdk-testing = { module = "org.meshtastic:sdk-testing", version.ref = "meshtastic-sdk" } + [plugins] # Android android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 185a8a65a8..bc25896a29 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -60,6 +60,20 @@ dependencyResolutionManagement { rootProject.name = "MeshtasticAndroid" +// meshtastic-sdk composite build — dependency substitution maps published Maven artifacts +// to the local source projects so SDK changes are reflected in this build immediately. +includeBuild("../meshtastic-sdk") { + dependencySubstitution { + substitute(module("org.meshtastic:sdk-proto")).using(project(":proto")) + substitute(module("org.meshtastic:sdk-core")).using(project(":core")) + substitute(module("org.meshtastic:sdk-transport-ble")).using(project(":transport-ble")) + substitute(module("org.meshtastic:sdk-transport-tcp")).using(project(":transport-tcp")) + substitute(module("org.meshtastic:sdk-transport-serial")).using(project(":transport-serial")) + substitute(module("org.meshtastic:sdk-storage-sqldelight")).using(project(":storage-sqldelight")) + substitute(module("org.meshtastic:sdk-testing")).using(project(":testing")) + } +} + // https://docs.gradle.org/current/userguide/declaring_dependencies.html#sec:type-safe-project-accessors enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")