From 8b459881e0a00f3587a3ee6639f0e6f92010d77c Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 2 May 2026 19:05:07 -0500 Subject: [PATCH 01/53] feat: integrate meshtastic-sdk POC vertical slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires core flows to the meshtastic-sdk (0.1.0-SNAPSHOT) while keeping the legacy path alive. Goal: prove the SDK works with a real Android app and surface API deficiencies. Build: - settings.gradle.kts: composite build inclusion for meshtastic-sdk (../meshtastic-sdk) with dependency substitution for all SDK artifacts - libs.versions.toml: sdk = "0.1.0-SNAPSHOT", mavenCentral snapshots repo - app/build.gradle.kts: sdk-core, sdk-proto, sdk-transport-ble, sdk-storage-sqldelight dependencies Bootstrap: - MeshUtilApplication: AndroidContextHolder.context set in onCreate() before startKoin so SqlDelightStorageProvider can locate app files - RadioClientProvider (@Single, binds SdkClientLifecycle): mutex-serialized rebuildAndConnect(), strips 'x' prefix from BLE devAddr, holds RadioClient StateFlow - RadioClientViewModel: exposes RadioClientProvider to UI layer SDK ViewModels (POC quality, compile-verified): - SdkNodeListViewModel: NodeChange.Snapshot/Added/Updated/Removed → UiNode - SdkMessagingViewModel: sendText() via client.sendText(), incomingText via client.textMessages (Gap B — now fixed in SDK) - SdkConfigViewModel: configBundle reads, setConfig/setOwner writes, loadChannels() via admin, Gap G workaround (local override map) - SdkTelemetryViewModel: TelemetryApi.observe(NodeId), requestDeviceMetrics Service lifecycle: - SdkClientLifecycle interface in core:service (avoids reverse dep from service → app); RadioClientProvider implements it - MeshService.onDestroy: calls sdkClientLifecycle.disconnect() before serviceJob.cancel() - BlePeripheralFactory.kt in core:ble: public buildPeripheralForAddress() wrapper (Gap F workaround; proper fix needed in SDK transport-ble) SDK gaps discovered and logged: Gap B - textMessages flow (FIXED in SDK feat/meshtastic-android-integration-gaps) Gap C - channels StateFlow (no reactive cache, only admin.listChannels()) Gap F - BleTransport MAC string factory (requires live Peripheral today) Gap G - configBundle not refreshed after editSettings writes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/build.gradle.kts | 11 ++ .../org/meshtastic/app/MeshUtilApplication.kt | 5 + .../app/radio/RadioClientProvider.kt | 136 +++++++++++++ .../app/radio/RadioClientViewModel.kt | 105 +++++++++++ .../app/radio/SdkConfigViewModel.kt | 178 ++++++++++++++++++ .../app/radio/SdkMessagingViewModel.kt | 151 +++++++++++++++ .../app/radio/SdkNodeListViewModel.kt | 110 +++++++++++ .../app/radio/SdkTelemetryViewModel.kt | 97 ++++++++++ .../main/kotlin/org/meshtastic/app/ui/Main.kt | 14 ++ .../core/ble/BlePeripheralFactory.kt | 39 ++++ .../meshtastic/core/service/MeshService.kt | 3 + .../core/service/SdkClientLifecycle.kt | 29 +++ gradle/libs.versions.toml | 12 ++ settings.gradle.kts | 14 ++ 14 files changed, 904 insertions(+) create mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt create mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt create mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt create mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt create mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt create mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt create mode 100644 core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BlePeripheralFactory.kt create mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/SdkClientLifecycle.kt 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/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index 09c867a328..8ff6bc2645 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -40,6 +40,7 @@ import org.koin.androidx.workmanager.koin.workManagerFactory import org.koin.plugin.module.dsl.startKoin import org.meshtastic.app.di.AndroidKoinApp import org.meshtastic.core.common.ContextServices +import org.meshtastic.sdk.storage.sqldelight.AndroidContextHolder import org.meshtastic.core.database.DatabaseManager import org.meshtastic.core.repository.MeshPrefs import org.meshtastic.core.service.worker.MeshLogCleanupWorker @@ -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..98ec99f9b8 --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt @@ -0,0 +1,136 @@ +/* + * 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.ble.buildPeripheralForSavedAddress +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.storage.sqldelight.SqlDelightStorageProvider +import org.meshtastic.sdk.transport.ble.BleTransport + +/** + * 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`. + * + * **Thread-safety:** [rebuildAndConnect] is serialized by a [Mutex] so concurrent calls + * (e.g., radio address change during an active connection attempt) queue safely. + * + * **Scope:** The provider owns a long-lived [CoroutineScope] for connection management. + * It is NOT tied to any screen lifecycle — callers use [WhileSubscribed][kotlinx.coroutines.flow.SharingStarted] + * on derived StateFlows for screen-scoped work. + * + * SDK gap F tracked here: [BleTransport] requires a Kable [Peripheral][com.juul.kable.Peripheral] + * rather than accepting a MAC address string directly. [buildPeripheralForSavedAddress] bridges + * that gap on the Android side until the SDK adds a convenience factory. + */ +@Single(binds = [SdkClientLifecycle::class]) +class RadioClientProvider( + private val context: Context, + private val radioPrefs: RadioPrefs, +) : SdkClientLifecycle { + private val _client = MutableStateFlow(null) + + /** Active [RadioClient], or `null` when disconnected or between connections. */ + 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]. + * + * If [RadioPrefs.devAddr] is not a BLE address this call is a no-op (other transport + * types will be added in follow-up work). + * + * Callers that cannot suspend should use [rebuildAndConnectAsync]. + */ + suspend fun rebuildAndConnect() = mutex.withLock { + val rawAddress = radioPrefs.devAddr.value ?: run { + Logger.w { "RadioClientProvider: no saved device address — skipping connect" } + return@withLock + } + + // Only BLE is wired for this POC + val interfaceChar = rawAddress.firstOrNull() + if (InterfaceId.forIdChar(interfaceChar ?: ' ') != InterfaceId.BLUETOOTH) { + Logger.w { "RadioClientProvider: non-BLE transport not yet wired ($rawAddress)" } + return@withLock + } + + val macAddress = rawAddress.substring(1) // strip leading 'x' + + // 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" } } + } + + Logger.i { "RadioClientProvider: building new client for $macAddress" } + + val peripheral = buildPeripheralForSavedAddress(macAddress) + val transport = BleTransport(peripheral, macAddress) + + 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" } + } + + /** Fire-and-forget version of [rebuildAndConnect] for non-suspending call sites. */ + 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" } } + } + } + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt new file mode 100644 index 0000000000..17cd9a780e --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt @@ -0,0 +1,105 @@ +/* + * 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 androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +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 kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.sdk.ConnectionState +import org.meshtastic.sdk.MeshEvent + +/** + * POC ViewModel that exposes the SDK [RadioClient] connection lifecycle to the UI. + * + * **Connection state:** Uses `flatMapLatest` on the `StateFlow` so that any screen + * collecting [sdkConnectionState] automatically switches to the new client's connection flow when + * [RadioClientProvider.rebuildAndConnect] replaces the active client. + * [SharingStarted.WhileSubscribed] with a 5 s timeout keeps the upstream active briefly after the + * last subscriber leaves (e.g., configuration change) so the next subscriber doesn't miss a fast + * `Connected` event. + * + * **Events:** Collected with [SharingStarted.Eagerly] so that [MeshEvent]s (device rebooted, + * storage degraded, security warnings) are never dropped while navigating between screens. + * The collection is launched in [viewModelScope] which is tied to the application lifecycle via + * Koin's `@KoinViewModel` singleton scope — not to any individual screen. + * + * SDK gaps surfaced here: + * - [ConnectionState.Configuring] has no counterpart in the legacy [org.meshtastic.core.model.ConnectionState] + * - [ConnectionState.Reconnecting] has no counterpart in the legacy model + */ +@KoinViewModel +class RadioClientViewModel( + private val provider: RadioClientProvider, +) : ViewModel() { + + /** Live SDK connection state; `null` if no client is active (no radio configured). */ + val sdkConnectionState: StateFlow = provider.client + .flatMapLatest { client -> client?.connection ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** + * Human-readable label for the SDK connection state. + * Useful as a debug overlay in POC builds to see SDK state alongside the legacy state. + */ + val sdkConnectionLabel: StateFlow = sdkConnectionState + .map { it.toLabel() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "SDK: —") + + init { + // Collect events eagerly so none are dropped during navigation. + // This scope lives as long as the ViewModel (application lifetime via Koin singleton). + provider.client + .flatMapLatest { client -> client?.events ?: emptyFlow() } + .onEach { event -> + when (event) { + is MeshEvent.StorageDegraded -> + Logger.w { "[SDK] StorageDegraded: ${event.reason}" } + is MeshEvent.DeviceRebooted -> + Logger.i { "[SDK] DeviceRebooted" } + is MeshEvent.SecurityWarning -> + Logger.w { "[SDK] SecurityWarning: $event" } + else -> Logger.d { "[SDK] Event: $event" } + } + } + .launchIn(viewModelScope) + } + + /** Kick off a (re)connect using the current saved radio address. */ + fun connect() = provider.rebuildAndConnectAsync() + + /** Disconnect the active client. */ + fun disconnect() = provider.disconnect() +} + +private fun ConnectionState?.toLabel(): String = when (this) { + null -> "SDK: no client" + ConnectionState.Disconnected -> "SDK: Disconnected" + is ConnectionState.Connecting -> "SDK: Connecting (#${attempt})" + is ConnectionState.Configuring -> "SDK: Configuring — ${phase.name} (${(progress * 100).toInt()}%)" + ConnectionState.Connected -> "SDK: Connected ✓" + is ConnectionState.Reconnecting -> "SDK: Reconnecting (#${attempt}) — $cause" +} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt new file mode 100644 index 0000000000..3019cb4698 --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt @@ -0,0 +1,178 @@ +/* + * 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 androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.User +import org.meshtastic.sdk.AdminResult +import org.meshtastic.sdk.ConfigBundle + +/** + * POC ViewModel that reads device configuration from the SDK's [ConfigBundle] and + * writes changes back via [org.meshtastic.sdk.AdminApi.editSettings]. + * + * **Read path:** [RadioClient.configBundle] is a [StateFlow] cached from the handshake — zero + * RPCs required. It contains all [Config] and [ModuleConfig] entries as they were at connect time. + * + * **Write path:** [editSettings] issues a single-RPC batch write. On success we apply an + * optimistic local overlay via [_localConfigOverrides] so UI reads see the new value immediately. + * + * **SDK Gap G surfaced:** After a successful [AdminApi.editSettings] write, [RadioClient.configBundle] + * is NOT automatically refreshed — it still holds the pre-write snapshot. Until the SDK emits a + * fresh [ConfigBundle] after each write, callers must maintain their own optimistic overlay (as + * done here). Logged as Gap G for SDK fix. + * + * **SDK Gap C surfaced:** [ConfigBundle] has no `channels` field; channels are only available via + * [org.meshtastic.sdk.AdminApi.listChannels] (8 serial RPCs). Exposed via [loadChannels]. + * Logged as Gap C. + */ +@KoinViewModel +class SdkConfigViewModel( + private val provider: RadioClientProvider, +) : ViewModel() { + + /** The raw ConfigBundle from the handshake; null until connected+configured. */ + val configBundle: StateFlow = provider.client + .flatMapLatest { it?.configBundle ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** + * Optimistic local config overrides applied after successful writes. + * Keyed by Config type tag (e.g. "device", "lora"). Merged with [configBundle] in [deviceConfig]. + * + * Gap G: remove this overlay once SDK emits a fresh configBundle after editSettings writes. + */ + private val _localConfigOverrides = MutableStateFlow>(emptyMap()) + val localConfigOverrides: StateFlow> = _localConfigOverrides.asStateFlow() + + /** Device config — merged with any pending local override. */ + val deviceConfig: StateFlow = configBundle + .map { bundle -> + // Prefer local override if present (Gap G workaround) + _localConfigOverrides.value["device"]?.device + ?: bundle?.configs?.firstOrNull { it.device != null }?.device + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** LoRa config — merged with any pending local override. */ + val loraConfig: StateFlow = configBundle + .map { bundle -> + _localConfigOverrides.value["lora"]?.lora + ?: bundle?.configs?.firstOrNull { it.lora != null }?.lora + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** + * Write a config update to the radio via [AdminApi.editSettings]. + * + * On success, applies the new config as a local optimistic override so UI sees it + * immediately (Gap G workaround — SDK doesn't refresh configBundle after writes). + */ + fun setConfig(config: Config, typeKey: String) { + val client = provider.client.value ?: run { + Logger.w { "[SDK] setConfig: no active client" } + return + } + viewModelScope.launch { + when (val result = client.admin.editSettings { setConfig(config) }) { + is AdminResult.Success -> { + Logger.i { "[SDK] setConfig($typeKey) succeeded" } + _localConfigOverrides.update { it + (typeKey to config) } + } + AdminResult.Timeout -> + Logger.w { "[SDK] setConfig($typeKey): Timeout" } + AdminResult.Unauthorized -> + Logger.w { "[SDK] setConfig($typeKey): Unauthorized" } + AdminResult.SessionKeyExpired -> + Logger.w { "[SDK] setConfig($typeKey): SessionKeyExpired — reconnect needed" } + AdminResult.NodeUnreachable -> + Logger.w { "[SDK] setConfig($typeKey): NodeUnreachable" } + is AdminResult.Failed -> + Logger.e { "[SDK] setConfig($typeKey): Failed — ${result.routingError}" } + } + } + } + + /** Convenience: update device config. */ + fun setDeviceConfig(device: Config.DeviceConfig) = setConfig(Config(device = device), "device") + + /** Convenience: update LoRa config. */ + fun setLoraConfig(lora: Config.LoRaConfig) = setConfig(Config(lora = lora), "lora") + + /** + * Update owner name on the radio. + * + * Gap G: `ownNode` StateFlow on RadioClient is not refreshed after setOwner either — + * same root cause as config writes. + */ + fun setOwner(longName: String, shortName: String) { + val client = provider.client.value ?: return + viewModelScope.launch { + when (val result = client.admin.setOwner(User(long_name = longName, short_name = shortName))) { + is AdminResult.Success -> Logger.i { "[SDK] setOwner succeeded" } + else -> Logger.w { "[SDK] setOwner failed: $result" } + } + } + } + + /** + * Load all 8 channels via serial RPCs. + * + * Gap C: No reactive `client.channels: StateFlow>` — only 8 serial RPCs via + * [AdminApi.listChannels]. Callers must re-request on every mount. + * SDK fix: cache channels from storage during handshake and expose as StateFlow. + */ + fun loadChannels(onResult: (AdminResult>) -> Unit) { + val client = provider.client.value ?: return + viewModelScope.launch { + val result = client.admin.listChannels() + Logger.i { "[SDK] listChannels → $result" } + onResult(result) + } + } + + /** + * Diagnostics: log the full ConfigBundle contents. + * Useful for POC validation — call from a debug menu or LaunchedEffect. + */ + fun logConfigBundle() { + val bundle = configBundle.value + if (bundle == null) { + Logger.i { "[SDK] configBundle: null (not yet connected)" } + return + } + Logger.i { "[SDK] myNodeNum=${bundle.myInfo.my_node_num}" } + Logger.i { "[SDK] firmwareVersion=${bundle.metadata.firmware_version}" } + bundle.configs.forEach { c -> Logger.d { "[SDK] Config: $c" } } + bundle.moduleConfigs.forEach { mc -> Logger.d { "[SDK] ModuleConfig: $mc" } } + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt new file mode 100644 index 0000000000..4b95d2d623 --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt @@ -0,0 +1,151 @@ +/* + * 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 androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.sdk.ChannelIndex +import org.meshtastic.sdk.MessageHandle +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.SendState +import org.meshtastic.sdk.asText + +/** Stable Compose model for a received text message. */ +@Immutable +data class IncomingTextMessage( + val fromNodeNum: Int, + val channelIndex: Int, + val text: String, + val rxTimeSeconds: Int, +) + +/** Stable Compose model for an outbound message's delivery status. */ +@Immutable +data class OutboundStatus( + val messageId: Long, + val state: SendState, +) + +/** + * POC ViewModel that wires text messaging to the SDK's [RadioClient]. + * + * **Inbound:** Filters [RadioClient.packets] for TEXT_MESSAGE_APP packets using the SDK's + * [org.meshtastic.sdk.asText] extension. Accumulated in [incomingMessages] (capped at 200 for + * the POC to avoid unbounded memory growth). + * + * **Outbound:** [sendText] calls [RadioClient.sendText] synchronously (non-suspending), receives + * a [MessageHandle], and tracks [SendState] transitions in [outboundStatuses]. + * + * **SDK Gap B surfaced:** [RadioClient] has [org.meshtastic.sdk.asText] as a packet-level + * extension, but no reactive `RadioClient.textMessages: Flow` convenience. + * Callers must filter `packets` themselves. Log as Gap B for SDK fix. + * + * Note: Inbound packet collection uses `SharingStarted.Eagerly` (via [launchIn]) so messages are + * never dropped while navigating between screens. + */ +@KoinViewModel +class SdkMessagingViewModel( + private val provider: RadioClientProvider, +) : ViewModel() { + + private val _incomingMessages = MutableStateFlow>(emptyList()) + val incomingMessages: StateFlow> = _incomingMessages.asStateFlow() + + private val _outboundStatuses = MutableStateFlow>(emptyList()) + val outboundStatuses: StateFlow> = _outboundStatuses.asStateFlow() + + init { + // Eagerly collect inbound text packets — must not drop while navigating. + // Gap B: no RadioClient.textMessages flow; manually filter packets. + provider.client + .flatMapLatest { client -> client?.packets ?: emptyFlow() } + .mapNotNull { packet -> + val text = packet.asText() ?: return@mapNotNull null + IncomingTextMessage( + fromNodeNum = packet.from, + channelIndex = packet.channel, + text = text, + rxTimeSeconds = packet.rx_time, + ) + } + .onEach { msg -> + Logger.d { "[SDK] Received text from ${msg.fromNodeNum} ch${msg.channelIndex}: ${msg.text}" } + _incomingMessages.update { prev -> + (prev + msg).takeLast(MAX_MESSAGES) + } + } + .launchIn(viewModelScope) + } + + /** + * Send a text message via the SDK. + * + * @param text the message text + * @param channelIndex 0–7; defaults to primary channel (0) + * @param toNodeNum destination node num; 0xFFFFFFFF (default) = broadcast + */ + fun sendText( + text: String, + channelIndex: Int = 0, + toNodeNum: Int = BROADCAST_NODE_NUM, + ) { + val client = provider.client.value ?: run { + Logger.w { "[SDK] sendText: no active client" } + return + } + val handle: MessageHandle = client.sendText( + text = text, + channel = ChannelIndex(channelIndex), + to = NodeId(toNodeNum), + ) + + // Track delivery state for this outbound message + handle.state + .onEach { state -> + Logger.d { "[SDK] Message ${handle.id} → $state" } + _outboundStatuses.update { prev -> + val updated = OutboundStatus( + messageId = handle.id.raw.toLong(), + state = state, + ) + val existing = prev.indexOfFirst { it.messageId == updated.messageId } + if (existing >= 0) prev.toMutableList().also { it[existing] = updated } + else (prev + updated).takeLast(MAX_MESSAGES) + } + } + .launchIn(viewModelScope) + } + + companion object { + private const val MAX_MESSAGES = 200 + private const val BROADCAST_NODE_NUM = -1 // 0xFFFFFFFF as signed Int + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt new file mode 100644 index 0000000000..cd1bd3788e --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt @@ -0,0 +1,110 @@ +/* + * 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 androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.proto.NodeInfo +import org.meshtastic.sdk.NodeChange +import org.meshtastic.sdk.NodeId + +/** + * Stable, Compose-safe representation of a mesh node. + * + * Wire-generated [NodeInfo] is NOT `@Stable`; never pass it directly to Compose. This wrapper + * holds only the primitive/String fields the node list UI needs. + */ +@Immutable +data class UiNode( + val num: Int, + val longName: String, + val shortName: String, + val snr: Float, + val hopsAway: Int?, + val lastHeard: Int, + val viaMqtt: Boolean, +) + +private fun NodeInfo.toUiNode() = UiNode( + num = num, + longName = user?.long_name.orEmpty(), + shortName = user?.short_name.orEmpty(), + snr = snr, + hopsAway = hops_away, + lastHeard = last_heard, + viaMqtt = via_mqtt, +) + +/** + * POC ViewModel that drives a node list directly from the SDK's [org.meshtastic.sdk.RadioClient]. + * + * **Fold pattern:** + * 1. `flatMapLatest` switches to the new client's `nodes` flow whenever [RadioClientProvider] + * replaces the active client. + * 2. `.catch {}` before `.scan {}` so that a transport error re-emits a safe [NodeChange.Snapshot] + * (empty map) rather than terminating the downstream scan accumulator. + * 3. `.scan {}` folds delta events — [NodeChange.Added], [NodeChange.Updated], + * [NodeChange.Removed] — onto the accumulator map. The initial [NodeChange.Snapshot] + * is guaranteed by the SDK for every new subscriber; no explicit replay config needed. + * 4. `.flowOn(Dispatchers.Default)` keeps folding off the main thread. + * 5. `.stateIn(WhileSubscribed(5_000))` keeps the upstream alive for 5 s after the last + * subscriber (safe across config changes; SDK re-sends a Snapshot for later subscribers). + * + * This ViewModel is registered as a Koin singleton alongside [RadioClientViewModel]. Both are + * instantiated at [org.meshtastic.app.ui.MainScreen] startup so the node map is warm before any + * screen subscribes. + */ +@KoinViewModel +class SdkNodeListViewModel( + provider: RadioClientProvider, +) : ViewModel() { + + val nodes: StateFlow> = provider.client + .flatMapLatest { client -> + if (client == null) return@flatMapLatest flowOf(emptyList()) + client.nodes + .catch { e -> + Logger.e(e) { "[SDK] nodes flow error — resetting to empty" } + emit(NodeChange.Snapshot(emptyMap())) + } + .scan(emptyMap()) { acc, change -> + when (change) { + is NodeChange.Snapshot -> change.nodes + is NodeChange.Added -> acc + (NodeId(change.node.num) to change.node) + is NodeChange.Updated -> acc + (NodeId(change.node.num) to change.node) + is NodeChange.Removed -> acc - change.nodeId + } + } + .map { nodeMap -> nodeMap.values.map(NodeInfo::toUiNode) } + .flowOn(Dispatchers.Default) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) +} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt new file mode 100644 index 0000000000..d1d390cc6b --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt @@ -0,0 +1,97 @@ +/* + * 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 androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.proto.Telemetry +import org.meshtastic.sdk.AdminResult +import org.meshtastic.sdk.NodeId + +/** + * POC ViewModel that surfaces per-node telemetry from [TelemetryApi.observe]. + * + * **Gap D verified:** [TelemetryApi.observe] returns a plain [kotlinx.coroutines.flow.Flow] + * of unsolicited periodic [Telemetry] packets (device metrics, environment metrics, etc.). + * It does NOT auto-poll — packets arrive only when the radio pushes them. + * To request an immediate telemetry update, call [requestDeviceMetrics] which issues an RPC. + * + * Telemetry fields are nullable (Wire proto) — check per-field before display: + * [Telemetry.device_metrics], [Telemetry.environment_metrics], + * [Telemetry.air_quality_metrics], [Telemetry.power_metrics] + * + * Usage: observe [deviceMetrics] / [environmentMetrics] in a node-detail Composable, + * call [requestDeviceMetrics] on screen entry to prime the display. + */ +@KoinViewModel +class SdkTelemetryViewModel( + private val provider: RadioClientProvider, +) : ViewModel() { + + /** + * Observe all raw [Telemetry] packets for [nodeId]. + * + * Re-subscribes automatically when [RadioClientProvider.client] changes (reconnect). + * Errors are caught and logged — the flow resets to null rather than crashing. + */ + private fun telemetryFor(nodeId: NodeId): StateFlow = + provider.client + .flatMapLatest { c -> + if (c == null) flowOf(null) + else c.telemetry.observe(nodeId) + .catch { e -> + Logger.e(e) { "[SDK] telemetry.observe(${nodeId.raw}) error" } + emit(Telemetry()) + } + .map { it as Telemetry? } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + /** Latest telemetry (any type) for the local node (NodeId.LOCAL). */ + val localTelemetry: StateFlow = telemetryFor(NodeId.LOCAL) + + /** + * Request an immediate device-metrics telemetry packet from [nodeId]. + * The result will be pushed back through [telemetryFor]'s [TelemetryApi.observe] flow. + */ + fun requestDeviceMetrics(nodeId: NodeId = NodeId.LOCAL) { + val client = provider.client.value ?: return + viewModelScope.launch { + when (val r = client.telemetry.requestDevice(nodeId)) { + is AdminResult.Success -> + Logger.d { "[SDK] requestDeviceMetrics(${nodeId.raw}): ${r.value}" } + else -> Logger.w { "[SDK] requestDeviceMetrics(${nodeId.raw}) failed: $r" } + } + } + } + + /** + * Build a per-node telemetry StateFlow for a specific node num. + * Compose screens can call this once per node-detail screen. + */ + fun observeNode(nodeNum: Int): StateFlow = telemetryFor(NodeId(nodeNum)) +} 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..524cfa07cb 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -29,8 +29,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.app.BuildConfig +import org.meshtastic.app.radio.RadioClientViewModel +import org.meshtastic.app.radio.SdkNodeListViewModel import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.TopLevelDestination @@ -54,6 +57,17 @@ import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph @Composable fun MainScreen() { val viewModel: UIViewModel = koinViewModel() + // Instantiate the SDK ViewModel so event collection starts at app launch (Eagerly scope). + // sdkConnectionLabel logged below for POC visibility; will feed the connection toolbar later. + val radioClientViewModel: RadioClientViewModel = koinViewModel() + // Warm the SDK node list at launch so it's ready before any screen subscribes. + val sdkNodeListViewModel: SdkNodeListViewModel = koinViewModel() + val sdkNodeCount by sdkNodeListViewModel.nodes + .map { it.size } + .collectAsStateWithLifecycle(initialValue = 0) + val sdkLabel by radioClientViewModel.sdkConnectionLabel.collectAsStateWithLifecycle() + LaunchedEffect(sdkLabel) { Logger.d { sdkLabel } } + LaunchedEffect(sdkNodeCount) { Logger.d { "SDK nodes: $sdkNodeCount" } } // Land on Connections for first-run / no-device-selected; otherwise on Nodes. Read synchronously // from the StateFlow (seeded from persisted prefs) so the initial tab is set in one shot. val initialTab = 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/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 0f4bc60b7d..022c2f12f1 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 @@ -86,6 +86,8 @@ class MeshService : Service() { 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) } @@ -218,6 +220,7 @@ class MeshService : Service() { if (isServiceInitialized) { orchestrator.stop() } + sdkClientLifecycle.disconnect() serviceJob.cancel() super.onDestroy() } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SdkClientLifecycle.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SdkClientLifecycle.kt new file mode 100644 index 0000000000..a8727cb2c5 --- /dev/null +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SdkClientLifecycle.kt @@ -0,0 +1,29 @@ +/* + * 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 + +/** + * 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/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") From dd67120b5c2561f31b26760443f4adecba939365 Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 2 May 2026 19:27:51 -0500 Subject: [PATCH 02/53] refactor(radio): use SDK BleTransport(address) factory (Gap F resolved) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two-step buildPeripheralForSavedAddress() + BleTransport(peripheral, address) with the new SDK factory BleTransport(address) { autoConnectIf { true } }. The SDK's transport-ble androidMain now exposes BleTransport(address: String, builderAction: PeripheralBuilder.() -> Unit) directly, removing the need for the core:ble workaround. The autoConnectIf { true } flag preserves bonded-device behavior (avoids GATT 133 on reconnect without a fresh advertisement). Gap F note removed from RadioClientProvider KDoc — gap is resolved in SDK. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/app/MeshUtilApplication.kt | 2 +- .../app/radio/RadioClientProvider.kt | 61 ++++------ .../app/radio/RadioClientViewModel.kt | 55 ++++----- .../app/radio/SdkConfigViewModel.kt | 109 +++++++++--------- .../app/radio/SdkMessagingViewModel.kt | 77 +++++-------- .../app/radio/SdkNodeListViewModel.kt | 71 ++++++------ .../app/radio/SdkTelemetryViewModel.kt | 51 ++++---- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 4 +- 8 files changed, 191 insertions(+), 239 deletions(-) diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt index 8ff6bc2645..e6bff8de17 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt @@ -40,11 +40,11 @@ import org.koin.androidx.workmanager.koin.workManagerFactory import org.koin.plugin.module.dsl.startKoin import org.meshtastic.app.di.AndroidKoinApp import org.meshtastic.core.common.ContextServices -import org.meshtastic.sdk.storage.sqldelight.AndroidContextHolder 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 diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt index 98ec99f9b8..5ecdf66899 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.Single -import org.meshtastic.core.ble.buildPeripheralForSavedAddress import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.service.SdkClientLifecycle @@ -40,25 +39,11 @@ import org.meshtastic.sdk.transport.ble.BleTransport /** * 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`. - * - * **Thread-safety:** [rebuildAndConnect] is serialized by a [Mutex] so concurrent calls - * (e.g., radio address change during an active connection attempt) queue safely. - * - * **Scope:** The provider owns a long-lived [CoroutineScope] for connection management. - * It is NOT tied to any screen lifecycle — callers use [WhileSubscribed][kotlinx.coroutines.flow.SharingStarted] - * on derived StateFlows for screen-scoped work. - * - * SDK gap F tracked here: [BleTransport] requires a Kable [Peripheral][com.juul.kable.Peripheral] - * rather than accepting a MAC address string directly. [buildPeripheralForSavedAddress] bridges - * that gap on the Android side until the SDK adds a convenience factory. + * 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]) -class RadioClientProvider( - private val context: Context, - private val radioPrefs: RadioPrefs, -) : SdkClientLifecycle { +class RadioClientProvider(private val context: Context, private val radioPrefs: RadioPrefs) : SdkClientLifecycle { private val _client = MutableStateFlow(null) /** Active [RadioClient], or `null` when disconnected or between connections. */ @@ -68,19 +53,21 @@ class RadioClientProvider( 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]. + * Tear down the existing client (if any) and build + connect a new one using the current saved radio address from + * [RadioPrefs]. * - * If [RadioPrefs.devAddr] is not a BLE address this call is a no-op (other transport - * types will be added in follow-up work). + * If [RadioPrefs.devAddr] is not a BLE address this call is a no-op (other transport types will be added in + * follow-up work). * * Callers that cannot suspend should use [rebuildAndConnectAsync]. */ suspend fun rebuildAndConnect() = mutex.withLock { - val rawAddress = radioPrefs.devAddr.value ?: run { - Logger.w { "RadioClientProvider: no saved device address — skipping connect" } - return@withLock - } + val rawAddress = + radioPrefs.devAddr.value + ?: run { + Logger.w { "RadioClientProvider: no saved device address — skipping connect" } + return@withLock + } // Only BLE is wired for this POC val interfaceChar = rawAddress.firstOrNull() @@ -94,20 +81,17 @@ class RadioClientProvider( // 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" } } - } + old?.let { runCatching { it.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect old client" } } } Logger.i { "RadioClientProvider: building new client for $macAddress" } - val peripheral = buildPeripheralForSavedAddress(macAddress) - val transport = BleTransport(peripheral, macAddress) - - val newClient = RadioClient.Builder() - .transport(transport) - .storage(SqlDelightStorageProvider(baseDir = context.filesDir.absolutePath)) - .autoReconnect(AutoReconnectConfig()) // enabled=true; Disabled is SDK default — must opt in - .build() + val transport = BleTransport(macAddress) { autoConnectIf { true } } + 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() @@ -118,8 +102,7 @@ class RadioClientProvider( /** Fire-and-forget version of [rebuildAndConnect] for non-suspending call sites. */ fun rebuildAndConnectAsync() { scope.launch { - runCatching { rebuildAndConnect() } - .onFailure { e -> Logger.e(e) { "RadioClientProvider: connect failed" } } + runCatching { rebuildAndConnect() }.onFailure { e -> Logger.e(e) { "RadioClientProvider: connect failed" } } } } diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt index 17cd9a780e..6e10501a1e 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt @@ -35,39 +35,35 @@ import org.meshtastic.sdk.MeshEvent /** * POC ViewModel that exposes the SDK [RadioClient] connection lifecycle to the UI. * - * **Connection state:** Uses `flatMapLatest` on the `StateFlow` so that any screen - * collecting [sdkConnectionState] automatically switches to the new client's connection flow when - * [RadioClientProvider.rebuildAndConnect] replaces the active client. - * [SharingStarted.WhileSubscribed] with a 5 s timeout keeps the upstream active briefly after the - * last subscriber leaves (e.g., configuration change) so the next subscriber doesn't miss a fast - * `Connected` event. + * **Connection state:** Uses `flatMapLatest` on the `StateFlow` so that any screen collecting + * [sdkConnectionState] automatically switches to the new client's connection flow when + * [RadioClientProvider.rebuildAndConnect] replaces the active client. [SharingStarted.WhileSubscribed] with a 5 s + * timeout keeps the upstream active briefly after the last subscriber leaves (e.g., configuration change) so the next + * subscriber doesn't miss a fast `Connected` event. * - * **Events:** Collected with [SharingStarted.Eagerly] so that [MeshEvent]s (device rebooted, - * storage degraded, security warnings) are never dropped while navigating between screens. - * The collection is launched in [viewModelScope] which is tied to the application lifecycle via - * Koin's `@KoinViewModel` singleton scope — not to any individual screen. + * **Events:** Collected with [SharingStarted.Eagerly] so that [MeshEvent]s (device rebooted, storage degraded, security + * warnings) are never dropped while navigating between screens. The collection is launched in [viewModelScope] which is + * tied to the application lifecycle via Koin's `@KoinViewModel` singleton scope — not to any individual screen. * * SDK gaps surfaced here: * - [ConnectionState.Configuring] has no counterpart in the legacy [org.meshtastic.core.model.ConnectionState] * - [ConnectionState.Reconnecting] has no counterpart in the legacy model */ @KoinViewModel -class RadioClientViewModel( - private val provider: RadioClientProvider, -) : ViewModel() { +class RadioClientViewModel(private val provider: RadioClientProvider) : ViewModel() { /** Live SDK connection state; `null` if no client is active (no radio configured). */ - val sdkConnectionState: StateFlow = provider.client - .flatMapLatest { client -> client?.connection ?: flowOf(null) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val sdkConnectionState: StateFlow = + provider.client + .flatMapLatest { client -> client?.connection ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) /** - * Human-readable label for the SDK connection state. - * Useful as a debug overlay in POC builds to see SDK state alongside the legacy state. + * Human-readable label for the SDK connection state. Useful as a debug overlay in POC builds to see SDK state + * alongside the legacy state. */ - val sdkConnectionLabel: StateFlow = sdkConnectionState - .map { it.toLabel() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "SDK: —") + val sdkConnectionLabel: StateFlow = + sdkConnectionState.map { it.toLabel() }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "SDK: —") init { // Collect events eagerly so none are dropped during navigation. @@ -76,12 +72,9 @@ class RadioClientViewModel( .flatMapLatest { client -> client?.events ?: emptyFlow() } .onEach { event -> when (event) { - is MeshEvent.StorageDegraded -> - Logger.w { "[SDK] StorageDegraded: ${event.reason}" } - is MeshEvent.DeviceRebooted -> - Logger.i { "[SDK] DeviceRebooted" } - is MeshEvent.SecurityWarning -> - Logger.w { "[SDK] SecurityWarning: $event" } + is MeshEvent.StorageDegraded -> Logger.w { "[SDK] StorageDegraded: ${event.reason}" } + is MeshEvent.DeviceRebooted -> Logger.i { "[SDK] DeviceRebooted" } + is MeshEvent.SecurityWarning -> Logger.w { "[SDK] SecurityWarning: $event" } else -> Logger.d { "[SDK] Event: $event" } } } @@ -95,11 +88,13 @@ class RadioClientViewModel( fun disconnect() = provider.disconnect() } +private const val PERCENT = 100 + private fun ConnectionState?.toLabel(): String = when (this) { null -> "SDK: no client" ConnectionState.Disconnected -> "SDK: Disconnected" - is ConnectionState.Connecting -> "SDK: Connecting (#${attempt})" - is ConnectionState.Configuring -> "SDK: Configuring — ${phase.name} (${(progress * 100).toInt()}%)" + is ConnectionState.Connecting -> "SDK: Connecting (#$attempt)" + is ConnectionState.Configuring -> "SDK: Configuring — ${phase.name} (${(progress * PERCENT).toInt()}%)" ConnectionState.Connected -> "SDK: Connected ✓" - is ConnectionState.Reconnecting -> "SDK: Reconnecting (#${attempt}) — $cause" + is ConnectionState.Reconnecting -> "SDK: Reconnecting (#$attempt) — $cause" } diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt index 3019cb4698..4c7028a606 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt @@ -37,37 +37,34 @@ import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.ConfigBundle /** - * POC ViewModel that reads device configuration from the SDK's [ConfigBundle] and - * writes changes back via [org.meshtastic.sdk.AdminApi.editSettings]. + * POC ViewModel that reads device configuration from the SDK's [ConfigBundle] and writes changes back via + * [org.meshtastic.sdk.AdminApi.editSettings]. * - * **Read path:** [RadioClient.configBundle] is a [StateFlow] cached from the handshake — zero - * RPCs required. It contains all [Config] and [ModuleConfig] entries as they were at connect time. + * **Read path:** [RadioClient.configBundle] is a [StateFlow] cached from the handshake — zero RPCs required. It + * contains all [Config] and [ModuleConfig] entries as they were at connect time. * - * **Write path:** [editSettings] issues a single-RPC batch write. On success we apply an - * optimistic local overlay via [_localConfigOverrides] so UI reads see the new value immediately. + * **Write path:** [editSettings] issues a single-RPC batch write. On success we apply an optimistic local overlay via + * [_localConfigOverrides] so UI reads see the new value immediately. * - * **SDK Gap G surfaced:** After a successful [AdminApi.editSettings] write, [RadioClient.configBundle] - * is NOT automatically refreshed — it still holds the pre-write snapshot. Until the SDK emits a - * fresh [ConfigBundle] after each write, callers must maintain their own optimistic overlay (as - * done here). Logged as Gap G for SDK fix. + * **SDK Gap G surfaced:** After a successful [AdminApi.editSettings] write, [RadioClient.configBundle] is NOT + * automatically refreshed — it still holds the pre-write snapshot. Until the SDK emits a fresh [ConfigBundle] after + * each write, callers must maintain their own optimistic overlay (as done here). Logged as Gap G for SDK fix. * * **SDK Gap C surfaced:** [ConfigBundle] has no `channels` field; channels are only available via - * [org.meshtastic.sdk.AdminApi.listChannels] (8 serial RPCs). Exposed via [loadChannels]. - * Logged as Gap C. + * [org.meshtastic.sdk.AdminApi.listChannels] (8 serial RPCs). Exposed via [loadChannels]. Logged as Gap C. */ @KoinViewModel -class SdkConfigViewModel( - private val provider: RadioClientProvider, -) : ViewModel() { +class SdkConfigViewModel(private val provider: RadioClientProvider) : ViewModel() { /** The raw ConfigBundle from the handshake; null until connected+configured. */ - val configBundle: StateFlow = provider.client - .flatMapLatest { it?.configBundle ?: flowOf(null) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val configBundle: StateFlow = + provider.client + .flatMapLatest { it?.configBundle ?: flowOf(null) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) /** - * Optimistic local config overrides applied after successful writes. - * Keyed by Config type tag (e.g. "device", "lora"). Merged with [configBundle] in [deviceConfig]. + * Optimistic local config overrides applied after successful writes. Keyed by Config type tag (e.g. "device", + * "lora"). Merged with [configBundle] in [deviceConfig]. * * Gap G: remove this overlay once SDK emits a fresh configBundle after editSettings writes. */ @@ -75,49 +72,53 @@ class SdkConfigViewModel( val localConfigOverrides: StateFlow> = _localConfigOverrides.asStateFlow() /** Device config — merged with any pending local override. */ - val deviceConfig: StateFlow = configBundle - .map { bundle -> - // Prefer local override if present (Gap G workaround) - _localConfigOverrides.value["device"]?.device - ?: bundle?.configs?.firstOrNull { it.device != null }?.device - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val deviceConfig: StateFlow = + configBundle + .map { bundle -> + // Prefer local override if present (Gap G workaround) + _localConfigOverrides.value["device"]?.device + ?: bundle?.configs?.firstOrNull { it.device != null }?.device + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) /** LoRa config — merged with any pending local override. */ - val loraConfig: StateFlow = configBundle - .map { bundle -> - _localConfigOverrides.value["lora"]?.lora - ?: bundle?.configs?.firstOrNull { it.lora != null }?.lora - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val loraConfig: StateFlow = + configBundle + .map { bundle -> + _localConfigOverrides.value["lora"]?.lora ?: bundle?.configs?.firstOrNull { it.lora != null }?.lora + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) /** * Write a config update to the radio via [AdminApi.editSettings]. * - * On success, applies the new config as a local optimistic override so UI sees it - * immediately (Gap G workaround — SDK doesn't refresh configBundle after writes). + * On success, applies the new config as a local optimistic override so UI sees it immediately (Gap G workaround — + * SDK doesn't refresh configBundle after writes). */ fun setConfig(config: Config, typeKey: String) { - val client = provider.client.value ?: run { - Logger.w { "[SDK] setConfig: no active client" } - return - } + val client = + provider.client.value + ?: run { + Logger.w { "[SDK] setConfig: no active client" } + return + } viewModelScope.launch { when (val result = client.admin.editSettings { setConfig(config) }) { is AdminResult.Success -> { Logger.i { "[SDK] setConfig($typeKey) succeeded" } _localConfigOverrides.update { it + (typeKey to config) } } - AdminResult.Timeout -> - Logger.w { "[SDK] setConfig($typeKey): Timeout" } - AdminResult.Unauthorized -> - Logger.w { "[SDK] setConfig($typeKey): Unauthorized" } + + AdminResult.Timeout -> Logger.w { "[SDK] setConfig($typeKey): Timeout" } + + AdminResult.Unauthorized -> Logger.w { "[SDK] setConfig($typeKey): Unauthorized" } + AdminResult.SessionKeyExpired -> Logger.w { "[SDK] setConfig($typeKey): SessionKeyExpired — reconnect needed" } - AdminResult.NodeUnreachable -> - Logger.w { "[SDK] setConfig($typeKey): NodeUnreachable" } - is AdminResult.Failed -> - Logger.e { "[SDK] setConfig($typeKey): Failed — ${result.routingError}" } + + AdminResult.NodeUnreachable -> Logger.w { "[SDK] setConfig($typeKey): NodeUnreachable" } + + is AdminResult.Failed -> Logger.e { "[SDK] setConfig($typeKey): Failed — ${result.routingError}" } } } } @@ -131,8 +132,8 @@ class SdkConfigViewModel( /** * Update owner name on the radio. * - * Gap G: `ownNode` StateFlow on RadioClient is not refreshed after setOwner either — - * same root cause as config writes. + * Gap G: `ownNode` StateFlow on RadioClient is not refreshed after setOwner either — same root cause as config + * writes. */ fun setOwner(longName: String, shortName: String) { val client = provider.client.value ?: return @@ -147,9 +148,9 @@ class SdkConfigViewModel( /** * Load all 8 channels via serial RPCs. * - * Gap C: No reactive `client.channels: StateFlow>` — only 8 serial RPCs via - * [AdminApi.listChannels]. Callers must re-request on every mount. - * SDK fix: cache channels from storage during handshake and expose as StateFlow. + * Gap C: No reactive `client.channels: StateFlow>` — only 8 serial RPCs via [AdminApi.listChannels]. + * Callers must re-request on every mount. SDK fix: cache channels from storage during handshake and expose as + * StateFlow. */ fun loadChannels(onResult: (AdminResult>) -> Unit) { val client = provider.client.value ?: return @@ -161,8 +162,8 @@ class SdkConfigViewModel( } /** - * Diagnostics: log the full ConfigBundle contents. - * Useful for POC validation — call from a debug menu or LaunchedEffect. + * Diagnostics: log the full ConfigBundle contents. Useful for POC validation — call from a debug menu or + * LaunchedEffect. */ fun logConfigBundle() { val bundle = configBundle.value diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt index 4b95d2d623..ce01c1fbde 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt @@ -21,7 +21,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.emptyFlow @@ -29,7 +28,6 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import org.koin.core.annotation.KoinViewModel import org.meshtastic.sdk.ChannelIndex @@ -40,41 +38,29 @@ import org.meshtastic.sdk.asText /** Stable Compose model for a received text message. */ @Immutable -data class IncomingTextMessage( - val fromNodeNum: Int, - val channelIndex: Int, - val text: String, - val rxTimeSeconds: Int, -) +data class IncomingTextMessage(val fromNodeNum: Int, val channelIndex: Int, val text: String, val rxTimeSeconds: Int) /** Stable Compose model for an outbound message's delivery status. */ -@Immutable -data class OutboundStatus( - val messageId: Long, - val state: SendState, -) +@Immutable data class OutboundStatus(val messageId: Long, val state: SendState) /** * POC ViewModel that wires text messaging to the SDK's [RadioClient]. * - * **Inbound:** Filters [RadioClient.packets] for TEXT_MESSAGE_APP packets using the SDK's - * [org.meshtastic.sdk.asText] extension. Accumulated in [incomingMessages] (capped at 200 for - * the POC to avoid unbounded memory growth). + * **Inbound:** Filters [RadioClient.packets] for TEXT_MESSAGE_APP packets using the SDK's [org.meshtastic.sdk.asText] + * extension. Accumulated in [incomingMessages] (capped at 200 for the POC to avoid unbounded memory growth). * - * **Outbound:** [sendText] calls [RadioClient.sendText] synchronously (non-suspending), receives - * a [MessageHandle], and tracks [SendState] transitions in [outboundStatuses]. + * **Outbound:** [sendText] calls [RadioClient.sendText] synchronously (non-suspending), receives a [MessageHandle], and + * tracks [SendState] transitions in [outboundStatuses]. * - * **SDK Gap B surfaced:** [RadioClient] has [org.meshtastic.sdk.asText] as a packet-level - * extension, but no reactive `RadioClient.textMessages: Flow` convenience. - * Callers must filter `packets` themselves. Log as Gap B for SDK fix. + * **SDK Gap B surfaced:** [RadioClient] has [org.meshtastic.sdk.asText] as a packet-level extension, but no reactive + * `RadioClient.textMessages: Flow` convenience. Callers must filter `packets` themselves. Log as + * Gap B for SDK fix. * - * Note: Inbound packet collection uses `SharingStarted.Eagerly` (via [launchIn]) so messages are - * never dropped while navigating between screens. + * Note: Inbound packet collection uses `SharingStarted.Eagerly` (via [launchIn]) so messages are never dropped while + * navigating between screens. */ @KoinViewModel -class SdkMessagingViewModel( - private val provider: RadioClientProvider, -) : ViewModel() { +class SdkMessagingViewModel(private val provider: RadioClientProvider) : ViewModel() { private val _incomingMessages = MutableStateFlow>(emptyList()) val incomingMessages: StateFlow> = _incomingMessages.asStateFlow() @@ -98,9 +84,7 @@ class SdkMessagingViewModel( } .onEach { msg -> Logger.d { "[SDK] Received text from ${msg.fromNodeNum} ch${msg.channelIndex}: ${msg.text}" } - _incomingMessages.update { prev -> - (prev + msg).takeLast(MAX_MESSAGES) - } + _incomingMessages.update { prev -> (prev + msg).takeLast(MAX_MESSAGES) } } .launchIn(viewModelScope) } @@ -112,33 +96,28 @@ class SdkMessagingViewModel( * @param channelIndex 0–7; defaults to primary channel (0) * @param toNodeNum destination node num; 0xFFFFFFFF (default) = broadcast */ - fun sendText( - text: String, - channelIndex: Int = 0, - toNodeNum: Int = BROADCAST_NODE_NUM, - ) { - val client = provider.client.value ?: run { - Logger.w { "[SDK] sendText: no active client" } - return - } - val handle: MessageHandle = client.sendText( - text = text, - channel = ChannelIndex(channelIndex), - to = NodeId(toNodeNum), - ) + fun sendText(text: String, channelIndex: Int = 0, toNodeNum: Int = BROADCAST_NODE_NUM) { + val client = + provider.client.value + ?: run { + Logger.w { "[SDK] sendText: no active client" } + return + } + val handle: MessageHandle = + client.sendText(text = text, channel = ChannelIndex(channelIndex), to = NodeId(toNodeNum)) // Track delivery state for this outbound message handle.state .onEach { state -> Logger.d { "[SDK] Message ${handle.id} → $state" } _outboundStatuses.update { prev -> - val updated = OutboundStatus( - messageId = handle.id.raw.toLong(), - state = state, - ) + val updated = OutboundStatus(messageId = handle.id.raw.toLong(), state = state) val existing = prev.indexOfFirst { it.messageId == updated.messageId } - if (existing >= 0) prev.toMutableList().also { it[existing] = updated } - else (prev + updated).takeLast(MAX_MESSAGES) + if (existing >= 0) { + prev.toMutableList().also { it[existing] = updated } + } else { + (prev + updated).takeLast(MAX_MESSAGES) + } } } .launchIn(viewModelScope) diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt index cd1bd3788e..d9aece6807 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt @@ -24,7 +24,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn @@ -39,8 +38,8 @@ import org.meshtastic.sdk.NodeId /** * Stable, Compose-safe representation of a mesh node. * - * Wire-generated [NodeInfo] is NOT `@Stable`; never pass it directly to Compose. This wrapper - * holds only the primitive/String fields the node list UI needs. + * Wire-generated [NodeInfo] is NOT `@Stable`; never pass it directly to Compose. This wrapper holds only the + * primitive/String fields the node list UI needs. */ @Immutable data class UiNode( @@ -67,44 +66,42 @@ private fun NodeInfo.toUiNode() = UiNode( * POC ViewModel that drives a node list directly from the SDK's [org.meshtastic.sdk.RadioClient]. * * **Fold pattern:** - * 1. `flatMapLatest` switches to the new client's `nodes` flow whenever [RadioClientProvider] - * replaces the active client. - * 2. `.catch {}` before `.scan {}` so that a transport error re-emits a safe [NodeChange.Snapshot] - * (empty map) rather than terminating the downstream scan accumulator. - * 3. `.scan {}` folds delta events — [NodeChange.Added], [NodeChange.Updated], - * [NodeChange.Removed] — onto the accumulator map. The initial [NodeChange.Snapshot] - * is guaranteed by the SDK for every new subscriber; no explicit replay config needed. + * 1. `flatMapLatest` switches to the new client's `nodes` flow whenever [RadioClientProvider] replaces the active + * client. + * 2. `.catch {}` before `.scan {}` so that a transport error re-emits a safe [NodeChange.Snapshot] (empty map) rather + * than terminating the downstream scan accumulator. + * 3. `.scan {}` folds delta events — [NodeChange.Added], [NodeChange.Updated], [NodeChange.Removed] — onto the + * accumulator map. The initial [NodeChange.Snapshot] is guaranteed by the SDK for every new subscriber; no explicit + * replay config needed. * 4. `.flowOn(Dispatchers.Default)` keeps folding off the main thread. - * 5. `.stateIn(WhileSubscribed(5_000))` keeps the upstream alive for 5 s after the last - * subscriber (safe across config changes; SDK re-sends a Snapshot for later subscribers). + * 5. `.stateIn(WhileSubscribed(5_000))` keeps the upstream alive for 5 s after the last subscriber (safe across config + * changes; SDK re-sends a Snapshot for later subscribers). * - * This ViewModel is registered as a Koin singleton alongside [RadioClientViewModel]. Both are - * instantiated at [org.meshtastic.app.ui.MainScreen] startup so the node map is warm before any - * screen subscribes. + * This ViewModel is registered as a Koin singleton alongside [RadioClientViewModel]. Both are instantiated at + * [org.meshtastic.app.ui.MainScreen] startup so the node map is warm before any screen subscribes. */ @KoinViewModel -class SdkNodeListViewModel( - provider: RadioClientProvider, -) : ViewModel() { +class SdkNodeListViewModel(provider: RadioClientProvider) : ViewModel() { - val nodes: StateFlow> = provider.client - .flatMapLatest { client -> - if (client == null) return@flatMapLatest flowOf(emptyList()) - client.nodes - .catch { e -> - Logger.e(e) { "[SDK] nodes flow error — resetting to empty" } - emit(NodeChange.Snapshot(emptyMap())) - } - .scan(emptyMap()) { acc, change -> - when (change) { - is NodeChange.Snapshot -> change.nodes - is NodeChange.Added -> acc + (NodeId(change.node.num) to change.node) - is NodeChange.Updated -> acc + (NodeId(change.node.num) to change.node) - is NodeChange.Removed -> acc - change.nodeId + val nodes: StateFlow> = + provider.client + .flatMapLatest { client -> + if (client == null) return@flatMapLatest flowOf(emptyList()) + client.nodes + .catch { e -> + Logger.e(e) { "[SDK] nodes flow error — resetting to empty" } + emit(NodeChange.Snapshot(emptyMap())) } - } - .map { nodeMap -> nodeMap.values.map(NodeInfo::toUiNode) } - .flowOn(Dispatchers.Default) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + .scan(emptyMap()) { acc, change -> + when (change) { + is NodeChange.Snapshot -> change.nodes + is NodeChange.Added -> acc + (NodeId(change.node.num) to change.node) + is NodeChange.Updated -> acc + (NodeId(change.node.num) to change.node) + is NodeChange.Removed -> acc - change.nodeId + } + } + .map { nodeMap -> nodeMap.values.map(NodeInfo::toUiNode) } + .flowOn(Dispatchers.Default) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) } diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt index d1d390cc6b..2eaa5fb438 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt @@ -35,63 +35,62 @@ import org.meshtastic.sdk.NodeId /** * POC ViewModel that surfaces per-node telemetry from [TelemetryApi.observe]. * - * **Gap D verified:** [TelemetryApi.observe] returns a plain [kotlinx.coroutines.flow.Flow] - * of unsolicited periodic [Telemetry] packets (device metrics, environment metrics, etc.). - * It does NOT auto-poll — packets arrive only when the radio pushes them. - * To request an immediate telemetry update, call [requestDeviceMetrics] which issues an RPC. + * **Gap D verified:** [TelemetryApi.observe] returns a plain [kotlinx.coroutines.flow.Flow] of unsolicited periodic + * [Telemetry] packets (device metrics, environment metrics, etc.). It does NOT auto-poll — packets arrive only when the + * radio pushes them. To request an immediate telemetry update, call [requestDeviceMetrics] which issues an RPC. * - * Telemetry fields are nullable (Wire proto) — check per-field before display: - * [Telemetry.device_metrics], [Telemetry.environment_metrics], - * [Telemetry.air_quality_metrics], [Telemetry.power_metrics] + * Telemetry fields are nullable (Wire proto) — check per-field before display: [Telemetry.device_metrics], + * [Telemetry.environment_metrics], [Telemetry.air_quality_metrics], [Telemetry.power_metrics] * - * Usage: observe [deviceMetrics] / [environmentMetrics] in a node-detail Composable, - * call [requestDeviceMetrics] on screen entry to prime the display. + * Usage: observe [deviceMetrics] / [environmentMetrics] in a node-detail Composable, call [requestDeviceMetrics] on + * screen entry to prime the display. */ +@Suppress("MagicNumber") @KoinViewModel -class SdkTelemetryViewModel( - private val provider: RadioClientProvider, -) : ViewModel() { +class SdkTelemetryViewModel(private val provider: RadioClientProvider) : ViewModel() { /** * Observe all raw [Telemetry] packets for [nodeId]. * - * Re-subscribes automatically when [RadioClientProvider.client] changes (reconnect). - * Errors are caught and logged — the flow resets to null rather than crashing. + * Re-subscribes automatically when [RadioClientProvider.client] changes (reconnect). Errors are caught and logged — + * the flow resets to null rather than crashing. */ - private fun telemetryFor(nodeId: NodeId): StateFlow = - provider.client - .flatMapLatest { c -> - if (c == null) flowOf(null) - else c.telemetry.observe(nodeId) + private fun telemetryFor(nodeId: NodeId): StateFlow = provider.client + .flatMapLatest { c -> + if (c == null) { + flowOf(null) + } else { + c.telemetry + .observe(nodeId) .catch { e -> Logger.e(e) { "[SDK] telemetry.observe(${nodeId.raw}) error" } emit(Telemetry()) } .map { it as Telemetry? } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) // 5s screen-death buffer /** Latest telemetry (any type) for the local node (NodeId.LOCAL). */ val localTelemetry: StateFlow = telemetryFor(NodeId.LOCAL) /** - * Request an immediate device-metrics telemetry packet from [nodeId]. - * The result will be pushed back through [telemetryFor]'s [TelemetryApi.observe] flow. + * Request an immediate device-metrics telemetry packet from [nodeId]. The result will be pushed back through + * [telemetryFor]'s [TelemetryApi.observe] flow. */ fun requestDeviceMetrics(nodeId: NodeId = NodeId.LOCAL) { val client = provider.client.value ?: return viewModelScope.launch { when (val r = client.telemetry.requestDevice(nodeId)) { - is AdminResult.Success -> - Logger.d { "[SDK] requestDeviceMetrics(${nodeId.raw}): ${r.value}" } + is AdminResult.Success -> Logger.d { "[SDK] requestDeviceMetrics(${nodeId.raw}): ${r.value}" } else -> Logger.w { "[SDK] requestDeviceMetrics(${nodeId.raw}) failed: $r" } } } } /** - * Build a per-node telemetry StateFlow for a specific node num. - * Compose screens can call this once per node-detail screen. + * Build a per-node telemetry StateFlow for a specific node num. Compose screens can call this once per node-detail + * screen. */ fun observeNode(nodeNum: Int): StateFlow = telemetryFor(NodeId(nodeNum)) } 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 524cfa07cb..4adb1b4729 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -62,9 +62,7 @@ fun MainScreen() { val radioClientViewModel: RadioClientViewModel = koinViewModel() // Warm the SDK node list at launch so it's ready before any screen subscribes. val sdkNodeListViewModel: SdkNodeListViewModel = koinViewModel() - val sdkNodeCount by sdkNodeListViewModel.nodes - .map { it.size } - .collectAsStateWithLifecycle(initialValue = 0) + val sdkNodeCount by sdkNodeListViewModel.nodes.map { it.size }.collectAsStateWithLifecycle(initialValue = 0) val sdkLabel by radioClientViewModel.sdkConnectionLabel.collectAsStateWithLifecycle() LaunchedEffect(sdkLabel) { Logger.d { sdkLabel } } LaunchedEffect(sdkNodeCount) { Logger.d { "SDK nodes: $sdkNodeCount" } } From 384746cc750aae02707720a82933093242a6beb8 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 4 May 2026 17:06:13 -0500 Subject: [PATCH 03/53] feat: enrich Android POC with SDK integration improvements - RadioClientProvider: support BLE, TCP, and Serial transports via InterfaceId routing; handle MOCK/NOP gracefully - SdkNodeListViewModel: use SDK's MeshNode model for 20+ enriched fields (isOnline, battery, position, signal quality, etc.) - SdkConfigViewModel: remove Gap G workaround (_localConfigOverrides), add reactive channels StateFlow from SDK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../app/radio/RadioClientProvider.kt | 56 ++++++++++--- .../app/radio/SdkConfigViewModel.kt | 79 ++++--------------- .../app/radio/SdkNodeListViewModel.kt | 56 ++++++++++--- 3 files changed, 105 insertions(+), 86 deletions(-) diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt index 5ecdf66899..b3ceeac638 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt @@ -33,8 +33,11 @@ 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. @@ -56,10 +59,7 @@ class RadioClientProvider(private val context: Context, private val radioPrefs: * Tear down the existing client (if any) and build + connect a new one using the current saved radio address from * [RadioPrefs]. * - * If [RadioPrefs.devAddr] is not a BLE address this call is a no-op (other transport types will be added in - * follow-up work). - * - * Callers that cannot suspend should use [rebuildAndConnectAsync]. + * Supports BLE (`x` prefix), TCP (`t` prefix, format `tHOST:PORT`), and Serial (`s` prefix, format `sPORTNAME`). */ suspend fun rebuildAndConnect() = mutex.withLock { val rawAddress = @@ -69,23 +69,41 @@ class RadioClientProvider(private val context: Context, private val radioPrefs: return@withLock } - // Only BLE is wired for this POC - val interfaceChar = rawAddress.firstOrNull() - if (InterfaceId.forIdChar(interfaceChar ?: ' ') != InterfaceId.BLUETOOTH) { - Logger.w { "RadioClientProvider: non-BLE transport not yet wired ($rawAddress)" } + 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) + } - val macAddress = rawAddress.substring(1) // strip leading 'x' + 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" } } } - Logger.i { "RadioClientProvider: building new client for $macAddress" } - - val transport = BleTransport(macAddress) { autoConnectIf { true } } val newClient = RadioClient.Builder() .transport(transport) @@ -96,7 +114,7 @@ class RadioClientProvider(private val context: Context, private val radioPrefs: _client.value = newClient newClient.connect() - Logger.i { "RadioClientProvider: client connected" } + Logger.i { "RadioClientProvider: client connected via ${InterfaceId.forIdChar(interfaceChar)}" } } /** Fire-and-forget version of [rebuildAndConnect] for non-suspending call sites. */ @@ -116,4 +134,16 @@ class RadioClientProvider(private val context: Context, private val radioPrefs: } } } + + 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/radio/SdkConfigViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt index 4c7028a606..9f0f55b467 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt @@ -19,15 +19,12 @@ package org.meshtastic.app.radio import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel import org.meshtastic.proto.Config @@ -43,15 +40,10 @@ import org.meshtastic.sdk.ConfigBundle * **Read path:** [RadioClient.configBundle] is a [StateFlow] cached from the handshake — zero RPCs required. It * contains all [Config] and [ModuleConfig] entries as they were at connect time. * - * **Write path:** [editSettings] issues a single-RPC batch write. On success we apply an optimistic local overlay via - * [_localConfigOverrides] so UI reads see the new value immediately. + * **Write path:** [editSettings] issues a single-RPC batch write. The SDK auto-refreshes [configBundle] after a + * successful commit (Gap G resolved). * - * **SDK Gap G surfaced:** After a successful [AdminApi.editSettings] write, [RadioClient.configBundle] is NOT - * automatically refreshed — it still holds the pre-write snapshot. Until the SDK emits a fresh [ConfigBundle] after - * each write, callers must maintain their own optimistic overlay (as done here). Logged as Gap G for SDK fix. - * - * **SDK Gap C surfaced:** [ConfigBundle] has no `channels` field; channels are only available via - * [org.meshtastic.sdk.AdminApi.listChannels] (8 serial RPCs). Exposed via [loadChannels]. Logged as Gap C. + * **Gap C resolved:** [RadioClient.channels] is now a reactive StateFlow seeded from the handshake. */ @KoinViewModel class SdkConfigViewModel(private val provider: RadioClientProvider) : ViewModel() { @@ -62,38 +54,28 @@ class SdkConfigViewModel(private val provider: RadioClientProvider) : ViewModel( .flatMapLatest { it?.configBundle ?: flowOf(null) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - /** - * Optimistic local config overrides applied after successful writes. Keyed by Config type tag (e.g. "device", - * "lora"). Merged with [configBundle] in [deviceConfig]. - * - * Gap G: remove this overlay once SDK emits a fresh configBundle after editSettings writes. - */ - private val _localConfigOverrides = MutableStateFlow>(emptyMap()) - val localConfigOverrides: StateFlow> = _localConfigOverrides.asStateFlow() - - /** Device config — merged with any pending local override. */ + /** Device config — read directly from SDK configBundle (Gap G resolved, no local overlay needed). */ val deviceConfig: StateFlow = configBundle - .map { bundle -> - // Prefer local override if present (Gap G workaround) - _localConfigOverrides.value["device"]?.device - ?: bundle?.configs?.firstOrNull { it.device != null }?.device - } + .map { bundle -> bundle?.configs?.firstOrNull { it.device != null }?.device } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - /** LoRa config — merged with any pending local override. */ + /** LoRa config — read directly from SDK configBundle. */ val loraConfig: StateFlow = configBundle - .map { bundle -> - _localConfigOverrides.value["lora"]?.lora ?: bundle?.configs?.firstOrNull { it.lora != null }?.lora - } + .map { bundle -> bundle?.configs?.firstOrNull { it.lora != null }?.lora } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + /** Reactive channel list from the SDK (Gap C resolved — seeded from handshake, updated on setChannel). */ + val channels: StateFlow> = + provider.client + .flatMapLatest { client -> client?.channels?.map { it.orEmpty() } ?: flowOf(emptyList()) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** * Write a config update to the radio via [AdminApi.editSettings]. * - * On success, applies the new config as a local optimistic override so UI sees it immediately (Gap G workaround — - * SDK doesn't refresh configBundle after writes). + * The SDK automatically refreshes configBundle after a successful commit. */ fun setConfig(config: Config, typeKey: String) { val client = @@ -104,20 +86,12 @@ class SdkConfigViewModel(private val provider: RadioClientProvider) : ViewModel( } viewModelScope.launch { when (val result = client.admin.editSettings { setConfig(config) }) { - is AdminResult.Success -> { - Logger.i { "[SDK] setConfig($typeKey) succeeded" } - _localConfigOverrides.update { it + (typeKey to config) } - } - + is AdminResult.Success -> Logger.i { "[SDK] setConfig($typeKey) succeeded" } AdminResult.Timeout -> Logger.w { "[SDK] setConfig($typeKey): Timeout" } - AdminResult.Unauthorized -> Logger.w { "[SDK] setConfig($typeKey): Unauthorized" } - AdminResult.SessionKeyExpired -> Logger.w { "[SDK] setConfig($typeKey): SessionKeyExpired — reconnect needed" } - AdminResult.NodeUnreachable -> Logger.w { "[SDK] setConfig($typeKey): NodeUnreachable" } - is AdminResult.Failed -> Logger.e { "[SDK] setConfig($typeKey): Failed — ${result.routingError}" } } } @@ -129,12 +103,7 @@ class SdkConfigViewModel(private val provider: RadioClientProvider) : ViewModel( /** Convenience: update LoRa config. */ fun setLoraConfig(lora: Config.LoRaConfig) = setConfig(Config(lora = lora), "lora") - /** - * Update owner name on the radio. - * - * Gap G: `ownNode` StateFlow on RadioClient is not refreshed after setOwner either — same root cause as config - * writes. - */ + /** Update owner name on the radio. */ fun setOwner(longName: String, shortName: String) { val client = provider.client.value ?: return viewModelScope.launch { @@ -145,22 +114,6 @@ class SdkConfigViewModel(private val provider: RadioClientProvider) : ViewModel( } } - /** - * Load all 8 channels via serial RPCs. - * - * Gap C: No reactive `client.channels: StateFlow>` — only 8 serial RPCs via [AdminApi.listChannels]. - * Callers must re-request on every mount. SDK fix: cache channels from storage during handshake and expose as - * StateFlow. - */ - fun loadChannels(onResult: (AdminResult>) -> Unit) { - val client = provider.client.value ?: return - viewModelScope.launch { - val result = client.admin.listChannels() - Logger.i { "[SDK] listChannels → $result" } - onResult(result) - } - } - /** * Diagnostics: log the full ConfigBundle contents. Useful for POC validation — call from a debug menu or * LaunchedEffect. diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt index d9aece6807..684af8f12e 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt @@ -32,14 +32,18 @@ import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.KoinViewModel import org.meshtastic.proto.NodeInfo +import org.meshtastic.sdk.ConnectionQuality +import org.meshtastic.sdk.MeshNode import org.meshtastic.sdk.NodeChange import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.SignalQuality +import org.meshtastic.sdk.toMeshNode /** * Stable, Compose-safe representation of a mesh node. * - * Wire-generated [NodeInfo] is NOT `@Stable`; never pass it directly to Compose. This wrapper holds only the - * primitive/String fields the node list UI needs. + * Wire-generated [NodeInfo] is NOT `@Stable`; never pass it directly to Compose. This wrapper holds the + * fields the node list UI needs for display, filtering, and sorting. */ @Immutable data class UiNode( @@ -50,16 +54,45 @@ data class UiNode( val hopsAway: Int?, val lastHeard: Int, val viaMqtt: Boolean, + // Enriched fields + val isOnline: Boolean, + val connectionQuality: ConnectionQuality, + val signalQuality: SignalQuality, + val batteryLevel: Int?, + val voltage: Float?, + val channelUtilization: Float?, + val airUtilTx: Float?, + val latitude: Double?, + val longitude: Double?, + val altitude: Int?, + val isFavorite: Boolean, + val isIgnored: Boolean, + val isMuted: Boolean, + val hwModel: String?, ) -private fun NodeInfo.toUiNode() = UiNode( - num = num, - longName = user?.long_name.orEmpty(), - shortName = user?.short_name.orEmpty(), +private fun MeshNode.toUiNode() = UiNode( + num = nodeNum, + longName = longName ?: "Unknown", + shortName = shortName ?: "?", snr = snr, - hopsAway = hops_away, - lastHeard = last_heard, - viaMqtt = via_mqtt, + hopsAway = hopsAway, + lastHeard = lastHeard, + viaMqtt = viaMqtt, + isOnline = isOnline, + connectionQuality = connectionQuality, + signalQuality = signalQuality, + batteryLevel = batteryLevel, + voltage = voltage, + channelUtilization = channelUtilization, + airUtilTx = airUtilTx, + latitude = latitude, + longitude = longitude, + altitude = altitude, + isFavorite = isFavorite, + isIgnored = isIgnored, + isMuted = isMuted, + hwModel = hwModel?.name, ) /** @@ -100,7 +133,10 @@ class SdkNodeListViewModel(provider: RadioClientProvider) : ViewModel() { is NodeChange.Removed -> acc - change.nodeId } } - .map { nodeMap -> nodeMap.values.map(NodeInfo::toUiNode) } + .map { nodeMap -> + val now = (System.currentTimeMillis() / 1000).toInt() + nodeMap.values.map { it.toMeshNode(now).toUiNode() } + } .flowOn(Dispatchers.Default) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) From f5e2aabc00f1c2a55832aed781216846277bded7 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 4 May 2026 18:36:46 -0500 Subject: [PATCH 04/53] feat: add SdkRadioControllerImpl and SdkStateBridge for SDK hard cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the legacy AIDL-based AndroidRadioControllerImpl with an SDK-backed implementation that delegates all operations through RadioClient: SdkRadioControllerImpl: - Full RadioController interface impl (~35 methods) - Local admin ops → SDK AdminApi/TelemetryApi/RoutingApi - Remote admin ops → raw MeshPacket via RadioClient.send() - Registered as @Single(binds = [RadioController::class]) in Koin SdkStateBridge: - Bridges SDK reactive flows (connection, nodes, packets, events) into ServiceRepository and NodeManager for legacy UI compatibility - Connection state mapping (SDK states → app ConnectionState enum) - Node snapshot/added/updated/removed → NodeManager - Inbound telemetry/position/user packets → NodeManager handlers - Events (reboot, security warnings, drops) → notification layer AndroidRadioControllerImpl: - @Single annotation disabled (commented out) to prevent Koin conflict - Class retained for reference/fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../app/radio/RadioClientProvider.kt | 5 +- .../app/radio/SdkRadioControllerImpl.kt | 475 ++++++++++++++++++ .../meshtastic/app/radio/SdkStateBridge.kt | 178 +++++++ .../service/AndroidRadioControllerImpl.kt | 3 +- 4 files changed, 658 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt create mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt index b3ceeac638..85f548e659 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt @@ -46,7 +46,10 @@ import org.meshtastic.sdk.transport.tcp.TcpTransport * service can react to connection changes with `flatMapLatest`. */ @Single(binds = [SdkClientLifecycle::class]) -class RadioClientProvider(private val context: Context, private val radioPrefs: RadioPrefs) : SdkClientLifecycle { +class RadioClientProvider( + private val context: Context, + private val radioPrefs: RadioPrefs, +) : SdkClientLifecycle { private val _client = MutableStateFlow(null) /** Active [RadioClient], or `null` when disconnected or between connections. */ diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt new file mode 100644 index 0000000000..549155de54 --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt @@ -0,0 +1,475 @@ +/* + * 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 co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.StateFlow +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.koin.core.annotation.Single +import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.NodeRepository +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.Data +import org.meshtastic.proto.MeshPacket +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 +import java.util.concurrent.atomic.AtomicInteger + +/** + * [RadioController] implementation that delegates all operations through the meshtastic-sdk. + * + * This replaces [org.meshtastic.core.service.AndroidRadioControllerImpl] in the hard-cutover POC. Feature modules + * continue injecting [RadioController] and get SDK-backed behavior without code changes. + * + * **Command dispatch:** All admin, telemetry, and routing operations go through [RadioClient.admin], + * [RadioClient.telemetry], and [RadioClient.routing] respectively. + * + * **State distribution:** Handled separately by [SdkStateBridge], which feeds SDK flows back into + * [ServiceRepository] and [org.meshtastic.core.repository.NodeManager]. + */ +@Single(binds = [RadioController::class]) +@Suppress("TooManyFunctions", "LongParameterList") +class SdkRadioControllerImpl( + private val provider: RadioClientProvider, + private val serviceRepository: ServiceRepository, + private val nodeRepository: NodeRepository, + private val locationManager: MeshLocationManager, +) : RadioController { + + private val packetIdCounter = AtomicInteger(1) + + private val client: RadioClient? + get() = provider.client.value + + private fun requireClient(): RadioClient { + return client ?: run { + Logger.w { "SdkRadioControllerImpl: 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 destNum = when (packet.to) { + null, DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST + else -> DataPacket.idToDefaultNodeNum(packet.to?.removePrefix("!")) ?: DataPacket.NODENUM_BROADCAST + } + val meshPacket = MeshPacket( + to = destNum, + channel = packet.channel, + decoded = Data( + portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP, + payload = packet.bytes ?: ByteString.EMPTY, + want_response = false, + ), + ) + c.send(meshPacket) + } + + // ── 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, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.setOwner(user) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(set_owner = user)) + } + } + + override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.setConfig(config) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(set_config = config)) + } + } + + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.setModuleConfig(config) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(set_module_config = config)) + } + } + + override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.setChannel(channel) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(set_channel = channel)) + } + } + + 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, + ) + if (isLocalNode(destNum)) { + c.admin.setFixedPosition(protoPos) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(set_fixed_position = protoPos)) + } + } + + override suspend fun setRingtone(destNum: Int, ringtone: String) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.setRingtone(ringtone) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(set_ringtone_message = ringtone)) + } + } + + override suspend fun setCannedMessages(destNum: Int, messages: String) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.setCannedMessages(messages) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(set_canned_message_module_messages = messages)) + } + } + + // ── Remote admin (getters) ────────────────────────────────────────────── + + override suspend fun getOwner(destNum: Int, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.getOwner() + } else { + sendRemoteAdmin(c, destNum, AdminMessage(get_owner_request = true), wantResponse = true) + } + } + + override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { + val c = requireClient() + val type = AdminMessage.ConfigType.fromValue(configType) ?: return + if (isLocalNode(destNum)) { + c.admin.getConfig(type) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(get_config_request = type), wantResponse = true) + } + } + + override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) { + val c = requireClient() + val type = AdminMessage.ModuleConfigType.fromValue(moduleConfigType) ?: return + if (isLocalNode(destNum)) { + c.admin.getModuleConfig(type) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(get_module_config_request = type), wantResponse = true) + } + } + + override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.getChannel(ChannelIndex(index)) + } else { + sendRemoteAdmin(c, destNum, AdminMessage(get_channel_request = index + 1), wantResponse = true) + } + } + + override suspend fun getRingtone(destNum: Int, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.getRingtone() + } else { + sendRemoteAdmin(c, destNum, AdminMessage(get_ringtone_request = true), wantResponse = true) + } + } + + override suspend fun getCannedMessages(destNum: Int, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.getCannedMessages() + } else { + sendRemoteAdmin(c, destNum, AdminMessage(get_canned_message_module_messages_request = true), wantResponse = true) + } + } + + override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.getDeviceConnectionStatus() + } else { + sendRemoteAdmin(c, destNum, AdminMessage(get_device_connection_status_request = true), wantResponse = true) + } + } + + // ── Lifecycle commands ─────────────────────────────────────────────────── + + override suspend fun reboot(destNum: Int, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.reboot() + } else { + sendRemoteAdmin(c, destNum, AdminMessage(reboot_seconds = 0)) + } + } + + override suspend fun rebootToDfu(nodeNum: Int) { + val c = requireClient() + if (isLocalNode(nodeNum)) { + c.admin.enterDfuMode() + } else { + sendRemoteAdmin(c, nodeNum, AdminMessage(enter_dfu_mode_request = true)) + } + } + + override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.rebootOta() + } else { + sendRemoteAdmin(c, destNum, AdminMessage(reboot_ota_seconds = 0)) + } + } + + override suspend fun shutdown(destNum: Int, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.shutdown() + } else { + sendRemoteAdmin(c, destNum, AdminMessage(shutdown_seconds = 0)) + } + } + + override suspend fun factoryReset(destNum: Int, packetId: Int) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.factoryReset() + } else { + sendRemoteAdmin(c, destNum, AdminMessage(factory_reset_config = 1)) + } + } + + override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) { + val c = requireClient() + if (isLocalNode(destNum)) { + c.admin.nodeDbReset() + } else { + sendRemoteAdmin(c, destNum, AdminMessage(nodedb_reset = true)) + } + } + + override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) { + val c = requireClient() + c.admin.removeNode(NodeId(nodeNum)) + } + + // ── 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 + // Send an empty NODEINFO_APP packet with want_response to request user info + c.send( + MeshPacket( + to = destNum, + want_ack = true, + decoded = Data( + portnum = PortNum.NODEINFO_APP, + payload = byteArrayOf().toByteString(), + want_response = true, + ), + ), + ) + } + + override suspend fun requestTraceroute(requestId: Int, destNum: Int) { + val c = requireClient() + c.routing.traceRoute(NodeId(destNum)) + } + + override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { + val c = requireClient() + val node = NodeId(destNum) + // TelemetryType enum values: 0=DEVICE, 1=ENVIRONMENT, 2=AIR_QUALITY, 3=POWER, 4=LOCAL_STATS, 5=HEALTH + when (typeValue) { + 0 -> c.telemetry.requestDevice(node) + 1 -> c.telemetry.requestEnvironment(node) + 2 -> c.telemetry.requestAirQuality(node) + 3 -> c.telemetry.requestPower(node) + 4 -> c.telemetry.requestLocalStats() + 5 -> c.telemetry.requestHealth(node) + 6 -> c.telemetry.requestHost(node) + 7 -> c.telemetry.requestTrafficManagement(node) + else -> Logger.w { "Unknown telemetry type: $typeValue" } + } + } + + override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) { + val c = requireClient() + c.routing.requestNeighborInfo(NodeId(destNum)) + } + + // ── Edit settings (transactional) ─────────────────────────────────────── + + override suspend fun beginEditSettings(destNum: Int) { + val c = client ?: return + // Send raw begin_edit_settings admin message for compatibility with the split begin/commit pattern + val msg = AdminMessage(begin_edit_settings = true) + val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum) + sendRemoteAdmin(c, target.raw, msg) + } + + override suspend fun commitEditSettings(destNum: Int) { + val c = client ?: return + val msg = AdminMessage(commit_edit_settings = true) + val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum) + sendRemoteAdmin(c, target.raw, msg) + } + + // ── Utility ───────────────────────────────────────────────────────────── + + override fun getPacketId(): Int = packetIdCounter.getAndIncrement() + + override fun startProvideLocation() { + // Location provision is managed at the app level; no-op until bridge wires it + } + + override fun stopProvideLocation() { + locationManager.stop() + } + + override fun setDeviceAddress(address: String) { + // Changing device address requires rebuilding the SDK client connection + provider.rebuildAndConnectAsync() + } + + // ── Private helpers ───────────────────────────────────────────────────── + + private fun isLocalNode(destNum: Int): Boolean { + if (destNum == 0) return true + val ownNum = client?.ownNode?.value?.num ?: return true + return destNum == ownNum + } + + /** + * Sends a raw admin message to a remote node via the SDK's send path. + * Used for remote-admin operations where destNum != local node. + */ + private suspend fun sendRemoteAdmin( + c: RadioClient, + destNum: Int, + adminMsg: AdminMessage, + wantResponse: Boolean = false, + ) { + val payload = AdminMessage.ADAPTER.encode(adminMsg).toByteString() + c.send( + MeshPacket( + to = destNum, + want_ack = true, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = payload, + want_response = wantResponse, + ), + ), + ) + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt new file mode 100644 index 0000000000..d1fbed791c --- /dev/null +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt @@ -0,0 +1,178 @@ +/* + * 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 co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +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 org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.ConnectionState as AppConnectionState +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User +import org.meshtastic.proto.Position as ProtoPosition +import org.meshtastic.sdk.ConnectionState as SdkConnectionState +import org.meshtastic.sdk.MeshEvent +import org.meshtastic.sdk.NodeChange + +/** + * Bridges SDK reactive flows into the legacy repository layer. + * + * The SDK owns the transport and all state; this bridge maps SDK emissions into [ServiceRepository] + * and [NodeManager] so that existing feature-module UI code (which observes those repositories) + * continues to work without modification. + * + * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientProvider.client] + * and starts/stops collection as clients come and go. No explicit lifecycle management needed. + * + * **Mapping policy:** + * - SDK `Connected` → app `Connected` + * - SDK `Connecting` / `Configuring` → app `Connecting` + * - SDK `Reconnecting` → app `DeviceSleep` (firmware went to sleep or transport dropped) + * - SDK `Disconnected` → app `Disconnected` + */ +@Single +class SdkStateBridge( + private val provider: RadioClientProvider, + private val serviceRepository: ServiceRepository, + private val nodeManager: NodeManager, + private val dispatchers: CoroutineDispatchers, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) + + init { + startBridge() + } + + private fun startBridge() { + + // ── Connection state bridge ───────────────────────────────────────── + provider.client + .flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) } + .onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) } + .launchIn(scope) + + // ── Node updates bridge ───────────────────────────────────────────── + provider.client + .flatMapLatest { client -> client?.nodes ?: flowOf() } + .onEach { change -> + when (change) { + is NodeChange.Snapshot -> { + nodeManager.clear() + change.nodes.forEach { (_, nodeInfo) -> + nodeManager.installNodeInfo(nodeInfo, withBroadcast = false) + } + nodeManager.setNodeDbReady(true) + } + is NodeChange.Added -> { + nodeManager.installNodeInfo(change.node, withBroadcast = true) + } + is NodeChange.Updated -> { + nodeManager.installNodeInfo(change.node, withBroadcast = true) + } + is NodeChange.Removed -> { + nodeManager.removeByNodenum(change.nodeId.raw) + } + } + } + .launchIn(scope) + + // ── Own node identity bridge ──────────────────────────────────────── + provider.client + .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } + .onEach { ownNode -> + if (ownNode != null) { + nodeManager.setMyNodeNum(ownNode.num) + } + } + .launchIn(scope) + + // ── Inbound packet bridge (telemetry, position, user updates) ─────── + provider.client + .flatMapLatest { client -> client?.packets ?: flowOf() } + .onEach { packet -> + val decoded = packet.decoded ?: return@onEach + val fromNum = packet.from + val myNodeNum = nodeManager.myNodeNum.value ?: 0 + val payloadBytes = decoded.payload?.toByteArray() ?: return@onEach + + when (decoded.portnum) { + PortNum.TELEMETRY_APP -> { + runCatching { Telemetry.ADAPTER.decode(payloadBytes) } + .onSuccess { nodeManager.handleReceivedTelemetry(fromNum, it) } + } + PortNum.POSITION_APP -> { + runCatching { ProtoPosition.ADAPTER.decode(payloadBytes) } + .onSuccess { nodeManager.handleReceivedPosition(fromNum, myNodeNum, it, packet.rx_time.toLong()) } + } + PortNum.NODEINFO_APP -> { + runCatching { User.ADAPTER.decode(payloadBytes) } + .onSuccess { nodeManager.handleReceivedUser(fromNum, it, packet.channel) } + } + else -> { + // Other port types are handled directly by feature VMs via SDK flows + } + } + } + .launchIn(scope) + + // ── Events bridge (notifications, warnings) ───────────────────────── + provider.client + .flatMapLatest { client -> client?.events ?: flowOf() } + .onEach { event -> + when (event) { + is MeshEvent.DeviceRebooted -> { + Logger.i { "[SdkBridge] Device rebooted" } + serviceRepository.setClientNotification( + ClientNotification(message = "Device rebooted"), + ) + } + 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" } + } + } + } + .launchIn(scope) + + Logger.i { "SdkStateBridge started" } + } + + companion object { + /** Map SDK connection states to the app's legacy connection states. */ + fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) { + is SdkConnectionState.Disconnected -> AppConnectionState.Disconnected + is SdkConnectionState.Connecting -> AppConnectionState.Connecting + is SdkConnectionState.Configuring -> AppConnectionState.Connecting + is SdkConnectionState.Connected -> AppConnectionState.Connected + is SdkConnectionState.Reconnecting -> AppConnectionState.DeviceSleep + } + } +} 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 index af7cb85c20..9c413f6c78 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt @@ -20,7 +20,6 @@ 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 @@ -40,7 +39,7 @@ import org.meshtastic.proto.User * All radio commands are forwarded through [AndroidServiceRepository.meshService]. If the service is not yet bound, * commands are silently dropped with a warning log. */ -@Single +// @Single — disabled for SDK hard-cutover POC; SdkRadioControllerImpl provides RadioController instead @Suppress("TooManyFunctions") class AndroidRadioControllerImpl( private val context: Context, From 9319b0c3a39b34d055696c2e1170e0dc1543c994 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 4 May 2026 18:57:46 -0500 Subject: [PATCH 05/53] =?UTF-8?q?refactor:=20tighten=20SDK=20integration?= =?UTF-8?q?=20=E2=80=94=20eliminate=20old=20pipeline=20indirection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SdkStateBridge: - Remove redundant manual proto decode (telemetry/position/user) — SDK's NodeChange flow already provides updated NodeInfo with all fields - Forward raw packets to meshPacketFlow for RadioConfigViewModel + TAK - Handle ServiceActions directly via SDK AdminApi (Favorite, Ignore, Mute, Reaction, ImportContact, SendContact, GetDeviceMetadata) - Bypasses entire CommandSender → MeshActionHandler → MeshRouter chain MeshServiceOrchestrator: - Remove radioInterfaceService.connect() — SDK owns transport now - Remove receivedData → messageProcessor wiring — SDK handles packets - Remove serviceAction → actionHandler subscription — SdkStateBridge handles - Remove resetReceivedBuffer — no raw byte pipeline to drain - Retain: TAK integration, database init, service notifications - Remove unused constructor params (serviceRepository, messageProcessor, router) Architecture after this commit: - All in-app flows: Feature VM → RadioController → SdkRadioControllerImpl → SDK - ServiceActions: UI → ServiceRepository.onServiceAction → SdkStateBridge → SDK - AIDL binder: only remaining path to old CommandSender/MeshActionHandler (for external 3rd-party app compat only) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/app/radio/SdkStateBridge.kt | 193 ++++++++++++------ .../core/service/MeshServiceOrchestrator.kt | 62 ++---- .../service/MeshServiceOrchestratorTest.kt | 160 ++------------- 3 files changed, 160 insertions(+), 255 deletions(-) diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt index d1fbed791c..d9395ab7a7 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt @@ -23,41 +23,54 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState as AppConnectionState +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.ClientNotification +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum -import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User -import org.meshtastic.proto.Position as ProtoPosition +import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.ConnectionState as SdkConnectionState import org.meshtastic.sdk.MeshEvent import org.meshtastic.sdk.NodeChange +import org.meshtastic.sdk.NodeId /** - * Bridges SDK reactive flows into the legacy repository layer. + * Bridges SDK reactive flows into the legacy repository layer and routes [ServiceAction]s + * directly through the SDK, bypassing the old CommandSender/MeshActionHandler pipeline. * * The SDK owns the transport and all state; this bridge maps SDK emissions into [ServiceRepository] * and [NodeManager] so that existing feature-module UI code (which observes those repositories) * continues to work without modification. * - * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientProvider.client] - * and starts/stops collection as clients come and go. No explicit lifecycle management needed. + * **Node state:** The SDK's [NodeChange] flow provides fully-updated [NodeInfo] instances that + * already include position, telemetry, and user changes. No manual packet decoding is needed. + * + * **Packets:** Raw [MeshPacket]s are forwarded to [ServiceRepository.emitMeshPacket] for + * consumers that need them (RadioConfigViewModel admin responses, TAK integration). + * + * **ServiceActions:** Handled inline via SDK [AdminApi] — eliminates the old + * MeshServiceOrchestrator → MeshActionHandler → CommandSender dispatch chain. * - * **Mapping policy:** - * - SDK `Connected` → app `Connected` - * - SDK `Connecting` / `Configuring` → app `Connecting` - * - SDK `Reconnecting` → app `DeviceSleep` (firmware went to sleep or transport dropped) - * - SDK `Disconnected` → app `Disconnected` + * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientProvider.client] + * and starts/stops collection as clients come and go. */ @Single +@Suppress("TooManyFunctions") class SdkStateBridge( private val provider: RadioClientProvider, private val serviceRepository: ServiceRepository, private val nodeManager: NodeManager, + private val packetRepository: Lazy, private val dispatchers: CoroutineDispatchers, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) @@ -68,13 +81,13 @@ class SdkStateBridge( private fun startBridge() { - // ── Connection state bridge ───────────────────────────────────────── + // ── Connection state ──────────────────────────────────────────────── provider.client .flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) } .onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) } .launchIn(scope) - // ── Node updates bridge ───────────────────────────────────────────── + // ── Node updates (position, telemetry, user all included in NodeInfo) ─ provider.client .flatMapLatest { client -> client?.nodes ?: flowOf() } .onEach { change -> @@ -86,59 +99,26 @@ class SdkStateBridge( } nodeManager.setNodeDbReady(true) } - is NodeChange.Added -> { - nodeManager.installNodeInfo(change.node, withBroadcast = true) - } - is NodeChange.Updated -> { - nodeManager.installNodeInfo(change.node, withBroadcast = true) - } - is NodeChange.Removed -> { - nodeManager.removeByNodenum(change.nodeId.raw) - } + is NodeChange.Added -> nodeManager.installNodeInfo(change.node, withBroadcast = true) + is NodeChange.Updated -> nodeManager.installNodeInfo(change.node, withBroadcast = true) + is NodeChange.Removed -> nodeManager.removeByNodenum(change.nodeId.raw) } } .launchIn(scope) - // ── Own node identity bridge ──────────────────────────────────────── + // ── Own node identity ─────────────────────────────────────────────── provider.client .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } - .onEach { ownNode -> - if (ownNode != null) { - nodeManager.setMyNodeNum(ownNode.num) - } - } + .onEach { ownNode -> if (ownNode != null) nodeManager.setMyNodeNum(ownNode.num) } .launchIn(scope) - // ── Inbound packet bridge (telemetry, position, user updates) ─────── + // ── Raw packet forward (for RadioConfigViewModel + TAK) ───────────── provider.client .flatMapLatest { client -> client?.packets ?: flowOf() } - .onEach { packet -> - val decoded = packet.decoded ?: return@onEach - val fromNum = packet.from - val myNodeNum = nodeManager.myNodeNum.value ?: 0 - val payloadBytes = decoded.payload?.toByteArray() ?: return@onEach - - when (decoded.portnum) { - PortNum.TELEMETRY_APP -> { - runCatching { Telemetry.ADAPTER.decode(payloadBytes) } - .onSuccess { nodeManager.handleReceivedTelemetry(fromNum, it) } - } - PortNum.POSITION_APP -> { - runCatching { ProtoPosition.ADAPTER.decode(payloadBytes) } - .onSuccess { nodeManager.handleReceivedPosition(fromNum, myNodeNum, it, packet.rx_time.toLong()) } - } - PortNum.NODEINFO_APP -> { - runCatching { User.ADAPTER.decode(payloadBytes) } - .onSuccess { nodeManager.handleReceivedUser(fromNum, it, packet.channel) } - } - else -> { - // Other port types are handled directly by feature VMs via SDK flows - } - } - } + .onEach { packet -> serviceRepository.emitMeshPacket(packet) } .launchIn(scope) - // ── Events bridge (notifications, warnings) ───────────────────────── + // ── Events (notifications, security, backpressure) ────────────────── provider.client .flatMapLatest { client -> client?.events ?: flowOf() } .onEach { event -> @@ -149,24 +129,109 @@ class SdkStateBridge( ClientNotification(message = "Device rebooted"), ) } - 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" } - } + 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" } } } .launchIn(scope) - Logger.i { "SdkStateBridge started" } + // ── ServiceAction routing (replaces MeshServiceOrchestrator dispatch) ─ + serviceRepository.serviceAction + .onEach { action -> handleServiceAction(action) } + .launchIn(scope) + + Logger.i { "SdkStateBridge started — SDK owns transport + ServiceAction dispatch" } + } + + // ── ServiceAction handling ─────────────────────────────────────────────── + + private suspend fun handleServiceAction(action: ServiceAction) { + val client = provider.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 + } + + when (action) { + is ServiceAction.Favorite -> { + val node = action.node + client.admin.setFavorite(NodeId(node.num), !node.isFavorite) + nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } + } + + is ServiceAction.Ignore -> { + val node = action.node + val newIgnored = !node.isIgnored + client.admin.setIgnored(NodeId(node.num), newIgnored) + nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnored) } + packetRepository.value.updateFilteredBySender(node.user.id, newIgnored) + } + + is ServiceAction.Mute -> { + val node = action.node + client.admin.toggleMuted(NodeId(node.num)) + nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } + } + + is ServiceAction.Reaction -> { + val channel = action.contactKey[0].digitToInt() + val destId = action.contactKey.substring(1) + val destNum = DataPacket.idToDefaultNodeNum(destId.removePrefix("!")) + ?: DataPacket.NODENUM_BROADCAST + client.send( + MeshPacket( + to = destNum, + channel = channel, + want_ack = true, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = action.emoji.encodeToByteArray().toByteString(), + emoji = EMOJI_INDICATOR, + reply_id = action.replyId, + ), + ), + ) + } + + is ServiceAction.ImportContact -> { + val verified = action.contact.copy(manually_verified = true) + client.admin.addContact(verified) + nodeManager.handleReceivedUser( + verified.node_num, + verified.user ?: User(), + manuallyVerified = true, + ) + } + + is ServiceAction.SendContact -> { + val result = runCatching { client.admin.addContact(action.contact) } + action.result.complete(result.getOrNull() is AdminResult.Success) + } + + is ServiceAction.GetDeviceMetadata -> { + val payload = AdminMessage.ADAPTER.encode( + AdminMessage(get_device_metadata_request = true), + ).toByteString() + client.send( + MeshPacket( + to = action.destNum, + want_ack = true, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = payload, + want_response = true, + ), + ), + ) + } + } } companion object { - /** Map SDK connection states to the app's legacy connection states. */ + private const val EMOJI_INDICATOR = 1 + fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) { is SdkConnectionState.Disconnected -> AppConnectionState.Disconnected is SdkConnectionState.Connecting -> AppConnectionState.Connecting 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..bc46f452c5 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,25 +17,20 @@ 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.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServerManager @@ -52,10 +47,7 @@ import org.meshtastic.core.takserver.TAKServerManager @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 serviceNotifications: MeshServiceNotifications, private val takServerManager: TAKServerManager, private val takMeshIntegration: TAKMeshIntegration, @@ -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,15 +81,10 @@ 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() @@ -110,31 +102,14 @@ 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. + // Ensure the per-device database is active before SDK connects. databaseManager.switchActiveDatabase(radioInterfaceService.getDeviceAddress()) - Logger.i { "Per-device database initialized, connecting radio" } - radioInterfaceService.connect() + 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) + // NOTE: Radio connection, packet routing, and ServiceAction dispatch are now handled + // by RadioClientProvider + SdkStateBridge. The old radioInterfaceService.connect() / + // receivedData / serviceAction subscription paths are no longer needed. nodeManager.loadCachedNodeDB() } @@ -142,22 +117,13 @@ class MeshServiceOrchestrator( /** * 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/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index 87109be1ec..545b5b5cb1 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,14 +30,9 @@ 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.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository @@ -57,62 +50,49 @@ 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 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) + // TAKMeshIntegration deps (final class — constructed directly) + private val commandSender: CommandSender = mock(MockMode.autofill) + private val nodeRepository: NodeRepository = mock(MockMode.autofill) + private val serviceRepository: ServiceRepository = mock(MockMode.autofill) + private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill) + private val cotHandler: CoTHandler = mock(MockMode.autofill) + @OptIn(ExperimentalCoroutinesApi::class) private val testDispatcher = UnconfinedTestDispatcher() @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 { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig()) every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) - every { router.actionHandler } returns actionHandler + every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() - val takMeshIntegration = - TAKMeshIntegration( - takServerManager = takServerManager, - commandSender = commandSender, - nodeRepository = nodeRepository, - serviceRepository = serviceRepository, - meshConfigHandler = meshConfigHandler, - cotHandler = cotHandler, - ) + val takMeshIntegration = TAKMeshIntegration( + takServerManager = takServerManager, + commandSender = commandSender, + nodeRepository = nodeRepository, + serviceRepository = serviceRepository, + meshConfigHandler = meshConfigHandler, + cotHandler = cotHandler, + ) return MeshServiceOrchestrator( radioInterfaceService = radioInterfaceService, - serviceRepository = serviceRepository, nodeManager = nodeManager, - messageProcessor = messageProcessor, - router = router, serviceNotifications = serviceNotifications, takServerManager = takServerManager, takMeshIntegration = takMeshIntegration, @@ -169,37 +149,6 @@ class MeshServiceOrchestratorTest { 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() } @@ -222,79 +171,4 @@ class MeshServiceOrchestratorTest { 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() - } } From 2162e0a3401bb5e0a4d067b5f31a801f43d41a64 Mon Sep 17 00:00:00 2001 From: James Rich Date: Mon, 4 May 2026 19:32:03 -0500 Subject: [PATCH 06/53] =?UTF-8?q?feat:=20full=20SDK=20integration=20?= =?UTF-8?q?=E2=80=94=20drop=20AIDL,=20eliminate=20pipeline,=20SDK-backed?= =?UTF-8?q?=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Drop AIDL - Delete all 6 .aidl files (core/api/src/main/aidl/) - Strip binder object from MeshService (onBind returns null) - Simplify MeshServiceClient to lifecycle observer (no AIDL binding) - Remove meshService field from AndroidServiceRepository - Delete ServiceClient, AndroidRadioControllerImpl, FakeIMeshService, IMeshServiceContractTest Phase 2: Migrate TAK to RadioController - GenericCoTHandler: RadioController replaces CommandSender - TAKMeshIntegration: RadioController replaces CommandSender - CoreTakServerModule: DI updated Phase 3: Port remaining consumers - MeshConnectionManagerImpl: RadioController, remove time sync/passkey (SDK handles internally), wrap telemetry in coroutine - MeshConfigFlowManagerImpl: remove CommandSender dependency, use 0L for currentPacketId - RefreshLocalStatsAction: RadioController for widget telemetry - EnsureRemoteAdminSessionUseCase: serviceRepository.onServiceAction() replaces meshActionHandler Phase 4: Delete dead pipeline - Delete FromRadioPacketHandler interface + FromRadioPacketHandlerImpl - Delete MeshMessageProcessor interface + MeshMessageProcessorImpl - Delete related tests - Remove MeshMessageProcessor dep from MeshActionHandlerImpl - Keep CommandSender/MeshRouter/MeshActionHandler for DirectRadioControllerImpl (desktop target) Phase 5: SDK-backed NodeRepository - New SdkNodeRepositoryImpl: in-memory StateFlow backed by NodeManager - SDK handles persistence via its own SqlDelight storage - Deactivate Room-backed NodeRepositoryImpl (@Single removed) - NodeManagerImpl propagates myNodeNum to SdkNodeRepositoryImpl - Cold start: brief empty state until SDK emits snapshot (<1s) - Node notes: in-memory for POC (does not survive process death) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/core/model/DataPacket.aidl | 3 - .../org/meshtastic/core/model/MeshUser.aidl | 3 - .../org/meshtastic/core/model/MyNodeInfo.aidl | 3 - .../org/meshtastic/core/model/NodeInfo.aidl | 3 - .../org/meshtastic/core/model/Position.aidl | 3 - .../meshtastic/core/service/IMeshService.aidl | 207 ---------- .../manager/FromRadioPacketHandlerImpl.kt | 156 -------- .../data/manager/MeshActionHandlerImpl.kt | 3 - .../data/manager/MeshConfigFlowManagerImpl.kt | 4 +- .../data/manager/MeshConnectionManagerImpl.kt | 35 +- .../data/manager/MeshMessageProcessorImpl.kt | 298 --------------- .../core/data/manager/NodeManagerImpl.kt | 3 + .../data/repository/NodeRepositoryImpl.kt | 2 +- .../data/repository/SdkNodeRepositoryImpl.kt | 224 +++++++++++ .../manager/FromRadioPacketHandlerImplTest.kt | 179 --------- .../data/manager/MeshActionHandlerImplTest.kt | 4 - .../manager/MeshConfigFlowManagerImplTest.kt | 4 - .../manager/MeshConnectionManagerImplTest.kt | 9 +- .../manager/MeshMessageProcessorImplTest.kt | 356 ------------------ .../EnsureRemoteAdminSessionUseCase.kt | 4 +- .../EnsureRemoteAdminSessionUseCaseTest.kt | 21 +- .../core/repository/FromRadioPacketHandler.kt | 25 -- .../core/repository/MeshMessageProcessor.kt | 31 -- .../core/service/IMeshServiceContractTest.kt | 42 --- .../service/AndroidRadioControllerImpl.kt | 222 ----------- .../core/service/AndroidServiceRepository.kt | 17 +- .../meshtastic/core/service/MeshService.kt | 222 +---------- .../core/service/MeshServiceClient.kt | 69 +--- .../meshtastic/core/service/ServiceClient.kt | 145 ------- .../core/service/testing/FakeIMeshService.kt | 128 ------- .../service/MeshServiceOrchestratorTest.kt | 6 +- .../core/takserver/TAKMeshIntegration.kt | 6 +- .../core/takserver/di/CoreTakServerModule.kt | 10 +- .../takserver/fountain/GenericCoTHandler.kt | 14 +- .../feature/widget/RefreshLocalStatsAction.kt | 8 +- 35 files changed, 304 insertions(+), 2165 deletions(-) delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl delete mode 100644 core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshMessageProcessor.kt delete mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/IMeshServiceContractTest.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceClient.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/testing/FakeIMeshService.kt 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/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/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt index e16852d251..4595a6de47 100644 --- 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 @@ -37,7 +37,6 @@ 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 @@ -67,7 +66,6 @@ class MeshActionHandlerImpl( 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 { @@ -394,7 +392,6 @@ class MeshActionHandlerImpl( 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 index 9e32381861..5c39dedfa6 100644 --- 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 @@ -25,7 +25,6 @@ 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 @@ -54,7 +53,6 @@ class MeshConfigFlowManagerImpl( 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, @@ -278,7 +276,7 @@ class MeshConfigFlowManagerImpl( firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() }, couldUpdate = false, shouldUpdate = false, - currentPacketId = commandSender.getCurrentPacketId() and 0xffffffffL, + currentPacketId = 0L, messageTimeoutMsec = 300000, minAppVersion = min_app_version, maxChannels = 8, 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 index a62cb5bedc..dc1f129182 100644 --- 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 @@ -33,10 +33,11 @@ import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceType +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.repository.AppWidgetUpdater -import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.DataPair import org.meshtastic.core.repository.HandshakeConstants import org.meshtastic.core.repository.HistoryManager @@ -56,7 +57,6 @@ 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 @@ -79,7 +79,7 @@ class MeshConnectionManagerImpl( private val mqttManager: MqttManager, private val historyManager: HistoryManager, private val radioConfigRepository: RadioConfigRepository, - private val commandSender: CommandSender, + private val radioController: RadioController, private val sessionManager: SessionManager, private val nodeManager: NodeManager, private val analytics: PlatformAnalytics, @@ -128,7 +128,15 @@ class MeshConnectionManagerImpl( .shouldProvideNodeLocation(myNodeEntity.myNodeNum) .onEach { shouldProvide -> if (shouldProvide) { - locationManager.start(scope) { pos -> commandSender.sendPosition(pos) } + locationManager.start(scope) { pos -> + scope.handledLaunch { + val packet = DataPacket( + bytes = okio.ByteString.of(*org.meshtastic.proto.Position.ADAPTER.encode(pos)), + dataType = org.meshtastic.proto.PortNum.POSITION_APP.value, + ) + radioController.sendMessage(packet) + } + } } else { locationManager.stop() } @@ -325,15 +333,8 @@ class MeshConnectionManagerImpl( 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) } + // NOTE: Time sync and session passkey seeding are handled by the SDK's RadioClient + // during its own handshake — no need to send set_time_only or get_owner_request here. // Start MQTT if enabled scope.handledLaunch { @@ -354,9 +355,11 @@ class MeshConnectionManagerImpl( } } - // 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) + // Request immediate LocalStats and DeviceMetrics update on connection + scope.handledLaunch { + radioController.requestTelemetry(myNodeNum.hashCode(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal) + radioController.requestTelemetry(myNodeNum.hashCode() + 1, myNodeNum, TelemetryType.DEVICE.ordinal) + } } private fun reportConnection() { 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/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index c3e21955a1..58b0cad919 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -38,6 +38,7 @@ 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.data.repository.SdkNodeRepositoryImpl import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.ServiceBroadcasts @@ -88,6 +89,8 @@ class NodeManagerImpl( override fun setMyNodeNum(num: Int?) { myNodeNum.value = num + // Propagate to SdkNodeRepositoryImpl so ourNodeInfo/myId reactive flows update + (nodeRepository as? SdkNodeRepositoryImpl)?.setMyNodeNum(num) } override val firmwareEdition = MutableStateFlow(null) 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 index 14cc42b302..09dc86fccb 100644 --- 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 @@ -55,7 +55,7 @@ import org.meshtastic.proto.LocalStats import org.meshtastic.proto.User /** Repository for managing node-related data, including hardware info, node database, and identity. */ -@Single +// @Single — Replaced by SdkNodeRepositoryImpl in SDK mode. Kept for reference/desktop fallback. @Suppress("TooManyFunctions") class NodeRepositoryImpl( @Named("ProcessLifecycle") private val processLifecycle: Lifecycle, 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..f75b65e470 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -0,0 +1,224 @@ +/* + * 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.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.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import org.meshtastic.core.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.onlineTimeThreshold +import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.User + +/** + * SDK-backed [NodeRepository] implementation using in-memory StateFlows. + * + * The SDK manages node persistence internally via its SqlDelight storage layer. + * This repository stores nodes in-memory and is populated by [NodeManager] via the + * SDK's NodeChange flow (bridged through SdkStateBridge). + * + * Cold start: nodes are empty until the SDK emits its snapshot from storage (<1s). + * Node notes: stored in-memory for this POC (will not survive process death). + */ +@Single(binds = [NodeRepository::class]) +@Suppress("TooManyFunctions") +class SdkNodeRepositoryImpl( + private val localStatsDataSource: LocalStatsDataSource, + @Named("ServiceScope") private val scope: CoroutineScope, +) : NodeRepository { + + private val _nodeDBbyNum = MutableStateFlow>(emptyMap()) + private val _myNodeInfo = MutableStateFlow(null) + private val _myNodeNum = MutableStateFlow(null) + + // Local-only notes storage (in-memory for POC; does not survive process death) + private val nodeNotes = MutableStateFlow>(emptyMap()) + + 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.Eagerly, null) + + override val myId: StateFlow = + ourNodeInfo.map { it?.user?.id } + .stateIn(scope, SharingStarted.Eagerly, null) + + override val localStats: StateFlow = + localStatsDataSource.localStatsFlow + .stateIn(scope, SharingStarted.Eagerly, 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 = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId)) + + override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(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.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) + } + + 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() + } + + override suspend fun upsert(node: Node) { + _nodeDBbyNum.update { map -> map + (node.num to node) } + // Also keep _myNodeNum consistent + if (node.num == _myNodeNum.value) { + // ourNodeInfo will auto-update via combine + } + } + + 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 } + nodeNotes.update { it - num } + } + + override suspend fun deleteNodes(nodeNums: List) { + _nodeDBbyNum.update { map -> map - nodeNums.toSet() } + nodeNotes.update { notes -> notes - nodeNums.toSet() } + } + + 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) { + nodeNotes.update { it + (num to notes) } + _nodeDBbyNum.update { map -> + val node = map[num] ?: return@update map + map + (num to node.copy(notes = notes)) + } + } + + override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { + _nodeDBbyNum.update { map -> + val node = map[nodeNum] ?: return@update map + map + (nodeNum to node.copy(metadata = metadata)) + } + } + + /** Called by [NodeManager] to set the local node number for ourNodeInfo/myId derivation. */ + fun setMyNodeNum(num: Int?) { + _myNodeNum.value = 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 } // simplified — no GPS-based distance in POC + 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/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/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt index 5b29e9f262..9e28e74818 100644 --- 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 @@ -39,7 +39,6 @@ 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 @@ -72,7 +71,6 @@ class MeshActionHandlerImplTest { 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) @@ -105,7 +103,6 @@ class MeshActionHandlerImplTest { uiPrefs = uiPrefs, databaseManager = databaseManager, notificationManager = notificationManager, - messageProcessor = lazy { messageProcessor }, radioConfigRepository = radioConfigRepository, scope = scope, ) @@ -124,7 +121,6 @@ class MeshActionHandlerImplTest { verify { meshPrefs.setDeviceAddress("new_addr") } verify { nodeManager.clear() } - verifySuspend { messageProcessor.clearEarlyPackets() } verifySuspend { databaseManager.switchActiveDatabase("new_addr") } verify { notificationManager.cancelAll() } verify { nodeManager.loadCachedNodeDB() } 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 index af0925d38c..5a96722840 100644 --- 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 @@ -32,7 +32,6 @@ 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 @@ -63,7 +62,6 @@ class MeshConfigFlowManagerImplTest { 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) @@ -87,7 +85,6 @@ class MeshConfigFlowManagerImplTest { @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) @@ -103,7 +100,6 @@ class MeshConfigFlowManagerImplTest { serviceRepository = serviceRepository, serviceBroadcasts = serviceBroadcasts, analytics = analytics, - commandSender = commandSender, heartbeatSender = DataLayerHeartbeatSender(packetHandler), notificationPrefs = notificationPrefs, scope = testScope, 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 index fadd19542e..e36efda4fe 100644 --- 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 @@ -35,8 +35,8 @@ import kotlinx.coroutines.test.runTest import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node +import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.AppWidgetUpdater -import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshServiceNotifications @@ -75,7 +75,7 @@ class MeshConnectionManagerImplTest { 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 radioController = mock(MockMode.autofill) private val sessionManager = mock(MockMode.autofill) private val nodeManager = mock(MockMode.autofill) private val analytics = mock(MockMode.autofill) @@ -105,7 +105,6 @@ class MeshConnectionManagerImplTest { 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 @@ -125,7 +124,7 @@ class MeshConnectionManagerImplTest { mqttManager, historyManager, radioConfigRepository, - commandSender, + radioController, sessionManager, nodeManager, analytics, @@ -290,8 +289,8 @@ class MeshConnectionManagerImplTest { store_forward = ModuleConfig.StoreForwardConfig(enabled = true), ) moduleConfigFlow.value = moduleConfig - every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit every { nodeManager.myNodeNum } returns MutableStateFlow(123) + everySuspend { radioController.requestTelemetry(any(), any(), any()) } returns Unit every { mqttManager.startProxy(any(), any()) } returns Unit every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit every { nodeManager.getMyNodeInfo() } returns null 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/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/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt index aa4f0e2eb4..8e25fc0f97 100644 --- 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 @@ -36,7 +36,6 @@ 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 @@ -68,9 +67,8 @@ class EnsureRemoteAdminSessionUseCaseTest { @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) + EnsureRemoteAdminSessionUseCase(sessionManager, connectedRepo(ConnectionState.Disconnected), this) val result = useCase(destNum) @@ -81,8 +79,7 @@ class EnsureRemoteAdminSessionUseCaseTest { 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 useCase = EnsureRemoteAdminSessionUseCase(sessionManager, connectedRepo(), this) val result = useCase(destNum) @@ -93,30 +90,30 @@ class EnsureRemoteAdminSessionUseCaseTest { 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) + val repo = connectedRepo() // Simulate the radio responding by emitting on the refresh flow when the metadata request fires. - everySuspend { handler.onServiceAction(any()) } calls + everySuspend { repo.onServiceAction(any()) } calls { refresh.tryEmit(destNum) Unit } - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) + val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, repo, this) val result = useCase(destNum) assertEquals(EnsureSessionResult.Refreshed, result) - verifySuspend { handler.onServiceAction(ServiceAction.GetDeviceMetadata(destNum)) } + verifySuspend { repo.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 repo = connectedRepo() + everySuspend { repo.onServiceAction(any()) } returns Unit - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, handler, connectedRepo(), this) + val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, repo, this) var observed: EnsureSessionResult? = null val job = launch { observed = useCase(destNum) } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt deleted file mode 100644 index ab3d26abf8..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FromRadioPacketHandler.kt +++ /dev/null @@ -1,25 +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.FromRadio - -/** Interface for dispatching non-packet [FromRadio] variants to their respective handlers. */ -interface FromRadioPacketHandler { - /** Processes a [FromRadio] message. */ - fun handleFromRadio(proto: FromRadio) -} 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/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/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 9c413f6c78..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt +++ /dev/null @@ -1,222 +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.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 — disabled for SDK hard-cutover POC; SdkRadioControllerImpl provides RadioController instead -@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/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 022c2f12f1..307afbc7f8 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,50 +28,25 @@ 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.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 notifications: MeshServiceNotifications by inject() @@ -82,8 +57,6 @@ class MeshService : Service() { private val orchestrator: MeshServiceOrchestrator by inject() - private val router: MeshRouter by inject() - private val dispatchers: CoroutineDispatchers by inject() private val sdkClientLifecycle: SdkClientLifecycle by inject() @@ -93,9 +66,6 @@ class MeshService : Service() { 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) @@ -105,11 +75,6 @@ class MeshService : Service() { 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) } @@ -212,7 +177,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" } @@ -224,185 +189,4 @@ class MeshService : Service() { 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/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/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index 545b5b5cb1..ec30d024c0 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -30,7 +30,7 @@ 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.repository.CommandSender +import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshServiceNotifications @@ -58,7 +58,7 @@ class MeshServiceOrchestratorTest { private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) // TAKMeshIntegration deps (final class — constructed directly) - private val commandSender: CommandSender = mock(MockMode.autofill) + private val radioController: RadioController = mock(MockMode.autofill) private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill) @@ -83,7 +83,7 @@ class MeshServiceOrchestratorTest { val takMeshIntegration = TAKMeshIntegration( takServerManager = takServerManager, - commandSender = commandSender, + radioController = radioController, nodeRepository = nodeRepository, serviceRepository = serviceRepository, meshConfigHandler = meshConfigHandler, 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..6f496e377b 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,7 +28,7 @@ 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.model.RadioController import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository @@ -44,7 +44,7 @@ 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, @@ -135,7 +135,7 @@ class TAKMeshIntegration( 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..f0c8eedda8 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,7 +19,7 @@ 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.model.RadioController import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository @@ -37,20 +37,20 @@ 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, cotHandler: CoTHandler, ): TAKMeshIntegration = TAKMeshIntegration( takServerManager, - commandSender, + radioController, nodeRepository, serviceRepository, meshConfigHandler, 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..374b463053 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, 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" } } @@ -119,7 +119,7 @@ class GenericCoTHandler(private val commandSender: CommandSender, private val ta 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, @@ -195,7 +195,7 @@ class GenericCoTHandler(private val commandSender: CommandSender, private val ta 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/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..e0e2768756 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,15 +23,15 @@ 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 class RefreshLocalStatsAction : ActionCallback, KoinComponent { - private val commandSender: CommandSender by inject() + private val radioController: RadioController by inject() private val nodeManager: NodeManager by inject() override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { @@ -41,7 +41,7 @@ class RefreshLocalStatsAction : return } - commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal) - commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal) + radioController.requestTelemetry(myNodeNum.hashCode(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal) + radioController.requestTelemetry(myNodeNum.hashCode() + 1, myNodeNum, TelemetryType.DEVICE.ordinal) } } From 2c438b9a3fbfb90bf34d3718537b18655f3d1cb7 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 06:11:21 -0500 Subject: [PATCH 07/53] docs: add MIGRATION-REMAINING.md tracking clean-break progress Documents completed phases (A-G partial), remaining blockers, and recommended execution order for full SDK migration. Key remaining items: - Desktop SDK migration (unblocks ~2,000 LOC deletion) - Module restructuring (unblocks VM direct-binding) - 22 VM migrations to RadioClient - 4 deferred UseCase deletions - Phase C completion (packet flow cleanup) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MIGRATION-REMAINING.md | 186 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 MIGRATION-REMAINING.md diff --git a/MIGRATION-REMAINING.md b/MIGRATION-REMAINING.md new file mode 100644 index 0000000000..3a86cf3f0e --- /dev/null +++ b/MIGRATION-REMAINING.md @@ -0,0 +1,186 @@ +# SDK Migration — Remaining Work + +> Auto-generated from migration session. Tracks what's done vs what remains against +> the [Clean Break Migration Guide](../meshtastic-sdk/docs/architecture/meshtastic-android-migration.md). + +--- + +## Summary + +**Completed:** ~70% of the Clean Break migration. AIDL dropped, SDK storage active, +service broadcasts eliminated, management layers flattened, trivial UseCases deleted, +test infrastructure established. + +**Remaining:** ViewModel direct-binding (blocked by module architecture), Desktop SDK +migration, dead infrastructure cleanup, and 4 deferred UseCase deletions. + +**Net change so far:** ~52 files changed, +162 / -2,395 lines across all sessions. + +--- + +## What's Done (by migration doc phase) + +### Phase 1: Environment & Dependency Alignment ✅ +- SDK composite build integrated (`settings.gradle.kts`) +- Wire proto types used throughout (`org.meshtastic.proto.*`) +- `sdk-core`, `sdk-proto`, `sdk-transport-*`, `sdk-storage-sqldelight`, `sdk-testing` all wired + +### Phase 2: One-Time Data Migration ✅ +- Room auto-migration 38→39 with `AutoMigration38to39` spec +- `onPostMigrate` copies favorites/notes/ignored/muted/manuallyVerified from `nodes` → `node_metadata` +- `NodeMetadataEntity` + `NodeMetadataDao` created +- `SdkNodeRepositoryImpl` enriches SDK nodes with persisted metadata + +### Phase 3: The Great Deletion (partial) ✅ +- `ServiceBroadcasts` — deleted (both `core/service` android + `core/repository` common) +- `MeshConnectionManagerImpl` — deleted (438 LOC) +- `MeshConnectionManager` interface — deleted +- `NodeRepositoryImpl` (old Room-backed) — deleted (290 LOC) +- `NodeInfoWriteDataSource` + `SwitchingNodeInfoWriteDataSource` — deleted +- AIDL — already removed in prior session + +### Phase 4: RadioClient as Core Dependency ✅ +- `RadioClientProvider` implemented in `app/` with BLE/TCP/Serial support +- Exposed as `StateFlow` for reactive observation +- Auto-reconnect enabled +- `SdkClientLifecycle` interface bridges to `core/service` without reverse dependency + +### Phase 5: Thin Foreground Service ✅ +- `MeshService` stripped to lifecycle holder + notification management +- Uses `ServiceRepository` for connection state (bridged from SDK) +- `MeshServiceOrchestrator` simplified to TAK lifecycle + notifications + DB init + widget + +### Phase 6: UI & Domain (partial) +- **6.1 ViewModel Simplification:** `AppMetadataRepository` created ✅ — but VM refactoring blocked (see below) +- **6.2 UseCase Decimation:** 8 trivial UseCases deleted ✅ — 4 deferred, complex ones kept + +### Phase 7: UI/VM Direct Binding +- POC VMs exist (`SdkNodeListViewModel`, `SdkConfigViewModel`, etc.) ✅ +- Production VMs still use repository layer (blocked — see below) + +### Phase 8: Feature Integrations ✅ +- Location publishing moved to `SdkStateBridge` +- TAK integration preserved (uses `ServiceAction` dispatch through `SdkStateBridge`) + +### Phase 9: Testing Strategy ✅ +- `sdk-testing` dependency added to `app` and `core/data` +- `TestRadioClientProvider` created with `FakeRadioTransport(autoHandshake=true)` +- Integration test validates connect → handshake → node injection → observation + +--- + +## What Remains + +### 1. ViewModel Direct-Binding (Phase 6.1 — BLOCKED) + +**Blocker:** Feature modules (`feature/node`, `feature/settings`, `feature/messaging`, etc.) +are KMP `commonMain` and cannot depend on the SDK directly. Only the `app` module has +`implementation(libs.sdk.core)`. The `RadioClientProvider` lives in `app/`. + +**Current state:** Feature VMs inject `NodeRepository`, `ServiceRepository`, +`RadioConfigRepository`, `RadioController` — all of which are already SDK-backed thin +adapters populated by `SdkStateBridge`. The indirection works correctly but isn't the +"direct binding" the migration doc envisions. + +**To unblock (choose one):** +1. **Option A — SDK dependency in `core/repository`:** Add `api(libs.sdk.core)` to + `core/repository/build.gradle.kts`. Create a `RadioClientAccessor` interface in + `core/repository` exposing `client: StateFlow`. Feature modules can then + inject it. Trade-off: couples `core/repository` to SDK API surface. +2. **Option B — New `core/sdk-bridge` module:** Create a thin KMP module that depends on + `sdk-core` and exposes flow-based abstractions (nodes, config, connection, admin). + Feature modules depend on this instead of raw `RadioClient`. More modular but adds a module. +3. **Option C — Move VMs to `app`:** Move production VMs out of `feature/*` into `app/`. + Breaks KMP desktop/iOS target sharing. Not recommended. + +**VMs to migrate (22 total):** +| Tier | VMs | Current Params | Target | +|------|-----|----------------|--------| +| Critical (5) | RadioConfigVM, SettingsVM, MessageVM, MetricsVM, NodeListVM | 9-19 | 3-5 | +| Moderate (6) | NodeDetailVM, ContactsVM, BaseMapVM, DebugVM, ChannelVM, FilterSettingsVM | 3-8 | 2-3 | +| Simple (3) | CleanNodeDatabaseVM, QuickChatVM, CompassVM | 1-4 | 1-2 | + +### 2. Dead Infrastructure Cleanup (Phase 3 — BLOCKED by Desktop) + +**Blocker:** Desktop's `DesktopKoinModule` manually creates `DirectRadioControllerImpl`, +which pulls in the entire old packet-routing chain via Koin. + +**Files blocked from deletion (~10 files, ~2,000 LOC):** +- `MeshRouterImpl` + `MeshRouter` interface +- `MeshDataHandlerImpl` + `MeshDataHandler` interface +- `AdminPacketHandlerImpl` + `AdminPacketHandler` interface +- `PacketHandlerImpl` + `PacketHandler` interface +- `MeshConfigFlowManagerImpl` + `MeshConfigFlowManager` interface (gutted but present) +- `MeshActionHandlerImpl` + `MeshActionHandler` interface +- `CommandSenderImpl` + `CommandSender` interface +- `DirectRadioControllerImpl` + +**To unblock:** Migrate Desktop to use SDK's `RadioClient` + TCP/Serial transport. +Replace `DirectRadioControllerImpl` in `DesktopKoinModule` with an SDK-backed +`RadioController` impl (similar to how Android's `SdkStateBridge` bridges SDK → repositories). + +### 3. Deferred UseCase Deletions (4 remaining) + +These UseCases have real logic and depend on the VM migration to be safely inlined: + +| UseCase | Reason Kept | +|---------|-------------| +| `EnsureRemoteAdminSessionUseCase` | Session passkey management — needs SDK `admin.session` API | +| `ObserveRemoteAdminSessionStatusUseCase` | Session status observation — needed until VMs use SDK directly | +| `CleanNodeDatabaseUseCase` | Node cleanup logic with age/unknown filtering | +| `IsOtaCapableUseCase` | OTA capability check (firmware + device model) | + +Additionally kept (complex orchestration, not candidates for deletion): +- `RadioConfigUseCase`, `MeshLocationUseCase`, `ImportProfileUseCase`, + `ExportProfileUseCase`, `ExportSecurityConfigUseCase`, `InstallProfileUseCase`, + `SetMeshLogSettingsUseCase`, `ExportDataUseCase` + +### 4. Remaining Phase C Items (deferred) + +| Item | Description | Status | +|------|-------------|--------| +| C3 | Move raw packet forwarding — VMs observe `client.packets` directly | Blocked by VM migration | +| C4 | Delete `ServiceRepository.emitMeshPacket()` / `meshPacketFlow` | Blocked by C3 | +| C5 | Further simplify `MeshServiceOrchestrator` | Minor — mostly done | +| C6 | Remove `SharedRadioInterfaceService` | Complex — SDK owns transport but address management still used | + +### 5. Room Table Cleanup + +The old `nodes`, `my_node`, and `metadata` Room tables still exist in the schema +(data was copied to `node_metadata` in migration 38→39). A future migration should +DROP these tables to reduce DB size. + +### 6. `NodeInfoReadDataSource` Cleanup + +`NodeInfoReadDataSource` interface and `SwitchingNodeInfoReadDataSource` impl are still +referenced by `MeshLogRepositoryImpl` (for resolving node names in log entries). +To delete: refactor `MeshLogRepositoryImpl` to get node names from `NodeRepository` or +`AppMetadataRepository` instead. + +--- + +## Recommended Execution Order + +1. **Desktop SDK migration** — unblocks item #2 (dead code deletion, ~2,000 LOC) +2. **Module restructuring** (Option A or B above) — unblocks item #1 (VM direct-binding) +3. **VM migration** — migrate 22 VMs to use RadioClient directly (per-VM PRs) +4. **UseCase cleanup** — delete 4 deferred UseCases after VM migration +5. **Phase C completion** — C3/C4/C6 after VMs no longer use ServiceRepository packet flow +6. **Room table cleanup** — DROP legacy tables in a final migration + +--- + +## What STAYS (permanent architecture) + +These components are NOT candidates for deletion — they serve app-local purposes +the SDK doesn't cover: + +- `PacketRepository` — message persistence (SDK doesn't persist chat history) +- `MeshLogRepository` — debug logging (app-local concern) +- `QuickChatActionRepository` — quick-chat templates (app preference) +- `DeviceHardwareRepository` / `FirmwareReleaseRepository` — GitHub API clients +- `NodeMetadataDao` / `AppMetadataRepository` — favorites, notes, ignore, mute +- `MeshServiceOrchestrator` (simplified) — TAK lifecycle, notifications, DB init +- `SdkStateBridge` (reduced) — SDK → repository bridging, location publishing, TAK dispatch +- `RadioClientProvider` — SDK client lifecycle management +- `ContactSettings` table — app-local mute/read state per contact From 74ba959b24871a20e129155fe0c253d9b5aa967c Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 06:41:37 -0500 Subject: [PATCH 08/53] =?UTF-8?q?feat:=20DRY=20SDK=20integration=20?= =?UTF-8?q?=E2=80=94=20shared=20bridge,=20Desktop=20cutover,=20dead=20infr?= =?UTF-8?q?a=20deletion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major architectural changes: 1. SHARED KMP BRIDGE (core/data/radio/): - RadioClientAccessor: Platform-agnostic interface for SDK RadioClient - SdkRadioController: Shared RadioController impl (replaces per-platform copies) - SdkStateBridge: Shared SDK→repository bridge (event dispatch, node sync) - SdkPacketHandler: Thin SDK-backed PacketHandler for MQTT/XModem/History 2. DESKTOP SDK CUTOVER: - DesktopRadioClientProvider: TCP + Serial transport support - Removed DirectRadioControllerImpl (old desktop radio path) - Desktop now shares the same SDK bridge code as Android 3. DEAD INFRASTRUCTURE DELETION (~5,100 LOC removed): - PacketHandlerImpl, MeshDataHandlerImpl, MeshRouterImpl - CommandSenderImpl, MeshActionHandlerImpl, MeshConfigFlowManagerImpl - MeshConnectionManagerImpl, AdminPacketHandlerImpl - ServiceBroadcasts (Android intent-based pub/sub) - NodeRepositoryImpl (Room-backed, replaced by SdkNodeRepositoryImpl) - 8 trivial UseCases (SetLocale, SetTheme, ToggleAnalytics, etc.) - All associated test files for deleted impls - Deleted interfaces: AdminPacketHandler, CommandSender, MeshActionHandler, MeshConfigFlowManager, MeshConnectionManager, MeshRouter, ServiceBroadcasts 4. NEW FEATURES: - NodeMetadataEntity + Room migration 38→39 (persistent favorites/notes) - AppMetadataRepository (clean access to node metadata) - MessagePersistenceHandler (focused rememberDataPacket for StoreForward) All three targets compile clean: :app:compileGoogleDebugKotlin, :desktop:compileKotlin, :core:data:jvmTest passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../app/radio/RadioClientProvider.kt | 9 +- .../app/radio/TestRadioClientProvider.kt | 55 + .../app/radio/TestRadioClientProviderTest.kt | 115 ++ core/data/build.gradle.kts | 4 + .../datasource/NodeInfoWriteDataSource.kt | 43 - .../SwitchingNodeInfoWriteDataSource.kt | 72 -- .../data/manager/AdminPacketHandlerImpl.kt | 85 -- .../core/data/manager/CommandSenderImpl.kt | 466 ------- .../data/manager/DataLayerHeartbeatSender.kt | 54 - .../data/manager/MeshActionHandlerImpl.kt | 401 ------ .../data/manager/MeshConfigFlowManagerImpl.kt | 308 ----- .../data/manager/MeshConnectionManagerImpl.kt | 438 ------- .../core/data/manager/MeshDataHandlerImpl.kt | 531 -------- .../core/data/manager/MeshRouterImpl.kt | 66 - .../data/manager/MessagePersistenceHandler.kt | 195 +++ .../data/manager/NeighborInfoHandlerImpl.kt | 4 +- .../core/data/manager/NodeManagerImpl.kt | 6 +- .../core/data/manager/PacketHandlerImpl.kt | 295 ----- .../manager/StoreForwardPacketHandlerImpl.kt | 3 - .../manager/TelemetryPacketHandlerImpl.kt | 7 +- .../core/data/radio/RadioClientAccessor.kt | 39 + .../core/data/radio/SdkPacketHandler.kt | 89 ++ .../core/data/radio/SdkRadioController.kt | 50 +- .../core/data}/radio/SdkStateBridge.kt | 66 +- .../repository/AppMetadataRepositoryImpl.kt | 82 ++ .../data/repository/NodeRepositoryImpl.kt | 290 ----- .../data/repository/SdkNodeRepositoryImpl.kt | 48 +- .../manager/AdminPacketHandlerImplTest.kt | 224 ---- .../data/manager/MeshActionHandlerImplTest.kt | 583 --------- .../manager/MeshConfigFlowManagerImplTest.kt | 471 ------- .../manager/MeshConnectionManagerImplTest.kt | 430 ------- .../core/data/manager/MeshDataHandlerTest.kt | 706 ----------- .../core/data/manager/NodeManagerImplTest.kt | 4 +- .../data/manager/PacketHandlerImplTest.kt | 143 --- .../StoreForwardPacketHandlerImplTest.kt | 4 - .../manager/TelemetryPacketHandlerImplTest.kt | 8 +- .../repository/CommonNodeRepositoryTest.kt | 123 -- .../39.json | 1105 +++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 24 +- .../core/database/dao/NodeMetadataDao.kt | 57 + .../database/entity/NodeMetadataEntity.kt | 35 + .../settings/SetAppIntroCompletedUseCase.kt | 27 - .../settings/SetDatabaseCacheLimitUseCase.kt | 30 - .../usecase/settings/SetLocaleUseCase.kt | 27 - .../SetNotificationSettingsUseCase.kt | 30 - .../settings/SetProvideLocationUseCase.kt | 27 - .../usecase/settings/SetThemeUseCase.kt | 27 - .../settings/ToggleAnalyticsUseCase.kt | 28 - .../ToggleHomoglyphEncodingUseCase.kt | 28 - .../SetDatabaseCacheLimitUseCaseTest.kt | 49 - .../SetNotificationSettingsUseCaseTest.kt | 58 - .../settings/ToggleAnalyticsUseCaseTest.kt | 48 - .../ToggleHomoglyphEncodingUseCaseTest.kt | 48 - .../core/repository/AdminPacketHandler.kt | 30 - .../core/repository/AppMetadataRepository.kt | 49 + .../core/repository/CommandSender.kt | 86 -- .../core/repository/MeshActionHandler.kt | 119 -- .../core/repository/MeshConfigFlowManager.kt | 51 - .../core/repository/MeshConnectionManager.kt | 40 - .../meshtastic/core/repository/MeshRouter.kt | 44 - .../core/repository/ServiceBroadcasts.kt | 39 - .../core/service/ServiceBroadcastsTest.kt | 135 -- .../org/meshtastic/core/service/Constants.kt | 1 - .../meshtastic/core/service/MeshService.kt | 8 +- .../core/service/ServiceBroadcasts.kt | 164 --- .../core/service/DirectRadioControllerImpl.kt | 237 ---- .../core/service/MeshServiceOrchestrator.kt | 26 +- .../service/MeshServiceOrchestratorTest.kt | 9 +- desktop/build.gradle.kts | 7 + .../desktop/di/DesktopKoinModule.kt | 23 +- .../desktop/radio/DesktopMessageQueue.kt | 4 +- .../radio/DesktopRadioClientProvider.kt | 158 +++ .../org/meshtastic/desktop/stub/NoopStubs.kt | 16 - .../feature/settings/SettingsViewModel.kt | 29 +- .../settings/radio/RadioConfigViewModel.kt | 8 +- .../feature/settings/SettingsViewModelTest.kt | 18 - .../radio/RadioConfigViewModelTest.kt | 18 +- 77 files changed, 2190 insertions(+), 7294 deletions(-) create mode 100644 app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.kt create mode 100644 app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProviderTest.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataLayerHeartbeatSender.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/RadioClientAccessor.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketHandler.kt rename app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt => core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt (91%) rename {app/src/main/kotlin/org/meshtastic/app => core/data/src/commonMain/kotlin/org/meshtastic/core/data}/radio/SdkStateBridge.kt (82%) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonNodeRepositoryTest.kt create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json create mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt create mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeMetadataEntity.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminPacketHandler.kt create mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppMetadataRepository.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshActionHandler.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigFlowManager.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConnectionManager.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshRouter.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceBroadcasts.kt delete mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ServiceBroadcastsTest.kt delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt delete mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/DirectRadioControllerImpl.kt create mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioClientProvider.kt diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt index 85f548e659..620a55e3f2 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt +++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.Single +import org.meshtastic.core.data.radio.RadioClientAccessor import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.service.SdkClientLifecycle @@ -45,15 +46,15 @@ import org.meshtastic.sdk.transport.tcp.TcpTransport * This is the SDK integration point for the POC. The [RadioClient] is exposed as a [StateFlow] so ViewModels and the * service can react to connection changes with `flatMapLatest`. */ -@Single(binds = [SdkClientLifecycle::class]) +@Single(binds = [SdkClientLifecycle::class, RadioClientAccessor::class]) class RadioClientProvider( private val context: Context, private val radioPrefs: RadioPrefs, -) : SdkClientLifecycle { +) : SdkClientLifecycle, RadioClientAccessor { private val _client = MutableStateFlow(null) /** Active [RadioClient], or `null` when disconnected or between connections. */ - val client: StateFlow = _client.asStateFlow() + override val client: StateFlow = _client.asStateFlow() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val mutex = Mutex() @@ -121,7 +122,7 @@ class RadioClientProvider( } /** Fire-and-forget version of [rebuildAndConnect] for non-suspending call sites. */ - fun rebuildAndConnectAsync() { + override fun rebuildAndConnectAsync() { scope.launch { runCatching { rebuildAndConnect() }.onFailure { e -> Logger.e(e) { "RadioClientProvider: connect failed" } } } diff --git a/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.kt b/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.kt new file mode 100644 index 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/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/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/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/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/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt deleted file mode 100644 index 4595a6de47..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt +++ /dev/null @@ -1,401 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import okio.ByteString -import okio.ByteString.Companion.toByteString -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.ignoreExceptionSuspend -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.safeCatching -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.Reaction -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.DataPair -import org.meshtastic.core.repository.MeshActionHandler -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.MeshPrefs -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.Channel -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.OTAMode -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.User - -@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod") -@Single -class MeshActionHandlerImpl( - private val nodeManager: NodeManager, - private val commandSender: CommandSender, - private val packetRepository: Lazy, - private val serviceBroadcasts: ServiceBroadcasts, - private val dataHandler: Lazy, - private val analytics: PlatformAnalytics, - private val meshPrefs: MeshPrefs, - private val uiPrefs: UiPrefs, - private val databaseManager: DatabaseManager, - private val notificationManager: NotificationManager, - private val radioConfigRepository: RadioConfigRepository, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshActionHandler { - - companion object { - private const val DEFAULT_REBOOT_DELAY = 5 - private const val EMOJI_INDICATOR = 1 - } - - override suspend fun onServiceAction(action: ServiceAction) { - Logger.d { "ServiceAction dispatched: ${action::class.simpleName}" } - ignoreExceptionSuspend { - val myNodeNum = nodeManager.myNodeNum.value - if (myNodeNum == null) { - Logger.w { "MeshActionHandlerImpl: myNodeNum is null, skipping ServiceAction!" } - if (action is ServiceAction.SendContact) { - action.result.complete(false) - } - return@ignoreExceptionSuspend - } - when (action) { - is ServiceAction.Favorite -> handleFavorite(action, myNodeNum) - - is ServiceAction.Ignore -> handleIgnore(action, myNodeNum) - - is ServiceAction.Mute -> handleMute(action, myNodeNum) - - is ServiceAction.Reaction -> handleReaction(action, myNodeNum) - - is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum) - - is ServiceAction.SendContact -> { - val accepted = - safeCatching { - commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) } - } - .getOrDefault(false) - action.result.complete(accepted) - } - - is ServiceAction.GetDeviceMetadata -> { - commandSender.sendAdmin(action.destNum, wantResponse = true) { - AdminMessage(get_device_metadata_request = true) - } - } - } - } - } - - private fun handleFavorite(action: ServiceAction.Favorite, myNodeNum: Int) { - val node = action.node - commandSender.sendAdmin(myNodeNum) { - if (node.isFavorite) { - AdminMessage(remove_favorite_node = node.num) - } else { - AdminMessage(set_favorite_node = node.num) - } - } - nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } - } - - private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) { - val node = action.node - val newIgnoredStatus = !node.isIgnored - commandSender.sendAdmin(myNodeNum) { - if (newIgnoredStatus) { - AdminMessage(set_ignored_node = node.num) - } else { - AdminMessage(remove_ignored_node = node.num) - } - } - nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnoredStatus) } - scope.handledLaunch { packetRepository.value.updateFilteredBySender(node.user.id, newIgnoredStatus) } - } - - private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) { - val node = action.node - commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) } - nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } - } - - private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) { - val channel = action.contactKey[0].digitToInt() - val destId = action.contactKey.substring(1) - val dataPacket = - DataPacket( - to = destId, - dataType = PortNum.TEXT_MESSAGE_APP.value, - bytes = action.emoji.encodeToByteArray().toByteString(), - channel = channel, - replyId = action.replyId, - wantAck = true, - emoji = EMOJI_INDICATOR, - ) - .apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL } - commandSender.sendData(dataPacket) - rememberReaction(action, dataPacket.id, myNodeNum) - } - - private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) { - val verifiedContact = action.contact.copy(manually_verified = true) - commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) } - nodeManager.handleReceivedUser( - verifiedContact.node_num, - verifiedContact.user ?: User(), - manuallyVerified = true, - ) - } - - private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) { - scope.handledLaunch { - val user = nodeManager.nodeDBbyNodeNum[myNodeNum]?.user ?: User(id = nodeManager.getMyId()) - val reaction = - Reaction( - replyId = action.replyId, - user = user, - emoji = action.emoji, - timestamp = nowMillis, - snr = 0f, - rssi = 0, - hopsAway = 0, - packetId = packetId, - status = MessageStatus.QUEUED, - to = action.contactKey.substring(1), - channel = action.contactKey[0].digitToInt(), - ) - packetRepository.value.insertReaction(reaction, myNodeNum) - } - } - - override fun handleSetOwner(u: MeshUser, myNodeNum: Int) { - Logger.d { "Setting owner: longName=${u.longName}, shortName=${u.shortName}" } - val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed) - commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) } - nodeManager.handleReceivedUser(myNodeNum, newUser) - } - - override fun handleSend(p: DataPacket, myNodeNum: Int) { - commandSender.sendData(p) - serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN) - dataHandler.value.rememberDataPacket(p, myNodeNum, false) - val bytes = p.bytes ?: ByteString.EMPTY - analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType)) - } - - override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) { - if (destNum != myNodeNum) { - val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value - val currentPosition = - when { - provideLocation && position.isValid() -> position - - provideLocation -> - nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } - ?: Position(0.0, 0.0, 0) - - else -> Position(0.0, 0.0, 0) - } - commandSender.requestPosition(destNum, currentPosition) - } - } - - override fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) { - nodeManager.removeByNodenum(nodeNum) - commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) } - } - - override fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) { - val u = User.ADAPTER.decode(payload) - commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) } - nodeManager.handleReceivedUser(destNum, u) - } - - override fun handleGetRemoteOwner(id: Int, destNum: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) } - } - - override fun handleSetConfig(payload: ByteArray, myNodeNum: Int) { - val c = Config.ADAPTER.decode(payload) - commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) } - // Optimistically persist the config locally so CommandSender picks up - // the new values (e.g. hop_limit) immediately instead of waiting for - // the next want_config handshake. - scope.handledLaunch { radioConfigRepository.setLocalConfig(c) } - } - - override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) { - val c = Config.ADAPTER.decode(payload) - commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) } - // When targeting the local node, optimistically persist the config so the - // UI reflects changes immediately (matching handleSetConfig behaviour). - if (destNum == nodeManager.myNodeNum.value) { - scope.handledLaunch { radioConfigRepository.setLocalConfig(c) } - } - } - - override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { - if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) { - AdminMessage(get_device_metadata_request = true) - } else { - AdminMessage(get_config_request = AdminMessage.ConfigType.fromValue(config)) - } - } - } - - override fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) { - val c = ModuleConfig.ADAPTER.decode(payload) - commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) } - c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) } - // Optimistically persist module config locally so the UI reflects the - // new values immediately instead of waiting for the next want_config handshake. - if (destNum == nodeManager.myNodeNum.value) { - scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(c) } - } - } - - override fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { - AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config)) - } - } - - override fun handleSetRingtone(destNum: Int, ringtone: String) { - commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) } - } - - override fun handleGetRingtone(id: Int, destNum: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) } - } - - override fun handleSetCannedMessages(destNum: Int, messages: String) { - commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) } - } - - override fun handleGetCannedMessages(id: Int, destNum: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { - AdminMessage(get_canned_message_module_messages_request = true) - } - } - - override fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) { - if (payload != null) { - val c = Channel.ADAPTER.decode(payload) - commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) } - // Optimistically persist the channel settings locally so the UI - // reflects changes immediately instead of waiting for the next - // want_config handshake. - scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) } - } - } - - override fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) { - if (payload != null) { - val c = Channel.ADAPTER.decode(payload) - commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) } - // When targeting the local node, optimistically persist the channel so - // the UI reflects changes immediately (matching handleSetChannel behaviour). - if (destNum == nodeManager.myNodeNum.value) { - scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) } - } - } - } - - override fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) { - commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) } - } - - override fun handleRequestNeighborInfo(requestId: Int, destNum: Int) { - commandSender.requestNeighborInfo(requestId, destNum) - } - - override fun handleBeginEditSettings(destNum: Int) { - commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) } - } - - override fun handleCommitEditSettings(destNum: Int) { - commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) } - } - - override fun handleRebootToDfu(destNum: Int) { - commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) } - } - - override fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) { - commandSender.requestTelemetry(requestId, destNum, type) - } - - override fun handleRequestShutdown(requestId: Int, destNum: Int) { - commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) } - } - - override fun handleRequestReboot(requestId: Int, destNum: Int) { - Logger.i { "Reboot requested for node $destNum" } - commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) } - } - - override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { - val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA - val otaEvent = - AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY) - commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) } - } - - override fun handleRequestFactoryReset(requestId: Int, destNum: Int) { - Logger.i { "Factory reset requested for node $destNum" } - commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) } - } - - override fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) { - commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) } - } - - override fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) { - commandSender.sendAdmin(destNum, requestId, wantResponse = true) { - AdminMessage(get_device_connection_status_request = true) - } - } - - override fun handleUpdateLastAddress(deviceAddr: String?) { - val currentAddr = meshPrefs.deviceAddress.value - if (deviceAddr != currentAddr) { - Logger.i { "Device address changed, switching database and clearing node DB" } - meshPrefs.setDeviceAddress(deviceAddr) - scope.handledLaunch { - nodeManager.clear() - databaseManager.switchActiveDatabase(deviceAddr) - notificationManager.cancelAll() - nodeManager.loadCachedNodeDB() - } - } - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt deleted file mode 100644 index 5c39dedfa6..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ /dev/null @@ -1,308 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import co.touchlab.kermit.Logger -import kotlinx.atomicfu.atomic -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DeviceVersion -import org.meshtastic.core.repository.HandshakeConstants -import org.meshtastic.core.repository.MeshConfigFlowManager -import org.meshtastic.core.repository.MeshConnectionManager -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.NotificationPrefs -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.FileInfo -import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.NodeInfo -import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo -import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo - -@Suppress("LongParameterList", "TooManyFunctions") -@Single -class MeshConfigFlowManagerImpl( - private val nodeManager: NodeManager, - private val connectionManager: Lazy, - private val nodeRepository: NodeRepository, - private val radioConfigRepository: RadioConfigRepository, - private val serviceRepository: ServiceRepository, - private val serviceBroadcasts: ServiceBroadcasts, - private val analytics: PlatformAnalytics, - private val heartbeatSender: DataLayerHeartbeatSender, - private val notificationPrefs: NotificationPrefs, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshConfigFlowManager { - private val wantConfigDelay = 100L - - /** Monotonically increasing generation so async clears from a stale handshake are discarded. */ - private val handshakeGeneration = atomic(0L) - - /** - * Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase, - * eliminating the possibility of accessing stale or uninitialized fields. - * - * Guards [handleConfigComplete] so that duplicate or out-of-order `config_complete_id` signals from the firmware - * cannot trigger the wrong stage handler or drive the state machine backward. - */ - private sealed class HandshakeState { - /** No handshake in progress. */ - data object Idle : HandshakeState() - - /** - * Stage 1: receiving device config, module config, channels, and metadata. - * - * [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed - * together by [buildMyNodeInfo] at Stage 1 completion. - */ - data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null) : - HandshakeState() - - /** - * Stage 2: receiving node-info packets from the firmware. - * - * [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until - * `config_complete_id` arrives. - */ - data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List = emptyList()) : - HandshakeState() - - /** Both stages finished. The app is fully connected. */ - data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState() - } - - private var handshakeState: HandshakeState = HandshakeState.Idle - - override val newNodeCount: Int - get() = (handshakeState as? HandshakeState.ReceivingNodeInfo)?.nodes?.size ?: 0 - - override fun handleConfigComplete(configCompleteId: Int) { - val state = handshakeState - when (configCompleteId) { - HandshakeConstants.CONFIG_NONCE -> { - if (state !is HandshakeState.ReceivingConfig) { - Logger.w { "Ignoring Stage 1 config_complete in state=$state" } - return - } - handleConfigOnlyComplete(state) - } - - HandshakeConstants.NODE_INFO_NONCE -> { - if (state !is HandshakeState.ReceivingNodeInfo) { - Logger.w { "Ignoring Stage 2 config_complete in state=$state" } - return - } - handleNodeInfoComplete(state) - } - - else -> Logger.w { "Config complete id mismatch: $configCompleteId" } - } - } - - private fun handleConfigOnlyComplete(state: HandshakeState.ReceivingConfig) { - Logger.i { "Config-only complete (Stage 1)" } - - val finalizedInfo = buildMyNodeInfo(state.rawMyNodeInfo, state.metadata) - if (finalizedInfo == null) { - Logger.w { "Stage 1 failed: could not build MyNodeInfo, retrying Stage 1" } - handshakeState = HandshakeState.Idle - scope.handledLaunch { - delay(wantConfigDelay) - connectionManager.value.startConfigOnly() - } - return - } - - // Warn if firmware is below the absolute minimum supported version. - // The UI layer already enforces this via FirmwareVersionCheck, so we just log here - // for diagnostics rather than hard-disconnecting. - finalizedInfo.firmwareVersion?.let { fwVersion -> - if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) { - Logger.w { - "Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION} — " + - "protocol incompatibilities may occur" - } - } - } - - handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo) - Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" } - connectionManager.value.onRadioConfigLoaded() - - scope.handledLaunch { - delay(wantConfigDelay) - heartbeatSender.sendHeartbeat("inter-stage") - delay(wantConfigDelay) - Logger.i { "Requesting NodeInfo (Stage 2)" } - connectionManager.value.startNodeInfoOnly() - } - } - - private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) { - Logger.i { "NodeInfo complete (Stage 2)" } - - val info = state.myNodeInfo - - // Transition state immediately (synchronously) to prevent duplicate handling. - // The async work below (DB writes, broadcasts) proceeds without the guard. - // Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot. - // Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored. - handshakeState = HandshakeState.Complete(myNodeInfo = info) - - val entities = - state.nodes.mapNotNull { nodeInfo -> - nodeManager.installNodeInfo(nodeInfo, withBroadcast = false) - nodeManager.nodeDBbyNodeNum[nodeInfo.num] - ?: run { - Logger.w { "Node ${nodeInfo.num} missing from DB after installNodeInfo; skipping" } - null - } - } - - scope.handledLaunch { - nodeRepository.installConfig(info, entities) - analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown") - nodeManager.setNodeDbReady(true) - nodeManager.setAllowNodeDbWrites(true) - serviceRepository.setConnectionState(ConnectionState.Connected) - serviceBroadcasts.broadcastConnection() - connectionManager.value.onNodeDbReady() - } - } - - override fun handleMyInfo(myInfo: ProtoMyNodeInfo) { - Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" } - - // Transition to Stage 1, discarding any stale data from a prior interrupted handshake. - handshakeState = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo) - nodeManager.setMyNodeNum(myInfo.my_node_num) - nodeManager.setFirmwareEdition(myInfo.firmware_edition) - applyEventFirmwareNotificationDefaults(myInfo.firmware_edition) - - // Bump the generation so that a pending clear from a prior (interrupted) handshake - // will see a stale snapshot and skip its writes, preventing it from wiping config - // that was saved by this (newer) handshake's incoming packets. - val gen = handshakeGeneration.incrementAndGet() - - // Clear persisted radio config so the new handshake starts from a clean slate. - // DataStore serializes its own writes, so the clear will precede subsequent - // setLocalConfig / updateChannelSettings calls dispatched by later packets in this - // session (handleFromRadio processes packets sequentially, so later dispatches always - // occur after this one returns). - scope.handledLaunch { - if (handshakeGeneration.value != gen) return@handledLaunch // Stale handshake; skip. - radioConfigRepository.clearChannelSet() - radioConfigRepository.clearLocalConfig() - radioConfigRepository.clearLocalModuleConfig() - radioConfigRepository.clearDeviceUIConfig() - radioConfigRepository.clearFileManifest() - } - } - - override fun handleLocalMetadata(metadata: DeviceMetadata) { - Logger.i { "Local Metadata received: ${metadata.firmware_version}" } - val state = handshakeState - if (state is HandshakeState.ReceivingConfig) { - handshakeState = state.copy(metadata = metadata) - // Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete, - // but the DB write does not need to wait until then. - if (metadata != DeviceMetadata()) { - scope.handledLaunch { nodeRepository.insertMetadata(state.rawMyNodeInfo.my_node_num, metadata) } - } - } else { - Logger.w { "Ignoring metadata outside Stage 1 (state=$state)" } - } - } - - override fun handleNodeInfo(info: NodeInfo) { - val state = handshakeState - if (state is HandshakeState.ReceivingNodeInfo) { - handshakeState = state.copy(nodes = state.nodes + info) - } else { - Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" } - } - } - - override fun handleFileInfo(info: FileInfo) { - Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" } - scope.handledLaunch { radioConfigRepository.addFileInfo(info) } - } - - override fun triggerWantConfig() { - connectionManager.value.startConfigOnly() - } - - /** - * Builds a [SharedMyNodeInfo] from the raw proto and optional firmware metadata. Pure function — no side effects. - * Returns null only if construction throws. - */ - private fun buildMyNodeInfo(raw: ProtoMyNodeInfo, metadata: DeviceMetadata?): SharedMyNodeInfo? = try { - with(raw) { - SharedMyNodeInfo( - myNodeNum = my_node_num, - hasGPS = false, - model = - when (val hwModel = metadata?.hw_model) { - null, - HardwareModel.UNSET, - -> null - - else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase() - }, - firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() }, - couldUpdate = false, - shouldUpdate = false, - currentPacketId = 0L, - messageTimeoutMsec = 300000, - minAppVersion = min_app_version, - maxChannels = 8, - hasWifi = metadata?.hasWifi == true, - channelUtilization = 0f, - airUtilTx = 0f, - deviceId = device_id.utf8(), - pioEnv = pio_env.ifEmpty { null }, - ) - } - } catch (@Suppress("TooGenericExceptionCaught") ex: Exception) { - Logger.e(ex) { "Failed to build MyNodeInfo" } - null - } - - private fun applyEventFirmwareNotificationDefaults(edition: FirmwareEdition) { - if (edition != FirmwareEdition.VANILLA) { - if (!notificationPrefs.nodeEventsAutoDisabledForEvent.value) { - notificationPrefs.setNodeEventsEnabled(false) - notificationPrefs.setNodeEventsAutoDisabledForEvent(true) - } - } else { - if (notificationPrefs.nodeEventsAutoDisabledForEvent.value) { - notificationPrefs.setNodeEventsEnabled(true) - notificationPrefs.setNodeEventsAutoDisabledForEvent(false) - } - } - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt deleted file mode 100644 index dc1f129182..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ /dev/null @@ -1,438 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.common.util.nowSeconds -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.repository.AppWidgetUpdater -import org.meshtastic.core.repository.DataPair -import org.meshtastic.core.repository.HandshakeConstants -import org.meshtastic.core.repository.HistoryManager -import org.meshtastic.core.repository.MeshConnectionManager -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.MeshWorkerManager -import org.meshtastic.core.repository.MqttManager -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.PacketHandler -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.repository.SessionManager -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.proto.Config -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.ToRadio -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.time.DurationUnit - -@Suppress("LongParameterList", "TooManyFunctions") -@Single -class MeshConnectionManagerImpl( - private val radioInterfaceService: RadioInterfaceService, - private val serviceRepository: ServiceRepository, - private val serviceBroadcasts: ServiceBroadcasts, - private val serviceNotifications: MeshServiceNotifications, - private val uiPrefs: UiPrefs, - private val packetHandler: PacketHandler, - private val nodeRepository: NodeRepository, - private val locationManager: MeshLocationManager, - private val mqttManager: MqttManager, - private val historyManager: HistoryManager, - private val radioConfigRepository: RadioConfigRepository, - private val radioController: RadioController, - private val sessionManager: SessionManager, - private val nodeManager: NodeManager, - private val analytics: PlatformAnalytics, - private val packetRepository: PacketRepository, - private val workerManager: MeshWorkerManager, - private val appWidgetUpdater: AppWidgetUpdater, - private val heartbeatSender: DataLayerHeartbeatSender, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshConnectionManager { - /** - * Serializes [onConnectionChanged] to prevent TOCTOU races when multiple coroutines emit state transitions - * concurrently (e.g. flow collector vs. sleep-timeout coroutine). - */ - private val connectionMutex = Mutex() - - private var preHandshakeJob: Job? = null - private var sleepTimeout: Job? = null - private var locationRequestsJob: Job? = null - private var handshakeTimeout: Job? = null - private var connectTimeMsec = 0L - private var connectionRestored = false - - init { - // Bridge transport-level state into the canonical app-level state. - // This is the ONLY consumer of RadioInterfaceService.connectionState — it applies - // light-sleep policy and handshake awareness before writing to ServiceRepository. - radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope) - - // Ensure notification title and content stay in sync with state changes - serviceRepository.connectionState.onEach { updateStatusNotification() }.launchIn(scope) - - scope.launch { - try { - appWidgetUpdater.updateAll() - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.e(e) { "Failed to kickstart LocalStatsWidget" } - } - } - - nodeRepository.myNodeInfo - .onEach { myNodeEntity -> - locationRequestsJob?.cancel() - if (myNodeEntity != null) { - locationRequestsJob = - uiPrefs - .shouldProvideNodeLocation(myNodeEntity.myNodeNum) - .onEach { shouldProvide -> - if (shouldProvide) { - locationManager.start(scope) { pos -> - scope.handledLaunch { - val packet = DataPacket( - bytes = okio.ByteString.of(*org.meshtastic.proto.Position.ADAPTER.encode(pos)), - dataType = org.meshtastic.proto.PortNum.POSITION_APP.value, - ) - radioController.sendMessage(packet) - } - } - } else { - locationManager.stop() - } - } - .launchIn(scope) - } - } - .launchIn(scope) - } - - /** - * Bridges a transport-level [ConnectionState] into the canonical app-level state. - * - * Applies light-sleep policy (power-saving / router role) to decide whether a [ConnectionState.DeviceSleep] event - * should be surfaced as sleep or as a full disconnect, then delegates to [onConnectionChanged] for the actual state - * transition. - */ - private suspend fun onRadioConnectionState(newState: ConnectionState) { - val localConfig = radioConfigRepository.localConfigFlow.first() - val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER - val lsEnabled = localConfig.power?.is_power_saving == true || isRouter - - val effectiveState = - when (newState) { - is ConnectionState.Connected -> ConnectionState.Connected - - is ConnectionState.DeviceSleep -> - if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected - - is ConnectionState.Connecting -> ConnectionState.Connecting - - is ConnectionState.Disconnected -> ConnectionState.Disconnected - } - onConnectionChanged(effectiveState) - } - - private suspend fun onConnectionChanged(c: ConnectionState) = connectionMutex.withLock { - val current = serviceRepository.connectionState.value - if (current == c) return@withLock - - // If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting) - if (c is ConnectionState.Connected && current is ConnectionState.Connecting) { - Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" } - return@withLock - } - - Logger.i { "onConnectionChanged: $current -> $c" } - - sleepTimeout?.cancel() - sleepTimeout = null - preHandshakeJob?.cancel() - preHandshakeJob = null - handshakeTimeout?.cancel() - handshakeTimeout = null - - when (c) { - is ConnectionState.Connecting -> serviceRepository.setConnectionState(ConnectionState.Connecting) - is ConnectionState.Connected -> handleConnected() - is ConnectionState.DeviceSleep -> handleDeviceSleep() - is ConnectionState.Disconnected -> handleDisconnected() - } - } - - private fun handleConnected() { - // Track whether this connection was restored from device sleep (vs. a fresh connect), - // matching Apple's "connectionRestored" attribute for cross-platform DataDog parity. - connectionRestored = serviceRepository.connectionState.value is ConnectionState.DeviceSleep - // The service state remains 'Connecting' until config is fully loaded - if (serviceRepository.connectionState.value != ConnectionState.Connected) { - serviceRepository.setConnectionState(ConnectionState.Connecting) - } - serviceBroadcasts.broadcastConnection() - connectTimeMsec = nowMillis - - // Send a wake-up heartbeat before the config request. The firmware may be in a - // power-saving state where the NimBLE callback context needs warming up. The 100ms - // delay ensures the heartbeat BLE write is enqueued before the want_config_id - // (sendToRadio is fire-and-forget through async coroutine launches). - preHandshakeJob = - scope.handledLaunch { - heartbeatSender.sendHeartbeat("pre-handshake") - delay(PRE_HANDSHAKE_SETTLE_MS) - Logger.i { "Starting mesh handshake (Stage 1)" } - startConfigOnly() - } - } - - private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) { - handshakeTimeout?.cancel() - handshakeTimeout = - scope.handledLaunch { - delay(timeout) - if (serviceRepository.connectionState.value is ConnectionState.Connecting) { - // Attempt one retry. Note: the firmware silently drops identical consecutive - // writes (per-connection dedup). If the first want_config_id was received and - // the stall is on our side, the retry will be dropped and the reconnect below - // will trigger instead — which is the right recovery in that case. - Logger.w { - "Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled" - } - action() - delay(HANDSHAKE_RETRY_TIMEOUT) - if (serviceRepository.connectionState.value is ConnectionState.Connecting) { - Logger.e { "Handshake still stalled after retry, forcing reconnect" } - onConnectionChanged(ConnectionState.Disconnected) - } - } - } - } - - private fun tearDownConnection() { - packetHandler.stopPacketQueue() - sessionManager.clearAll() // Prevent stale per-node passkeys on reconnect. - locationManager.stop() - mqttManager.stop() - } - - private fun handleDeviceSleep() { - serviceRepository.setConnectionState(ConnectionState.DeviceSleep) - tearDownConnection() - - if (connectTimeMsec != 0L) { - val now = nowMillis - val duration = now - connectTimeMsec - connectTimeMsec = 0L - analytics.track( - EVENT_CONNECTED_SECONDS, - DataPair(EVENT_CONNECTED_SECONDS, duration.milliseconds.toDouble(DurationUnit.SECONDS)), - ) - } - - sleepTimeout = - scope.handledLaunch { - try { - val localConfig = radioConfigRepository.localConfigFlow.first() - val rawTimeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS - // Cap the timeout so routers or power-saving configs (ls_secs=3600) don't - // leave the UI stuck in DeviceSleep for over an hour. - val timeout = rawTimeout.coerceAtMost(MAX_SLEEP_TIMEOUT_SECONDS) - Logger.d { "Waiting for sleeping device, timeout=$timeout secs (raw=$rawTimeout)" } - delay(timeout.seconds) - Logger.w { "Device timed out, setting disconnected" } - onConnectionChanged(ConnectionState.Disconnected) - } catch (_: CancellationException) { - Logger.d { "device sleep timeout cancelled" } - } - } - - serviceBroadcasts.broadcastConnection() - } - - private fun handleDisconnected() { - serviceRepository.setConnectionState(ConnectionState.Disconnected) - tearDownConnection() - - analytics.track( - EVENT_MESH_DISCONNECT, - DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size), - DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }), - ) - analytics.track(EVENT_NUM_NODES, DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size)) - - serviceBroadcasts.broadcastConnection() - } - - override fun startConfigOnly() { - val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) } - startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action) - action() - } - - override fun startNodeInfoOnly() { - val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) } - startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action) - action() - } - - override fun onRadioConfigLoaded() { - scope.handledLaunch { - val queuedPackets = packetRepository.getQueuedPackets() - queuedPackets.forEach { packet -> - try { - workerManager.enqueueSendMessage(packet.id) - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - Logger.e(e) { "Failed to enqueue queued packet worker" } - } - } - } - } - - override fun onNodeDbReady() { - handshakeTimeout?.cancel() - handshakeTimeout = null - - val myNodeNum = nodeManager.myNodeNum.value ?: 0 - - // NOTE: Time sync and session passkey seeding are handled by the SDK's RadioClient - // during its own handshake — no need to send set_time_only or get_owner_request here. - - // Start MQTT if enabled - scope.handledLaunch { - val moduleConfig = radioConfigRepository.moduleConfigFlow.first() - mqttManager.startProxy( - moduleConfig.mqtt?.enabled == true, - moduleConfig.mqtt?.proxy_to_client_enabled == true, - ) - } - - reportConnection() - - // Request history - scope.handledLaunch { - val moduleConfig = radioConfigRepository.moduleConfigFlow.first() - moduleConfig.store_forward?.let { - historyManager.requestHistoryReplay("onNodeDbReady", myNodeNum, it, "Unknown") - } - } - - // Request immediate LocalStats and DeviceMetrics update on connection - scope.handledLaunch { - radioController.requestTelemetry(myNodeNum.hashCode(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal) - radioController.requestTelemetry(myNodeNum.hashCode() + 1, myNodeNum, TelemetryType.DEVICE.ordinal) - } - } - - private fun reportConnection() { - val myNode = nodeManager.getMyNodeInfo() - val radioModel = DataPair(KEY_RADIO_MODEL, myNode?.model ?: "unknown") - analytics.track( - EVENT_MESH_CONNECT, - DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size), - DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }), - radioModel, - ) - - // DataDog RUM custom action matching Apple's "connect" event for cross-platform analytics. - val transportType = radioInterfaceService.getDeviceAddress()?.let { DeviceType.fromAddress(it)?.name } - analytics.trackConnect( - firmwareVersion = myNode?.firmwareVersion, - transportType = transportType, - hardwareModel = myNode?.model, - nodes = nodeManager.nodeDBbyNodeNum.size, - connectionRestored = connectionRestored, - ) - } - - override fun updateTelemetry(t: Telemetry) { - t.local_stats?.let { nodeRepository.updateLocalStats(it) } - updateStatusNotification(t) - } - - override fun updateStatusNotification(telemetry: Telemetry?) { - serviceNotifications.updateServiceStateNotification( - serviceRepository.connectionState.value, - telemetry = telemetry, - ) - } - - companion object { - private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30 - - // Maximum time (in seconds) to wait for a sleeping device before declaring it - // disconnected, regardless of the device's ls_secs configuration. Without this - // cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour. - private const val MAX_SLEEP_TIMEOUT_SECONDS = 300 - - /** - * Delay between the pre-handshake heartbeat and the want_config_id send. - * - * Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the - * config request arrives. 100ms is well above observed ESP32 task scheduling latency (~10–50ms) while adding - * negligible connection latency. - */ - private const val PRE_HANDSHAKE_SETTLE_MS = 100L - - private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds - - /** - * Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes. - * 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+ - * nodes. - */ - private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds - - // Shorter window for the retry attempt: if the device genuinely didn't receive the - // first want_config_id the retry completes within a few seconds. Waiting another 30s - // before reconnecting just delays recovery unnecessarily. - private val HANDSHAKE_RETRY_TIMEOUT = 15.seconds - - private const val EVENT_CONNECTED_SECONDS = "connected_seconds" - private const val EVENT_MESH_DISCONNECT = "mesh_disconnect" - private const val EVENT_NUM_NODES = "num_nodes" - private const val EVENT_MESH_CONNECT = "mesh_connect" - - private const val KEY_NUM_NODES = "num_nodes" - private const val KEY_NUM_ONLINE = "num_online" - private const val KEY_RADIO_MODEL = "radio_model" - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt deleted file mode 100644 index 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/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/MessagePersistenceHandler.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt new file mode 100644 index 0000000000..85d576004a --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.manager + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.koin.core.annotation.Named +import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.repository.MeshDataHandler +import org.meshtastic.core.repository.MeshServiceNotifications +import org.meshtastic.core.repository.MessageFilter +import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.RadioConfigRepository +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.critical_alert +import org.meshtastic.core.resources.getStringSuspend +import org.meshtastic.core.resources.unknown_username +import org.meshtastic.core.resources.waypoint_received +import org.meshtastic.proto.PortNum + +/** + * SDK-era implementation of [MeshDataHandler] focused on message persistence and notifications. + * + * The full packet-routing logic (handleReceivedData) is no longer needed — the SDK's packet flow + * is consumed directly by VMs and SdkStateBridge. This class retains only [rememberDataPacket] + * which is called by [StoreForwardPacketHandlerImpl] to persist forwarded messages. + */ +@Single +class MessagePersistenceHandler( + private val nodeManager: NodeManager, + private val packetRepository: Lazy, + private val notificationManager: NotificationManager, + private val serviceNotifications: MeshServiceNotifications, + private val radioConfigRepository: RadioConfigRepository, + private val messageFilter: MessageFilter, + @Named("ServiceScope") private val scope: CoroutineScope, +) : MeshDataHandler { + + private val rememberDataType = + setOf( + PortNum.TEXT_MESSAGE_APP.value, + PortNum.ALERT_APP.value, + PortNum.WAYPOINT_APP.value, + PortNum.NODE_STATUS_APP.value, + ) + + override fun handleReceivedData( + packet: org.meshtastic.proto.MeshPacket, + myNodeNum: Int, + logUuid: String?, + logInsertJob: kotlinx.coroutines.Job?, + ) { + // No-op: Incoming packet routing is handled by SdkStateBridge / VM packet observers. + // This method exists only to satisfy the MeshDataHandler interface contract. + } + + override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) { + if (dataPacket.dataType !in rememberDataType) return + val fromLocal = + dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum) + val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST + val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from + + val contactKey = "${dataPacket.channel}$contactId" + + scope.handledLaunch { + packetRepository.value.apply { + val existingPackets = findPacketsWithId(dataPacket.id) + if (existingPackets.isNotEmpty()) { + Logger.d { + "Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " + + "to=${dataPacket.to} contactKey=$contactKey" + + " (already have ${existingPackets.size} packet(s))" + } + return@handledLaunch + } + + val isFiltered = shouldFilterMessage(dataPacket, contactKey) + + insert( + dataPacket, + myNodeNum, + contactKey, + nowMillis, + read = fromLocal || isFiltered, + filtered = isFiltered, + ) + if (!isFiltered) { + handlePacketNotification(dataPacket, contactKey, updateNotification) + } + } + } + } + + @Suppress("ReturnCount") + private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean { + val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true + if (isIgnored) return true + + if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false + val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled + return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled) + } + + private suspend fun handlePacketNotification( + dataPacket: DataPacket, + contactKey: String, + updateNotification: Boolean, + ) { + val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted + val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true + val isSilent = conversationMuted || nodeMuted + if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) { + scope.launch { + notificationManager.dispatch( + Notification( + title = getSenderName(dataPacket), + message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert), + category = Notification.Category.Alert, + contactKey = contactKey, + ), + ) + } + } else if (updateNotification && !isSilent) { + scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) } + } + } + + private suspend fun getSenderName(packet: DataPacket): String { + if (packet.from == DataPacket.ID_LOCAL) { + val myId = nodeManager.getMyId() + return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) + } + return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) + } + + private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) { + when (dataPacket.dataType) { + PortNum.TEXT_MESSAGE_APP.value -> { + val message = dataPacket.text!! + val channelName = + if (dataPacket.to == DataPacket.ID_BROADCAST) { + radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name + } else { + null + } + serviceNotifications.updateMessageNotification( + contactKey, + getSenderName(dataPacket), + message, + dataPacket.to == DataPacket.ID_BROADCAST, + channelName, + isSilent, + ) + } + + PortNum.WAYPOINT_APP.value -> { + val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name) + notificationManager.dispatch( + Notification( + title = getSenderName(dataPacket), + message = message, + category = Notification.Category.Message, + contactKey = contactKey, + isSilent = isSilent, + ), + ) + } + + else -> return + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 2975341cca..51221f9ef3 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -26,7 +26,6 @@ import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.repository.NeighborInfoHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NeighborInfo @@ -35,7 +34,6 @@ import org.meshtastic.proto.NeighborInfo class NeighborInfoHandlerImpl( private val nodeManager: NodeManager, private val serviceRepository: ServiceRepository, - private val serviceBroadcasts: ServiceBroadcasts, private val nodeRepository: NodeRepository, ) : NeighborInfoHandler { @@ -59,7 +57,7 @@ class NeighborInfoHandlerImpl( } // Update Node DB - nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) } + nodeManager.nodeDBbyNodeNum[from]?.let { /* SDK client.nodes is canonical source */ } // Format for UI response val requestId = packet.decoded?.request_id ?: 0 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index 58b0cad919..0d845b71c2 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -41,7 +41,6 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.data.repository.SdkNodeRepositoryImpl import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.getStringSuspend import org.meshtastic.core.resources.new_node_seen @@ -60,7 +59,6 @@ import org.meshtastic.proto.Position as ProtoPosition @Single(binds = [NodeManager::class, NodeIdLookup::class]) class NodeManagerImpl( private val nodeRepository: NodeRepository, - private val serviceBroadcasts: ServiceBroadcasts, private val notificationManager: NotificationManager, @Named("ServiceScope") private val scope: CoroutineScope, ) : NodeManager { @@ -197,9 +195,7 @@ class NodeManagerImpl( scope.handledLaunch { nodeRepository.upsert(result) } } - if (withBroadcast) { - serviceBroadcasts.broadcastNodeChange(result) - } + } override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt deleted file mode 100644 index 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 index 6504faf80f..cd2a31a57b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -30,7 +30,6 @@ import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum @@ -43,7 +42,6 @@ import kotlin.time.Duration.Companion.milliseconds class StoreForwardPacketHandlerImpl( private val nodeManager: NodeManager, private val packetRepository: Lazy, - private val serviceBroadcasts: ServiceBroadcasts, private val historyManager: HistoryManager, private val dataHandler: Lazy, @Named("ServiceScope") private val scope: CoroutineScope, @@ -125,7 +123,6 @@ class StoreForwardPacketHandlerImpl( rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, myNodeNum = nodeManager.myNodeNum.value ?: 0, ) - serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt index 0780893960..8c044af727 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt @@ -28,7 +28,6 @@ import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.decodeOrNull import org.meshtastic.core.model.util.toOneLiner -import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager @@ -48,7 +47,6 @@ import kotlin.time.Duration.Companion.milliseconds @Single class TelemetryPacketHandlerImpl( private val nodeManager: NodeManager, - private val connectionManager: Lazy, private val notificationManager: NotificationManager, @Named("ServiceScope") private val scope: CoroutineScope, ) : TelemetryPacketHandler { @@ -66,9 +64,8 @@ class TelemetryPacketHandlerImpl( Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" } val fromNum = packet.from val isRemote = (fromNum != myNodeNum) - if (!isRemote) { - connectionManager.value.updateTelemetry(t) - } + // Note: Local telemetry notification update was previously handled by + // MeshConnectionManager.updateTelemetry(), now managed via SDK flows. nodeManager.updateNode(fromNum) { node: Node -> val metrics = t.device_metrics diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/RadioClientAccessor.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/RadioClientAccessor.kt new file mode 100644 index 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/SdkPacketHandler.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketHandler.kt new file mode 100644 index 0000000000..8ded4976c1 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketHandler.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.radio + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.annotation.Single +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.repository.PacketHandler +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.QueueStatus +import org.meshtastic.proto.ToRadio + +/** + * SDK-backed [PacketHandler] that sends packets through the active [RadioClient]. + * + * Replaces the monolithic [PacketHandlerImpl] which routed through the old + * `RadioInterfaceService.sendToRadio()` pipeline. This thin implementation only supports the + * `sendToRadio` surface needed by MQTT, XModem, and History managers. + * + * Queue management (QueueStatus, packet ordering) is handled internally by the SDK engine. + */ +@Single(binds = [PacketHandler::class]) +class SdkPacketHandler( + private val accessor: RadioClientAccessor, + private val dispatchers: CoroutineDispatchers, +) : PacketHandler { + + private val scope = CoroutineScope(SupervisorJob() + dispatchers.io) + + override fun sendToRadio(p: ToRadio) { + val packet = p.packet + if (packet != null) { + // Regular MeshPacket — route through the tracked send path. + sendToRadio(packet) + return + } + // Non-packet ToRadio (mqttClientProxyMessage, xmodemPacket) — send as raw frame. + val client = accessor.client.value ?: run { + Logger.w { "SdkPacketHandler: no client, dropping non-packet ToRadio" } + return + } + scope.launch { + runCatching { client.sendRaw(p) } + .onFailure { e -> Logger.w(e) { "SdkPacketHandler: sendRaw(ToRadio) failed" } } + } + } + + override fun sendToRadio(packet: MeshPacket) { + val client = accessor.client.value ?: run { + Logger.w { "SdkPacketHandler: no client, dropping packet id=${packet.id}" } + return + } + client.send(packet) + } + + override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean { + val client = accessor.client.value ?: return false + return runCatching { client.send(packet) }.isSuccess + } + + override fun handleQueueStatus(queueStatus: QueueStatus) { + // Queue management is internal to the SDK engine; no-op. + } + + override fun removeResponse(dataRequestId: Int, complete: Boolean) { + // Response tracking is internal to the SDK engine; no-op. + } + + override fun stopPacketQueue() { + // Queue management is internal to the SDK engine; no-op. + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt similarity index 91% rename from app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt rename to core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 549155de54..b1e9c5efae 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkRadioControllerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -14,11 +14,11 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.app.radio +package org.meshtastic.core.data.radio import co.touchlab.kermit.Logger +import kotlinx.atomicfu.atomic import kotlinx.coroutines.flow.StateFlow -import okio.ByteString import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState @@ -42,37 +42,35 @@ import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.ChannelIndex import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.RadioClient -import java.util.concurrent.atomic.AtomicInteger /** - * [RadioController] implementation that delegates all operations through the meshtastic-sdk. + * Shared KMP [RadioController] implementation that delegates all operations through the meshtastic-sdk. * - * This replaces [org.meshtastic.core.service.AndroidRadioControllerImpl] in the hard-cutover POC. Feature modules - * continue injecting [RadioController] and get SDK-backed behavior without code changes. + * Feature modules inject [RadioController] and get SDK-backed behavior without needing platform-specific code. * * **Command dispatch:** All admin, telemetry, and routing operations go through [RadioClient.admin], * [RadioClient.telemetry], and [RadioClient.routing] respectively. * - * **State distribution:** Handled separately by [SdkStateBridge], which feeds SDK flows back into + * **State distribution:** Handled by [SdkStateBridge], which feeds SDK flows into * [ServiceRepository] and [org.meshtastic.core.repository.NodeManager]. */ @Single(binds = [RadioController::class]) @Suppress("TooManyFunctions", "LongParameterList") -class SdkRadioControllerImpl( - private val provider: RadioClientProvider, +class SdkRadioController( + private val accessor: RadioClientAccessor, private val serviceRepository: ServiceRepository, private val nodeRepository: NodeRepository, private val locationManager: MeshLocationManager, ) : RadioController { - private val packetIdCounter = AtomicInteger(1) + private val packetIdCounter = atomic(1) private val client: RadioClient? - get() = provider.client.value + get() = accessor.client.value private fun requireClient(): RadioClient { return client ?: run { - Logger.w { "SdkRadioControllerImpl: no active RadioClient" } + Logger.w { "SdkRadioController: no active RadioClient" } throw IllegalStateException("RadioClient not connected") } } @@ -105,7 +103,7 @@ class SdkRadioControllerImpl( channel = packet.channel, decoded = Data( portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP, - payload = packet.bytes ?: ByteString.EMPTY, + payload = packet.bytes ?: okio.ByteString.EMPTY, want_response = false, ), ) @@ -272,7 +270,12 @@ class SdkRadioControllerImpl( if (isLocalNode(destNum)) { c.admin.getCannedMessages() } else { - sendRemoteAdmin(c, destNum, AdminMessage(get_canned_message_module_messages_request = true), wantResponse = true) + sendRemoteAdmin( + c, + destNum, + AdminMessage(get_canned_message_module_messages_request = true), + wantResponse = true, + ) } } @@ -281,7 +284,12 @@ class SdkRadioControllerImpl( if (isLocalNode(destNum)) { c.admin.getDeviceConnectionStatus() } else { - sendRemoteAdmin(c, destNum, AdminMessage(get_device_connection_status_request = true), wantResponse = true) + sendRemoteAdmin( + c, + destNum, + AdminMessage(get_device_connection_status_request = true), + wantResponse = true, + ) } } @@ -366,7 +374,6 @@ class SdkRadioControllerImpl( override suspend fun requestUserInfo(destNum: Int) { val c = client ?: return - // Send an empty NODEINFO_APP packet with want_response to request user info c.send( MeshPacket( to = destNum, @@ -388,7 +395,6 @@ class SdkRadioControllerImpl( override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { val c = requireClient() val node = NodeId(destNum) - // TelemetryType enum values: 0=DEVICE, 1=ENVIRONMENT, 2=AIR_QUALITY, 3=POWER, 4=LOCAL_STATS, 5=HEALTH when (typeValue) { 0 -> c.telemetry.requestDevice(node) 1 -> c.telemetry.requestEnvironment(node) @@ -411,7 +417,6 @@ class SdkRadioControllerImpl( override suspend fun beginEditSettings(destNum: Int) { val c = client ?: return - // Send raw begin_edit_settings admin message for compatibility with the split begin/commit pattern val msg = AdminMessage(begin_edit_settings = true) val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum) sendRemoteAdmin(c, target.raw, msg) @@ -429,7 +434,7 @@ class SdkRadioControllerImpl( override fun getPacketId(): Int = packetIdCounter.getAndIncrement() override fun startProvideLocation() { - // Location provision is managed at the app level; no-op until bridge wires it + // Location provision is managed at the app level; no-op here } override fun stopProvideLocation() { @@ -437,8 +442,7 @@ class SdkRadioControllerImpl( } override fun setDeviceAddress(address: String) { - // Changing device address requires rebuilding the SDK client connection - provider.rebuildAndConnectAsync() + accessor.rebuildAndConnectAsync() } // ── Private helpers ───────────────────────────────────────────────────── @@ -449,10 +453,6 @@ class SdkRadioControllerImpl( return destNum == ownNum } - /** - * Sends a raw admin message to a remote node via the SDK's send path. - * Used for remote-admin operations where destNum != local node. - */ private suspend fun sendRemoteAdmin( c: RadioClient, destNum: Int, diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt similarity index 82% rename from app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt rename to core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index d9395ab7a7..39825cface 100644 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -14,24 +14,29 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package org.meshtastic.app.radio +package org.meshtastic.core.data.radio import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState as AppConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.service.ServiceAction +import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.ServiceRepository +import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Data @@ -52,28 +57,23 @@ import org.meshtastic.sdk.NodeId * and [NodeManager] so that existing feature-module UI code (which observes those repositories) * continues to work without modification. * - * **Node state:** The SDK's [NodeChange] flow provides fully-updated [NodeInfo] instances that - * already include position, telemetry, and user changes. No manual packet decoding is needed. - * - * **Packets:** Raw [MeshPacket]s are forwarded to [ServiceRepository.emitMeshPacket] for - * consumers that need them (RadioConfigViewModel admin responses, TAK integration). - * - * **ServiceActions:** Handled inline via SDK [AdminApi] — eliminates the old - * MeshServiceOrchestrator → MeshActionHandler → CommandSender dispatch chain. - * - * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientProvider.client] + * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientAccessor.client] * and starts/stops collection as clients come and go. */ @Single @Suppress("TooManyFunctions") class SdkStateBridge( - private val provider: RadioClientProvider, + private val accessor: RadioClientAccessor, private val serviceRepository: ServiceRepository, private val nodeManager: NodeManager, private val packetRepository: Lazy, + private val locationManager: MeshLocationManager, + private val uiPrefs: UiPrefs, + private val radioController: RadioController, private val dispatchers: CoroutineDispatchers, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) + private var locationJob: Job? = null init { startBridge() @@ -82,13 +82,13 @@ class SdkStateBridge( private fun startBridge() { // ── Connection state ──────────────────────────────────────────────── - provider.client + accessor.client .flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) } .onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) } .launchIn(scope) // ── Node updates (position, telemetry, user all included in NodeInfo) ─ - provider.client + accessor.client .flatMapLatest { client -> client?.nodes ?: flowOf() } .onEach { change -> when (change) { @@ -107,19 +107,19 @@ class SdkStateBridge( .launchIn(scope) // ── Own node identity ─────────────────────────────────────────────── - provider.client + accessor.client .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } .onEach { ownNode -> if (ownNode != null) nodeManager.setMyNodeNum(ownNode.num) } .launchIn(scope) // ── Raw packet forward (for RadioConfigViewModel + TAK) ───────────── - provider.client + accessor.client .flatMapLatest { client -> client?.packets ?: flowOf() } .onEach { packet -> serviceRepository.emitMeshPacket(packet) } .launchIn(scope) // ── Events (notifications, security, backpressure) ────────────────── - provider.client + accessor.client .flatMapLatest { client -> client?.events ?: flowOf() } .onEach { event -> when (event) { @@ -141,13 +141,43 @@ class SdkStateBridge( .onEach { action -> handleServiceAction(action) } .launchIn(scope) + // ── Location publishing ───────────────────────────────────────────── + accessor.client + .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } + .onEach { ownNode -> + locationJob?.cancel() + locationJob = null + if (ownNode != null) { + locationJob = uiPrefs.shouldProvideNodeLocation(ownNode.num) + .onEach { shouldProvide -> + if (shouldProvide) { + locationManager.start(scope) { pos -> + scope.launch { + val packet = DataPacket( + bytes = okio.ByteString.of( + *org.meshtastic.proto.Position.ADAPTER.encode(pos), + ), + dataType = PortNum.POSITION_APP.value, + ) + radioController.sendMessage(packet) + } + } + } else { + locationManager.stop() + } + } + .launchIn(scope) + } + } + .launchIn(scope) + Logger.i { "SdkStateBridge started — SDK owns transport + ServiceAction dispatch" } } // ── ServiceAction handling ─────────────────────────────────────────────── private suspend fun handleServiceAction(action: ServiceAction) { - val client = provider.client.value + val client = accessor.client.value if (client == null) { Logger.w { "[SdkBridge] ServiceAction ${action::class.simpleName} dropped — no client" } if (action is ServiceAction.SendContact) action.result.complete(false) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt new file mode 100644 index 0000000000..b69d04db0c --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single +import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.entity.NodeMetadataEntity +import org.meshtastic.core.repository.AppMetadataRepository +import org.meshtastic.core.repository.NodeMetadata + +@Single(binds = [AppMetadataRepository::class]) +class AppMetadataRepositoryImpl( + private val dbManager: DatabaseProvider, +) : AppMetadataRepository { + + override val metadataByNum: Flow> = + dbManager.currentDb.flatMapLatest { db -> db.nodeMetadataDao().getAllFlow() } + .map { list -> list.associate { it.num to it.toModel() } } + + override suspend fun setFavorite(nodeNum: Int, isFavorite: Boolean) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setFavorite(nodeNum, isFavorite) } + } + + override suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setIgnored(nodeNum, isIgnored) } + } + + override suspend fun setMuted(nodeNum: Int, isMuted: Boolean) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setMuted(nodeNum, isMuted) } + } + + override suspend fun setNotes(nodeNum: Int, notes: String) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setNotes(nodeNum, notes) } + } + + override suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean) { + ensureExists(nodeNum) + dbManager.withDb { it.nodeMetadataDao().setManuallyVerified(nodeNum, verified) } + } + + override suspend fun delete(nodeNum: Int) { + dbManager.withDb { it.nodeMetadataDao().delete(nodeNum) } + } + + private suspend fun ensureExists(nodeNum: Int) { + dbManager.withDb { db -> + if (db.nodeMetadataDao().getByNum(nodeNum) == null) { + db.nodeMetadataDao().upsert(NodeMetadataEntity(num = nodeNum)) + } + } + } +} + +private fun NodeMetadataEntity.toModel() = NodeMetadata( + num = num, + isFavorite = isFavorite, + isIgnored = isIgnored, + isMuted = isMuted, + notes = notes, + manuallyVerified = manuallyVerified, +) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt deleted file mode 100644 index 09dc86fccb..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 — Replaced by SdkNodeRepositoryImpl in SDK mode. Kept for reference/desktop fallback. -@Suppress("TooManyFunctions") -class NodeRepositoryImpl( - @Named("ProcessLifecycle") private val processLifecycle: Lifecycle, - private val nodeInfoReadDataSource: NodeInfoReadDataSource, - private val nodeInfoWriteDataSource: NodeInfoWriteDataSource, - private val dispatchers: CoroutineDispatchers, - private val localStatsDataSource: LocalStatsDataSource, -) : NodeRepository { - /** Hardware info about our local device (can be null if not connected). */ - override val myNodeInfo: StateFlow = - nodeInfoReadDataSource - .myNodeInfoFlow() - .map { it?.toMyNodeInfo() } - .flowOn(dispatchers.io) - .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null) - - private val _ourNodeInfo = MutableStateFlow(null) - - /** Information about the locally connected node, as seen from the mesh. */ - override val ourNodeInfo: StateFlow - get() = _ourNodeInfo - - private val _myId = MutableStateFlow(null) - - /** The unique userId (hex string) of our local node. */ - override val myId: StateFlow - get() = _myId - - /** The latest local stats telemetry received from the locally connected node. */ - override val localStats: StateFlow = - localStatsDataSource.localStatsFlow.stateIn( - processLifecycle.coroutineScope, - SharingStarted.Eagerly, - LocalStats(), - ) - - /** Update the cached local stats telemetry. */ - override fun updateLocalStats(stats: LocalStats) { - processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) } - } - - /** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */ - override val nodeDBbyNum: StateFlow> = - nodeInfoReadDataSource - .nodeDBbyNumFlow() - .mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } } - .flowOn(dispatchers.io) - .conflate() - .stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap()) - - init { - // Backfill denormalized name columns for existing nodes on startup - processLifecycle.coroutineScope.launch { - processLifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) { - withContext(dispatchers.io) { nodeInfoWriteDataSource.backfillDenormalizedNames() } - } - } - - // Keep ourNodeInfo and myId correctly updated based on current connection and node DB - combine(nodeDBbyNum, nodeInfoReadDataSource.myNodeInfoFlow()) { db, info -> info?.myNodeNum?.let { db[it] } } - .onEach { node -> - _ourNodeInfo.value = node - _myId.value = node?.user?.id - } - .launchIn(processLifecycle.coroutineScope) - } - - /** - * Returns the node number used for log queries. Maps [nodeNum] to [MeshLog.NODE_NUM_LOCAL] (0) if it is the locally - * connected node. - */ - override fun effectiveLogNodeId(nodeNum: Int): Flow = nodeInfoReadDataSource - .myNodeInfoFlow() - .map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum } - .distinctUntilChanged() - - fun getNodeEntityDBbyNumFlow() = - nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } } - - /** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */ - override fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId } - ?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId)) - - /** Returns the [User] info for a given [nodeNum]. */ - override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) - - private val last4 = 4 - - /** Returns the [User] info for a given [userId]. Falls back to a generic user if not found. */ - override fun getUser(userId: String): User { - val found = nodeDBbyNum.value.values.find { it.user.id == userId }?.user - if (found != null && found.long_name.isNotBlank() && found.short_name.isNotBlank()) { - return found - } - - val fallbackId = userId.takeLast(last4) - val defaultLong = - if (userId == DataPacket.ID_LOCAL) { - ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local" - } else { - "Meshtastic $fallbackId" - } - val defaultShort = - if (userId == DataPacket.ID_LOCAL) { - ourNodeInfo.value?.user?.short_name?.takeIf { it.isNotBlank() } ?: "Local" - } else { - fallbackId - } - - return found?.copy( - long_name = found.long_name.takeIf { it.isNotBlank() } ?: defaultLong, - short_name = found.short_name.takeIf { it.isNotBlank() } ?: defaultShort, - ) ?: User(id = userId, long_name = defaultLong, short_name = defaultShort) - } - - /** Returns a flow of nodes filtered and sorted according to the parameters. */ - override fun getNodes( - sort: NodeSortOption, - filter: String, - includeUnknown: Boolean, - onlyOnline: Boolean, - onlyDirect: Boolean, - ): Flow> = nodeInfoReadDataSource - .getNodesFlow( - sort = sort.sqlValue, - filter = filter, - includeUnknown = includeUnknown, - hopsAwayMax = if (onlyDirect) 0 else -1, - lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1, - ) - .mapLatest { list -> list.map { it.toModel() } } - .flowOn(dispatchers.io) - .conflate() - - /** Upserts a [Node] to the database. */ - override suspend fun upsert(node: Node) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node.toEntity()) } - - /** Installs initial configuration data (local info and remote nodes) into the database. */ - override suspend fun installConfig(mi: MyNodeInfo, nodes: List) = withContext(dispatchers.io) { - nodeInfoWriteDataSource.installConfig(mi.toEntity(), nodes.map { it.toEntity() }) - } - - /** Deletes all nodes from the database, optionally preserving favorites. */ - override suspend fun clearNodeDB(preserveFavorites: Boolean) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.clearNodeDB(preserveFavorites) } - - /** Clears the local node's connection info. */ - override suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() } - - /** Deletes a node and its metadata by [num]. */ - override suspend fun deleteNode(num: Int) = withContext(dispatchers.io) { - nodeInfoWriteDataSource.deleteNode(num) - nodeInfoWriteDataSource.deleteMetadata(num) - } - - /** Deletes multiple nodes and their metadata. */ - override suspend fun deleteNodes(nodeNums: List) = withContext(dispatchers.io) { - nodeInfoWriteDataSource.deleteNodes(nodeNums) - nodeNums.forEach { nodeInfoWriteDataSource.deleteMetadata(it) } - } - - override suspend fun getNodesOlderThan(lastHeard: Int): List = - withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard).map { it.toModel() } } - - override suspend fun getUnknownNodes(): List = - withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes().map { it.toModel() } } - - /** Persists hardware metadata for a node. */ - override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(MetadataEntity(nodeNum, metadata)) } - - /** Flow emitting the count of nodes currently considered "online". */ - override val onlineNodeCount: Flow = - nodeInfoReadDataSource - .nodeDBbyNumFlow() - .mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } } - .flowOn(dispatchers.io) - .conflate() - - /** Flow emitting the total number of nodes in the database. */ - override val totalNodeCount: Flow = - nodeInfoReadDataSource - .nodeDBbyNumFlow() - .mapLatest { map -> map.values.count() } - .flowOn(dispatchers.io) - .conflate() - - override suspend fun setNodeNotes(num: Int, notes: String) = - withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) } - - private fun MyNodeInfo.toEntity() = MyNodeEntity( - myNodeNum = myNodeNum, - model = model, - firmwareVersion = firmwareVersion, - couldUpdate = couldUpdate, - shouldUpdate = shouldUpdate, - currentPacketId = currentPacketId, - messageTimeoutMsec = messageTimeoutMsec, - minAppVersion = minAppVersion, - maxChannels = maxChannels, - hasWifi = hasWifi, - deviceId = deviceId, - pioEnv = pioEnv, - ) - - private fun Node.toEntity() = NodeEntity( - num = num, - user = user, - position = position, - latitude = latitude, - longitude = longitude, - snr = snr, - rssi = rssi, - lastHeard = lastHeard, - deviceTelemetry = org.meshtastic.proto.Telemetry(device_metrics = deviceMetrics), - channel = channel, - viaMqtt = viaMqtt, - hopsAway = hopsAway, - isFavorite = isFavorite, - isIgnored = isIgnored, - isMuted = isMuted, - environmentTelemetry = org.meshtastic.proto.Telemetry(environment_metrics = environmentMetrics), - powerTelemetry = org.meshtastic.proto.Telemetry(power_metrics = powerMetrics), - paxcounter = paxcounter, - publicKey = publicKey, - notes = notes, - manuallyVerified = manuallyVerified, - nodeStatus = nodeStatus, - lastTransport = lastTransport, - ) -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt index f75b65e470..83c6d528d3 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -23,12 +23,15 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.Named import org.koin.core.annotation.Single +import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.entity.NodeMetadataEntity import org.meshtastic.core.datastore.LocalStatsDataSource import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.MeshLog @@ -50,12 +53,13 @@ import org.meshtastic.proto.User * SDK's NodeChange flow (bridged through SdkStateBridge). * * Cold start: nodes are empty until the SDK emits its snapshot from storage (<1s). - * Node notes: stored in-memory for this POC (will not survive process death). + * Node metadata (favorites, notes, ignored, muted) persists via Room's node_metadata table. */ @Single(binds = [NodeRepository::class]) @Suppress("TooManyFunctions") class SdkNodeRepositoryImpl( private val localStatsDataSource: LocalStatsDataSource, + private val dbManager: DatabaseProvider, @Named("ServiceScope") private val scope: CoroutineScope, ) : NodeRepository { @@ -63,8 +67,15 @@ class SdkNodeRepositoryImpl( private val _myNodeInfo = MutableStateFlow(null) private val _myNodeNum = MutableStateFlow(null) - // Local-only notes storage (in-memory for POC; does not survive process death) - private val nodeNotes = MutableStateFlow>(emptyMap()) + // Cached metadata from Room (loaded on init, updated on writes) + private val _metadataCache = MutableStateFlow>(emptyMap()) + + init { + scope.launch { + dbManager.currentDb.flatMapLatest { db -> db.nodeMetadataDao().getAllFlow() } + .collect { list -> _metadataCache.value = list.associateBy { it.num } } + } + } override val nodeDBbyNum: StateFlow> = _nodeDBbyNum @@ -151,11 +162,20 @@ class SdkNodeRepositoryImpl( } override suspend fun upsert(node: Node) { - _nodeDBbyNum.update { map -> map + (node.num to node) } - // Also keep _myNodeNum consistent - if (node.num == _myNodeNum.value) { - // ourNodeInfo will auto-update via combine + // Merge persisted metadata with incoming node data + val meta = _metadataCache.value[node.num] + val enriched = if (meta != null) { + node.copy( + isFavorite = meta.isFavorite, + isIgnored = meta.isIgnored, + isMuted = meta.isMuted, + notes = meta.notes, + manuallyVerified = meta.manuallyVerified, + ) + } else { + node } + _nodeDBbyNum.update { map -> map + (enriched.num to enriched) } } override suspend fun installConfig(mi: MyNodeInfo, nodes: List) { @@ -178,12 +198,12 @@ class SdkNodeRepositoryImpl( override suspend fun deleteNode(num: Int) { _nodeDBbyNum.update { it - num } - nodeNotes.update { it - num } + dbManager.withDb { it.nodeMetadataDao().delete(num) } } override suspend fun deleteNodes(nodeNums: List) { _nodeDBbyNum.update { map -> map - nodeNums.toSet() } - nodeNotes.update { notes -> notes - nodeNums.toSet() } + dbManager.withDb { db -> nodeNums.forEach { db.nodeMetadataDao().delete(it) } } } override suspend fun getNodesOlderThan(lastHeard: Int): List = @@ -193,7 +213,8 @@ class SdkNodeRepositoryImpl( _nodeDBbyNum.value.values.filter { it.user.hw_model == HardwareModel.UNSET } override suspend fun setNodeNotes(num: Int, notes: String) { - nodeNotes.update { it + (num to notes) } + ensureMetadataExists(num) + dbManager.withDb { it.nodeMetadataDao().setNotes(num, notes) } _nodeDBbyNum.update { map -> val node = map[num] ?: return@update map map + (num to node.copy(notes = notes)) @@ -212,6 +233,13 @@ class SdkNodeRepositoryImpl( _myNodeNum.value = num } + /** Ensures a metadata row exists for the given node, creating a default if needed. */ + private suspend fun ensureMetadataExists(num: Int) { + if (_metadataCache.value[num] == null) { + dbManager.withDb { it.nodeMetadataDao().upsert(NodeMetadataEntity(num = num)) } + } + } + private fun sortComparator(sort: NodeSortOption): Comparator = when (sort) { NodeSortOption.LAST_HEARD -> compareByDescending { it.lastHeard } NodeSortOption.ALPHABETICAL -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.user.long_name } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImplTest.kt deleted file mode 100644 index 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/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt deleted file mode 100644 index 9e28e74818..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt +++ /dev/null @@ -1,583 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import dev.mokkery.MockMode -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.everySuspend -import dev.mokkery.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import dev.mokkery.verify.VerifyMode.Companion.not -import dev.mokkery.verifySuspend -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.Position -import org.meshtastic.core.model.service.ServiceAction -import org.meshtastic.core.repository.CommandSender -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.MeshPrefs -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.proto.AdminMessage -import org.meshtastic.proto.Channel -import org.meshtastic.proto.Config -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.SharedContact -import org.meshtastic.proto.User -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class MeshActionHandlerImplTest { - - private val nodeManager = mock(MockMode.autofill) - private val commandSender = mock(MockMode.autofill) - private val packetRepository = mock(MockMode.autofill) - private val serviceBroadcasts = mock(MockMode.autofill) - private val dataHandler = mock(MockMode.autofill) - private val analytics = mock(MockMode.autofill) - private val meshPrefs = mock(MockMode.autofill) - private val uiPrefs = mock(MockMode.autofill) - private val databaseManager = mock(MockMode.autofill) - private val notificationManager = mock(MockMode.autofill) - private val radioConfigRepository = mock(MockMode.autofill) - - private val myNodeNumFlow = MutableStateFlow(MY_NODE_NUM) - - private lateinit var handler: MeshActionHandlerImpl - - private val testDispatcher = UnconfinedTestDispatcher() - private val testScope = TestScope(testDispatcher) - - companion object { - private const val MY_NODE_NUM = 12345 - private const val REMOTE_NODE_NUM = 67890 - } - - @BeforeTest - fun setUp() { - every { nodeManager.myNodeNum } returns myNodeNumFlow - every { nodeManager.getMyId() } returns "!12345678" - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - } - - private fun createHandler(scope: CoroutineScope): MeshActionHandlerImpl = MeshActionHandlerImpl( - nodeManager = nodeManager, - commandSender = commandSender, - packetRepository = lazy { packetRepository }, - serviceBroadcasts = serviceBroadcasts, - dataHandler = lazy { dataHandler }, - analytics = analytics, - meshPrefs = meshPrefs, - uiPrefs = uiPrefs, - databaseManager = databaseManager, - notificationManager = notificationManager, - radioConfigRepository = radioConfigRepository, - scope = scope, - ) - - // ---- handleUpdateLastAddress (device-switch path — P0 critical) ---- - - @Test - fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") - everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit - - handler.handleUpdateLastAddress("new_addr") - advanceUntilIdle() - - verify { meshPrefs.setDeviceAddress("new_addr") } - verify { nodeManager.clear() } - verifySuspend { databaseManager.switchActiveDatabase("new_addr") } - verify { notificationManager.cancelAll() } - verify { nodeManager.loadCachedNodeDB() } - } - - @Test - fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr") - - handler.handleUpdateLastAddress("same_addr") - advanceUntilIdle() - - verify(not) { meshPrefs.setDeviceAddress(any()) } - verify(not) { nodeManager.clear() } - } - - @Test - fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr") - everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit - - handler.handleUpdateLastAddress(null) - advanceUntilIdle() - - verify { meshPrefs.setDeviceAddress(null) } - verify { nodeManager.clear() } - verifySuspend { databaseManager.switchActiveDatabase(null) } - } - - @Test - fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow(null) - - handler.handleUpdateLastAddress(null) - advanceUntilIdle() - - verify(not) { meshPrefs.setDeviceAddress(any()) } - } - - @Test - fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - every { meshPrefs.deviceAddress } returns MutableStateFlow("old") - everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit - - handler.handleUpdateLastAddress("new") - advanceUntilIdle() - - // Verify critical sequence: clear -> switchDB -> cancelNotifications -> loadCachedNodeDB - verify { nodeManager.clear() } - verifySuspend { databaseManager.switchActiveDatabase("new") } - verify { notificationManager.cancelAll() } - verify { nodeManager.loadCachedNodeDB() } - } - - // ---- onServiceAction: null myNodeNum early-return ---- - - @Test - fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - myNodeNumFlow.value = null - - val node = createTestNode(REMOTE_NODE_NUM) - handler.onServiceAction(ServiceAction.Favorite(node)) - advanceUntilIdle() - - verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- onServiceAction: Favorite ---- - - @Test - fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false) - - handler.onServiceAction(ServiceAction.Favorite(node)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.updateNode(any(), any(), any(), any()) } - } - - @Test - fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true) - - handler.onServiceAction(ServiceAction.Favorite(node)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.updateNode(any(), any(), any(), any()) } - } - - // ---- onServiceAction: Ignore ---- - - @Test - fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false) - - handler.onServiceAction(ServiceAction.Ignore(node)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.updateNode(any(), any(), any(), any()) } - verifySuspend { packetRepository.updateFilteredBySender(any(), any()) } - } - - // ---- onServiceAction: Mute ---- - - @Test - fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - val node = createTestNode(REMOTE_NODE_NUM, isMuted = false) - - handler.onServiceAction(ServiceAction.Mute(node)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.updateNode(any(), any(), any(), any()) } - } - - // ---- onServiceAction: GetDeviceMetadata ---- - - @Test - fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- onServiceAction: SendContact ---- - - @Test - fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true - - val action = ServiceAction.SendContact(SharedContact()) - handler.onServiceAction(action) - advanceUntilIdle() - - assertTrue(action.result.isCompleted) - assertTrue(action.result.await()) - } - - @Test - fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false - - val action = ServiceAction.SendContact(SharedContact()) - handler.onServiceAction(action) - advanceUntilIdle() - - assertTrue(action.result.isCompleted) - assertFalse(action.result.await()) - } - - // ---- onServiceAction: ImportContact ---- - - @Test - fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - - val contact = - SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser")) - handler.onServiceAction(ServiceAction.ImportContact(contact)) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) } - } - - // ---- handleSetOwner ---- - - @Test - fun handleSetOwner_sendsAdminAndUpdatesLocalNode() { - handler = createHandler(testScope) - val meshUser = - MeshUser( - id = "!12345678", - longName = "Test Long", - shortName = "TL", - hwModel = HardwareModel.UNSET, - isLicensed = false, - ) - - handler.handleSetOwner(meshUser, MY_NODE_NUM) - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) } - } - - // ---- handleSend ---- - - @Test - fun handleSend_sendsDataAndBroadcastsStatus() { - handler = createHandler(testScope) - val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0) - - handler.handleSend(packet, MY_NODE_NUM) - - verify { commandSender.sendData(any()) } - verify { serviceBroadcasts.broadcastMessageStatus(any(), any()) } - verify { dataHandler.rememberDataPacket(any(), any(), any()) } - } - - // ---- handleRequestPosition: 3 branches ---- - - @Test - fun handleRequestPosition_sameNode_doesNothing() { - handler = createHandler(testScope) - - handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM) - - verify(not) { commandSender.requestPosition(any(), any()) } - } - - @Test - fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() { - handler = createHandler(testScope) - every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) - - val validPosition = Position(37.7749, -122.4194, 10) - handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) - - verify { commandSender.requestPosition(REMOTE_NODE_NUM, validPosition) } - } - - @Test - fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() { - handler = createHandler(testScope) - every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - val invalidPosition = Position(0.0, 0.0, 0) - handler.handleRequestPosition(REMOTE_NODE_NUM, invalidPosition, MY_NODE_NUM) - - // Falls back to Position(0.0, 0.0, 0) when node has no position in DB - verify { commandSender.requestPosition(any(), any()) } - } - - @Test - fun handleRequestPosition_doNotProvide_sendsZeroPosition() { - handler = createHandler(testScope) - every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false) - - val validPosition = Position(37.7749, -122.4194, 10) - handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM) - - // Should send zero position regardless of valid input - verify { commandSender.requestPosition(any(), any()) } - } - - // ---- handleSetConfig: optimistic persist ---- - - @Test - fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit - - val config = Config(lora = Config.LoRaConfig(hop_limit = 5)) - val payload = Config.ADAPTER.encode(config) - - handler.handleSetConfig(payload, MY_NODE_NUM) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verifySuspend { radioConfigRepository.setLocalConfig(any()) } - } - - // ---- handleSetModuleConfig: conditional persist ---- - - @Test - fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - myNodeNumFlow.value = MY_NODE_NUM - everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit - - val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) - val payload = ModuleConfig.ADAPTER.encode(moduleConfig) - - handler.handleSetModuleConfig(0, MY_NODE_NUM, payload) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verifySuspend { radioConfigRepository.setLocalModuleConfig(any()) } - } - - @Test - fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - myNodeNumFlow.value = MY_NODE_NUM - - val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) - val payload = ModuleConfig.ADAPTER.encode(moduleConfig) - - handler.handleSetModuleConfig(0, REMOTE_NODE_NUM, payload) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verifySuspend(not) { radioConfigRepository.setLocalModuleConfig(any()) } - } - - // ---- handleSetChannel: null payload guard ---- - - @Test - fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) { - handler = createHandler(backgroundScope) - everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit - - val channel = Channel(index = 1) - val payload = Channel.ADAPTER.encode(channel) - - handler.handleSetChannel(payload, MY_NODE_NUM) - advanceUntilIdle() - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verifySuspend { radioConfigRepository.updateChannelSettings(any()) } - } - - @Test - fun handleSetChannel_nullPayload_doesNothing() { - handler = createHandler(testScope) - - handler.handleSetChannel(null, MY_NODE_NUM) - - verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- handleRemoveByNodenum ---- - - @Test - fun handleRemoveByNodenum_removesAndSendsAdmin() { - handler = createHandler(testScope) - - handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM) - - verify { nodeManager.removeByNodenum(REMOTE_NODE_NUM) } - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- handleSetRemoteOwner ---- - - @Test - fun handleSetRemoteOwner_decodesAndSendsAdmin() { - handler = createHandler(testScope) - - val user = User(id = "!remote01", long_name = "Remote", short_name = "RM") - val payload = User.ADAPTER.encode(user) - - handler.handleSetRemoteOwner(1, REMOTE_NODE_NUM, payload) - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) } - } - - // ---- handleGetRemoteConfig: sessionkey vs regular ---- - - @Test - fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() { - handler = createHandler(testScope) - - handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - @Test - fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() { - handler = createHandler(testScope) - - handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value) - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- handleSetRemoteChannel: null payload guard ---- - - @Test - fun handleSetRemoteChannel_nullPayload_doesNothing() { - handler = createHandler(testScope) - - handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null) - - verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - @Test - fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() { - handler = createHandler(testScope) - - val channel = Channel(index = 2) - val payload = Channel.ADAPTER.encode(channel) - - handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, payload) - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- handleRequestRebootOta: null hash ---- - - @Test - fun handleRequestRebootOta_withNullHash_sendsAdmin() { - handler = createHandler(testScope) - - handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null) - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - @Test - fun handleRequestRebootOta_withHash_sendsAdmin() { - handler = createHandler(testScope) - - val hash = byteArrayOf(0x01, 0x02, 0x03) - handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash) - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- handleRequestNodedbReset ---- - - @Test - fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() { - handler = createHandler(testScope) - - handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true) - - verify { commandSender.sendAdmin(any(), any(), any(), any()) } - } - - // ---- Helper ---- - - private fun createTestNode( - num: Int, - isFavorite: Boolean = false, - isIgnored: Boolean = false, - isMuted: Boolean = false, - ): Node = Node( - num = num, - user = User(id = "!${num.toString(16).padStart(8, '0')}", long_name = "Node $num", short_name = "N$num"), - isFavorite = isFavorite, - isIgnored = isIgnored, - isMuted = isMuted, - ) -} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt deleted file mode 100644 index 5a96722840..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ /dev/null @@ -1,471 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import dev.mokkery.MockMode -import dev.mokkery.answering.calls -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import dev.mokkery.verify.VerifyMode -import dev.mokkery.verifySuspend -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import okio.ByteString.Companion.encodeUtf8 -import org.meshtastic.core.repository.HandshakeConstants -import org.meshtastic.core.repository.MeshConnectionManager -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.NotificationPrefs -import org.meshtastic.core.repository.PacketHandler -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.DeviceMetadata -import org.meshtastic.proto.FileInfo -import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.HardwareModel -import org.meshtastic.proto.NodeInfo -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo - -@OptIn(ExperimentalCoroutinesApi::class) -class MeshConfigFlowManagerImplTest { - - private val nodeManager = mock(MockMode.autofill) - private val connectionManager = mock(MockMode.autofill) - private val nodeRepository = mock(MockMode.autofill) - private val radioConfigRepository = mock(MockMode.autofill) - private val serviceRepository = mock(MockMode.autofill) - private val serviceBroadcasts = mock(MockMode.autofill) - private val analytics = mock(MockMode.autofill) - private val packetHandler = mock(MockMode.autofill) - private val notificationPrefs = mock(MockMode.autofill) - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private lateinit var manager: MeshConfigFlowManagerImpl - - private val myNodeNum = 12345 - - private val protoMyNodeInfo = - ProtoMyNodeInfo( - my_node_num = myNodeNum, - min_app_version = 30000, - device_id = "test-device".encodeUtf8(), - pio_env = "", - ) - - private val metadata = - DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false) - - @BeforeTest - fun setUp() { - every { packetHandler.sendToRadio(any()) } returns Unit - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - every { nodeManager.myNodeNum } returns MutableStateFlow(null) - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) - every { notificationPrefs.nodeEventsEnabled } returns MutableStateFlow(true) - - manager = - MeshConfigFlowManagerImpl( - nodeManager = nodeManager, - connectionManager = lazy { connectionManager }, - nodeRepository = nodeRepository, - radioConfigRepository = radioConfigRepository, - serviceRepository = serviceRepository, - serviceBroadcasts = serviceBroadcasts, - analytics = analytics, - heartbeatSender = DataLayerHeartbeatSender(packetHandler), - notificationPrefs = notificationPrefs, - scope = testScope, - ) - } - - // ---------- handleMyInfo ---------- - - @Test - fun `handleMyInfo transitions to ReceivingConfig and sets myNodeNum`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - verify { nodeManager.setMyNodeNum(myNodeNum) } - } - - @Test - fun `handleMyInfo clears persisted radio config`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - verifySuspend { radioConfigRepository.clearChannelSet() } - verifySuspend { radioConfigRepository.clearLocalConfig() } - verifySuspend { radioConfigRepository.clearLocalModuleConfig() } - verifySuspend { radioConfigRepository.clearDeviceUIConfig() } - verifySuspend { radioConfigRepository.clearFileManifest() } - } - - // ---------- handleLocalMetadata ---------- - - @Test - fun `handleLocalMetadata persists metadata when in ReceivingConfig state`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - verifySuspend { nodeRepository.insertMetadata(myNodeNum, metadata) } - } - - @Test - fun `handleLocalMetadata skips empty metadata`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - // Default/empty DeviceMetadata should not trigger insertMetadata - manager.handleLocalMetadata(DeviceMetadata()) - advanceUntilIdle() - - // insertMetadata should only have been called zero times for default metadata - // (we just verify no crash occurs) - } - - @Test - fun `handleLocalMetadata ignored outside ReceivingConfig state`() = testScope.runTest { - // State is Idle — handleLocalMetadata should be a no-op - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - // No crash, no insertMetadata call - } - - // ---------- handleConfigComplete Stage 1 ---------- - - @Test - fun `Stage 1 complete builds MyNodeInfo and transitions to ReceivingNodeInfo`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - verify { connectionManager.onRadioConfigLoaded() } - verify { connectionManager.startNodeInfoOnly() } - } - - @Test - fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest { - val sentPackets = mutableListOf() - every { packetHandler.sendToRadio(any()) } calls - { call -> - sentPackets.add(call.arg(0)) - } - - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - sentPackets.clear() // Clear any packets from prior phases - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - val heartbeats = sentPackets.filter { it.heartbeat != null } - assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat") - assertEquals( - true, - heartbeats[0].heartbeat!!.nonce != 0, - "Inter-stage heartbeat should have a non-zero nonce", - ) - } - - @Test - fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest { - val oldMetadata = - DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false) - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(oldMetadata) - advanceUntilIdle() - - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - // Handshake should still progress despite old firmware - verify { connectionManager.onRadioConfigLoaded() } - verify { connectionManager.startNodeInfoOnly() } - } - - @Test - fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - // No metadata provided - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - verify { connectionManager.onRadioConfigLoaded() } - } - - @Test - fun `Stage 1 complete id ignored when not in ReceivingConfig state`() = testScope.runTest { - // State is Idle — should be a no-op - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - // No crash, no onRadioConfigLoaded - } - - @Test - fun `Duplicate Stage 1 config_complete does not re-trigger`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - // Now in ReceivingNodeInfo — a second Stage 1 complete should be ignored - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - } - - // ---------- handleNodeInfo ---------- - - @Test - fun `handleNodeInfo accumulates nodes during Stage 2`() = testScope.runTest { - // Transition to Stage 2 - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - // Now in ReceivingNodeInfo - manager.handleNodeInfo(NodeInfo(num = 100)) - manager.handleNodeInfo(NodeInfo(num = 200)) - - assertEquals(2, manager.newNodeCount) - } - - @Test - fun `handleNodeInfo ignored outside Stage 2`() = testScope.runTest { - // State is Idle - manager.handleNodeInfo(NodeInfo(num = 999)) - - assertEquals(0, manager.newNodeCount) - } - - // ---------- handleConfigComplete Stage 2 ---------- - - @Test - fun `Stage 2 complete processes nodes and sets Connected state`() = testScope.runTest { - val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100) - every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode) - - // Full handshake: MyInfo -> metadata -> Stage 1 complete -> nodes -> Stage 2 complete - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - manager.handleNodeInfo(NodeInfo(num = 100)) - manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) - advanceUntilIdle() - - verify { nodeManager.installNodeInfo(any(), withBroadcast = false) } - verify { nodeManager.setNodeDbReady(true) } - verify { nodeManager.setAllowNodeDbWrites(true) } - verify { serviceBroadcasts.broadcastConnection() } - verify { connectionManager.onNodeDbReady() } - } - - @Test - fun `Stage 2 complete id ignored when not in ReceivingNodeInfo state`() = testScope.runTest { - manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) - advanceUntilIdle() - // No crash - } - - @Test - fun `Stage 2 complete with no nodes still transitions to Connected`() = testScope.runTest { - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - - // No handleNodeInfo calls — empty node list - manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) - advanceUntilIdle() - - verify { nodeManager.setNodeDbReady(true) } - verify { connectionManager.onNodeDbReady() } - } - - // ---------- Unknown config_complete_id ---------- - - @Test - fun `Unknown config_complete_id is ignored`() = testScope.runTest { - manager.handleConfigComplete(99999) - advanceUntilIdle() - // No crash - } - - // ---------- newNodeCount ---------- - - @Test - fun `newNodeCount returns 0 when not in ReceivingNodeInfo state`() { - assertEquals(0, manager.newNodeCount) - } - - // ---------- handleFileInfo ---------- - - @Test - fun `handleFileInfo delegates to radioConfigRepository`() = testScope.runTest { - val fileInfo = FileInfo(file_name = "firmware.bin", size_bytes = 1024) - manager.handleFileInfo(fileInfo) - advanceUntilIdle() - - verifySuspend { radioConfigRepository.addFileInfo(fileInfo) } - } - - // ---------- triggerWantConfig ---------- - - @Test - fun `triggerWantConfig delegates to connectionManager startConfigOnly`() { - manager.triggerWantConfig() - verify { connectionManager.startConfigOnly() } - } - - // ---------- Full handshake flow ---------- - - @Test - fun `Full handshake from Idle to Complete`() = testScope.runTest { - val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100) - every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode) - - // Stage 0: Idle -> handleMyInfo - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - verify { nodeManager.setMyNodeNum(myNodeNum) } - - // Receive metadata during Stage 1 - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - // Stage 1 complete - manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE) - advanceUntilIdle() - verify { connectionManager.onRadioConfigLoaded() } - - // Receive NodeInfo during Stage 2 - manager.handleNodeInfo(NodeInfo(num = 100)) - assertEquals(1, manager.newNodeCount) - - // Stage 2 complete - manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE) - advanceUntilIdle() - - verify { nodeManager.setNodeDbReady(true) } - verify { connectionManager.onNodeDbReady() } - - // After complete, newNodeCount should be 0 (state is Complete) - assertEquals(0, manager.newNodeCount) - } - - // ---------- Interrupted handshake ---------- - - @Test - fun `handleMyInfo resets stale handshake state`() = testScope.runTest { - // Start first handshake - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - manager.handleLocalMetadata(metadata) - advanceUntilIdle() - - // Before Stage 1 completes, a new handleMyInfo arrives (device rebooted) - val newMyInfo = protoMyNodeInfo.copy(my_node_num = 99999) - manager.handleMyInfo(newMyInfo) - advanceUntilIdle() - - verify { nodeManager.setMyNodeNum(99999) } - } - - // ---------- Event firmware notification defaults ---------- - - @Test - fun `handleMyInfo disables node notifications for event firmware`() = testScope.runTest { - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) - - val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON) - manager.handleMyInfo(eventMyInfo) - advanceUntilIdle() - - verify { notificationPrefs.setNodeEventsEnabled(false) } - verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(true) } - } - - @Test - fun `handleMyInfo does not re-disable if already auto-disabled`() = testScope.runTest { - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true) - - val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON) - manager.handleMyInfo(eventMyInfo) - advanceUntilIdle() - - verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) } - } - - @Test - fun `handleMyInfo re-enables node notifications when vanilla firmware reconnects`() = testScope.runTest { - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true) - - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - verify { notificationPrefs.setNodeEventsEnabled(true) } - verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(false) } - } - - @Test - fun `handleMyInfo does not touch prefs for vanilla when not previously auto-disabled`() = testScope.runTest { - every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) - - manager.handleMyInfo(protoMyNodeInfo) - advanceUntilIdle() - - verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) } - verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsAutoDisabledForEvent(any()) } - } -} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt deleted file mode 100644 index e36efda4fe..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt +++ /dev/null @@ -1,430 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.data.manager - -import dev.mokkery.MockMode -import dev.mokkery.answering.calls -import dev.mokkery.answering.returns -import dev.mokkery.every -import dev.mokkery.everySuspend -import dev.mokkery.matcher.any -import dev.mokkery.mock -import dev.mokkery.verify -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.AppWidgetUpdater -import org.meshtastic.core.repository.HistoryManager -import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.MeshWorkerManager -import org.meshtastic.core.repository.MqttManager -import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.PacketHandler -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.PlatformAnalytics -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceBroadcasts -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.repository.SessionManager -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.proto.Config -import org.meshtastic.proto.LocalConfig -import org.meshtastic.proto.LocalModuleConfig -import org.meshtastic.proto.ModuleConfig -import kotlin.test.AfterTest -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) -class MeshConnectionManagerImplTest { - private val radioInterfaceService = mock(MockMode.autofill) - private val serviceRepository = mock(MockMode.autofill) - private val serviceBroadcasts = mock(MockMode.autofill) - private val serviceNotifications = mock(MockMode.autofill) - private val uiPrefs = mock(MockMode.autofill) - private val packetHandler = mock(MockMode.autofill) - private val nodeRepository = FakeNodeRepository() - private val locationManager = mock(MockMode.autofill) - private val mqttManager = mock(MockMode.autofill) - private val historyManager = mock(MockMode.autofill) - private val radioConfigRepository = mock(MockMode.autofill) - private val radioController = mock(MockMode.autofill) - private val sessionManager = mock(MockMode.autofill) - private val nodeManager = mock(MockMode.autofill) - private val analytics = mock(MockMode.autofill) - private val packetRepository = mock(MockMode.autofill) - private val workerManager = mock(MockMode.autofill) - private val appWidgetUpdater = mock(MockMode.autofill) - - private val dataPacket = DataPacket(id = 456, time = 0L, to = "0", from = "0", bytes = null, dataType = 0) - - private val radioConnectionState = MutableStateFlow(ConnectionState.Disconnected) - private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected) - private val localConfigFlow = MutableStateFlow(LocalConfig()) - private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig()) - - private val testDispatcher = UnconfinedTestDispatcher() - - private lateinit var manager: MeshConnectionManagerImpl - - @BeforeTest - fun setUp() { - every { radioInterfaceService.connectionState } returns radioConnectionState - every { radioConfigRepository.localConfigFlow } returns localConfigFlow - every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow - every { serviceRepository.connectionState } returns connectionStateFlow - every { serviceRepository.setConnectionState(any()) } calls - { call -> - connectionStateFlow.value = call.arg(0) - } - every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit - every { packetHandler.stopPacketQueue() } returns Unit - every { locationManager.stop() } returns Unit - every { mqttManager.stop() } returns Unit - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - every { packetHandler.sendToRadio(any()) } returns Unit - } - - private fun createManager(scope: CoroutineScope): MeshConnectionManagerImpl = MeshConnectionManagerImpl( - radioInterfaceService, - serviceRepository, - serviceBroadcasts, - serviceNotifications, - uiPrefs, - packetHandler, - nodeRepository, - locationManager, - mqttManager, - historyManager, - radioConfigRepository, - radioController, - sessionManager, - nodeManager, - analytics, - packetRepository, - workerManager, - appWidgetUpdater, - DataLayerHeartbeatSender(packetHandler), - scope, - ) - - @AfterTest fun tearDown() = Unit - - @Test - fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) { - manager = createManager(backgroundScope) - radioConnectionState.value = ConnectionState.Connected - advanceUntilIdle() - - assertEquals( - ConnectionState.Connecting, - serviceRepository.connectionState.value, - "State should be Connecting after radio Connected", - ) - verify { serviceBroadcasts.broadcastConnection() } - } - - @Test - fun `Connected state sends pre-handshake heartbeat before config request`() = runTest(testDispatcher) { - val sentPackets = mutableListOf() - every { packetHandler.sendToRadio(any()) } calls - { call -> - sentPackets.add(call.arg(0)) - } - - manager = createManager(backgroundScope) - radioConnectionState.value = ConnectionState.Connected - // Advance past PRE_HANDSHAKE_SETTLE_MS (100ms) but NOT the 30s stall guard timeout - advanceTimeBy(200) - - // First ToRadio should be a heartbeat, second should be want_config_id - assertEquals(2, sentPackets.size, "Expected heartbeat + want_config_id, got ${sentPackets.size} packets") - val heartbeat = sentPackets[0] - val wantConfig = sentPackets[1] - - assertEquals(true, heartbeat.heartbeat != null, "First packet should be a heartbeat") - assertEquals(true, heartbeat.heartbeat!!.nonce != 0, "Heartbeat should have a non-zero nonce") - assertEquals( - org.meshtastic.core.repository.HandshakeConstants.CONFIG_NONCE, - wantConfig.want_config_id, - "Second packet should be want_config_id with CONFIG_NONCE", - ) - } - - @Test - fun `Disconnect during pre-handshake settle cancels config start`() = runTest(testDispatcher) { - val sentPackets = mutableListOf() - every { packetHandler.sendToRadio(any()) } calls - { call -> - sentPackets.add(call.arg(0)) - } - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - manager = createManager(backgroundScope) - radioConnectionState.value = ConnectionState.Connected - // Advance only 50ms — within the 100ms settle window - advanceTimeBy(50) - - // Should have sent only the heartbeat so far, not want_config_id - assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes") - - // Disconnect before the settle delay completes — should cancel the pending config start - radioConnectionState.value = ConnectionState.Disconnected - advanceTimeBy(200) - - // The want_config_id should NOT have been sent because the job was cancelled - val configPackets = sentPackets.filter { it.want_config_id != null } - assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect") - } - - @Test - fun `Disconnected state stops services`() = runTest(testDispatcher) { - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - manager = createManager(backgroundScope) - // Transition to Connected first so that Disconnected actually does something - radioConnectionState.value = ConnectionState.Connected - advanceUntilIdle() - - radioConnectionState.value = ConnectionState.Disconnected - advanceUntilIdle() - - assertEquals( - ConnectionState.Disconnected, - serviceRepository.connectionState.value, - "State should be Disconnected after radio Disconnected", - ) - verify { packetHandler.stopPacketQueue() } - verify { locationManager.stop() } - verify { mqttManager.stop() } - } - - @Test - fun `DeviceSleep behavior when power saving is off maps to Disconnected`() = runTest(testDispatcher) { - // Power saving disabled + Role CLIENT - val config = - LocalConfig( - power = Config.PowerConfig(is_power_saving = false), - device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT), - ) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - manager = createManager(backgroundScope) - advanceUntilIdle() - - radioConnectionState.value = ConnectionState.DeviceSleep - advanceUntilIdle() - - assertEquals( - ConnectionState.Disconnected, - serviceRepository.connectionState.value, - "State should be Disconnected when power saving is off", - ) - } - - @Test - fun `DeviceSleep behavior when power saving is on stays in DeviceSleep`() = runTest(testDispatcher) { - // Power saving enabled - val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - - manager = createManager(backgroundScope) - advanceUntilIdle() - - radioConnectionState.value = ConnectionState.DeviceSleep - advanceUntilIdle() - - assertEquals( - ConnectionState.DeviceSleep, - serviceRepository.connectionState.value, - "State should stay in DeviceSleep when power saving is on", - ) - } - - @Test - fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) { - manager = createManager(backgroundScope) - val packetId = 456 - everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket) - every { workerManager.enqueueSendMessage(any()) } returns Unit - - manager.onRadioConfigLoaded() - advanceUntilIdle() - - verify { workerManager.enqueueSendMessage(packetId) } - } - - @Test - fun `onNodeDbReady starts MQTT and requests history`() = runTest(testDispatcher) { - val moduleConfig = - LocalModuleConfig( - mqtt = ModuleConfig.MQTTConfig(enabled = true, proxy_to_client_enabled = true), - store_forward = ModuleConfig.StoreForwardConfig(enabled = true), - ) - moduleConfigFlow.value = moduleConfig - every { nodeManager.myNodeNum } returns MutableStateFlow(123) - everySuspend { radioController.requestTelemetry(any(), any(), any()) } returns Unit - every { mqttManager.startProxy(any(), any()) } returns Unit - every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit - every { nodeManager.getMyNodeInfo() } returns null - - manager = createManager(backgroundScope) - manager.onNodeDbReady() - advanceUntilIdle() - - verify { mqttManager.startProxy(true, true) } - verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) } - } - - @Test - fun `DeviceSleep timeout is capped at MAX_SLEEP_TIMEOUT_SECONDS for high ls_secs`() = runTest(testDispatcher) { - // Router with ls_secs=3600 — previously this created a 3630s timeout. - // With the cap, it should be clamped to 300s. - val config = - LocalConfig( - power = Config.PowerConfig(is_power_saving = true, ls_secs = 3600), - device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER), - ) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - manager = createManager(backgroundScope) - advanceUntilIdle() - - // Transition to Connected then DeviceSleep - radioConnectionState.value = ConnectionState.Connected - advanceUntilIdle() - radioConnectionState.value = ConnectionState.DeviceSleep - advanceUntilIdle() - - assertEquals( - ConnectionState.DeviceSleep, - serviceRepository.connectionState.value, - "Should be in DeviceSleep initially", - ) - - // Advance 300 seconds (the cap) + 1 second to trigger the timeout. - advanceTimeBy(301_000L) - - assertEquals( - ConnectionState.Disconnected, - serviceRepository.connectionState.value, - "Should transition to Disconnected after capped timeout (300s), not the raw 3630s", - ) - } - - @Test - fun `rapid state transitions are serialized by connectionMutex`() = runTest(testDispatcher) { - // Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected) - val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true)) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - // Record every state transition so we can verify ordering - val observed = mutableListOf() - every { serviceRepository.setConnectionState(any()) } calls - { call -> - val state = call.arg(0) - observed.add(state) - connectionStateFlow.value = state - } - - manager = createManager(backgroundScope) - advanceUntilIdle() - - // Rapid-fire: Connected -> DeviceSleep -> Disconnected without yielding between them. - // Without the Mutex, the intermediate DeviceSleep could be missed or applied out of order. - radioConnectionState.value = ConnectionState.Connected - radioConnectionState.value = ConnectionState.DeviceSleep - radioConnectionState.value = ConnectionState.Disconnected - advanceUntilIdle() - - // Verify final state - assertEquals( - ConnectionState.Disconnected, - serviceRepository.connectionState.value, - "Final state should be Disconnected after rapid transitions", - ) - - // Verify that all intermediate states were observed in correct order. - // Connected triggers handleConnected() which sets Connecting (handshake start), - // then DeviceSleep, then Disconnected. - assertEquals( - listOf(ConnectionState.Connecting, ConnectionState.DeviceSleep, ConnectionState.Disconnected), - observed, - "State transitions should be serialized in order: Connecting -> DeviceSleep -> Disconnected", - ) - } - - @Test - fun `concurrent sleep-timeout and radio state change are serialized`() { - val standardDispatcher = StandardTestDispatcher() - runTest(standardDispatcher) { - // Power saving enabled with a short ls_secs so the sleep timeout fires quickly - val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1)) - every { radioConfigRepository.localConfigFlow } returns flowOf(config) - every { nodeManager.nodeDBbyNodeNum } returns emptyMap() - - val observed = mutableListOf() - every { serviceRepository.setConnectionState(any()) } calls - { call -> - val state = call.arg(0) - observed.add(state) - connectionStateFlow.value = state - } - - manager = createManager(backgroundScope) - advanceUntilIdle() - - // Transition to Connected -> DeviceSleep to start the sleep timer - radioConnectionState.value = ConnectionState.Connected - advanceUntilIdle() - radioConnectionState.value = ConnectionState.DeviceSleep - advanceUntilIdle() - - observed.clear() - - // Before the sleep timeout fires, emit Connected from the radio (simulating device - // waking up). Then let the timeout fire. The mutex ensures they don't race. - radioConnectionState.value = ConnectionState.Connected - // Advance past the sleep timeout (ls_secs=1 + 30s base = 31s) - advanceTimeBy(32_000L) - advanceUntilIdle() - - // The Connected transition should have cancelled the sleep timeout, so we should - // end up in Connecting (from handleConnected), NOT Disconnected (from timeout). - assertEquals( - ConnectionState.Connecting, - serviceRepository.connectionState.value, - "Connected should cancel the sleep timeout; final state should be Connecting", - ) - } - } -} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt deleted file mode 100644 index 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/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index 5090668672..2fd61e67bf 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -25,7 +25,6 @@ import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.HardwareModel @@ -43,7 +42,6 @@ import org.meshtastic.proto.Position as ProtoPosition class NodeManagerImplTest { private val nodeRepository: NodeRepository = mock(MockMode.autofill) - private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill) private val notificationManager: NotificationManager = mock(MockMode.autofill) private val testScope = TestScope() @@ -51,7 +49,7 @@ class NodeManagerImplTest { @BeforeTest fun setUp() { - nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope) + nodeManager = NodeManagerImpl(nodeRepository, notificationManager, testScope) } @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt deleted file mode 100644 index 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/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index 900245332f..c87790d9a7 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -36,7 +36,6 @@ import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum @@ -50,7 +49,6 @@ class StoreForwardPacketHandlerImplTest { private val nodeManager = mock(MockMode.autofill) private val packetRepository = mock(MockMode.autofill) - private val serviceBroadcasts = mock(MockMode.autofill) private val historyManager = mock(MockMode.autofill) private val dataHandler = mock(MockMode.autofill) @@ -69,7 +67,6 @@ class StoreForwardPacketHandlerImplTest { StoreForwardPacketHandlerImpl( nodeManager = nodeManager, packetRepository = lazy { packetRepository }, - serviceBroadcasts = serviceBroadcasts, historyManager = historyManager, dataHandler = lazy { dataHandler }, scope = testScope, @@ -222,7 +219,6 @@ class StoreForwardPacketHandlerImplTest { advanceUntilIdle() verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) } - verify { serviceBroadcasts.broadcastMessageStatus(42, any()) } } // ---------- SF++: CANON_ANNOUNCE ---------- diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt index 28bf22fdcc..71fa601576 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt @@ -27,7 +27,6 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NotificationManager import org.meshtastic.proto.Data @@ -44,7 +43,6 @@ import kotlin.test.Test class TelemetryPacketHandlerImplTest { private val nodeManager = mock(MockMode.autofill) - private val connectionManager = mock(MockMode.autofill) private val notificationManager = mock(MockMode.autofill) private val testDispatcher = StandardTestDispatcher() @@ -60,7 +58,6 @@ class TelemetryPacketHandlerImplTest { handler = TelemetryPacketHandlerImpl( nodeManager = nodeManager, - connectionManager = lazy { connectionManager }, notificationManager = notificationManager, scope = testScope, ) @@ -87,7 +84,7 @@ class TelemetryPacketHandlerImplTest { // ---------- Device metrics from local node ---------- @Test - fun `local device metrics updates telemetry on connectionManager`() = testScope.runTest { + fun `local device metrics updates node`() = testScope.runTest { val telemetry = Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.1f)) val packet = makeTelemetryPacket(myNodeNum, telemetry) @@ -96,14 +93,13 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { connectionManager.updateTelemetry(any()) } verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) } } // ---------- Device metrics from remote node ---------- @Test - fun `remote device metrics updates node but not connectionManager`() = testScope.runTest { + fun `remote device metrics updates node`() = testScope.runTest { val telemetry = Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 90, voltage = 4.2f)) val packet = makeTelemetryPacket(remoteNodeNum, telemetry) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonNodeRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonNodeRepositoryTest.kt deleted file mode 100644 index 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/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/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index d329d184cc..72f4e92098 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -23,11 +23,14 @@ import androidx.room3.DeleteTable import androidx.room3.RoomDatabase import androidx.room3.TypeConverters import androidx.room3.migration.AutoMigrationSpec +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.dao.DeviceHardwareDao import org.meshtastic.core.database.dao.FirmwareReleaseDao import org.meshtastic.core.database.dao.MeshLogDao import org.meshtastic.core.database.dao.NodeInfoDao +import org.meshtastic.core.database.dao.NodeMetadataDao import org.meshtastic.core.database.dao.PacketDao import org.meshtastic.core.database.dao.QuickChatActionDao import org.meshtastic.core.database.dao.TracerouteNodePositionDao @@ -38,6 +41,7 @@ import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.NodeEntity +import org.meshtastic.core.database.entity.NodeMetadataEntity import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.entity.QuickChatAction import org.meshtastic.core.database.entity.ReactionEntity @@ -48,6 +52,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity [ MyNodeEntity::class, NodeEntity::class, + NodeMetadataEntity::class, Packet::class, ContactSettings::class, MeshLog::class, @@ -95,8 +100,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 35, to = 36), AutoMigration(from = 36, to = 37), AutoMigration(from = 37, to = 38), + AutoMigration(from = 38, to = 39, spec = AutoMigration38to39::class), ], - version = 38, + version = 39, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) @@ -105,6 +111,8 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity abstract class MeshtasticDatabase : RoomDatabase() { abstract fun nodeInfoDao(): NodeInfoDao + abstract fun nodeMetadataDao(): NodeMetadataDao + abstract fun packetDao(): PacketDao abstract fun meshLogDao(): MeshLogDao @@ -138,3 +146,17 @@ class AutoMigration33to34 : AutoMigrationSpec @DeleteColumn(tableName = "packet", columnName = "retry_count") @DeleteColumn(tableName = "reactions", columnName = "retry_count") class AutoMigration34to35 : AutoMigrationSpec + +/** Copies favorites, notes, ignored, muted, and manuallyVerified from nodes → node_metadata. */ +class AutoMigration38to39 : AutoMigrationSpec { + override suspend fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL( + """ + INSERT OR IGNORE INTO node_metadata (num, is_favorite, is_ignored, is_muted, notes, manually_verified) + SELECT num, is_favorite, is_ignored, is_muted, notes, manually_verified + FROM nodes + WHERE is_favorite = 1 OR is_ignored = 1 OR is_muted = 1 OR notes != '' OR manually_verified = 1 + """.trimIndent() + ) + } +} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt new file mode 100644 index 0000000000..35f1f06e6d --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.NodeMetadataEntity + +@Dao +interface NodeMetadataDao { + + @Upsert + suspend fun upsert(metadata: NodeMetadataEntity) + + @Query("SELECT * FROM node_metadata") + fun getAllFlow(): Flow> + + @Query("SELECT * FROM node_metadata WHERE num = :num") + suspend fun getByNum(num: Int): NodeMetadataEntity? + + @Query("UPDATE node_metadata SET is_favorite = :isFavorite WHERE num = :num") + suspend fun setFavorite(num: Int, isFavorite: Boolean) + + @Query("UPDATE node_metadata SET is_ignored = :isIgnored WHERE num = :num") + suspend fun setIgnored(num: Int, isIgnored: Boolean) + + @Query("UPDATE node_metadata SET is_muted = :isMuted WHERE num = :num") + suspend fun setMuted(num: Int, isMuted: Boolean) + + @Query("UPDATE node_metadata SET notes = :notes WHERE num = :num") + suspend fun setNotes(num: Int, notes: String) + + @Query("UPDATE node_metadata SET manually_verified = :verified WHERE num = :num") + suspend fun setManuallyVerified(num: Int, verified: Boolean) + + @Query("DELETE FROM node_metadata WHERE num = :num") + suspend fun delete(num: Int) + + @Query("DELETE FROM node_metadata") + suspend fun deleteAll() +} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeMetadataEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeMetadataEntity.kt new file mode 100644 index 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/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt deleted file mode 100644 index cc3a1a37ea..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.UiPrefs - -@Single -open class SetAppIntroCompletedUseCase constructor(private val uiPrefs: UiPrefs) { - operator fun invoke(value: Boolean) { - uiPrefs.setAppIntroCompleted(value) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt deleted file mode 100644 index 8d3018266b..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.koin.core.annotation.Single -import org.meshtastic.core.common.database.DatabaseManager -import org.meshtastic.core.database.DatabaseConstants - -/** Use case for setting the database cache limit. */ -@Single -open class SetDatabaseCacheLimitUseCase constructor(private val databaseManager: DatabaseManager) { - operator fun invoke(limit: Int) { - val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT) - databaseManager.setCacheLimit(clamped) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt deleted file mode 100644 index 6e994f4efd..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetLocaleUseCase.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.UiPrefs - -@Single -open class SetLocaleUseCase constructor(private val uiPrefs: UiPrefs) { - operator fun invoke(value: String) { - uiPrefs.setLocale(value) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetNotificationSettingsUseCase.kt deleted file mode 100644 index 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/SetProvideLocationUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt deleted file mode 100644 index d768ba0091..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.UiPrefs - -@Single -open class SetProvideLocationUseCase constructor(private val uiPrefs: UiPrefs) { - operator fun invoke(myNodeNum: Int, provideLocation: Boolean) { - uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt deleted file mode 100644 index 58d260e32d..0000000000 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.domain.usecase.settings - -import org.koin.core.annotation.Single -import org.meshtastic.core.repository.UiPrefs - -@Single -open class SetThemeUseCase constructor(private val uiPrefs: UiPrefs) { - operator fun invoke(value: Int) { - uiPrefs.setTheme(value) - } -} diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt deleted file mode 100644 index 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/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/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/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/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/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/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/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/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/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/Constants.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt index 425b19fe2b..b5648d7d31 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt @@ -24,7 +24,6 @@ const val ACTION_NODE_CHANGE = MeshtasticIntent.ACTION_NODE_CHANGE const val ACTION_MESH_CONNECTED = MeshtasticIntent.ACTION_MESH_CONNECTED const val ACTION_MESH_DISCONNECTED = MeshtasticIntent.ACTION_MESH_DISCONNECTED -@Suppress("DEPRECATION") // Intentionally re-exported for backward-compat broadcast in ServiceBroadcasts const val ACTION_CONNECTION_CHANGED = MeshtasticIntent.ACTION_CONNECTION_CHANGED const val ACTION_MESSAGE_STATUS = MeshtasticIntent.ACTION_MESSAGE_STATUS diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 307afbc7f8..3a401a0c35 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt @@ -30,10 +30,10 @@ import org.koin.android.ext.android.inject import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceVersion -import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.RadioInterfaceService import org.meshtastic.core.repository.SERVICE_NOTIFY_ID +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.PortNum /** @@ -47,10 +47,10 @@ class MeshService : Service() { private val radioInterfaceService: RadioInterfaceService by inject() - private val connectionManager: MeshConnectionManager by inject() - private val notifications: MeshServiceNotifications by inject() + private val serviceRepository: ServiceRepository by inject() + /** Android-typed accessor for the foreground service notification. */ private val androidNotifications: MeshServiceNotificationsImpl get() = notifications as MeshServiceNotificationsImpl @@ -112,7 +112,7 @@ class MeshService : Service() { val a = radioInterfaceService.getDeviceAddress() val wantForeground = a != null && a != "n" - connectionManager.updateStatusNotification() + notifications.updateServiceStateNotification(serviceRepository.connectionState.value, null) val notification = androidNotifications.getServiceNotification() val foregroundServiceType = diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ServiceBroadcasts.kt deleted file mode 100644 index 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/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 bc46f452c5..a6644e4446 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt @@ -27,10 +27,11 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.di.CoroutineDispatchers -import org.meshtastic.core.repository.MeshConnectionManager +import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioInterfaceService +import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs import org.meshtastic.core.takserver.TAKMeshIntegration import org.meshtastic.core.takserver.TAKServerManager @@ -53,7 +54,8 @@ class MeshServiceOrchestrator( private val takMeshIntegration: TAKMeshIntegration, private val takPrefs: TakPrefs, private val databaseManager: DatabaseManager, - private val connectionManager: MeshConnectionManager, + private val serviceRepository: ServiceRepository, + private val appWidgetUpdater: AppWidgetUpdater, private val dispatchers: CoroutineDispatchers, ) { // Per-start coroutine scope. A fresh scope is created on each start() and cancelled on stop(), so all collectors @@ -86,7 +88,21 @@ class MeshServiceOrchestrator( scope = newScope serviceNotifications.initChannels() - connectionManager.updateStatusNotification() + serviceNotifications.updateServiceStateNotification(serviceRepository.connectionState.value, null) + + // Keep notification in sync with connection state changes + serviceRepository.connectionState + .onEach { state -> serviceNotifications.updateServiceStateNotification(state, null) } + .launchIn(newScope) + + // Kickstart app widget + newScope.handledLaunch { + try { + appWidgetUpdater.updateAll() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + Logger.e(e) { "Failed to kickstart LocalStatsWidget" } + } + } // Observe TAK server pref to start/stop takPrefs.isTakServerEnabled @@ -107,10 +123,6 @@ class MeshServiceOrchestrator( Logger.i { "Per-device database initialized" } } - // NOTE: Radio connection, packet routing, and ServiceAction dispatch are now handled - // by RadioClientProvider + SdkStateBridge. The old radioInterfaceService.connect() / - // receivedData / serviceAction subscription paths are no longer needed. - nodeManager.loadCachedNodeDB() } diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index ec30d024c0..baeaf6a4f9 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt @@ -30,9 +30,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.meshtastic.core.common.database.DatabaseManager import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.RadioController +import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.MeshConfigHandler -import org.meshtastic.core.repository.MeshConnectionManager import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository @@ -55,7 +56,7 @@ class MeshServiceOrchestratorTest { private val takServerManager: TAKServerManager = mock(MockMode.autofill) private val takPrefs: TakPrefs = mock(MockMode.autofill) private val databaseManager: DatabaseManager = mock(MockMode.autofill) - private val connectionManager: MeshConnectionManager = mock(MockMode.autofill) + private val appWidgetUpdater: AppWidgetUpdater = mock(MockMode.autofill) // TAKMeshIntegration deps (final class — constructed directly) private val radioController: RadioController = mock(MockMode.autofill) @@ -80,6 +81,7 @@ class MeshServiceOrchestratorTest { every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig()) every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() + every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) val takMeshIntegration = TAKMeshIntegration( takServerManager = takServerManager, @@ -98,7 +100,8 @@ class MeshServiceOrchestratorTest { takMeshIntegration = takMeshIntegration, takPrefs = takPrefs, databaseManager = databaseManager, - connectionManager = connectionManager, + serviceRepository = serviceRepository, + appWidgetUpdater = appWidgetUpdater, dispatchers = dispatchers, ) } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 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..e3f83bea1d 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -39,7 +39,7 @@ import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource import org.meshtastic.core.model.BootloaderOtaQuirk import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases -import org.meshtastic.core.model.RadioController +import org.meshtastic.core.data.radio.RadioClientAccessor import org.meshtastic.core.network.HttpClientDefaults import org.meshtastic.core.network.KermitHttpLogger import org.meshtastic.core.network.repository.MQTTRepository @@ -54,9 +54,8 @@ import org.meshtastic.core.repository.MessageQueue import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioTransportFactory -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.core.service.DirectRadioControllerImpl +import org.meshtastic.core.service.SdkClientLifecycle import org.meshtastic.core.service.ServiceRepositoryImpl import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.desktop.DesktopNotificationManager @@ -67,6 +66,7 @@ import org.meshtastic.desktop.notification.MacOSNotificationSender import org.meshtastic.desktop.notification.NativeNotificationSender import org.meshtastic.desktop.notification.WindowsNotificationSender import org.meshtastic.desktop.radio.DesktopMessageQueue +import org.meshtastic.desktop.radio.DesktopRadioClientProvider import org.meshtastic.desktop.radio.DesktopRadioTransportFactory import org.meshtastic.desktop.stub.NoopAppWidgetUpdater import org.meshtastic.desktop.stub.NoopCompassHeadingProvider @@ -77,7 +77,6 @@ import org.meshtastic.desktop.stub.NoopMeshLocationManager import org.meshtastic.desktop.stub.NoopMeshWorkerManager import org.meshtastic.desktop.stub.NoopPhoneLocationProvider import org.meshtastic.desktop.stub.NoopPlatformAnalytics -import org.meshtastic.desktop.stub.NoopServiceBroadcasts import org.meshtastic.feature.node.compass.CompassHeadingProvider import org.meshtastic.feature.node.compass.MagneticFieldProvider import org.meshtastic.feature.node.compass.PhoneLocationProvider @@ -159,17 +158,10 @@ private fun desktopPlatformStubsModule() = module { connectionFactory = get(), ) } - single { - DirectRadioControllerImpl( - serviceRepository = get(), - nodeRepository = get(), - commandSender = get(), - router = get(), - nodeManager = get(), - radioInterfaceService = get(), - locationManager = get(), - ) - } + // SDK-backed RadioClient lifecycle — replaces DirectRadioControllerImpl + single { DesktopRadioClientProvider(radioPrefs = get()) } + single { get() } + single { get() } single { when (DesktopOS.current()) { DesktopOS.Linux -> LinuxNotificationSender() @@ -181,7 +173,6 @@ private fun desktopPlatformStubsModule() = module { single { get() } single { DesktopMeshServiceNotifications(notificationManager = get()) } single { NoopPlatformAnalytics() } - single { NoopServiceBroadcasts() } single { NoopAppWidgetUpdater() } single { NoopMeshWorkerManager() } single { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopMessageQueue.kt index 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/stub/NoopStubs.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt index 081735e259..dbfc5b477b 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -28,12 +28,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.emptyFlow import org.meshtastic.core.model.ConnectionState -import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.model.MeshActivity -import org.meshtastic.core.model.MessageStatus -import org.meshtastic.core.model.Node import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.DataPair @@ -43,7 +40,6 @@ import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.MeshWorkerManager import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioInterfaceService -import org.meshtastic.core.repository.ServiceBroadcasts import org.meshtastic.proto.MqttClientProxyMessage import org.meshtastic.mqtt.ConnectionState as MqttConnectionState import org.meshtastic.proto.Position as ProtoPosition @@ -122,18 +118,6 @@ class NoopPlatformAnalytics : PlatformAnalytics { override val isPlatformServicesAvailable: Boolean = false } -class NoopServiceBroadcasts : ServiceBroadcasts { - override fun subscribeReceiver(receiverName: String, packageName: String) {} - - override fun broadcastReceivedData(dataPacket: DataPacket) {} - - override fun broadcastConnection() {} - - override fun broadcastNodeChange(node: Node) {} - - override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) {} -} - class NoopAppWidgetUpdater : AppWidgetUpdater { override suspend fun updateAll() {} } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt index 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/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index fd923a1336..f9258deb12 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -44,8 +44,6 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase import org.meshtastic.core.domain.usecase.settings.RadioResponseResult -import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase -import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MqttConnectionState import org.meshtastic.core.model.MqttProbeStatus @@ -121,8 +119,6 @@ open class RadioConfigViewModel( private val mapConsentPrefs: MapConsentPrefs, private val analyticsPrefs: AnalyticsPrefs, private val homoglyphEncodingPrefs: HomoglyphPrefs, - private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase, - private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase, protected val importProfileUseCase: ImportProfileUseCase, protected val exportProfileUseCase: ExportProfileUseCase, protected val exportSecurityConfigUseCase: ExportSecurityConfigUseCase, @@ -137,13 +133,13 @@ open class RadioConfigViewModel( val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed fun toggleAnalyticsAllowed() { - toggleAnalyticsUseCase() + analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value) } val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.homoglyphEncodingEnabled fun toggleHomoglyphCharactersEncodingEnabled() { - toggleHomoglyphEncodingUseCase() + homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value) } /** MQTT proxy connection state for the settings UI. */ diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt index 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..affa6edf30 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -44,8 +44,6 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase import org.meshtastic.core.domain.usecase.settings.RadioResponseResult -import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase -import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase import org.meshtastic.core.model.Node import org.meshtastic.core.repository.AnalyticsPrefs import org.meshtastic.core.repository.FileService @@ -89,8 +87,6 @@ class RadioConfigViewModelTest { private val analyticsPrefs: AnalyticsPrefs = mock(MockMode.autofill) private val homoglyphEncodingPrefs: HomoglyphPrefs = mock(MockMode.autofill) - private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mock(MockMode.autofill) - private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mock(MockMode.autofill) private val importProfileUseCase: ImportProfileUseCase = mock(MockMode.autofill) private val exportProfileUseCase: ExportProfileUseCase = mock(MockMode.autofill) private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mock(MockMode.autofill) @@ -146,8 +142,6 @@ class RadioConfigViewModelTest { mapConsentPrefs = mapConsentPrefs, analyticsPrefs = analyticsPrefs, homoglyphEncodingPrefs = homoglyphEncodingPrefs, - toggleAnalyticsUseCase = toggleAnalyticsUseCase, - toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase, importProfileUseCase = importProfileUseCase, exportProfileUseCase = exportProfileUseCase, exportSecurityConfigUseCase = exportSecurityConfigUseCase, @@ -181,21 +175,21 @@ class RadioConfigViewModelTest { } @Test - fun `toggleAnalyticsAllowed calls useCase`() { - every { toggleAnalyticsUseCase() } returns Unit + fun `toggleAnalyticsAllowed updates prefs`() { + every { analyticsPrefs.setAnalyticsAllowed(any()) } returns Unit viewModel.toggleAnalyticsAllowed() - verify { toggleAnalyticsUseCase() } + verify { analyticsPrefs.setAnalyticsAllowed(true) } } @Test - fun `toggleHomoglyphCharactersEncodingEnabled calls useCase`() { - every { toggleHomoglyphEncodingUseCase() } returns Unit + fun `toggleHomoglyphCharactersEncodingEnabled updates prefs`() { + every { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(any()) } returns Unit viewModel.toggleHomoglyphCharactersEncodingEnabled() - verify { toggleHomoglyphEncodingUseCase() } + verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(true) } } @Test From 3cdad0da28cce0ef04a6f907843992a0e0f64089 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 07:13:52 -0500 Subject: [PATCH 09/53] refactor: delete transport layer and dead intermediaries, slim RadioInterfaceService - Delete entire transport layer: BleRadioTransport, TcpRadioTransport, SerialRadioTransport, StreamTransport, HeartbeatSender, StreamFrameCodec, AndroidRadioTransportFactory, BaseRadioTransportFactory, MockRadioTransport, NopRadioTransport, BleReconnectPolicy, TcpTransport, SerialTransport - Delete MeshConfigHandler interface + impl (replaced by RadioConfigRepository) - Delete RadioTransportCallback, RadioTransport, RadioTransportFactory interfaces - Delete FakeRadioTransport, RadioTransportTest, MeshConfigHandlerImplTest - Delete UseCase tests (impls restored, tests for deleted patterns removed) - Slim RadioInterfaceService interface: remove transport internals, keep only device-address/connection surface needed by Scanner and connections UIs - Create SdkRadioInterfaceService: thin SDK-backed impl delegating to RadioPrefs + RadioClientAccessor - Update NoopRadioInterfaceService to match slimmed interface - Update JvmUsbScanner to use SDK's JvmSerialPorts.list() instead of deleted SerialTransport.getAvailablePorts() - Remove DesktopRadioTransportFactory from desktop DI module - Fix NodeListViewModel: replace RadioInterfaceService with RadioPrefs - Fix MeshServiceOrchestratorTest: align with updated constructor params - Fix UIViewModel: use emptyFlow() for meshActivity (SDK doesn't emit raw transport-level activity events) All targets compile clean, all JVM + Android unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/manager/MeshConfigHandlerImpl.kt | 125 ----- .../data/radio/SdkRadioInterfaceService.kt | 67 +++ .../data/manager/MeshConfigHandlerImplTest.kt | 231 -------- .../EnsureRemoteAdminSessionUseCaseTest.kt | 126 ----- .../settings/AdminActionsUseCaseTest.kt | 67 --- .../settings/CleanNodeDatabaseUseCaseTest.kt | 77 --- .../settings/IsOtaCapableUseCaseTest.kt | 184 ------- .../ProcessRadioResponseUseCaseTest.kt | 193 ------- .../settings/SetMeshLogSettingsUseCaseTest.kt | 60 --- .../radio/AndroidRadioTransportFactory.kt | 106 ---- .../network/radio/SerialRadioTransport.kt | 140 ----- .../radio/BaseRadioTransportFactory.kt | 80 --- .../core/network/radio/BleRadioTransport.kt | 505 ------------------ .../core/network/radio/BleReconnectPolicy.kt | 186 ------- .../core/network/radio/MockRadioTransport.kt | 379 ------------- .../core/network/radio/NopRadioTransport.kt | 36 -- .../core/network/radio/StreamTransport.kt | 80 --- .../core/network/transport/HeartbeatSender.kt | 57 -- .../network/transport/StreamFrameCodec.kt | 153 ------ .../BleRadioTransportReconnectCrashTest.kt | 331 ------------ .../network/radio/BleRadioTransportTest.kt | 199 ------- .../network/radio/BleReconnectPolicyTest.kt | 277 ---------- .../network/radio/ReconnectBackoffTest.kt | 75 --- .../core/network/radio/StreamTransportTest.kt | 87 --- .../network/transport/StreamFrameCodecTest.kt | 187 ------- .../core/network/radio/TcpRadioTransport.kt | 96 ---- .../core/network/transport/TcpTransport.kt | 333 ------------ .../core/network/SerialTransport.kt | 242 --------- .../core/repository/MeshConfigHandler.kt | 49 -- .../core/repository/RadioInterfaceService.kt | 85 +-- .../core/repository/RadioTransport.kt | 49 -- .../core/repository/RadioTransportCallback.kt | 41 -- .../core/repository/RadioTransportFactory.kt | 42 -- .../core/repository/RadioTransportTest.kt | 55 -- .../meshtastic/core/service/MeshService.kt | 6 +- .../core/service/MeshServiceOrchestrator.kt | 6 +- .../service/SharedRadioInterfaceService.kt | 417 --------------- .../service/MeshServiceOrchestratorTest.kt | 16 +- .../core/takserver/TAKMeshIntegration.kt | 6 +- .../core/takserver/di/CoreTakServerModule.kt | 6 +- .../core/testing/FakeMeshService.kt | 4 - .../core/testing/FakeRadioInterfaceService.kt | 117 ---- .../core/testing/FakeRadioTransport.kt | 38 -- .../core/ui/viewmodel/UIViewModel.kt | 9 +- .../desktop/di/DesktopKoinModule.kt | 10 - .../radio/DesktopRadioTransportFactory.kt | 71 --- .../org/meshtastic/desktop/stub/NoopStubs.kt | 28 - feature/connections/build.gradle.kts | 2 + .../domain/usecase/JvmUsbScanner.kt | 12 +- .../feature/node/list/NodeListViewModel.kt | 6 +- .../node/list/NodeListViewModelTest.kt | 8 +- 51 files changed, 117 insertions(+), 5645 deletions(-) delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioInterfaceService.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt delete mode 100644 core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt delete mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicyTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReconnectBackoffTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt delete mode 100644 core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/StreamFrameCodecTest.kt delete mode 100644 core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt delete mode 100644 core/network/src/jvmAndroidMain/kotlin/org/meshtastic/core/network/transport/TcpTransport.kt delete mode 100644 core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshConfigHandler.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportCallback.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransportFactory.kt delete mode 100644 core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt delete mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt delete mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt delete mode 100644 core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt 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/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/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/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 8e25fc0f97..0000000000 --- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/session/EnsureRemoteAdminSessionUseCaseTest.kt +++ /dev/null @@ -1,126 +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.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 useCase = - EnsureRemoteAdminSessionUseCase(sessionManager, 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 useCase = EnsureRemoteAdminSessionUseCase(sessionManager, 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 repo = connectedRepo() - // Simulate the radio responding by emitting on the refresh flow when the metadata request fires. - everySuspend { repo.onServiceAction(any()) } calls - { - refresh.tryEmit(destNum) - Unit - } - - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, repo, this) - - val result = useCase(destNum) - - assertEquals(EnsureSessionResult.Refreshed, result) - verifySuspend { repo.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 repo = connectedRepo() - everySuspend { repo.onServiceAction(any()) } returns Unit - - val useCase = EnsureRemoteAdminSessionUseCase(sessionManager, repo, 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/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/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/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/NopRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt deleted file mode 100644 index 9ed224d3fa..0000000000 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.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.network.radio - -import org.meshtastic.core.repository.RadioTransport - -/** - * An intentionally inert [RadioTransport] that silently discards all operations. - * - * 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. - */ -class NopRadioTransport(val address: String) : RadioTransport { - override fun handleSendToRadio(p: ByteArray) { - // No-op - } - - override suspend fun close() { - // No-op - } -} 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/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/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/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/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt index 3a401a0c35..e0bfcd64a3 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 @@ -31,7 +31,7 @@ import org.meshtastic.core.common.hasLocationPermission import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceVersion import org.meshtastic.core.repository.MeshServiceNotifications -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.ServiceRepository import org.meshtastic.proto.PortNum @@ -45,7 +45,7 @@ import org.meshtastic.proto.PortNum */ class MeshService : Service() { - private val radioInterfaceService: RadioInterfaceService by inject() + private val radioPrefs: RadioPrefs by inject() private val notifications: MeshServiceNotifications by inject() @@ -109,7 +109,7 @@ class MeshService : Service() { return START_NOT_STICKY } - val a = radioInterfaceService.getDeviceAddress() + val a = radioPrefs.devAddr.value val wantForeground = a != null && a != "n" notifications.updateServiceStateNotification(serviceRepository.connectionState.value, null) 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 a6644e4446..07a14aa74f 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 @@ -30,7 +30,7 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.NodeManager -import org.meshtastic.core.repository.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 @@ -47,7 +47,7 @@ import org.meshtastic.core.takserver.TAKServerManager @Suppress("LongParameterList") @Single class MeshServiceOrchestrator( - private val radioInterfaceService: RadioInterfaceService, + private val radioPrefs: RadioPrefs, private val nodeManager: NodeManager, private val serviceNotifications: MeshServiceNotifications, private val takServerManager: TAKServerManager, @@ -119,7 +119,7 @@ class MeshServiceOrchestrator( newScope.handledLaunch { // Ensure the per-device database is active before SDK connects. - databaseManager.switchActiveDatabase(radioInterfaceService.getDeviceAddress()) + databaseManager.switchActiveDatabase(radioPrefs.devAddr.value) Logger.i { "Per-device database initialized" } } 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 baeaf6a4f9..bdf6a3b176 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 @@ -33,11 +33,11 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.AppWidgetUpdater -import org.meshtastic.core.repository.MeshConfigHandler import org.meshtastic.core.repository.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 @@ -50,7 +50,7 @@ import kotlin.test.assertTrue class MeshServiceOrchestratorTest { - private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill) + private val radioPrefs: RadioPrefs = mock(MockMode.autofill) private val nodeManager: NodeManager = mock(MockMode.autofill) private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill) private val takServerManager: TAKServerManager = mock(MockMode.autofill) @@ -62,7 +62,7 @@ class MeshServiceOrchestratorTest { private val radioController: RadioController = mock(MockMode.autofill) private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val serviceRepository: ServiceRepository = mock(MockMode.autofill) - private val meshConfigHandler: MeshConfigHandler = mock(MockMode.autofill) + private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val cotHandler: CoTHandler = mock(MockMode.autofill) @OptIn(ExperimentalCoroutinesApi::class) @@ -78,7 +78,7 @@ class MeshServiceOrchestratorTest { every { takPrefs.isTakServerEnabled } returns takEnabledFlow every { takServerManager.isRunning } returns takRunningFlow every { takServerManager.inboundMessages } returns MutableSharedFlow() - every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig()) + every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig()) every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) every { serviceRepository.meshPacketFlow } returns MutableSharedFlow() every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected) @@ -88,12 +88,12 @@ class MeshServiceOrchestratorTest { radioController = radioController, nodeRepository = nodeRepository, serviceRepository = serviceRepository, - meshConfigHandler = meshConfigHandler, + radioConfigRepository = radioConfigRepository, cotHandler = cotHandler, ) return MeshServiceOrchestrator( - radioInterfaceService = radioInterfaceService, + radioPrefs = radioPrefs, nodeManager = nodeManager, serviceNotifications = serviceNotifications, takServerManager = takServerManager, @@ -146,7 +146,7 @@ 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() 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 6f496e377b..4b1097cc47 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 @@ -29,8 +29,8 @@ import kotlinx.coroutines.launch import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MeshConfigHandler 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 @@ -47,7 +47,7 @@ class TAKMeshIntegration( 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 -> 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 f0c8eedda8..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 @@ -20,8 +20,8 @@ import org.koin.core.annotation.Module import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.MeshConfigHandler 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 @@ -46,14 +46,14 @@ class CoreTakServerModule { radioController: RadioController, nodeRepository: NodeRepository, serviceRepository: ServiceRepository, - meshConfigHandler: MeshConfigHandler, + radioConfigRepository: RadioConfigRepository, cotHandler: CoTHandler, ): TAKMeshIntegration = TAKMeshIntegration( takServerManager, radioController, nodeRepository, serviceRepository, - meshConfigHandler, + radioConfigRepository, cotHandler, ) } 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/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/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..bff7f1da55 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 @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine @@ -55,7 +56,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 +82,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 +138,9 @@ class UIViewModel( } /** Emits events for mesh network send/receive activity. */ - val meshActivity: Flow = radioInterfaceService.meshActivity + val meshActivity: Flow = emptyFlow() - val currentDeviceAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow + val currentDeviceAddressFlow: StateFlow = radioPrefs.devAddr private val _scrollToTopEventFlow = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) 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 e3f83bea1d..d7a70f4ef9 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -53,7 +53,6 @@ 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.ServiceRepository import org.meshtastic.core.service.SdkClientLifecycle import org.meshtastic.core.service.ServiceRepositoryImpl @@ -67,7 +66,6 @@ import org.meshtastic.desktop.notification.NativeNotificationSender import org.meshtastic.desktop.notification.WindowsNotificationSender import org.meshtastic.desktop.radio.DesktopMessageQueue import org.meshtastic.desktop.radio.DesktopRadioClientProvider -import org.meshtastic.desktop.radio.DesktopRadioTransportFactory import org.meshtastic.desktop.stub.NoopAppWidgetUpdater import org.meshtastic.desktop.stub.NoopCompassHeadingProvider import org.meshtastic.desktop.stub.NoopLocationRepository @@ -150,14 +148,6 @@ fun desktopModule() = module { @Suppress("LongMethod") private fun desktopPlatformStubsModule() = module { single { ServiceRepositoryImpl() } - single { - DesktopRadioTransportFactory( - dispatchers = get(), - scanner = get(), - bluetoothRepository = get(), - connectionFactory = get(), - ) - } // SDK-backed RadioClient lifecycle — replaces DirectRadioControllerImpl single { DesktopRadioClientProvider(radioPrefs = get()) } single { get() } 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 dbfc5b477b..7a3e4b5631 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -20,17 +20,11 @@ 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.DeviceType import org.meshtastic.core.model.InterfaceId -import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.DataPair @@ -65,23 +59,10 @@ private fun logWarn(message: String) { 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()") } @@ -95,15 +76,6 @@ class NoopRadioInterfaceService : RadioInterfaceService { 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 diff --git a/feature/connections/build.gradle.kts b/feature/connections/build.gradle.kts index 18d7673a94..09447942c2 100644 --- a/feature/connections/build.gradle.kts +++ b/feature/connections/build.gradle.kts @@ -48,5 +48,7 @@ kotlin { } androidMain.dependencies { implementation(libs.usb.serial.android) } + + jvmMain.dependencies { implementation(libs.sdk.transport.serial) } } } 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/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..0d54dfeadb 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 @@ -32,7 +32,7 @@ import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.RadioController 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 @@ -48,7 +48,7 @@ class NodeListViewModel( private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, private val radioController: RadioController, - private val radioInterfaceService: RadioInterfaceService, + private val radioPrefs: RadioPrefs, val nodeManagementActions: NodeManagementActions, private val getFilteredNodesUseCase: GetFilteredNodesUseCase, val nodeFilterPreferences: NodeFilterPreferences, @@ -63,7 +63,7 @@ class NodeListViewModel( val connectionState = serviceRepository.connectionState val deviceType: StateFlow = - radioInterfaceService.currentDeviceAddressFlow + radioPrefs.devAddr .map { address -> address?.let { DeviceType.fromAddress(it) } } .stateInWhileSubscribed(initialValue = null) 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..df4f8962cb 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()) @@ -82,7 +82,7 @@ class NodeListViewModelTest { radioConfigRepository = radioConfigRepository, serviceRepository = serviceRepository, radioController = radioController, - radioInterfaceService = radioInterfaceService, + radioPrefs = radioPrefs, nodeManagementActions = nodeManagementActions, getFilteredNodesUseCase = getFilteredNodesUseCase, nodeFilterPreferences = nodeFilterPreferences, From f0aa99eaff6863eb60165af74f6b44361b3c341f Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 10:05:58 -0500 Subject: [PATCH 10/53] refactor: delete POC ViewModels and stale references - Delete SdkConfigViewModel, SdkMessagingViewModel, SdkTelemetryViewModel (unused POC) - Delete RadioClientViewModel, SdkNodeListViewModel (POC logging only) - Remove POC VM instantiation from Main.kt - Delete NoopRadioInterfaceService (superseded by SdkRadioInterfaceService) - Delete dead app/test/Fakes.kt (both classes unused) - Fix stale KDoc references to MeshConnectionManager, RadioInterfaceService.connectionState Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MIGRATION-REMAINING.md | 275 ++++++++---------- .../app/radio/RadioClientViewModel.kt | 100 ------- .../app/radio/SdkConfigViewModel.kt | 132 --------- .../app/radio/SdkMessagingViewModel.kt | 130 --------- .../app/radio/SdkNodeListViewModel.kt | 143 --------- .../app/radio/SdkTelemetryViewModel.kt | 96 ------ .../main/kotlin/org/meshtastic/app/ui/Main.kt | 12 - .../org/meshtastic/app/service/Fakes.kt | 80 ----- .../core/repository/HandshakeConstants.kt | 2 +- .../core/repository/ServiceRepository.kt | 19 +- .../core/service/ServiceRepositoryImpl.kt | 2 +- .../org/meshtastic/desktop/stub/NoopStubs.kt | 29 -- 12 files changed, 123 insertions(+), 897 deletions(-) delete mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt delete mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt delete mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt delete mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt delete mode 100644 app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt delete mode 100644 app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt diff --git a/MIGRATION-REMAINING.md b/MIGRATION-REMAINING.md index 3a86cf3f0e..00a3a50136 100644 --- a/MIGRATION-REMAINING.md +++ b/MIGRATION-REMAINING.md @@ -1,186 +1,141 @@ -# SDK Migration — Remaining Work +# SDK Migration — Status & Remaining Work -> Auto-generated from migration session. Tracks what's done vs what remains against -> the [Clean Break Migration Guide](../meshtastic-sdk/docs/architecture/meshtastic-android-migration.md). +> Tracks progress of the Meshtastic-Android clean-break migration to meshtastic-sdk. +> Updated: 2026-05-05 --- ## Summary -**Completed:** ~70% of the Clean Break migration. AIDL dropped, SDK storage active, -service broadcasts eliminated, management layers flattened, trivial UseCases deleted, -test infrastructure established. +**Completed:** ~90% of the Clean Break migration. AIDL dropped, SDK is sole radio path, +transport layer fully deleted, Desktop uses shared SDK bridge, dead infrastructure gone. -**Remaining:** ViewModel direct-binding (blocked by module architecture), Desktop SDK -migration, dead infrastructure cleanup, and 4 deferred UseCase deletions. +**Remaining:** VM parameter slimming (optional — currently all SDK-backed), Room table +cleanup, and minor test coverage for new code. -**Net change so far:** ~52 files changed, +162 / -2,395 lines across all sessions. +**Net change:** 145 files changed, +2,752 / -15,059 lines (net -12,307 LOC removed) --- -## What's Done (by migration doc phase) - -### Phase 1: Environment & Dependency Alignment ✅ -- SDK composite build integrated (`settings.gradle.kts`) -- Wire proto types used throughout (`org.meshtastic.proto.*`) -- `sdk-core`, `sdk-proto`, `sdk-transport-*`, `sdk-storage-sqldelight`, `sdk-testing` all wired - -### Phase 2: One-Time Data Migration ✅ -- Room auto-migration 38→39 with `AutoMigration38to39` spec -- `onPostMigrate` copies favorites/notes/ignored/muted/manuallyVerified from `nodes` → `node_metadata` -- `NodeMetadataEntity` + `NodeMetadataDao` created -- `SdkNodeRepositoryImpl` enriches SDK nodes with persisted metadata - -### Phase 3: The Great Deletion (partial) ✅ -- `ServiceBroadcasts` — deleted (both `core/service` android + `core/repository` common) -- `MeshConnectionManagerImpl` — deleted (438 LOC) -- `MeshConnectionManager` interface — deleted -- `NodeRepositoryImpl` (old Room-backed) — deleted (290 LOC) -- `NodeInfoWriteDataSource` + `SwitchingNodeInfoWriteDataSource` — deleted -- AIDL — already removed in prior session - -### Phase 4: RadioClient as Core Dependency ✅ -- `RadioClientProvider` implemented in `app/` with BLE/TCP/Serial support -- Exposed as `StateFlow` for reactive observation -- Auto-reconnect enabled -- `SdkClientLifecycle` interface bridges to `core/service` without reverse dependency - -### Phase 5: Thin Foreground Service ✅ -- `MeshService` stripped to lifecycle holder + notification management -- Uses `ServiceRepository` for connection state (bridged from SDK) -- `MeshServiceOrchestrator` simplified to TAK lifecycle + notifications + DB init + widget - -### Phase 6: UI & Domain (partial) -- **6.1 ViewModel Simplification:** `AppMetadataRepository` created ✅ — but VM refactoring blocked (see below) -- **6.2 UseCase Decimation:** 8 trivial UseCases deleted ✅ — 4 deferred, complex ones kept - -### Phase 7: UI/VM Direct Binding -- POC VMs exist (`SdkNodeListViewModel`, `SdkConfigViewModel`, etc.) ✅ -- Production VMs still use repository layer (blocked — see below) - -### Phase 8: Feature Integrations ✅ -- Location publishing moved to `SdkStateBridge` -- TAK integration preserved (uses `ServiceAction` dispatch through `SdkStateBridge`) - -### Phase 9: Testing Strategy ✅ -- `sdk-testing` dependency added to `app` and `core/data` -- `TestRadioClientProvider` created with `FakeRadioTransport(autoHandshake=true)` -- Integration test validates connect → handshake → node injection → observation +## 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 Remains - -### 1. ViewModel Direct-Binding (Phase 6.1 — BLOCKED) - -**Blocker:** Feature modules (`feature/node`, `feature/settings`, `feature/messaging`, etc.) -are KMP `commonMain` and cannot depend on the SDK directly. Only the `app` module has -`implementation(libs.sdk.core)`. The `RadioClientProvider` lives in `app/`. - -**Current state:** Feature VMs inject `NodeRepository`, `ServiceRepository`, -`RadioConfigRepository`, `RadioController` — all of which are already SDK-backed thin -adapters populated by `SdkStateBridge`. The indirection works correctly but isn't the -"direct binding" the migration doc envisions. - -**To unblock (choose one):** -1. **Option A — SDK dependency in `core/repository`:** Add `api(libs.sdk.core)` to - `core/repository/build.gradle.kts`. Create a `RadioClientAccessor` interface in - `core/repository` exposing `client: StateFlow`. Feature modules can then - inject it. Trade-off: couples `core/repository` to SDK API surface. -2. **Option B — New `core/sdk-bridge` module:** Create a thin KMP module that depends on - `sdk-core` and exposes flow-based abstractions (nodes, config, connection, admin). - Feature modules depend on this instead of raw `RadioClient`. More modular but adds a module. -3. **Option C — Move VMs to `app`:** Move production VMs out of `feature/*` into `app/`. - Breaks KMP desktop/iOS target sharing. Not recommended. - -**VMs to migrate (22 total):** -| Tier | VMs | Current Params | Target | -|------|-----|----------------|--------| -| Critical (5) | RadioConfigVM, SettingsVM, MessageVM, MetricsVM, NodeListVM | 9-19 | 3-5 | -| Moderate (6) | NodeDetailVM, ContactsVM, BaseMapVM, DebugVM, ChannelVM, FilterSettingsVM | 3-8 | 2-3 | -| Simple (3) | CleanNodeDatabaseVM, QuickChatVM, CompassVM | 1-4 | 1-2 | - -### 2. Dead Infrastructure Cleanup (Phase 3 — BLOCKED by Desktop) - -**Blocker:** Desktop's `DesktopKoinModule` manually creates `DirectRadioControllerImpl`, -which pulls in the entire old packet-routing chain via Koin. - -**Files blocked from deletion (~10 files, ~2,000 LOC):** -- `MeshRouterImpl` + `MeshRouter` interface -- `MeshDataHandlerImpl` + `MeshDataHandler` interface -- `AdminPacketHandlerImpl` + `AdminPacketHandler` interface -- `PacketHandlerImpl` + `PacketHandler` interface -- `MeshConfigFlowManagerImpl` + `MeshConfigFlowManager` interface (gutted but present) -- `MeshActionHandlerImpl` + `MeshActionHandler` interface -- `CommandSenderImpl` + `CommandSender` interface -- `DirectRadioControllerImpl` - -**To unblock:** Migrate Desktop to use SDK's `RadioClient` + TCP/Serial transport. -Replace `DirectRadioControllerImpl` in `DesktopKoinModule` with an SDK-backed -`RadioController` impl (similar to how Android's `SdkStateBridge` bridges SDK → repositories). - -### 3. Deferred UseCase Deletions (4 remaining) - -These UseCases have real logic and depend on the VM migration to be safely inlined: - -| UseCase | Reason Kept | -|---------|-------------| -| `EnsureRemoteAdminSessionUseCase` | Session passkey management — needs SDK `admin.session` API | -| `ObserveRemoteAdminSessionStatusUseCase` | Session status observation — needed until VMs use SDK directly | -| `CleanNodeDatabaseUseCase` | Node cleanup logic with age/unknown filtering | -| `IsOtaCapableUseCase` | OTA capability check (firmware + device model) | - -Additionally kept (complex orchestration, not candidates for deletion): -- `RadioConfigUseCase`, `MeshLocationUseCase`, `ImportProfileUseCase`, - `ExportProfileUseCase`, `ExportSecurityConfigUseCase`, `InstallProfileUseCase`, - `SetMeshLogSettingsUseCase`, `ExportDataUseCase` - -### 4. Remaining Phase C Items (deferred) - -| Item | Description | Status | -|------|-------------|--------| -| C3 | Move raw packet forwarding — VMs observe `client.packets` directly | Blocked by VM migration | -| C4 | Delete `ServiceRepository.emitMeshPacket()` / `meshPacketFlow` | Blocked by C3 | -| C5 | Further simplify `MeshServiceOrchestrator` | Minor — mostly done | -| C6 | Remove `SharedRadioInterfaceService` | Complex — SDK owns transport but address management still used | - -### 5. Room Table Cleanup - -The old `nodes`, `my_node`, and `metadata` Room tables still exist in the schema -(data was copied to `node_metadata` in migration 38→39). A future migration should -DROP these tables to reduce DB size. - -### 6. `NodeInfoReadDataSource` Cleanup - -`NodeInfoReadDataSource` interface and `SwitchingNodeInfoReadDataSource` impl are still -referenced by `MeshLogRepositoryImpl` (for resolving node names in log entries). -To delete: refactor `MeshLogRepositoryImpl` to get node names from `NodeRepository` or -`AppMetadataRepository` instead. +## 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) +- Desktop `NoopRadioInterfaceService` updated to match +- `JvmUsbScanner` migrated to SDK's `JvmSerialPorts.list()` + +### Pipeline ✅ +- **Fully deleted:** CommandSender, MeshRouter, MeshActionHandler, PacketHandlerImpl, MeshDataHandlerImpl, MeshConnectionManager, MeshConfigFlowManager, ServiceBroadcasts, DirectRadioControllerImpl +- `SdkRadioController` is sole RadioController impl +- `SdkStateBridge` bridges SDK events → repositories +- `SdkPacketHandler` routes MeshPackets via `client.send()`, raw ToRadio via `client.sendRaw()` + +### Data Layer ✅ +- Room migration 38→39: NodeMetadata persistence +- `SdkNodeRepositoryImpl` enriches SDK nodes with persisted favorites/notes/ignore +- SDK storage (SqlDelight) is source of truth for node data +- `AppMetadataRepository` provides firmware/hardware/model info + +### Desktop ✅ +- Fully cut over to SDK via shared KMP bridge +- `DesktopRadioClientProvider` manages TCP/Serial transport +- No transport stubs needed — SDK handles everything + +### 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) --- -## Recommended Execution Order +## What Remains -1. **Desktop SDK migration** — unblocks item #2 (dead code deletion, ~2,000 LOC) -2. **Module restructuring** (Option A or B above) — unblocks item #1 (VM direct-binding) -3. **VM migration** — migrate 22 VMs to use RadioClient directly (per-VM PRs) -4. **UseCase cleanup** — delete 4 deferred UseCases after VM migration -5. **Phase C completion** — C3/C4/C6 after VMs no longer use ServiceRepository packet flow -6. **Room table cleanup** — DROP legacy tables in a final migration +### 1. Room Table Cleanup (low priority) +- Migration 39→40: DROP legacy `nodes`, `my_node` tables +- Remove old `NodeEntity`, `MyNodeEntity` Room entities + DAOs +- SDK SqlDelight is already source of truth; Room tables are unused dead weight + +### 2. VM Parameter Slimming (optional, quality-of-life) +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 | + +### 3. NodeManager Merge (optional) +`NodeManager` (25 methods, 8+ consumers) could merge into `SdkNodeRepositoryImpl`. +Currently SDK feeds it via SdkStateBridge. Works fine as-is. + +### 4. MeshActivity Restoration (cosmetic) +`UIViewModel.meshActivity` currently emits `emptyFlow()`. Could be restored by +having `SdkStateBridge` emit send/receive events when SDK delivers/receives packets. +Purely cosmetic — affects connection icon animation only. + +### 5. 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 candidates for deletion — they serve app-local purposes -the SDK doesn't cover: +These components are NOT migration candidates: - `PacketRepository` — message persistence (SDK doesn't persist chat history) -- `MeshLogRepository` — debug logging (app-local concern) -- `QuickChatActionRepository` — quick-chat templates (app preference) -- `DeviceHardwareRepository` / `FirmwareReleaseRepository` — GitHub API clients +- `MeshLogRepository` — debug logging (app-local) +- `QuickChatActionRepository` — quick-chat templates +- `DeviceHardwareRepository` / `FirmwareReleaseRepository` — GitHub API - `NodeMetadataDao` / `AppMetadataRepository` — favorites, notes, ignore, mute -- `MeshServiceOrchestrator` (simplified) — TAK lifecycle, notifications, DB init -- `SdkStateBridge` (reduced) — SDK → repository bridging, location publishing, TAK dispatch -- `RadioClientProvider` — SDK client lifecycle management -- `ContactSettings` table — app-local mute/read state per contact +- `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/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt deleted file mode 100644 index 6e10501a1e..0000000000 --- a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientViewModel.kt +++ /dev/null @@ -1,100 +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.radio - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -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 kotlinx.coroutines.flow.stateIn -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.sdk.ConnectionState -import org.meshtastic.sdk.MeshEvent - -/** - * POC ViewModel that exposes the SDK [RadioClient] connection lifecycle to the UI. - * - * **Connection state:** Uses `flatMapLatest` on the `StateFlow` so that any screen collecting - * [sdkConnectionState] automatically switches to the new client's connection flow when - * [RadioClientProvider.rebuildAndConnect] replaces the active client. [SharingStarted.WhileSubscribed] with a 5 s - * timeout keeps the upstream active briefly after the last subscriber leaves (e.g., configuration change) so the next - * subscriber doesn't miss a fast `Connected` event. - * - * **Events:** Collected with [SharingStarted.Eagerly] so that [MeshEvent]s (device rebooted, storage degraded, security - * warnings) are never dropped while navigating between screens. The collection is launched in [viewModelScope] which is - * tied to the application lifecycle via Koin's `@KoinViewModel` singleton scope — not to any individual screen. - * - * SDK gaps surfaced here: - * - [ConnectionState.Configuring] has no counterpart in the legacy [org.meshtastic.core.model.ConnectionState] - * - [ConnectionState.Reconnecting] has no counterpart in the legacy model - */ -@KoinViewModel -class RadioClientViewModel(private val provider: RadioClientProvider) : ViewModel() { - - /** Live SDK connection state; `null` if no client is active (no radio configured). */ - val sdkConnectionState: StateFlow = - provider.client - .flatMapLatest { client -> client?.connection ?: flowOf(null) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - - /** - * Human-readable label for the SDK connection state. Useful as a debug overlay in POC builds to see SDK state - * alongside the legacy state. - */ - val sdkConnectionLabel: StateFlow = - sdkConnectionState.map { it.toLabel() }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "SDK: —") - - init { - // Collect events eagerly so none are dropped during navigation. - // This scope lives as long as the ViewModel (application lifetime via Koin singleton). - provider.client - .flatMapLatest { client -> client?.events ?: emptyFlow() } - .onEach { event -> - when (event) { - is MeshEvent.StorageDegraded -> Logger.w { "[SDK] StorageDegraded: ${event.reason}" } - is MeshEvent.DeviceRebooted -> Logger.i { "[SDK] DeviceRebooted" } - is MeshEvent.SecurityWarning -> Logger.w { "[SDK] SecurityWarning: $event" } - else -> Logger.d { "[SDK] Event: $event" } - } - } - .launchIn(viewModelScope) - } - - /** Kick off a (re)connect using the current saved radio address. */ - fun connect() = provider.rebuildAndConnectAsync() - - /** Disconnect the active client. */ - fun disconnect() = provider.disconnect() -} - -private const val PERCENT = 100 - -private fun ConnectionState?.toLabel(): String = when (this) { - null -> "SDK: no client" - ConnectionState.Disconnected -> "SDK: Disconnected" - is ConnectionState.Connecting -> "SDK: Connecting (#$attempt)" - is ConnectionState.Configuring -> "SDK: Configuring — ${phase.name} (${(progress * PERCENT).toInt()}%)" - ConnectionState.Connected -> "SDK: Connected ✓" - is ConnectionState.Reconnecting -> "SDK: Reconnecting (#$attempt) — $cause" -} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt deleted file mode 100644 index 9f0f55b467..0000000000 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkConfigViewModel.kt +++ /dev/null @@ -1,132 +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.radio - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.proto.Config -import org.meshtastic.proto.ModuleConfig -import org.meshtastic.proto.User -import org.meshtastic.sdk.AdminResult -import org.meshtastic.sdk.ConfigBundle - -/** - * POC ViewModel that reads device configuration from the SDK's [ConfigBundle] and writes changes back via - * [org.meshtastic.sdk.AdminApi.editSettings]. - * - * **Read path:** [RadioClient.configBundle] is a [StateFlow] cached from the handshake — zero RPCs required. It - * contains all [Config] and [ModuleConfig] entries as they were at connect time. - * - * **Write path:** [editSettings] issues a single-RPC batch write. The SDK auto-refreshes [configBundle] after a - * successful commit (Gap G resolved). - * - * **Gap C resolved:** [RadioClient.channels] is now a reactive StateFlow seeded from the handshake. - */ -@KoinViewModel -class SdkConfigViewModel(private val provider: RadioClientProvider) : ViewModel() { - - /** The raw ConfigBundle from the handshake; null until connected+configured. */ - val configBundle: StateFlow = - provider.client - .flatMapLatest { it?.configBundle ?: flowOf(null) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - - /** Device config — read directly from SDK configBundle (Gap G resolved, no local overlay needed). */ - val deviceConfig: StateFlow = - configBundle - .map { bundle -> bundle?.configs?.firstOrNull { it.device != null }?.device } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - - /** LoRa config — read directly from SDK configBundle. */ - val loraConfig: StateFlow = - configBundle - .map { bundle -> bundle?.configs?.firstOrNull { it.lora != null }?.lora } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - - /** Reactive channel list from the SDK (Gap C resolved — seeded from handshake, updated on setChannel). */ - val channels: StateFlow> = - provider.client - .flatMapLatest { client -> client?.channels?.map { it.orEmpty() } ?: flowOf(emptyList()) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) - - /** - * Write a config update to the radio via [AdminApi.editSettings]. - * - * The SDK automatically refreshes configBundle after a successful commit. - */ - fun setConfig(config: Config, typeKey: String) { - val client = - provider.client.value - ?: run { - Logger.w { "[SDK] setConfig: no active client" } - return - } - viewModelScope.launch { - when (val result = client.admin.editSettings { setConfig(config) }) { - is AdminResult.Success -> Logger.i { "[SDK] setConfig($typeKey) succeeded" } - AdminResult.Timeout -> Logger.w { "[SDK] setConfig($typeKey): Timeout" } - AdminResult.Unauthorized -> Logger.w { "[SDK] setConfig($typeKey): Unauthorized" } - AdminResult.SessionKeyExpired -> - Logger.w { "[SDK] setConfig($typeKey): SessionKeyExpired — reconnect needed" } - AdminResult.NodeUnreachable -> Logger.w { "[SDK] setConfig($typeKey): NodeUnreachable" } - is AdminResult.Failed -> Logger.e { "[SDK] setConfig($typeKey): Failed — ${result.routingError}" } - } - } - } - - /** Convenience: update device config. */ - fun setDeviceConfig(device: Config.DeviceConfig) = setConfig(Config(device = device), "device") - - /** Convenience: update LoRa config. */ - fun setLoraConfig(lora: Config.LoRaConfig) = setConfig(Config(lora = lora), "lora") - - /** Update owner name on the radio. */ - fun setOwner(longName: String, shortName: String) { - val client = provider.client.value ?: return - viewModelScope.launch { - when (val result = client.admin.setOwner(User(long_name = longName, short_name = shortName))) { - is AdminResult.Success -> Logger.i { "[SDK] setOwner succeeded" } - else -> Logger.w { "[SDK] setOwner failed: $result" } - } - } - } - - /** - * Diagnostics: log the full ConfigBundle contents. Useful for POC validation — call from a debug menu or - * LaunchedEffect. - */ - fun logConfigBundle() { - val bundle = configBundle.value - if (bundle == null) { - Logger.i { "[SDK] configBundle: null (not yet connected)" } - return - } - Logger.i { "[SDK] myNodeNum=${bundle.myInfo.my_node_num}" } - Logger.i { "[SDK] firmwareVersion=${bundle.metadata.firmware_version}" } - bundle.configs.forEach { c -> Logger.d { "[SDK] Config: $c" } } - bundle.moduleConfigs.forEach { mc -> Logger.d { "[SDK] ModuleConfig: $mc" } } - } -} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.kt deleted file mode 100644 index ce01c1fbde..0000000000 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkMessagingViewModel.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.app.radio - -import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.sdk.ChannelIndex -import org.meshtastic.sdk.MessageHandle -import org.meshtastic.sdk.NodeId -import org.meshtastic.sdk.SendState -import org.meshtastic.sdk.asText - -/** Stable Compose model for a received text message. */ -@Immutable -data class IncomingTextMessage(val fromNodeNum: Int, val channelIndex: Int, val text: String, val rxTimeSeconds: Int) - -/** Stable Compose model for an outbound message's delivery status. */ -@Immutable data class OutboundStatus(val messageId: Long, val state: SendState) - -/** - * POC ViewModel that wires text messaging to the SDK's [RadioClient]. - * - * **Inbound:** Filters [RadioClient.packets] for TEXT_MESSAGE_APP packets using the SDK's [org.meshtastic.sdk.asText] - * extension. Accumulated in [incomingMessages] (capped at 200 for the POC to avoid unbounded memory growth). - * - * **Outbound:** [sendText] calls [RadioClient.sendText] synchronously (non-suspending), receives a [MessageHandle], and - * tracks [SendState] transitions in [outboundStatuses]. - * - * **SDK Gap B surfaced:** [RadioClient] has [org.meshtastic.sdk.asText] as a packet-level extension, but no reactive - * `RadioClient.textMessages: Flow` convenience. Callers must filter `packets` themselves. Log as - * Gap B for SDK fix. - * - * Note: Inbound packet collection uses `SharingStarted.Eagerly` (via [launchIn]) so messages are never dropped while - * navigating between screens. - */ -@KoinViewModel -class SdkMessagingViewModel(private val provider: RadioClientProvider) : ViewModel() { - - private val _incomingMessages = MutableStateFlow>(emptyList()) - val incomingMessages: StateFlow> = _incomingMessages.asStateFlow() - - private val _outboundStatuses = MutableStateFlow>(emptyList()) - val outboundStatuses: StateFlow> = _outboundStatuses.asStateFlow() - - init { - // Eagerly collect inbound text packets — must not drop while navigating. - // Gap B: no RadioClient.textMessages flow; manually filter packets. - provider.client - .flatMapLatest { client -> client?.packets ?: emptyFlow() } - .mapNotNull { packet -> - val text = packet.asText() ?: return@mapNotNull null - IncomingTextMessage( - fromNodeNum = packet.from, - channelIndex = packet.channel, - text = text, - rxTimeSeconds = packet.rx_time, - ) - } - .onEach { msg -> - Logger.d { "[SDK] Received text from ${msg.fromNodeNum} ch${msg.channelIndex}: ${msg.text}" } - _incomingMessages.update { prev -> (prev + msg).takeLast(MAX_MESSAGES) } - } - .launchIn(viewModelScope) - } - - /** - * Send a text message via the SDK. - * - * @param text the message text - * @param channelIndex 0–7; defaults to primary channel (0) - * @param toNodeNum destination node num; 0xFFFFFFFF (default) = broadcast - */ - fun sendText(text: String, channelIndex: Int = 0, toNodeNum: Int = BROADCAST_NODE_NUM) { - val client = - provider.client.value - ?: run { - Logger.w { "[SDK] sendText: no active client" } - return - } - val handle: MessageHandle = - client.sendText(text = text, channel = ChannelIndex(channelIndex), to = NodeId(toNodeNum)) - - // Track delivery state for this outbound message - handle.state - .onEach { state -> - Logger.d { "[SDK] Message ${handle.id} → $state" } - _outboundStatuses.update { prev -> - val updated = OutboundStatus(messageId = handle.id.raw.toLong(), state = state) - val existing = prev.indexOfFirst { it.messageId == updated.messageId } - if (existing >= 0) { - prev.toMutableList().also { it[existing] = updated } - } else { - (prev + updated).takeLast(MAX_MESSAGES) - } - } - } - .launchIn(viewModelScope) - } - - companion object { - private const val MAX_MESSAGES = 200 - private const val BROADCAST_NODE_NUM = -1 // 0xFFFFFFFF as signed Int - } -} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.kt deleted file mode 100644 index 684af8f12e..0000000000 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkNodeListViewModel.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.app.radio - -import androidx.compose.runtime.Immutable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.scan -import kotlinx.coroutines.flow.stateIn -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.proto.NodeInfo -import org.meshtastic.sdk.ConnectionQuality -import org.meshtastic.sdk.MeshNode -import org.meshtastic.sdk.NodeChange -import org.meshtastic.sdk.NodeId -import org.meshtastic.sdk.SignalQuality -import org.meshtastic.sdk.toMeshNode - -/** - * Stable, Compose-safe representation of a mesh node. - * - * Wire-generated [NodeInfo] is NOT `@Stable`; never pass it directly to Compose. This wrapper holds the - * fields the node list UI needs for display, filtering, and sorting. - */ -@Immutable -data class UiNode( - val num: Int, - val longName: String, - val shortName: String, - val snr: Float, - val hopsAway: Int?, - val lastHeard: Int, - val viaMqtt: Boolean, - // Enriched fields - val isOnline: Boolean, - val connectionQuality: ConnectionQuality, - val signalQuality: SignalQuality, - val batteryLevel: Int?, - val voltage: Float?, - val channelUtilization: Float?, - val airUtilTx: Float?, - val latitude: Double?, - val longitude: Double?, - val altitude: Int?, - val isFavorite: Boolean, - val isIgnored: Boolean, - val isMuted: Boolean, - val hwModel: String?, -) - -private fun MeshNode.toUiNode() = UiNode( - num = nodeNum, - longName = longName ?: "Unknown", - shortName = shortName ?: "?", - snr = snr, - hopsAway = hopsAway, - lastHeard = lastHeard, - viaMqtt = viaMqtt, - isOnline = isOnline, - connectionQuality = connectionQuality, - signalQuality = signalQuality, - batteryLevel = batteryLevel, - voltage = voltage, - channelUtilization = channelUtilization, - airUtilTx = airUtilTx, - latitude = latitude, - longitude = longitude, - altitude = altitude, - isFavorite = isFavorite, - isIgnored = isIgnored, - isMuted = isMuted, - hwModel = hwModel?.name, -) - -/** - * POC ViewModel that drives a node list directly from the SDK's [org.meshtastic.sdk.RadioClient]. - * - * **Fold pattern:** - * 1. `flatMapLatest` switches to the new client's `nodes` flow whenever [RadioClientProvider] replaces the active - * client. - * 2. `.catch {}` before `.scan {}` so that a transport error re-emits a safe [NodeChange.Snapshot] (empty map) rather - * than terminating the downstream scan accumulator. - * 3. `.scan {}` folds delta events — [NodeChange.Added], [NodeChange.Updated], [NodeChange.Removed] — onto the - * accumulator map. The initial [NodeChange.Snapshot] is guaranteed by the SDK for every new subscriber; no explicit - * replay config needed. - * 4. `.flowOn(Dispatchers.Default)` keeps folding off the main thread. - * 5. `.stateIn(WhileSubscribed(5_000))` keeps the upstream alive for 5 s after the last subscriber (safe across config - * changes; SDK re-sends a Snapshot for later subscribers). - * - * This ViewModel is registered as a Koin singleton alongside [RadioClientViewModel]. Both are instantiated at - * [org.meshtastic.app.ui.MainScreen] startup so the node map is warm before any screen subscribes. - */ -@KoinViewModel -class SdkNodeListViewModel(provider: RadioClientProvider) : ViewModel() { - - val nodes: StateFlow> = - provider.client - .flatMapLatest { client -> - if (client == null) return@flatMapLatest flowOf(emptyList()) - client.nodes - .catch { e -> - Logger.e(e) { "[SDK] nodes flow error — resetting to empty" } - emit(NodeChange.Snapshot(emptyMap())) - } - .scan(emptyMap()) { acc, change -> - when (change) { - is NodeChange.Snapshot -> change.nodes - is NodeChange.Added -> acc + (NodeId(change.node.num) to change.node) - is NodeChange.Updated -> acc + (NodeId(change.node.num) to change.node) - is NodeChange.Removed -> acc - change.nodeId - } - } - .map { nodeMap -> - val now = (System.currentTimeMillis() / 1000).toInt() - nodeMap.values.map { it.toMeshNode(now).toUiNode() } - } - .flowOn(Dispatchers.Default) - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) -} diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt b/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.kt deleted file mode 100644 index 2eaa5fb438..0000000000 --- a/app/src/main/kotlin/org/meshtastic/app/radio/SdkTelemetryViewModel.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.app.radio - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import org.koin.core.annotation.KoinViewModel -import org.meshtastic.proto.Telemetry -import org.meshtastic.sdk.AdminResult -import org.meshtastic.sdk.NodeId - -/** - * POC ViewModel that surfaces per-node telemetry from [TelemetryApi.observe]. - * - * **Gap D verified:** [TelemetryApi.observe] returns a plain [kotlinx.coroutines.flow.Flow] of unsolicited periodic - * [Telemetry] packets (device metrics, environment metrics, etc.). It does NOT auto-poll — packets arrive only when the - * radio pushes them. To request an immediate telemetry update, call [requestDeviceMetrics] which issues an RPC. - * - * Telemetry fields are nullable (Wire proto) — check per-field before display: [Telemetry.device_metrics], - * [Telemetry.environment_metrics], [Telemetry.air_quality_metrics], [Telemetry.power_metrics] - * - * Usage: observe [deviceMetrics] / [environmentMetrics] in a node-detail Composable, call [requestDeviceMetrics] on - * screen entry to prime the display. - */ -@Suppress("MagicNumber") -@KoinViewModel -class SdkTelemetryViewModel(private val provider: RadioClientProvider) : ViewModel() { - - /** - * Observe all raw [Telemetry] packets for [nodeId]. - * - * Re-subscribes automatically when [RadioClientProvider.client] changes (reconnect). Errors are caught and logged — - * the flow resets to null rather than crashing. - */ - private fun telemetryFor(nodeId: NodeId): StateFlow = provider.client - .flatMapLatest { c -> - if (c == null) { - flowOf(null) - } else { - c.telemetry - .observe(nodeId) - .catch { e -> - Logger.e(e) { "[SDK] telemetry.observe(${nodeId.raw}) error" } - emit(Telemetry()) - } - .map { it as Telemetry? } - } - } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) // 5s screen-death buffer - - /** Latest telemetry (any type) for the local node (NodeId.LOCAL). */ - val localTelemetry: StateFlow = telemetryFor(NodeId.LOCAL) - - /** - * Request an immediate device-metrics telemetry packet from [nodeId]. The result will be pushed back through - * [telemetryFor]'s [TelemetryApi.observe] flow. - */ - fun requestDeviceMetrics(nodeId: NodeId = NodeId.LOCAL) { - val client = provider.client.value ?: return - viewModelScope.launch { - when (val r = client.telemetry.requestDevice(nodeId)) { - is AdminResult.Success -> Logger.d { "[SDK] requestDeviceMetrics(${nodeId.raw}): ${r.value}" } - else -> Logger.w { "[SDK] requestDeviceMetrics(${nodeId.raw}) failed: $r" } - } - } - } - - /** - * Build a per-node telemetry StateFlow for a specific node num. Compose screens can call this once per node-detail - * screen. - */ - fun observeNode(nodeNum: Int): StateFlow = telemetryFor(NodeId(nodeNum)) -} 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 4adb1b4729..46409b14eb 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -29,11 +29,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import co.touchlab.kermit.Logger -import kotlinx.coroutines.flow.map import org.koin.compose.viewmodel.koinViewModel import org.meshtastic.app.BuildConfig -import org.meshtastic.app.radio.RadioClientViewModel -import org.meshtastic.app.radio.SdkNodeListViewModel import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.TopLevelDestination @@ -57,15 +54,6 @@ import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph @Composable fun MainScreen() { val viewModel: UIViewModel = koinViewModel() - // Instantiate the SDK ViewModel so event collection starts at app launch (Eagerly scope). - // sdkConnectionLabel logged below for POC visibility; will feed the connection toolbar later. - val radioClientViewModel: RadioClientViewModel = koinViewModel() - // Warm the SDK node list at launch so it's ready before any screen subscribes. - val sdkNodeListViewModel: SdkNodeListViewModel = koinViewModel() - val sdkNodeCount by sdkNodeListViewModel.nodes.map { it.size }.collectAsStateWithLifecycle(initialValue = 0) - val sdkLabel by radioClientViewModel.sdkConnectionLabel.collectAsStateWithLifecycle() - LaunchedEffect(sdkLabel) { Logger.d { sdkLabel } } - LaunchedEffect(sdkNodeCount) { Logger.d { "SDK nodes: $sdkNodeCount" } } // Land on Connections for first-run / no-device-selected; otherwise on Nodes. Read synchronously // from the StateFlow (seeded from persisted prefs) so the initial tab is set in one shot. val initialTab = 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/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/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index 2a09e95c8b..98104d05df 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 @@ -32,12 +32,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,24 +41,21 @@ 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.Connected] — handshake complete, radio fully operational * - [ConnectionState.DeviceSleep] — radio entered light-sleep (transient disconnect) - * - * @see RadioInterfaceService.connectionState */ val connectionState: 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]. */ 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..2832a6de9a 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 @@ -42,7 +42,7 @@ 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 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 7a3e4b5631..8eeec2dabc 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt @@ -23,8 +23,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow -import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.InterfaceId import org.meshtastic.core.network.repository.MQTTRepository import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.DataPair @@ -33,7 +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.proto.MqttClientProxyMessage import org.meshtastic.mqtt.ConnectionState as MqttConnectionState import org.meshtastic.proto.Position as ProtoPosition @@ -54,32 +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 currentDeviceAddressFlow = MutableStateFlow(null) - - override fun isMockTransport(): Boolean = false - - 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 = "" -} - -// endregion - // region Notification / Platform Stubs (Android-only) class NoopPlatformAnalytics : PlatformAnalytics { From 10425e8c4e5f3f597be5d21df53be790c5e29fea Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 10:12:01 -0500 Subject: [PATCH 11/53] refactor: delete dead broadcast Constants.kt and unused actionReceived Constants.kt re-exported MeshtasticIntent constants for the deleted ServiceBroadcasts system. No consumers remain after broadcast removal. Also remove MeshService.actionReceived() companion method (no callers). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/core/service/Constants.kt | 38 ------------------- .../meshtastic/core/service/MeshService.kt | 7 ---- 2 files changed, 45 deletions(-) delete mode 100644 core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.kt 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 b5648d7d31..0000000000 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/Constants.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.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 - -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 e0bfcd64a3..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 @@ -34,7 +34,6 @@ import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.SERVICE_NOTIFY_ID import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.PortNum /** * Android foreground service that hosts the Meshtastic mesh radio connection. @@ -67,12 +66,6 @@ class MeshService : Service() { private var isServiceInitialized = false 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) val minDeviceVersion = DeviceVersion(DeviceVersion.MIN_FW_VERSION) From a0ee9e83990d7a48c28be9039966039f549a81d7 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 10:14:08 -0500 Subject: [PATCH 12/53] docs: update MIGRATION-REMAINING.md with latest cleanup status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MIGRATION-REMAINING.md | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/MIGRATION-REMAINING.md b/MIGRATION-REMAINING.md index 00a3a50136..6808ca1b88 100644 --- a/MIGRATION-REMAINING.md +++ b/MIGRATION-REMAINING.md @@ -7,13 +7,14 @@ ## Summary -**Completed:** ~90% of the Clean Break migration. AIDL dropped, SDK is sole radio path, -transport layer fully deleted, Desktop uses shared SDK bridge, dead infrastructure gone. +**Completed:** ~92% 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, stale broadcast constants removed. -**Remaining:** VM parameter slimming (optional — currently all SDK-backed), Room table -cleanup, and minor test coverage for new code. +**Remaining:** Room table cleanup, optional VM parameter slimming, and test coverage for +new bridge code. -**Net change:** 145 files changed, +2,752 / -15,059 lines (net -12,307 LOC removed) +**Net change:** 154 files changed, +3,655 / -15,301 lines (net -11,646 LOC removed) --- @@ -59,15 +60,20 @@ cleanup, and minor test coverage for new code. - **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) -- Desktop `NoopRadioInterfaceService` updated to match +- `NoopRadioInterfaceService` deleted (superseded by SdkRadioInterfaceService) - `JvmUsbScanner` migrated to SDK's `JvmSerialPorts.list()` ### Pipeline ✅ -- **Fully deleted:** CommandSender, MeshRouter, MeshActionHandler, PacketHandlerImpl, MeshDataHandlerImpl, MeshConnectionManager, MeshConfigFlowManager, ServiceBroadcasts, DirectRadioControllerImpl +- **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 - `SdkNodeRepositoryImpl` enriches SDK nodes with persisted favorites/notes/ignore @@ -94,7 +100,10 @@ cleanup, and minor test coverage for new code. ### 1. Room Table Cleanup (low priority) - Migration 39→40: DROP legacy `nodes`, `my_node` tables - Remove old `NodeEntity`, `MyNodeEntity` Room entities + DAOs -- SDK SqlDelight is already source of truth; Room tables are unused dead weight +- SDK SqlDelight is already source of truth; Room tables are redundant +- **Blocked by:** `NodeInfoReadDataSource` (used by `MeshLogRepositoryImpl` for node + name resolution), `PacketRepositoryImpl` (uses `NodeInfoDao.MAX_BIND_PARAMS`) +- Requires migrating node-name lookup to SDK APIs before tables can be dropped ### 2. VM Parameter Slimming (optional, quality-of-life) VMs currently inject SDK-backed adapters (RadioController, NodeRepository, etc.) From 1dd3637b507dcb3a7b9d0560f64b31f7d509b688 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 10:23:07 -0500 Subject: [PATCH 13/53] refactor: eliminate NodeInfoReadDataSource, use NodeRepository directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeshLogRepositoryImpl was the sole external consumer of NodeInfoReadDataSource — it only needed myNodeNum to identify the local node in logs. Replace with NodeRepository.myNodeInfo (already SDK-backed via SdkNodeRepositoryImpl). PacketRepositoryImpl referenced NodeInfoDao.MAX_BIND_PARAMS — inlined as a private constant since it's just the SQLite bind-param limit. With zero external consumers remaining, delete: - NodeInfoReadDataSource interface - SwitchingNodeInfoReadDataSource implementation The Room NodeInfoDao/NodeEntity/MyNodeEntity remain in the database module for now (internal tests + migration history) but have no external dependents — ready for a future Room migration 40 to DROP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MIGRATION-REMAINING.md | 18 +++--- .../data/datasource/NodeInfoReadDataSource.kt | 40 ------------- .../SwitchingNodeInfoReadDataSource.kt | 58 ------------------- .../data/repository/MeshLogRepositoryImpl.kt | 10 ++-- .../data/repository/PacketRepositoryImpl.kt | 4 +- .../repository/CommonMeshLogRepositoryTest.kt | 22 ++++--- 6 files changed, 30 insertions(+), 122 deletions(-) delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt diff --git a/MIGRATION-REMAINING.md b/MIGRATION-REMAINING.md index 6808ca1b88..406908bd1a 100644 --- a/MIGRATION-REMAINING.md +++ b/MIGRATION-REMAINING.md @@ -7,14 +7,14 @@ ## Summary -**Completed:** ~92% of the Clean Break migration. AIDL dropped, SDK is sole radio path, +**Completed:** ~93% 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, stale broadcast constants removed. +POC ViewModels removed, NodeInfoReadDataSource eliminated. **Remaining:** Room table cleanup, optional VM parameter slimming, and test coverage for new bridge code. -**Net change:** 154 files changed, +3,655 / -15,301 lines (net -11,646 LOC removed) +**Net change:** 159 files changed, +3,684 / -15,460 lines (net -11,776 LOC removed) --- @@ -97,13 +97,15 @@ new bridge code. ## What Remains -### 1. Room Table Cleanup (low priority) +### 1. Room Table Cleanup (medium priority — unblocked) - Migration 39→40: DROP legacy `nodes`, `my_node` tables -- Remove old `NodeEntity`, `MyNodeEntity` Room entities + DAOs +- Remove old `NodeEntity`, `MyNodeEntity` Room entities + `NodeInfoDao` - SDK SqlDelight is already source of truth; Room tables are redundant -- **Blocked by:** `NodeInfoReadDataSource` (used by `MeshLogRepositoryImpl` for node - name resolution), `PacketRepositoryImpl` (uses `NodeInfoDao.MAX_BIND_PARAMS`) -- Requires migrating node-name lookup to SDK APIs before tables can be dropped +- **No longer blocked:** `NodeInfoReadDataSource` eliminated, `PacketRepositoryImpl` + no longer depends on `NodeInfoDao` +- Remaining internal consumers: `MeshtasticDatabase.nodeInfoDao()` abstract method, + `CommonNodeInfoDaoTest`, `CommonPacketDaoTest`, `MigrationTest` +- Requires: Room schema migration file, entity deletion, DAO deletion, test updates ### 2. VM Parameter Slimming (optional, quality-of-life) VMs currently inject SDK-backed adapters (RadioController, NodeRepository, etc.) 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/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/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/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index c47fe5bf15..4f24bb09fa 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 @@ -28,7 +28,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 @@ -245,7 +244,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val } else { withContext(dispatchers.io) { val dao = dbManager.currentDb.value.packetDao() - ids.chunked(NodeInfoDao.MAX_BIND_PARAMS) + ids.chunked(MAX_SQLITE_BIND_PARAMS) .flatMap { dao.getPacketsByPacketIds(it) } .associateBy { it.packet.packetId } } @@ -515,5 +514,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/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( From 19eba729f78aeba3f8d8786ee6bda6214baeeef8 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 10:48:52 -0500 Subject: [PATCH 14/53] =?UTF-8?q?chore(database):=20Room=20migration=2039?= =?UTF-8?q?=E2=86=9240=20=E2=80=94=20drop=20legacy=20node=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove my_node, nodes, metadata tables via AutoMigration with @DeleteTable - Parameterize all 34 PacketDao queries to accept myNodeNum directly - Delete NodeInfoDao, NodeEntity, MyNodeEntity, MetadataEntity - Delete CommonNodeInfoDaoTest (dead) - Rewrite PacketRepositoryImpl to inject NodeRepository for myNodeNum - Rewrite CommonPacketDaoTest and CommonPacketRepositoryTest - Update MigrationTest to remove nodeInfoDao usage - Update core/database README to reflect current schema SDK is now sole source of truth for all node data. Room only stores messages, logs, and user annotations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/repository/PacketRepositoryImpl.kt | 119 +-- .../repository/CommonPacketRepositoryTest.kt | 24 +- core/database/README.md | 12 +- .../40.json | 755 ++++++++++++++++++ .../core/database/dao/MigrationTest.kt | 31 +- .../core/database/MeshtasticDatabase.kt | 17 +- .../core/database/dao/NodeInfoDao.kt | 406 ---------- .../meshtastic/core/database/dao/PacketDao.kt | 152 ++-- .../core/database/entity/MyNodeEntity.kt | 60 -- .../core/database/entity/NodeEntity.kt | 259 ------ .../database/dao/CommonNodeInfoDaoTest.kt | 115 --- .../core/database/dao/CommonPacketDaoTest.kt | 93 +-- 12 files changed, 953 insertions(+), 1090 deletions(-) create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/40.json delete mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt delete mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt delete mode 100644 core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt delete mode 100644 core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt 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 4f24bb09fa..1e5a487df1 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,7 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest @@ -46,15 +47,26 @@ import org.meshtastic.core.repository.PacketRepository as SharedPacketRepository @Suppress("TooManyFunctions", "LongParameterList") @Single -class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val dispatchers: CoroutineDispatchers) : - SharedPacketRepository { - - override fun getWaypoints(): Flow> = dbManager.currentDb - .flatMapLatest { db -> db.packetDao().getAllWaypointsFlow() } +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> = myNodeNumFlow + .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { 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> = myNodeNumFlow + .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys(num) } } .map { map -> map.mapValues { it.value.data } } override fun getContactsPaged(): Flow> = Pager( @@ -64,34 +76,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 = myNodeNumFlow + .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { 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 = myNodeNumFlow + .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { 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 = myNodeNumFlow + .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(num, contact) } } - override fun getUnreadCountTotal(): Flow = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() } + override fun getUnreadCountTotal(): Flow = myNodeNumFlow + .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { 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) { @@ -110,7 +122,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) = @@ -149,11 +161,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) @@ -176,7 +189,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 -> @@ -205,7 +218,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 @@ -224,28 +237,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() + val num = currentMyNodeNum ids.chunked(MAX_SQLITE_BIND_PARAMS) - .flatMap { dao.getPacketsByPacketIds(it) } + .flatMap { dao.getPacketsByPacketIds(num, it) } .associateBy { it.packet.packetId } } } @@ -283,8 +297,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 = @@ -302,28 +316,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( @@ -397,8 +411,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 @@ -408,7 +423,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 } @@ -419,17 +434,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) } @@ -454,11 +469,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 = myNodeNumFlow + .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { 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) { @@ -475,11 +490,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, 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..147ed09bdc 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 @@ -18,10 +18,10 @@ package org.meshtastic.core.data.repository import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.testing.FakeDatabaseProvider +import org.meshtastic.core.testing.FakeNodeRepository import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -31,12 +31,13 @@ abstract class CommonPacketRepositoryTest { protected lateinit var dbProvider: FakeDatabaseProvider private val testDispatcher = UnconfinedTestDispatcher() private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher) + private val nodeRepository = FakeNodeRepository() protected lateinit var repository: PacketRepositoryImpl fun setupRepo() { dbProvider = FakeDatabaseProvider() - repository = PacketRepositoryImpl(dbProvider, dispatchers) + repository = PacketRepositoryImpl(dbProvider, dispatchers, nodeRepository) } @AfterTest @@ -49,23 +50,8 @@ abstract class CommonPacketRepositoryTest { 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, - ), - ) + // Set the current node number so PacketRepositoryImpl can pass it to queries + nodeRepository.setMyNodeInfo(org.meshtastic.core.testing.TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum)) val packet = DataPacket(to = "0!ffffffff", bytes = okio.ByteString.EMPTY, dataType = 1, id = 123) 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/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..dd6966a564 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,7 +138,7 @@ 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, @@ -171,7 +148,7 @@ class MigrationTest { 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 72f4e92098..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 @@ -29,7 +29,6 @@ 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 @@ -38,9 +37,6 @@ 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 @@ -50,15 +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, @@ -101,15 +94,15 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity 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 = 39, + 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 @@ -160,3 +153,9 @@ class AutoMigration38to39 : AutoMigrationSpec { ) } } + +/** 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/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index 2aef7ef6d2..c7e136f9e4 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,19 @@ 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 +281,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 +407,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 +544,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/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..5977e08a13 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,33 +36,17 @@ 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: Int - get() = myNodeInfo.myNodeNum + private val myNodeNum = 42424242 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, @@ -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 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) } @@ -164,7 +143,7 @@ abstract class CommonPacketDaoTest { ), ) 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)) } } @@ -202,9 +181,8 @@ abstract class CommonPacketDaoTest { ), ) 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( + data = DataPacket( to = DataPacket.ID_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( + data = DataPacket( to = DataPacket.ID_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,8 +264,7 @@ abstract class CommonPacketDaoTest { contact_key = contactKey, received_time = baseTime + id, read = false, - data = - DataPacket( + data = DataPacket( to = DataPacket.ID_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 } } From 352cce660307c7a93e5a6f9fc568fd827ee2ce4b Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 10:50:35 -0500 Subject: [PATCH 15/53] =?UTF-8?q?docs:=20update=20MIGRATION-REMAINING.md?= =?UTF-8?q?=20=E2=80=94=20Room=20cleanup=20complete=20(97%)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MIGRATION-REMAINING.md | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/MIGRATION-REMAINING.md b/MIGRATION-REMAINING.md index 406908bd1a..cfdb6598a2 100644 --- a/MIGRATION-REMAINING.md +++ b/MIGRATION-REMAINING.md @@ -7,14 +7,14 @@ ## Summary -**Completed:** ~93% of the Clean Break migration. AIDL dropped, SDK is sole radio path, +**Completed:** ~97% 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. +POC ViewModels removed, NodeInfoReadDataSource eliminated, Room legacy tables dropped. -**Remaining:** Room table cleanup, optional VM parameter slimming, and test coverage for +**Remaining:** Optional VM parameter slimming, NodeManager merge, and test coverage for new bridge code. -**Net change:** 159 files changed, +3,684 / -15,460 lines (net -11,776 LOC removed) +**Net change:** 169 files changed, +4,638 / -16,549 lines (net -11,911 LOC removed) --- @@ -97,15 +97,12 @@ new bridge code. ## What Remains -### 1. Room Table Cleanup (medium priority — unblocked) -- Migration 39→40: DROP legacy `nodes`, `my_node` tables -- Remove old `NodeEntity`, `MyNodeEntity` Room entities + `NodeInfoDao` -- SDK SqlDelight is already source of truth; Room tables are redundant -- **No longer blocked:** `NodeInfoReadDataSource` eliminated, `PacketRepositoryImpl` - no longer depends on `NodeInfoDao` -- Remaining internal consumers: `MeshtasticDatabase.nodeInfoDao()` abstract method, - `CommonNodeInfoDaoTest`, `CommonPacketDaoTest`, `MigrationTest` -- Requires: Room schema migration file, entity deletion, DAO deletion, test updates +### 1. Room Table Cleanup ✅ (completed) +- Migration 39→40: DROP legacy `nodes`, `my_node`, `metadata` tables +- Deleted `NodeEntity`, `MyNodeEntity`, `MetadataEntity`, `NodeInfoDao` +- PacketDao parameterized with `myNodeNum` (34 queries) +- PacketRepositoryImpl injects NodeRepository for myNodeNum +- SDK SqlDelight is sole source of truth for all node data ### 2. VM Parameter Slimming (optional, quality-of-life) VMs currently inject SDK-backed adapters (RadioController, NodeRepository, etc.) From 551d4419a47526bdf6803102596a8338eb5406b0 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 11:14:23 -0500 Subject: [PATCH 16/53] refactor: merge NodeManager into SdkNodeRepositoryImpl + restore MeshActivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge NodeManagerImpl logic into SdkNodeRepositoryImpl (single source of truth for all node state — eliminates duplicate in-memory maps) - SdkNodeRepositoryImpl now binds NodeRepository, NodeManager, NodeIdLookup - Delete NodeManagerImpl.kt (377 LOC) - Add meshActivityFlow to ServiceRepository for nav-bar icon animation - Emit MeshActivity.Send from SdkPacketHandler and SdkRadioController - Emit MeshActivity.Receive from ServiceRepositoryImpl.emitMeshPacket() - Wire UIViewModel.meshActivity to serviceRepository.meshActivityFlow - Align insertMetadata signature (remove unnecessary suspend) - Adapt NodeManagerImplTest to test SdkNodeRepositoryImpl directly - Update FakeServiceRepository with meshActivityFlow stub All targets compile clean (Android + Desktop), all tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/manager/NodeManagerImpl.kt | 377 ------------------ .../core/data/radio/SdkPacketHandler.kt | 4 + .../core/data/radio/SdkRadioController.kt | 2 + .../data/repository/SdkNodeRepositoryImpl.kt | 354 ++++++++++++++-- .../core/data/manager/NodeManagerImplTest.kt | 16 +- .../core/repository/NodeRepository.kt | 2 +- .../core/repository/ServiceRepository.kt | 13 + .../core/service/ServiceRepositoryImpl.kt | 10 + .../core/testing/FakeNodeRepository.kt | 2 +- .../core/testing/FakeServiceRepository.kt | 8 + .../core/ui/viewmodel/UIViewModel.kt | 3 +- 11 files changed, 377 insertions(+), 414 deletions(-) delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt 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 0d845b71c2..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ /dev/null @@ -1,377 +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.data.repository.SdkNodeRepositoryImpl -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.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 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 - // Propagate to SdkNodeRepositoryImpl so ourNodeInfo/myId reactive flows update - (nodeRepository as? SdkNodeRepositoryImpl)?.setMyNodeNum(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) } - } - - - } - - 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/radio/SdkPacketHandler.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketHandler.kt index 8ded4976c1..68ce0a897a 100644 --- 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 @@ -22,7 +22,9 @@ 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 @@ -39,6 +41,7 @@ import org.meshtastic.proto.ToRadio @Single(binds = [PacketHandler::class]) class SdkPacketHandler( private val accessor: RadioClientAccessor, + private val serviceRepository: ServiceRepository, private val dispatchers: CoroutineDispatchers, ) : PacketHandler { @@ -68,6 +71,7 @@ class SdkPacketHandler( return } client.send(packet) + serviceRepository.emitMeshActivity(MeshActivity.Send) } override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index b1e9c5efae..c0b96f910f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -23,6 +23,7 @@ import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.MeshActivity import org.meshtastic.core.model.Position import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.MeshLocationManager @@ -108,6 +109,7 @@ class SdkRadioController( ), ) c.send(meshPacket) + serviceRepository.emitMeshActivity(MeshActivity.Send) } // ── Node operations ───────────────────────────────────────────────────── diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt index 83c6d528d3..9baada2bd5 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -16,6 +16,7 @@ */ 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 @@ -28,8 +29,10 @@ 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 @@ -37,31 +40,51 @@ 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.NodeInfo import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.model.DeviceMetrics +import org.meshtastic.core.model.EnvironmentMetrics +import org.meshtastic.core.model.MeshUser +import org.meshtastic.core.model.Position +import org.meshtastic.core.model.util.NodeIdLookup import org.meshtastic.core.model.util.onlineTimeThreshold +import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.Notification +import org.meshtastic.core.repository.NotificationManager +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.LocalStats +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 /** - * SDK-backed [NodeRepository] implementation using in-memory StateFlows. + * Unified node repository and manager — single source of truth for all mesh node state. * - * The SDK manages node persistence internally via its SqlDelight storage layer. - * This repository stores nodes in-memory and is populated by [NodeManager] via the - * SDK's NodeChange flow (bridged through SdkStateBridge). + * Replaces the previous split between `NodeManagerImpl` (write operations, in-memory atomicfu maps) + * and `SdkNodeRepositoryImpl` (repository interface, StateFlows). Now uses a single StateFlow + * with metadata enrichment on every write. * - * Cold start: nodes are empty until the SDK emits its snapshot from storage (<1s). + * The SDK manages node persistence via its SqlDelight storage. This class stores the live node + * database in-memory, populated by SdkStateBridge from the SDK's NodeChange flow. * Node metadata (favorites, notes, ignored, muted) persists via Room's node_metadata table. */ -@Single(binds = [NodeRepository::class]) -@Suppress("TooManyFunctions") +@Single(binds = [NodeRepository::class, NodeManager::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 { +) : NodeRepository, NodeManager { private val _nodeDBbyNum = MutableStateFlow>(emptyMap()) private val _myNodeInfo = MutableStateFlow(null) @@ -77,6 +100,8 @@ class SdkNodeRepositoryImpl( } } + // ── NodeRepository read surface ───────────────────────────────────────── + override val nodeDBbyNum: StateFlow> = _nodeDBbyNum override val myNodeInfo: StateFlow = _myNodeInfo @@ -161,21 +186,10 @@ class SdkNodeRepositoryImpl( .toList() } + // ── NodeRepository write surface ──────────────────────────────────────── + override suspend fun upsert(node: Node) { - // Merge persisted metadata with incoming node data - val meta = _metadataCache.value[node.num] - val enriched = if (meta != null) { - node.copy( - isFavorite = meta.isFavorite, - isIgnored = meta.isIgnored, - isMuted = meta.isMuted, - notes = meta.notes, - manuallyVerified = meta.manuallyVerified, - ) - } else { - node - } - _nodeDBbyNum.update { map -> map + (enriched.num to enriched) } + writeNode(node) } override suspend fun installConfig(mi: MyNodeInfo, nodes: List) { @@ -221,18 +235,270 @@ class SdkNodeRepositoryImpl( } } - override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { + override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { _nodeDBbyNum.update { map -> val node = map[nodeNum] ?: return@update map map + (nodeNum to node.copy(metadata = metadata)) } } - /** Called by [NodeManager] to set the local node number for ourNodeInfo/myId derivation. */ - fun setMyNodeNum(num: Int?) { + // ── NodeManager surface ───────────────────────────────────────────────── + + 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 val allowNodeDbWrites = MutableStateFlow(false) + + override fun setNodeDbReady(ready: Boolean) { + isNodeDbReady.value = ready + } + + override fun setAllowNodeDbWrites(allowed: Boolean) { + allowNodeDbWrites.value = allowed + } + + 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 loadCachedNodeDB() { + // No-op in SDK mode — the SDK emits a Snapshot NodeChange on connect + // which populates the node map directly via installNodeInfo(). + } + + override fun clear() { + _nodeDBbyNum.value = emptyMap() + isNodeDbReady.value = false + allowNodeDbWrites.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 ?: "" + } + + @Suppress("Deprecated") + override fun getNodes(): List = _nodeDBbyNum.value.values.map { it.toNodeInfo() } + + 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 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) { 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.NODENUM_BROADCAST) { + DataPacket.ID_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) { @@ -243,10 +509,46 @@ class SdkNodeRepositoryImpl( 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 } // simplified — no GPS-based distance in POC + 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 } } + + @Suppress("CyclomaticComplexMethod") + 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/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index 2fd61e67bf..e4b3a9b231 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -21,9 +21,11 @@ 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.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics @@ -41,21 +43,23 @@ import org.meshtastic.proto.Position as ProtoPosition class NodeManagerImplTest { - private val nodeRepository: NodeRepository = mock(MockMode.autofill) private val notificationManager: NotificationManager = mock(MockMode.autofill) private val testScope = TestScope() - private lateinit var nodeManager: NodeManagerImpl + private lateinit var nodeManager: SdkNodeRepositoryImpl @BeforeTest fun setUp() { - nodeManager = NodeManagerImpl(nodeRepository, notificationManager, testScope) + val dbProvider: DatabaseProvider = mock(MockMode.autofill) + val localStatsDataSource: LocalStatsDataSource = mock(MockMode.autofill) + nodeManager = SdkNodeRepositoryImpl(localStatsDataSource, dbProvider, notificationManager, testScope) } @Test fun `getOrCreateNode creates default user for unknown node`() { val nodeNum = 1234 - val result = nodeManager.getOrCreateNode(nodeNum) + nodeManager.updateNode(nodeNum) { it } + val result = nodeManager.nodeDBbyNodeNum[nodeNum] assertNotNull(result) assertEquals(nodeNum, result.num) @@ -69,7 +73,6 @@ 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) } val incomingDefaultUser = @@ -85,7 +88,6 @@ 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) 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..6615ded3c6 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 @@ -173,5 +173,5 @@ interface NodeRepository { * @param nodeNum The node number. * @param metadata The [DeviceMetadata] to save. */ - suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) + fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) } 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 98104d05df..5aefa697c8 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,6 +20,7 @@ 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 @@ -128,6 +129,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/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt index 2832a6de9a..c08c5bdf02 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 @@ -26,6 +26,7 @@ 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.MeshActivity import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.model.service.TracerouteResponse import org.meshtastic.core.repository.ServiceRepository @@ -93,6 +94,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/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt index 281c248b83..5c2f3ce624 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 @@ -164,7 +164,7 @@ class FakeNodeRepository : _nodeDBbyNum.value = nodes.associateBy { it.num } } - override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { + override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { val node = _nodeDBbyNum.value[nodeNum] ?: return _nodeDBbyNum.value = _nodeDBbyNum.value + (nodeNum to node.copy(metadata = metadata)) } 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..eacbf4ef58 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 @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow 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 @@ -75,6 +76,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/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 bff7f1da55..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 @@ -26,7 +26,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine @@ -138,7 +137,7 @@ class UIViewModel( } /** Emits events for mesh network send/receive activity. */ - val meshActivity: Flow = emptyFlow() + val meshActivity: Flow = serviceRepository.meshActivityFlow val currentDeviceAddressFlow: StateFlow = radioPrefs.devAddr From a12aa2691e6334004498de91d96896ae6d6f32b4 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 11:15:07 -0500 Subject: [PATCH 17/53] docs: update MIGRATION-REMAINING to ~100% complete NodeManager merge and MeshActivity restoration are done. Only optional VM param slimming and test coverage remain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MIGRATION-REMAINING.md | 49 +++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/MIGRATION-REMAINING.md b/MIGRATION-REMAINING.md index cfdb6598a2..559db6b6a4 100644 --- a/MIGRATION-REMAINING.md +++ b/MIGRATION-REMAINING.md @@ -7,14 +7,14 @@ ## Summary -**Completed:** ~97% of the Clean Break migration. AIDL dropped, SDK is sole radio path, +**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. +POC ViewModels removed, NodeInfoReadDataSource eliminated, Room legacy tables dropped, +NodeManager merged into SdkNodeRepositoryImpl, MeshActivity restored. -**Remaining:** Optional VM parameter slimming, NodeManager merge, and test coverage for -new bridge code. +**Remaining:** Optional VM parameter slimming and test coverage for new bridge code. -**Net change:** 169 files changed, +4,638 / -16,549 lines (net -11,911 LOC removed) +**Net change:** 170 files changed, +4,601 / -16,963 lines (net -12,362 LOC removed) --- @@ -76,15 +76,30 @@ new bridge code. ### Data Layer ✅ - Room migration 38→39: NodeMetadata persistence -- `SdkNodeRepositoryImpl` enriches SDK nodes with persisted favorites/notes/ignore +- Room migration 39→40: DROP legacy `nodes`, `my_node`, `metadata` tables +- `SdkNodeRepositoryImpl` implements NodeRepository + NodeManager + 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 ✅ +- `SdkNodeRepositoryImpl` now binds NodeRepository, NodeManager, NodeIdLookup +- Single in-memory StateFlow — no duplicate maps +- Metadata enrichment on every write (favorites, notes, ignore, mute) +- `NodeManagerImpl.kt` deleted (377 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 + ### UseCases Deleted ✅ - ProcessRadioResponse (tests only — impl kept, has real packet parsing logic) - AdminActions (tests only — impl kept, has real reboot/reset logic) @@ -95,16 +110,9 @@ new bridge code. --- -## What Remains - -### 1. Room Table Cleanup ✅ (completed) -- Migration 39→40: DROP legacy `nodes`, `my_node`, `metadata` tables -- Deleted `NodeEntity`, `MyNodeEntity`, `MetadataEntity`, `NodeInfoDao` -- PacketDao parameterized with `myNodeNum` (34 queries) -- PacketRepositoryImpl injects NodeRepository for myNodeNum -- SDK SqlDelight is sole source of truth for all node data +## What Remains (optional, quality-of-life) -### 2. VM Parameter Slimming (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: @@ -116,16 +124,7 @@ which work correctly. Direct SDK injection would reduce params but isn't require | NodeListVM | 9 | 5-6 | | NodeDetailVM | 7 | 4-5 | -### 3. NodeManager Merge (optional) -`NodeManager` (25 methods, 8+ consumers) could merge into `SdkNodeRepositoryImpl`. -Currently SDK feeds it via SdkStateBridge. Works fine as-is. - -### 4. MeshActivity Restoration (cosmetic) -`UIViewModel.meshActivity` currently emits `emptyFlow()`. Could be restored by -having `SdkStateBridge` emit send/receive events when SDK delivers/receives packets. -Purely cosmetic — affects connection icon animation only. - -### 5. Test Coverage +### 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 From e7d17675278adf8ea3291457c34beaf1f415dc8c Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 11:33:54 -0500 Subject: [PATCH 18/53] chore: remove ~220 LOC dead code from node layer Delete dead methods from NodeManager/NodeRepository interfaces: - allowNodeDbWrites, setAllowNodeDbWrites (never read) - loadCachedNodeDB (no-op, zero-value call in orchestrator) - getNodes(): List (deprecated, unused) - handleReceivedPaxcounter (zero callers) - handleReceivedNodeStatus, updateNodeStatus (zero callers) - insertMetadata (zero production callers) Delete NodeInfo data class (85 LOC): - All consumers now use Node domain model directly - Retained MeshUser, Position, DeviceMetrics, EnvironmentMetrics which have active consumers across feature modules Remove corresponding implementations from SdkNodeRepositoryImpl, FakeNodeRepository, and test verifications. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/repository/SdkNodeRepositoryImpl.kt | 77 ---------------- .../org/meshtastic/core/model/NodeInfo.kt | 87 ------------------- .../meshtastic/core/repository/NodeManager.kt | 28 ------ .../core/repository/NodeRepository.kt | 9 -- .../core/service/MeshServiceOrchestrator.kt | 2 - .../service/MeshServiceOrchestratorTest.kt | 2 - .../core/testing/FakeNodeRepository.kt | 6 -- .../core/testing/FakeNodeRepositoryTest.kt | 11 --- 8 files changed, 222 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt index 9baada2bd5..a0c9bc876e 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -40,12 +40,7 @@ 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.NodeInfo import org.meshtastic.core.model.NodeSortOption -import org.meshtastic.core.model.DeviceMetrics -import org.meshtastic.core.model.EnvironmentMetrics -import org.meshtastic.core.model.MeshUser -import org.meshtastic.core.model.Position import org.meshtastic.core.model.util.NodeIdLookup import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.repository.NodeManager @@ -55,12 +50,9 @@ 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.DeviceMetadata import org.meshtastic.proto.FirmwareEdition import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.LocalStats -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 @@ -235,13 +227,6 @@ class SdkNodeRepositoryImpl( } } - override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { - _nodeDBbyNum.update { map -> - val node = map[nodeNum] ?: return@update map - map + (nodeNum to node.copy(metadata = metadata)) - } - } - // ── NodeManager surface ───────────────────────────────────────────────── override val nodeDBbyNodeNum: Map @@ -251,16 +236,11 @@ class SdkNodeRepositoryImpl( get() = _nodeDBbyNum.value.values.associateBy { it.user.id } 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: StateFlow get() = _myNodeNum @@ -274,15 +254,9 @@ class SdkNodeRepositoryImpl( firmwareEdition.value = edition } - override fun loadCachedNodeDB() { - // No-op in SDK mode — the SDK emits a Snapshot NodeChange on connect - // which populates the node map directly via installNodeInfo(). - } - override fun clear() { _nodeDBbyNum.value = emptyMap() isNodeDbReady.value = false - allowNodeDbWrites.value = false _myNodeNum.value = null firmwareEdition.value = null } @@ -313,9 +287,6 @@ class SdkNodeRepositoryImpl( return _nodeDBbyNum.value[num]?.user?.id ?: "" } - @Suppress("Deprecated") - override fun getNodes(): List = _nodeDBbyNum.value.values.map { it.toNodeInfo() } - override fun removeByNodenum(nodeNum: Int) { _nodeDBbyNum.update { it - nodeNum } } @@ -403,18 +374,6 @@ class SdkNodeRepositoryImpl( } } - 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) { node -> var next = node @@ -515,40 +474,4 @@ class SdkNodeRepositoryImpl( NodeSortOption.VIA_MQTT -> compareByDescending { it.viaMqtt } NodeSortOption.VIA_FAVORITE -> compareByDescending { it.isFavorite } } - - @Suppress("CyclomaticComplexMethod") - 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/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.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/NodeInfo.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/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt index 80c1c5e538..8c2d192c17 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -19,12 +19,8 @@ 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 @@ -45,12 +41,6 @@ interface NodeManager : NodeIdLookup { /** 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 @@ -63,9 +53,6 @@ interface NodeManager : NodeIdLookup { /** 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() @@ -75,9 +62,6 @@ interface NodeManager : NodeIdLookup { /** 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) @@ -87,15 +71,6 @@ interface NodeManager : NodeIdLookup { /** 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) @@ -104,7 +79,4 @@ interface NodeManager : NodeIdLookup { /** 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 6615ded3c6..095f5d8c99 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,7 +21,6 @@ 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.proto.LocalStats import org.meshtastic.proto.User @@ -166,12 +165,4 @@ interface NodeRepository { * Used during the initial connection handshake. */ suspend fun installConfig(mi: MyNodeInfo, nodes: List) - - /** - * Persists hardware metadata for a node. - * - * @param nodeNum The node number. - * @param metadata The [DeviceMetadata] to save. - */ - fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) } 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 07a14aa74f..75cf1ec055 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 @@ -122,8 +122,6 @@ class MeshServiceOrchestrator( databaseManager.switchActiveDatabase(radioPrefs.devAddr.value) Logger.i { "Per-device database initialized" } } - - nodeManager.loadCachedNodeDB() } /** diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index bdf6a3b176..804c544872 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 @@ -115,7 +115,6 @@ class MeshServiceOrchestratorTest { assertTrue(orchestrator.isRunning) verify { serviceNotifications.initChannels() } - verify { nodeManager.loadCachedNodeDB() } orchestrator.stop() assertFalse(orchestrator.isRunning) @@ -169,7 +168,6 @@ class MeshServiceOrchestratorTest { // Components should only be initialized once verify(exactly(1)) { serviceNotifications.initChannels() } - verify(exactly(1)) { nodeManager.loadCachedNodeDB() } orchestrator.stop() assertFalse(orchestrator.isRunning) 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 5c2f3ce624..c83fb7d14a 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,7 +24,6 @@ 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.LocalStats import org.meshtastic.proto.User @@ -164,11 +163,6 @@ class FakeNodeRepository : _nodeDBbyNum.value = nodes.associateBy { it.num } } - override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) { - val node = _nodeDBbyNum.value[nodeNum] ?: return - _nodeDBbyNum.value = _nodeDBbyNum.value + (nodeNum to node.copy(metadata = metadata)) - } - // --- Helper methods for testing --- fun setNodes(nodes: List) { 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))) From 296f27dc73cf8921bfd28e028876c7d0ab675c22 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 11:44:40 -0500 Subject: [PATCH 19/53] refactor: merge NodeManager into NodeRepository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminate the vestigial NodeManager/NodeRepository interface split. All runtime node state management methods (handleReceivedUser, handleReceivedPosition, handleReceivedTelemetry, updateNode, etc.) now live directly on NodeRepository alongside the query surface. - Delete NodeManager.kt (82 LOC) - Extend NodeRepository with NodeIdLookup and add all manager methods - Update 8 consumers to inject NodeRepository instead of NodeManager - Remove dead nodeManager param from MeshServiceOrchestrator - Add NodeManager methods to FakeNodeRepository test double - Update all tests (mocks, constructor params, verifications) - SdkNodeRepositoryImpl now binds [NodeRepository, NodeIdLookup] Build: assembleDebug ✅, desktop:compileKotlin ✅, all jvmTests ✅ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/manager/MessagePersistenceHandler.kt | 14 ++-- .../data/manager/NeighborInfoHandlerImpl.kt | 8 +- .../manager/StoreForwardPacketHandlerImpl.kt | 8 +- .../manager/TelemetryPacketHandlerImpl.kt | 6 +- .../core/data/radio/SdkRadioController.kt | 2 +- .../core/data/radio/SdkStateBridge.kt | 30 +++---- .../data/repository/SdkNodeRepositoryImpl.kt | 9 +- .../StoreForwardPacketHandlerImplTest.kt | 8 +- .../manager/TelemetryPacketHandlerImplTest.kt | 16 ++-- .../meshtastic/core/repository/NodeManager.kt | 82 ------------------- .../core/repository/NodeRepository.kt | 63 +++++++++++++- .../core/service/MeshServiceOrchestrator.kt | 2 - .../service/MeshServiceOrchestratorTest.kt | 3 - .../core/testing/FakeNodeRepository.kt | 71 ++++++++++++++++ .../feature/widget/RefreshLocalStatsAction.kt | 6 +- 15 files changed, 184 insertions(+), 144 deletions(-) delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt index 85d576004a..22793bf0e8 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt @@ -28,7 +28,7 @@ import org.meshtastic.core.model.DataPacket import org.meshtastic.core.repository.MeshDataHandler import org.meshtastic.core.repository.MeshServiceNotifications import org.meshtastic.core.repository.MessageFilter -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.PacketRepository @@ -49,7 +49,7 @@ import org.meshtastic.proto.PortNum */ @Single class MessagePersistenceHandler( - private val nodeManager: NodeManager, + private val nodeRepository: NodeRepository, private val packetRepository: Lazy, private val notificationManager: NotificationManager, private val serviceNotifications: MeshServiceNotifications, @@ -116,7 +116,7 @@ class MessagePersistenceHandler( @Suppress("ReturnCount") private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean { - val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true + val isIgnored = nodeRepository.nodeDBbyID[dataPacket.from]?.isIgnored == true if (isIgnored) return true if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false @@ -130,7 +130,7 @@ class MessagePersistenceHandler( updateNotification: Boolean, ) { val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted - val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true + val nodeMuted = nodeRepository.nodeDBbyID[dataPacket.from]?.isMuted == true val isSilent = conversationMuted || nodeMuted if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) { scope.launch { @@ -150,10 +150,10 @@ class MessagePersistenceHandler( private suspend fun getSenderName(packet: DataPacket): String { if (packet.from == DataPacket.ID_LOCAL) { - val myId = nodeManager.getMyId() - return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) + val myId = nodeRepository.getMyId() + return nodeRepository.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) } - return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) + return nodeRepository.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) } private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 51221f9ef3..91c68b1aac 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -24,7 +24,6 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.repository.NeighborInfoHandler -import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.MeshPacket @@ -32,9 +31,8 @@ import org.meshtastic.proto.NeighborInfo @Single class NeighborInfoHandlerImpl( - private val nodeManager: NodeManager, - private val serviceRepository: ServiceRepository, private val nodeRepository: NodeRepository, + private val serviceRepository: ServiceRepository, ) : NeighborInfoHandler { private val startTimes = atomic(persistentMapOf()) @@ -51,13 +49,13 @@ class NeighborInfoHandlerImpl( // Store the last neighbor info from our connected radio val from = packet.from - if (from == nodeManager.myNodeNum.value) { + if (from == nodeRepository.myNodeNum.value) { lastNeighborInfo = ni Logger.d { "Stored last neighbor info from connected radio" } } // Update Node DB - nodeManager.nodeDBbyNodeNum[from]?.let { /* SDK client.nodes is canonical source */ } + nodeRepository.nodeDBbyNodeNum[from]?.let { /* SDK client.nodes is canonical source */ } // Format for UI response val requestId = packet.decoded?.request_id ?: 0 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index cd2a31a57b..6de3fee629 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -28,7 +28,7 @@ import org.meshtastic.core.model.MessageStatus import org.meshtastic.core.model.util.SfppHasher import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.proto.MeshPacket @@ -40,7 +40,7 @@ import kotlin.time.Duration.Companion.milliseconds /** Implementation of [StoreForwardPacketHandler] that handles both legacy S&F and SF++ packets. */ @Single class StoreForwardPacketHandlerImpl( - private val nodeManager: NodeManager, + private val nodeRepository: NodeRepository, private val packetRepository: Lazy, private val historyManager: HistoryManager, private val dataHandler: Lazy, @@ -111,7 +111,7 @@ class StoreForwardPacketHandlerImpl( Logger.d { "SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " + - "to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum.value} status=$status" + "to=${sfpp.encapsulated_to} myNodeNum=${nodeRepository.myNodeNum.value} status=$status" } scope.handledLaunch { packetRepository.value.updateSFPPStatus( @@ -121,7 +121,7 @@ class StoreForwardPacketHandlerImpl( hash = hash, status = status, rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, - myNodeNum = nodeManager.myNodeNum.value ?: 0, + myNodeNum = nodeRepository.myNodeNum.value ?: 0, ) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt index 8c044af727..57be6f7317 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt @@ -28,7 +28,7 @@ import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.decodeOrNull import org.meshtastic.core.model.util.toOneLiner -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager import org.meshtastic.core.repository.TelemetryPacketHandler @@ -46,7 +46,7 @@ import kotlin.time.Duration.Companion.milliseconds */ @Single class TelemetryPacketHandlerImpl( - private val nodeManager: NodeManager, + private val nodeRepository: NodeRepository, private val notificationManager: NotificationManager, @Named("ServiceScope") private val scope: CoroutineScope, ) : TelemetryPacketHandler { @@ -67,7 +67,7 @@ class TelemetryPacketHandlerImpl( // Note: Local telemetry notification update was previously handled by // MeshConnectionManager.updateTelemetry(), now managed via SDK flows. - nodeManager.updateNode(fromNum) { node: Node -> + nodeRepository.updateNode(fromNum) { node: Node -> val metrics = t.device_metrics val environment = t.environment_metrics val power = t.power_metrics diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index c0b96f910f..40ef57663a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -53,7 +53,7 @@ import org.meshtastic.sdk.RadioClient * [RadioClient.telemetry], and [RadioClient.routing] respectively. * * **State distribution:** Handled by [SdkStateBridge], which feeds SDK flows into - * [ServiceRepository] and [org.meshtastic.core.repository.NodeManager]. + * [ServiceRepository] and [org.meshtastic.core.repository.NodeRepository]. */ @Single(binds = [RadioController::class]) @Suppress("TooManyFunctions", "LongParameterList") diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 39825cface..176cc76f58 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -33,7 +33,7 @@ import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.MeshLocationManager -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs @@ -50,11 +50,11 @@ import org.meshtastic.sdk.NodeChange import org.meshtastic.sdk.NodeId /** - * Bridges SDK reactive flows into the legacy repository layer and routes [ServiceAction]s + * Bridges SDK reactive flows into the repository layer and routes [ServiceAction]s * directly through the SDK, bypassing the old CommandSender/MeshActionHandler pipeline. * * The SDK owns the transport and all state; this bridge maps SDK emissions into [ServiceRepository] - * and [NodeManager] so that existing feature-module UI code (which observes those repositories) + * and [NodeRepository] so that existing feature-module UI code (which observes those repositories) * continues to work without modification. * * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientAccessor.client] @@ -65,7 +65,7 @@ import org.meshtastic.sdk.NodeId class SdkStateBridge( private val accessor: RadioClientAccessor, private val serviceRepository: ServiceRepository, - private val nodeManager: NodeManager, + private val nodeRepository: NodeRepository, private val packetRepository: Lazy, private val locationManager: MeshLocationManager, private val uiPrefs: UiPrefs, @@ -93,15 +93,15 @@ class SdkStateBridge( .onEach { change -> when (change) { is NodeChange.Snapshot -> { - nodeManager.clear() + nodeRepository.clear() change.nodes.forEach { (_, nodeInfo) -> - nodeManager.installNodeInfo(nodeInfo, withBroadcast = false) + nodeRepository.installNodeInfo(nodeInfo, withBroadcast = false) } - nodeManager.setNodeDbReady(true) + nodeRepository.setNodeDbReady(true) } - is NodeChange.Added -> nodeManager.installNodeInfo(change.node, withBroadcast = true) - is NodeChange.Updated -> nodeManager.installNodeInfo(change.node, withBroadcast = true) - is NodeChange.Removed -> nodeManager.removeByNodenum(change.nodeId.raw) + is NodeChange.Added -> nodeRepository.installNodeInfo(change.node, withBroadcast = true) + is NodeChange.Updated -> nodeRepository.installNodeInfo(change.node, withBroadcast = true) + is NodeChange.Removed -> nodeRepository.removeByNodenum(change.nodeId.raw) } } .launchIn(scope) @@ -109,7 +109,7 @@ class SdkStateBridge( // ── Own node identity ─────────────────────────────────────────────── accessor.client .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } - .onEach { ownNode -> if (ownNode != null) nodeManager.setMyNodeNum(ownNode.num) } + .onEach { ownNode -> if (ownNode != null) nodeRepository.setMyNodeNum(ownNode.num) } .launchIn(scope) // ── Raw packet forward (for RadioConfigViewModel + TAK) ───────────── @@ -188,21 +188,21 @@ class SdkStateBridge( is ServiceAction.Favorite -> { val node = action.node client.admin.setFavorite(NodeId(node.num), !node.isFavorite) - nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } + nodeRepository.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } } is ServiceAction.Ignore -> { val node = action.node val newIgnored = !node.isIgnored client.admin.setIgnored(NodeId(node.num), newIgnored) - nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnored) } + nodeRepository.updateNode(node.num) { it.copy(isIgnored = newIgnored) } packetRepository.value.updateFilteredBySender(node.user.id, newIgnored) } is ServiceAction.Mute -> { val node = action.node client.admin.toggleMuted(NodeId(node.num)) - nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } + nodeRepository.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } } is ServiceAction.Reaction -> { @@ -228,7 +228,7 @@ class SdkStateBridge( is ServiceAction.ImportContact -> { val verified = action.contact.copy(manually_verified = true) client.admin.addContact(verified) - nodeManager.handleReceivedUser( + nodeRepository.handleReceivedUser( verified.node_num, verified.user ?: User(), manuallyVerified = true, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt index a0c9bc876e..6f0a5c6805 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -43,7 +43,6 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.model.util.NodeIdLookup import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.Notification import org.meshtastic.core.repository.NotificationManager @@ -61,7 +60,7 @@ import org.meshtastic.proto.Position as ProtoPosition /** * Unified node repository and manager — single source of truth for all mesh node state. * - * Replaces the previous split between `NodeManagerImpl` (write operations, in-memory atomicfu maps) + * Replaces the previous split between a write-operation layer (in-memory atomicfu maps) * and `SdkNodeRepositoryImpl` (repository interface, StateFlows). Now uses a single StateFlow * with metadata enrichment on every write. * @@ -69,14 +68,14 @@ import org.meshtastic.proto.Position as ProtoPosition * database in-memory, populated by SdkStateBridge from the SDK's NodeChange flow. * Node metadata (favorites, notes, ignored, muted) persists via Room's node_metadata table. */ -@Single(binds = [NodeRepository::class, NodeManager::class, NodeIdLookup::class]) +@Single(binds = [NodeRepository::class, NodeIdLookup::class]) @Suppress("TooManyFunctions", "LongParameterList") class SdkNodeRepositoryImpl( private val localStatsDataSource: LocalStatsDataSource, private val dbManager: DatabaseProvider, private val notificationManager: NotificationManager, @Named("ServiceScope") private val scope: CoroutineScope, -) : NodeRepository, NodeManager { +) : NodeRepository { private val _nodeDBbyNum = MutableStateFlow>(emptyMap()) private val _myNodeInfo = MutableStateFlow(null) @@ -227,7 +226,7 @@ class SdkNodeRepositoryImpl( } } - // ── NodeManager surface ───────────────────────────────────────────────── + // ── Runtime node state management ──────────────────────────────────────── override val nodeDBbyNodeNum: Map get() = _nodeDBbyNum.value diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index c87790d9a7..11916f60c1 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -34,7 +34,7 @@ import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket @@ -47,7 +47,7 @@ import kotlin.test.Test @OptIn(ExperimentalCoroutinesApi::class) class StoreForwardPacketHandlerImplTest { - private val nodeManager = mock(MockMode.autofill) + private val nodeRepository = mock(MockMode.autofill) private val packetRepository = mock(MockMode.autofill) private val historyManager = mock(MockMode.autofill) private val dataHandler = mock(MockMode.autofill) @@ -61,11 +61,11 @@ class StoreForwardPacketHandlerImplTest { @BeforeTest fun setUp() { - every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) + every { nodeRepository.myNodeNum } returns MutableStateFlow(myNodeNum) handler = StoreForwardPacketHandlerImpl( - nodeManager = nodeManager, + nodeRepository = nodeRepository, packetRepository = lazy { packetRepository }, historyManager = historyManager, dataHandler = lazy { dataHandler }, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt index 71fa601576..49d583f949 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt @@ -27,7 +27,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NotificationManager import org.meshtastic.proto.Data import org.meshtastic.proto.DeviceMetrics @@ -42,7 +42,7 @@ import kotlin.test.Test @OptIn(ExperimentalCoroutinesApi::class) class TelemetryPacketHandlerImplTest { - private val nodeManager = mock(MockMode.autofill) + private val nodeRepository = mock(MockMode.autofill) private val notificationManager = mock(MockMode.autofill) private val testDispatcher = StandardTestDispatcher() @@ -57,7 +57,7 @@ class TelemetryPacketHandlerImplTest { fun setUp() { handler = TelemetryPacketHandlerImpl( - nodeManager = nodeManager, + nodeRepository = nodeRepository, notificationManager = notificationManager, scope = testScope, ) @@ -93,7 +93,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(myNodeNum, any(), any(), any()) } } // ---------- Device metrics from remote node ---------- @@ -108,7 +108,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(remoteNodeNum, any(), any(), any()) } } // ---------- Environment metrics ---------- @@ -126,7 +126,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(remoteNodeNum, any(), any(), any()) } } // ---------- Power metrics ---------- @@ -140,7 +140,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(remoteNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(remoteNodeNum, any(), any(), any()) } } // ---------- Telemetry time handling ---------- @@ -154,7 +154,7 @@ class TelemetryPacketHandlerImplTest { handler.handleTelemetry(packet, dataPacket, myNodeNum) advanceUntilIdle() - verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) } + verify { nodeRepository.updateNode(myNodeNum, any(), any(), any()) } } // ---------- Null payload ---------- diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt deleted file mode 100644 index 8c2d192c17..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2026 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.meshtastic.core.repository - -import kotlinx.coroutines.flow.StateFlow -import org.meshtastic.core.model.MyNodeInfo -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.util.NodeIdLookup -import org.meshtastic.proto.FirmwareEdition -import org.meshtastic.proto.Telemetry -import org.meshtastic.proto.User -import org.meshtastic.proto.NodeInfo as ProtoNodeInfo -import org.meshtastic.proto.Position as ProtoPosition - -/** Interface for managing the in-memory node database and processing received node information. */ -@Suppress("TooManyFunctions") -interface NodeManager : NodeIdLookup { - /** Reactive map of all nodes by their number. */ - val nodeDBbyNodeNum: Map - - /** Reactive map of all nodes by their ID string. */ - val nodeDBbyID: Map - - /** Whether the node database is ready. */ - val isNodeDbReady: StateFlow - - /** Sets whether the node database is ready. */ - fun setNodeDbReady(ready: Boolean) - - /** The local node number as a thread-safe [StateFlow]. */ - val myNodeNum: StateFlow - - /** Sets the local node number. */ - fun setMyNodeNum(num: Int?) - - /** The firmware edition reported by the connected device. */ - val firmwareEdition: StateFlow - - /** Sets the firmware edition of the connected device. */ - fun setFirmwareEdition(edition: FirmwareEdition?) - - /** Clears the in-memory node database. */ - fun clear() - - /** Returns information about the local node. */ - fun getMyNodeInfo(): MyNodeInfo? - - /** Returns the local node ID. */ - fun getMyId(): String - - /** Processes a received user packet. */ - fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) - - /** Processes a received position packet. */ - fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) - - /** Processes a received telemetry packet. */ - fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) - - /** Updates a node using a transformation function. */ - fun updateNode(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, transform: (Node) -> Node) - - /** Removes a node from the in-memory database by its number. */ - fun removeByNodenum(nodeNum: Int) - - /** Installs node information from a ProtoNodeInfo object. */ - fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) -} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt index 095f5d8c99..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,19 +21,25 @@ import kotlinx.coroutines.flow.StateFlow import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption +import org.meshtastic.core.model.util.NodeIdLookup +import org.meshtastic.proto.FirmwareEdition import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +import org.meshtastic.proto.NodeInfo as ProtoNodeInfo +import org.meshtastic.proto.Position as ProtoPosition /** * Repository interface for managing node-related data. * * This component provides access to the mesh's node database, local device information, and mesh-wide statistics. It - * supports reactive queries for node lists, counts, and filtered/sorted views. + * supports reactive queries for node lists, counts, and filtered/sorted views, as well as runtime in-memory state + * management for processing incoming node packets from the radio. * * This interface is shared across platforms via Kotlin Multiplatform (KMP). */ @Suppress("TooManyFunctions") -interface NodeRepository { +interface NodeRepository : NodeIdLookup { /** Reactive flow of hardware info about our local radio device. */ val myNodeInfo: StateFlow @@ -165,4 +171,57 @@ interface NodeRepository { * Used during the initial connection handshake. */ suspend fun installConfig(mi: MyNodeInfo, nodes: List) + + // ── Runtime node state management ─────────────────────────────────────── + + /** Reactive map of all nodes by their number (snapshot access). */ + val nodeDBbyNodeNum: Map + + /** Reactive map of all nodes by their ID string. */ + val nodeDBbyID: Map + + /** Whether the node database is ready. */ + val isNodeDbReady: StateFlow + + /** Sets whether the node database is ready. */ + fun setNodeDbReady(ready: Boolean) + + /** The local node number as a thread-safe [StateFlow]. */ + val myNodeNum: StateFlow + + /** Sets the local node number. */ + fun setMyNodeNum(num: Int?) + + /** The firmware edition reported by the connected device. */ + val firmwareEdition: StateFlow + + /** Sets the firmware edition of the connected device. */ + fun setFirmwareEdition(edition: FirmwareEdition?) + + /** Clears the in-memory node database. */ + fun clear() + + /** Returns information about the local node. */ + fun getMyNodeInfo(): MyNodeInfo? + + /** Returns the local node ID. */ + fun getMyId(): String + + /** Processes a received user packet. */ + fun handleReceivedUser(fromNum: Int, p: User, channel: Int = 0, manuallyVerified: Boolean = false) + + /** Processes a received position packet. */ + fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) + + /** Processes a received telemetry packet. */ + fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) + + /** Updates a node using a transformation function. */ + fun updateNode(nodeNum: Int, withBroadcast: Boolean = true, channel: Int = 0, transform: (Node) -> Node) + + /** Removes a node from the in-memory database by its number. */ + fun removeByNodenum(nodeNum: Int) + + /** Installs node information from a ProtoNodeInfo object. */ + fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) } diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MeshServiceOrchestrator.kt index 75cf1ec055..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 @@ -29,7 +29,6 @@ import org.meshtastic.core.common.util.handledLaunch import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.TakPrefs @@ -48,7 +47,6 @@ import org.meshtastic.core.takserver.TAKServerManager @Single class MeshServiceOrchestrator( private val radioPrefs: RadioPrefs, - private val nodeManager: NodeManager, private val serviceNotifications: MeshServiceNotifications, private val takServerManager: TAKServerManager, private val takMeshIntegration: TAKMeshIntegration, diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt index 804c544872..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 @@ -34,7 +34,6 @@ import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.RadioController import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.NodeManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioPrefs @@ -51,7 +50,6 @@ import kotlin.test.assertTrue class MeshServiceOrchestratorTest { private val radioPrefs: RadioPrefs = mock(MockMode.autofill) - private val nodeManager: NodeManager = mock(MockMode.autofill) private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill) private val takServerManager: TAKServerManager = mock(MockMode.autofill) private val takPrefs: TakPrefs = mock(MockMode.autofill) @@ -94,7 +92,6 @@ class MeshServiceOrchestratorTest { return MeshServiceOrchestrator( radioPrefs = radioPrefs, - nodeManager = nodeManager, serviceNotifications = serviceNotifications, takServerManager = takServerManager, takMeshIntegration = takMeshIntegration, diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt index c83fb7d14a..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,8 +24,12 @@ import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.proto.FirmwareEdition import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +import org.meshtastic.proto.NodeInfo as ProtoNodeInfo +import org.meshtastic.proto.Position as ProtoPosition /** * A test double for [NodeRepository] that provides an in-memory implementation. @@ -163,6 +167,73 @@ class FakeNodeRepository : _nodeDBbyNum.value = nodes.associateBy { it.num } } + // ── Runtime node state management (from NodeManager merge) ────────────── + + override val nodeDBbyNodeNum: Map + get() = _nodeDBbyNum.value + + override val nodeDBbyID: Map + get() = _nodeDBbyNum.value.values.associateBy { it.user.id } + + private val _isNodeDbReady = MutableStateFlow(false) + override val isNodeDbReady: StateFlow = _isNodeDbReady + + override fun setNodeDbReady(ready: Boolean) { + _isNodeDbReady.value = ready + } + + private val _myNodeNum = MutableStateFlow(null) + override val myNodeNum: StateFlow = _myNodeNum + + override fun setMyNodeNum(num: Int?) { + _myNodeNum.value = num + } + + private val _firmwareEdition = MutableStateFlow(null) + override val firmwareEdition: StateFlow = _firmwareEdition + + override fun setFirmwareEdition(edition: FirmwareEdition?) { + _firmwareEdition.value = edition + } + + override fun clear() { + _nodeDBbyNum.value = emptyMap() + } + + override fun getMyNodeInfo(): MyNodeInfo? = _myNodeInfo.value + + override fun getMyId(): String = _myId.value ?: "" + + override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) { + // no-op for tests + } + + override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) { + // no-op for tests + } + + override fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) { + // no-op for tests + } + + override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) { + val current = _nodeDBbyNum.value[nodeNum] ?: return + _nodeDBbyNum.value = _nodeDBbyNum.value + (nodeNum to transform(current)) + } + + override fun removeByNodenum(nodeNum: Int) { + _nodeDBbyNum.value = _nodeDBbyNum.value - nodeNum + } + + override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) { + // Simplified: just store the node number + val num = info.num + val existing = _nodeDBbyNum.value[num] ?: Node(num = num, user = User()) + _nodeDBbyNum.value = _nodeDBbyNum.value + (num to existing) + } + + override fun toNodeID(nodeNum: Int): String = "!%08x".format(nodeNum) + // --- Helper methods for testing --- fun setNodes(nodes: List) { diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt index e0e2768756..b382ba4ab7 100644 --- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt +++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt @@ -25,17 +25,17 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.repository.NodeManager +import org.meshtastic.core.repository.NodeRepository class RefreshLocalStatsAction : ActionCallback, KoinComponent { private val radioController: RadioController by inject() - private val nodeManager: NodeManager by inject() + private val nodeRepository: NodeRepository by inject() override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - val myNodeNum = nodeManager.myNodeNum.value + val myNodeNum = nodeRepository.myNodeNum.value if (myNodeNum == null) { Logger.w { "RefreshLocalStatsAction: myNodeNum is null, skipping telemetry request" } return From 2db6db5ed9ec2cfcf549c17eeb8ef486eed2578b Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 11:56:45 -0500 Subject: [PATCH 20/53] fix: add error handling to SDK bridge and radio controller SdkStateBridge: - Wrap handleServiceAction in try/catch to prevent bridge death - Favorite/Ignore/Mute: only apply local state update on admin success (eliminates optimistic state inconsistency) - ImportContact: guard with runCatching, log failures - Extract dispatchAction for clean separation of concerns SdkRadioController: - Wrap sendMessage with try/catch + logging before re-throw - Wrap sendRemoteAdmin with try/catch + logging before re-throw - Ensures BLE disconnect errors are visible in logs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/radio/SdkRadioController.kt | 34 +++++++----- .../core/data/radio/SdkStateBridge.kt | 52 ++++++++++++++----- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 40ef57663a..c4fb269ffc 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -108,8 +108,13 @@ class SdkRadioController( want_response = false, ), ) - c.send(meshPacket) - serviceRepository.emitMeshActivity(MeshActivity.Send) + try { + c.send(meshPacket) + serviceRepository.emitMeshActivity(MeshActivity.Send) + } catch (e: Exception) { + Logger.e(e) { "sendMessage failed" } + throw e + } } // ── Node operations ───────────────────────────────────────────────────── @@ -462,16 +467,21 @@ class SdkRadioController( wantResponse: Boolean = false, ) { val payload = AdminMessage.ADAPTER.encode(adminMsg).toByteString() - c.send( - MeshPacket( - to = destNum, - want_ack = true, - decoded = Data( - portnum = PortNum.ADMIN_APP, - payload = payload, - want_response = wantResponse, + try { + c.send( + MeshPacket( + to = destNum, + want_ack = true, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = payload, + want_response = wantResponse, + ), ), - ), - ) + ) + } catch (e: Exception) { + Logger.e(e) { "sendRemoteAdmin to $destNum failed" } + throw e + } } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 176cc76f58..19d7f98b3e 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -184,25 +184,47 @@ class SdkStateBridge( 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 - client.admin.setFavorite(NodeId(node.num), !node.isFavorite) - nodeRepository.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) } + 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 - client.admin.setIgnored(NodeId(node.num), newIgnored) - nodeRepository.updateNode(node.num) { it.copy(isIgnored = newIgnored) } - packetRepository.value.updateFilteredBySender(node.user.id, newIgnored) + val result = runCatching { client.admin.setIgnored(NodeId(node.num), newIgnored) } + if (result.isSuccess) { + nodeRepository.updateNode(node.num) { it.copy(isIgnored = newIgnored) } + 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 - client.admin.toggleMuted(NodeId(node.num)) - nodeRepository.updateNode(node.num) { it.copy(isMuted = !node.isMuted) } + 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 -> { @@ -227,12 +249,16 @@ class SdkStateBridge( is ServiceAction.ImportContact -> { val verified = action.contact.copy(manually_verified = true) - client.admin.addContact(verified) - nodeRepository.handleReceivedUser( - verified.node_num, - verified.user ?: User(), - manuallyVerified = 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 -> { From 43ecd2eb73935ccb03c3663f3baee9483d61345e Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 12:00:09 -0500 Subject: [PATCH 21/53] chore: rename stale files and update migration doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NodeInfo.kt → MeshModels.kt (no longer contains NodeInfo class) - NodeManagerImplTest.kt → SdkNodeRepositoryImplTest.kt (tests SdkNodeRepositoryImpl) - Update MIGRATION-REMAINING.md with dead code removal, error handling, and NodeManager merge status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- MIGRATION-REMAINING.md | 18 +++- ...plTest.kt => SdkNodeRepositoryImplTest.kt} | 100 +++++++++--------- .../core/model/{NodeInfo.kt => MeshModels.kt} | 0 3 files changed, 66 insertions(+), 52 deletions(-) rename core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/{NodeManagerImplTest.kt => SdkNodeRepositoryImplTest.kt} (74%) rename core/model/src/commonMain/kotlin/org/meshtastic/core/model/{NodeInfo.kt => MeshModels.kt} (100%) diff --git a/MIGRATION-REMAINING.md b/MIGRATION-REMAINING.md index 559db6b6a4..9174fd07b5 100644 --- a/MIGRATION-REMAINING.md +++ b/MIGRATION-REMAINING.md @@ -77,7 +77,7 @@ NodeManager merged into SdkNodeRepositoryImpl, MeshActivity restored. ### Data Layer ✅ - Room migration 38→39: NodeMetadata persistence - Room migration 39→40: DROP legacy `nodes`, `my_node`, `metadata` tables -- `SdkNodeRepositoryImpl` implements NodeRepository + NodeManager + NodeIdLookup +- `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 @@ -88,10 +88,12 @@ NodeManager merged into SdkNodeRepositoryImpl, MeshActivity restored. - No transport stubs needed — SDK handles everything ### NodeManager Merge ✅ -- `SdkNodeRepositoryImpl` now binds NodeRepository, NodeManager, NodeIdLookup +- 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 @@ -100,6 +102,18 @@ NodeManager merged into SdkNodeRepositoryImpl, MeshActivity restored. - 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) 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 74% 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 e4b3a9b231..82313494fb 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 @@ -41,25 +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 notificationManager: NotificationManager = mock(MockMode.autofill) private val testScope = TestScope() - private lateinit var nodeManager: SdkNodeRepositoryImpl + private lateinit var nodeRepository: SdkNodeRepositoryImpl @BeforeTest fun setUp() { val dbProvider: DatabaseProvider = mock(MockMode.autofill) val localStatsDataSource: LocalStatsDataSource = mock(MockMode.autofill) - nodeManager = SdkNodeRepositoryImpl(localStatsDataSource, dbProvider, notificationManager, testScope) + nodeRepository = SdkNodeRepositoryImpl(localStatsDataSource, dbProvider, notificationManager, testScope) } @Test fun `getOrCreateNode creates default user for unknown node`() { val nodeNum = 1234 - nodeManager.updateNode(nodeNum) { it } - val result = nodeManager.nodeDBbyNodeNum[nodeNum] + nodeRepository.updateNode(nodeNum) { it } + val result = nodeRepository.nodeDBbyNodeNum[nodeNum] assertNotNull(result) assertEquals(nodeNum, result.num) @@ -73,14 +73,14 @@ class NodeManagerImplTest { val existingUser = User(id = "!12345678", long_name = "My Custom Name", short_name = "MCN", hw_model = HardwareModel.TLORA_V2) - 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) } @@ -91,14 +91,14 @@ class NodeManagerImplTest { 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) + val result = nodeRepository.toNodeID(DataPacket.NODENUM_BROADCAST) assertEquals(DataPacket.ID_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/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshModels.kt similarity index 100% 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 From e9cb4398499c8e5b2ce3bb0b0b552cc286dbe106 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 13:08:07 -0500 Subject: [PATCH 22/53] =?UTF-8?q?feat:=20rearchitect=20around=20SDK=20?= =?UTF-8?q?=E2=80=94=20decompose=20RadioController,=20simplify=20DataPacke?= =?UTF-8?q?t,=20integrate=20SDK=20utilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android rearchitecture consuming meshtastic-sdk improvements: A1 — ConnectionState Enrichment: - Rich sealed interface with Connecting(attempt), Configuring(phase, progress), Reconnecting(attempt) - SdkStateBridge maps SDK states preserving metadata A2 — MessageHandle Integration: - MessageDeliveryTracker: tracks delivery via SDK MessageHandle - SdkRadioController captures handles on send A3 — RadioController Decomposition: - Split into 5 focused interfaces: MessageSender, DeviceAdmin, RemoteAdmin, DeviceControl, DataRequester - RadioController extends all; SdkRadioController binds all via Koin A4 — DataPacket Simplification: - to/from fields changed from String? to Int (node numbers directly) - Removed string ID parsing layer; added BROADCAST/LOCAL constants - Updated ~40 consumer files across feature modules A5 — SDK Utility Consumption: - DeviceVersion, Capabilities, SfppHasher, LocationUtils delegate to SDK - Removed duplicated protocol logic A6 — Presence Events: - SdkStateBridge handles NodeChange.WentOffline/CameOnline - Updates node online status via repository Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../kotlin/org/meshtastic/app/map/MapView.kt | 4 +- .../org/meshtastic/app/map/MapViewModel.kt | 2 +- core/common/build.gradle.kts | 12 + .../core/common/util/LocationUtils.kt | 49 +--- .../data/manager/MessagePersistenceHandler.kt | 22 +- .../manager/StoreForwardPacketHandlerImpl.kt | 4 +- .../core/data/radio/MessageDeliveryTracker.kt | 88 ++++++ .../core/data/radio/SdkRadioController.kt | 29 +- .../core/data/radio/SdkStateBridge.kt | 36 ++- .../data/repository/PacketRepositoryImpl.kt | 22 +- .../data/repository/SdkNodeRepositoryImpl.kt | 12 +- .../data/manager/SdkNodeRepositoryImplTest.kt | 4 +- .../StoreForwardPacketHandlerImplTest.kt | 4 +- .../manager/TelemetryPacketHandlerImplTest.kt | 4 +- .../repository/CommonPacketRepositoryTest.kt | 2 +- .../core/database/dao/MigrationTest.kt | 2 +- .../meshtastic/core/database/entity/Packet.kt | 4 +- .../core/database/dao/CommonPacketDaoTest.kt | 18 +- core/model/build.gradle.kts | 12 + .../org/meshtastic/core/model/Capabilities.kt | 46 ++- .../meshtastic/core/model/ChannelOption.kt | 10 +- .../meshtastic/core/model/ConnectionState.kt | 19 +- .../org/meshtastic/core/model/DataPacket.kt | 79 +++-- .../meshtastic/core/model/DataRequester.kt | 26 ++ .../org/meshtastic/core/model/DeviceAdmin.kt | 28 ++ .../meshtastic/core/model/DeviceControl.kt | 28 ++ .../meshtastic/core/model/DeviceVersion.kt | 36 +-- .../meshtastic/core/model/MessageSender.kt | 23 ++ .../meshtastic/core/model/RadioController.kt | 269 +----------------- .../org/meshtastic/core/model/RemoteAdmin.kt | 40 +++ .../core/model/util/MeshDataMapper.kt | 4 +- .../meshtastic/core/model/util/SfppHasher.kt | 23 +- .../core/repository/PacketRepository.kt | 5 + .../core/repository/ServiceRepository.kt | 4 +- .../repository/usecase/SendMessageUseCase.kt | 19 +- .../usecase/SendMessageUseCaseTest.kt | 4 +- .../core/service/SendMessageWorkerTest.kt | 14 +- .../service/MeshServiceNotificationsImpl.kt | 10 +- .../meshtastic/core/service/ReplyReceiver.kt | 2 +- .../core/takserver/TAKMeshIntegration.kt | 2 +- .../takserver/fountain/GenericCoTHandler.kt | 6 +- .../core/ui/component/ConnectionsNavIcon.kt | 8 +- .../ui/component/MeshtasticNavigationSuite.kt | 4 +- .../core/ui/viewmodel/ConnectionsViewModel.kt | 4 +- .../connections/ui/ConnectionsScreen.kt | 4 +- .../ui/components/DeviceListItem.kt | 9 +- .../feature/map/BaseMapViewModel.kt | 6 +- .../meshtastic/feature/messaging/Message.kt | 2 +- .../feature/messaging/MessageListPaged.kt | 2 +- .../feature/messaging/MessageViewModel.kt | 6 +- .../feature/messaging/component/Reaction.kt | 9 +- .../messaging/ui/contact/ContactsViewModel.kt | 37 ++- .../feature/node/component/NodeStatusIcons.kt | 4 +- .../feature/widget/LocalStatsWidget.kt | 4 +- .../feature/widget/LocalStatsWidgetState.kt | 5 +- 55 files changed, 573 insertions(+), 558 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTracker.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt 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/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index 8a4a798a81..5acd0b1a06 100644 --- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -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/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..74810daac9 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,9 @@ 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 - - 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 -} +fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double = + PositionUtils.distance(latitudeA, longitudeA, latitudeB, longitudeB) /** * Computes the bearing in degrees between two points on Earth. @@ -73,18 +47,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/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt index 22793bf0e8..3d4dd73e2b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt @@ -78,12 +78,11 @@ class MessagePersistenceHandler( 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 fromLocal = dataPacket.from == DataPacket.LOCAL || dataPacket.from == myNodeNum + val toBroadcast = dataPacket.to == DataPacket.BROADCAST val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from - val contactKey = "${dataPacket.channel}$contactId" + val contactKey = "${dataPacket.channel}${DataPacket.nodeNumToId(contactId)}" scope.handledLaunch { packetRepository.value.apply { @@ -116,7 +115,7 @@ class MessagePersistenceHandler( @Suppress("ReturnCount") private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean { - val isIgnored = nodeRepository.nodeDBbyID[dataPacket.from]?.isIgnored == true + val isIgnored = nodeRepository.nodeDBbyNum.value[dataPacket.from]?.isIgnored == true if (isIgnored) return true if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false @@ -130,7 +129,7 @@ class MessagePersistenceHandler( updateNotification: Boolean, ) { val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted - val nodeMuted = nodeRepository.nodeDBbyID[dataPacket.from]?.isMuted == true + val nodeMuted = nodeRepository.nodeDBbyNum.value[dataPacket.from]?.isMuted == true val isSilent = conversationMuted || nodeMuted if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) { scope.launch { @@ -149,11 +148,10 @@ class MessagePersistenceHandler( } private suspend fun getSenderName(packet: DataPacket): String { - if (packet.from == DataPacket.ID_LOCAL) { - val myId = nodeRepository.getMyId() - return nodeRepository.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) + if (packet.from == DataPacket.LOCAL) { + return nodeRepository.ourNodeInfo.value?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) } - return nodeRepository.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) + return nodeRepository.nodeDBbyNum.value[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) } private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) { @@ -161,7 +159,7 @@ class MessagePersistenceHandler( PortNum.TEXT_MESSAGE_APP.value -> { val message = dataPacket.text!! val channelName = - if (dataPacket.to == DataPacket.ID_BROADCAST) { + if (dataPacket.to == DataPacket.BROADCAST) { radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name } else { null @@ -170,7 +168,7 @@ class MessagePersistenceHandler( contactKey, getSenderName(dataPacket), message, - dataPacket.to == DataPacket.ID_BROADCAST, + dataPacket.to == DataPacket.BROADCAST, channelName, isSilent, ) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index 6de3fee629..daf1b7f814 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -97,7 +97,7 @@ class StoreForwardPacketHandlerImpl( encryptedPayload = sfpp.message.toByteArray(), to = if (sfpp.encapsulated_to == 0) { - DataPacket.NODENUM_BROADCAST + DataPacket.BROADCAST } else { sfpp.encapsulated_to }, @@ -174,7 +174,7 @@ class StoreForwardPacketHandlerImpl( s.text != null -> { if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { - dataPacket.to = DataPacket.ID_BROADCAST + dataPacket.to = DataPacket.BROADCAST } val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value) dataHandler.value.rememberDataPacket(u, myNodeNum) 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..e3237143ae --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTracker.kt @@ -0,0 +1,88 @@ +/* + * 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.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.SendState + +/** + * 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() + + /** + * Begin tracking a [MessageHandle] for the given packet ID. + * Observes state transitions and updates message status in the repository. + */ + fun track(packetId: Int, handle: MessageHandle) { + scope.launch { + activeHandlesMutex.withLock { + activeHandles[packetId] = handle + } + + val repository = packetRepository.value + handle.state + .onEach { state -> + val status = mapSendState(state) + Logger.d { "[DeliveryTracker] Packet $packetId → $status" } + repository.updateMessageStatus(packetId, status) + } + .first { state -> + val terminal = state.isTerminal() + if (terminal) { + activeHandlesMutex.withLock { + if (activeHandles[packetId] === handle) { + activeHandles.remove(packetId) + } + } + } + terminal + } + } + } + + private fun mapSendState(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.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/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index c4fb269ffc..2905a2a987 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -23,9 +23,14 @@ import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single 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.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.repository.MeshLocationManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository @@ -55,13 +60,23 @@ import org.meshtastic.sdk.RadioClient * **State distribution:** Handled by [SdkStateBridge], which feeds SDK flows into * [ServiceRepository] and [org.meshtastic.core.repository.NodeRepository]. */ -@Single(binds = [RadioController::class]) +@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, ) : RadioController { private val packetIdCounter = atomic(1) @@ -95,13 +110,14 @@ class SdkRadioController( Logger.w { "sendMessage: no client, dropping packet" } return } - val destNum = when (packet.to) { - null, DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST - else -> DataPacket.idToDefaultNodeNum(packet.to?.removePrefix("!")) ?: DataPacket.NODENUM_BROADCAST - } + val destNum = packet.to + val packetId = packet.id.takeIf { it != 0 } ?: getPacketId() val meshPacket = MeshPacket( + id = packetId, to = destNum, channel = packet.channel, + want_ack = packet.wantAck, + hop_limit = packet.hopLimit, decoded = Data( portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP, payload = packet.bytes ?: okio.ByteString.EMPTY, @@ -109,7 +125,8 @@ class SdkRadioController( ), ) try { - c.send(meshPacket) + val handle = c.send(meshPacket) + deliveryTracker.track(packetId, handle) serviceRepository.emitMeshActivity(MeshActivity.Send) } catch (e: Exception) { Logger.e(e) { "sendMessage failed" } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 19d7f98b3e..943c8c142f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -27,10 +27,12 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.ConnectionState as AppConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.NodeRepository @@ -102,6 +104,26 @@ class SdkStateBridge( 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 -> { + 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())) + } + } + } + is 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())) + } + } + } } } .launchIn(scope) @@ -230,8 +252,7 @@ class SdkStateBridge( is ServiceAction.Reaction -> { val channel = action.contactKey[0].digitToInt() val destId = action.contactKey.substring(1) - val destNum = DataPacket.idToDefaultNodeNum(destId.removePrefix("!")) - ?: DataPacket.NODENUM_BROADCAST + val destNum = runCatching { DataPacket.parseNodeNum(destId) }.getOrDefault(DataPacket.BROADCAST) client.send( MeshPacket( to = destNum, @@ -288,12 +309,15 @@ class SdkStateBridge( companion object { private const val EMOJI_INDICATOR = 1 - fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) { + private fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) { is SdkConnectionState.Disconnected -> AppConnectionState.Disconnected - is SdkConnectionState.Connecting -> AppConnectionState.Connecting - is SdkConnectionState.Configuring -> AppConnectionState.Connecting + 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.DeviceSleep + is SdkConnectionState.Reconnecting -> AppConnectionState.Reconnecting(attempt = sdkState.attempt) } } } 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 1e5a487df1..d976996604 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 @@ -352,27 +352,22 @@ class PacketRepositoryImpl( 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 @@ -385,8 +380,7 @@ class PacketRepositoryImpl( 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 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt index 6f0a5c6805..1eb28ecdec 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -125,9 +125,9 @@ class SdkNodeRepositoryImpl( override fun getNode(userId: String): Node = _nodeDBbyNum.value.values.find { it.user.id == userId } - ?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId)) + ?: Node(num = runCatching { DataPacket.parseNodeNum(userId) }.getOrDefault(0), user = getUser(userId)) - override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum)) + override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToId(nodeNum)) private val last4 = 4 @@ -138,13 +138,13 @@ class SdkNodeRepositoryImpl( } val fallbackId = userId.takeLast(last4) val defaultLong = - if (userId == DataPacket.ID_LOCAL) { + if (userId == DataPacket.nodeNumToId(DataPacket.LOCAL)) { ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local" } else { "Meshtastic $fallbackId" } val defaultShort = - if (userId == DataPacket.ID_LOCAL) { + if (userId == DataPacket.nodeNumToId(DataPacket.LOCAL)) { ourNodeInfo.value?.user?.short_name?.takeIf { it.isNotBlank() } ?: "Local" } else { fallbackId @@ -408,8 +408,8 @@ class SdkNodeRepositoryImpl( // ── NodeIdLookup ──────────────────────────────────────────────────────── - override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) { - DataPacket.ID_BROADCAST + override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.BROADCAST) { + DataPacket.nodeNumToId(DataPacket.BROADCAST) } else { _nodeDBbyNum.value[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt index 82313494fb..1cccfc73f9 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/SdkNodeRepositoryImplTest.kt @@ -198,8 +198,8 @@ class SdkNodeRepositoryImplTest { @Test fun `toNodeID returns broadcast ID for broadcast nodeNum`() { - val result = nodeRepository.toNodeID(DataPacket.NODENUM_BROADCAST) - assertEquals(DataPacket.ID_BROADCAST, result) + val result = nodeRepository.toNodeID(DataPacket.BROADCAST) + assertEquals(DataPacket.nodeNumToId(DataPacket.BROADCAST), result) } @Test diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index 11916f60c1..f842545040 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -86,8 +86,8 @@ class StoreForwardPacketHandlerImplTest { private fun makeDataPacket(from: Int): DataPacket = DataPacket( id = 1, time = 1700000000000L, - to = DataPacket.ID_BROADCAST, - from = DataPacket.nodeNumToDefaultId(from), + to = DataPacket.BROADCAST, + from = from, bytes = null, dataType = PortNum.STORE_FORWARD_APP.value, ) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt index 49d583f949..6c013b6972 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt @@ -75,8 +75,8 @@ class TelemetryPacketHandlerImplTest { private fun makeDataPacket(from: Int): DataPacket = DataPacket( id = 1, time = 1700000000000L, - to = DataPacket.ID_BROADCAST, - from = DataPacket.nodeNumToDefaultId(from), + to = DataPacket.BROADCAST, + from = from, bytes = null, dataType = PortNum.TELEMETRY_APP.value, ) 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 147ed09bdc..cffa154c9b 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 @@ -53,7 +53,7 @@ abstract class CommonPacketRepositoryTest { // Set the current node number so PacketRepositoryImpl can pass it to queries nodeRepository.setMyNodeInfo(org.meshtastic.core.testing.TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum)) - val packet = DataPacket(to = "0!ffffffff", bytes = okio.ByteString.EMPTY, dataType = 1, id = 123) + val packet = DataPacket(to = DataPacket.BROADCAST, bytes = okio.ByteString.EMPTY, dataType = 1, id = 123) repository.savePacket(myNodeNum, contact, packet, 1000L) 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 dd6966a564..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 @@ -143,7 +143,7 @@ class MigrationTest { 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) } 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/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt index 5977e08a13..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 @@ -40,7 +40,7 @@ abstract class CommonPacketDaoTest { private val myNodeNum = 42424242 - private val testContactKeys = listOf("0${DataPacket.ID_BROADCAST}", "1!test1234") + private val testContactKeys = listOf("0${DataPacket.nodeNumToId(DataPacket.BROADCAST)}", "1!test1234") private fun generateTestPackets(nodeNum: Int) = testContactKeys.flatMap { contactKey -> List(SAMPLE_SIZE) { @@ -53,7 +53,7 @@ abstract class CommonPacketDaoTest { read = false, data = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = "Message $it!".encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, ), @@ -115,7 +115,7 @@ abstract class CommonPacketDaoTest { val messages = packetDao.getMessagesFrom(myNodeNum, contactKey).first() val packet = messages.first().packet.data - 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) @@ -136,7 +136,7 @@ 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, @@ -170,12 +170,12 @@ 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, ), @@ -208,7 +208,7 @@ abstract class CommonPacketDaoTest { received_time = nowMillis + index, read = false, data = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = text.encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, ), @@ -227,7 +227,7 @@ abstract class CommonPacketDaoTest { received_time = nowMillis + normalMessages.size + index, read = true, data = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = text.encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.value, ), @@ -265,7 +265,7 @@ abstract class CommonPacketDaoTest { received_time = baseTime + id, read = false, data = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = "Chunk $id".encodeToByteArray().toByteString(), dataType = PortNum.TEXT_MESSAGE_APP.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/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/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/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt new file mode 100644 index 0000000000..981e5d8a84 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt @@ -0,0 +1,26 @@ +/* + * 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 + +/** Focused interface for requesting data from nodes. */ +interface DataRequester { + suspend fun requestPosition(destNum: Int, currentPosition: Position) + suspend fun requestUserInfo(destNum: Int) + suspend fun requestTraceroute(requestId: Int, destNum: Int) + suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) + suspend fun requestNeighborInfo(requestId: Int, destNum: Int) +} 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..6554a7da6c --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt @@ -0,0 +1,28 @@ +/* + * 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 { + suspend fun setLocalConfig(config: Config) + suspend fun setLocalChannel(channel: Channel) + suspend fun beginEditSettings(destNum: Int) + suspend fun commitEditSettings(destNum: Int) +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt new file mode 100644 index 0000000000..1e42ec8203 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt @@ -0,0 +1,28 @@ +/* + * 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 + +/** Focused interface for device lifecycle control. */ +interface DeviceControl { + suspend fun reboot(destNum: Int, packetId: Int) + suspend fun rebootToDfu(nodeNum: Int) + suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) + suspend fun shutdown(destNum: Int, packetId: Int) + suspend fun factoryReset(destNum: Int, packetId: Int) + suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) + suspend fun removeByNodenum(packetId: Int, 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/MessageSender.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt new file mode 100644 index 0000000000..897c2501c9 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt @@ -0,0 +1,23 @@ +/* + * 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 + +/** Focused interface for sending messages over the mesh. */ +interface MessageSender { + suspend fun sendMessage(packet: DataPacket) + fun getPacketId(): Int +} 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..a79c745baa 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,14 +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 { +interface RadioController : MessageSender, DeviceAdmin, RemoteAdmin, DeviceControl, DataRequester { /** * Canonical app-level connection state, delegated from [ServiceRepository][connectionState]. * @@ -47,13 +45,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 +66,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..0773e4da4e --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt @@ -0,0 +1,40 @@ +/* + * 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.ModuleConfig +import org.meshtastic.proto.User + +/** Focused interface for remote node administration. */ +interface RemoteAdmin { + suspend fun setOwner(destNum: Int, user: User, packetId: Int) + suspend fun setConfig(destNum: Int, config: Config, packetId: Int) + suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) + suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) + suspend fun setFixedPosition(destNum: Int, position: Position) + suspend fun setRingtone(destNum: Int, ringtone: String) + suspend fun setCannedMessages(destNum: Int, messages: String) + suspend fun getOwner(destNum: Int, packetId: Int) + suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) + suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) + suspend fun getChannel(destNum: Int, index: Int, packetId: Int) + suspend fun getRingtone(destNum: Int, packetId: Int) + suspend fun getCannedMessages(destNum: Int, packetId: Int) + suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) +} 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 index 4df932c50f..7d35a8e318 100644 --- 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 @@ -33,8 +33,8 @@ open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) { open fun toDataPacket(packet: MeshPacket): DataPacket? { val decoded = packet.decoded ?: return null return DataPacket( - from = nodeIdLookup.toNodeID(packet.from), - to = nodeIdLookup.toNodeID(packet.to), + from = packet.from, + to = packet.to, time = packet.rx_time * 1000L, id = packet.id, dataType = decoded.portnum.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 index f24919f02c..3a6caf5425 100644 --- 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 @@ -16,27 +16,10 @@ */ package org.meshtastic.core.model.util -import okio.ByteString.Companion.toByteString +import org.meshtastic.sdk.SfppHash /** 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) - } + fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = + SfppHash.compute(encryptedPayload, to, from, id) } 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/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index 5aefa697c8..b41b71cda1 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 @@ -46,8 +46,10 @@ interface ServiceRepository { * * 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) */ val connectionState: StateFlow 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/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/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/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/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt index 4b1097cc47..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 @@ -130,7 +130,7 @@ class TAKMeshIntegration( val dataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = payload.toByteString(), dataType = PortNum.ATAK_PLUGIN.value, ) 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 374b463053..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 @@ -93,7 +93,7 @@ class GenericCoTHandler(private val radioController: RadioController, private va private suspend fun sendDirect(payload: ByteArray) { val dataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = payload.toByteString(), dataType = PortNum.ATAK_FORWARDER.value, ) @@ -115,7 +115,7 @@ class GenericCoTHandler(private val radioController: RadioController, private va for ((index, packetData) in packets.withIndex()) { val dataPacket = DataPacket( - to = DataPacket.ID_BROADCAST, + to = DataPacket.BROADCAST, bytes = packetData.toByteString(), dataType = PortNum.ATAK_FORWARDER.value, ) @@ -191,7 +191,7 @@ class GenericCoTHandler(private val radioController: RadioController, private va val dataPacket = DataPacket( - to = toNodeNum.toString(), + to = toNodeNum, bytes = ackPacket.toByteString(), dataType = PortNum.ATAK_FORWARDER.value, ) 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/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/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/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..0a180fd4bd 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 @@ -198,7 +198,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 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/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index fdfd3f05a0..455236e19c 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 @@ -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) } 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..d3c64bfde4 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 } } 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..85b746c05d 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 @@ -195,9 +195,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 +212,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/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..de6e547e56 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 @@ -109,7 +109,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 }, 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, From 27b2c19e699a23a97f677445d542e81fa2167db1 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 15:15:45 -0500 Subject: [PATCH 23/53] refactor: narrow ViewModel injections, add ConnectionAware, delete dead code, integration tests ViewModel Narrowing: - V1: Created ConnectionAware interface; MessageSender, DeviceAdmin, DeviceControl extend it - V2: Narrowed 6 ViewModels/actions to focused sub-interfaces (DeviceAdmin, MessageSender, DataRequester, DeviceControl) Cleanup: - C1: Deleted dead MeshDataMapper and its DI registration Integration Tests: - T2: SdkStateBridgeTest verifying WentOffline/CameOnline presence handling - Verified Koin resolution, full test suite passes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/app/map/MapViewModel.kt | 4 +- .../org/meshtastic/app/map/MapViewModel.kt | 4 +- .../meshtastic/core/data/di/CoreDataModule.kt | 4 - .../core/data/radio/SdkStateBridgeTest.kt | 189 ++++++++++++++++++ .../meshtastic/core/model/ConnectionAware.kt | 24 +++ .../org/meshtastic/core/model/DeviceAdmin.kt | 2 +- .../meshtastic/core/model/DeviceControl.kt | 2 +- .../meshtastic/core/model/MessageSender.kt | 2 +- .../meshtastic/core/model/RadioController.kt | 12 -- .../core/model/util/MeshDataMapper.kt | 55 ----- .../core/ui/qr/ScannedQrCodeViewModel.kt | 4 +- .../feature/map/BaseMapViewModel.kt | 4 +- .../feature/map/SharedMapViewModel.kt | 4 +- .../node/detail/CommonNodeRequestActions.kt | 22 +- .../node/detail/NodeManagementActions.kt | 10 +- .../feature/node/list/NodeListViewModel.kt | 4 +- .../node/detail/NodeManagementActionsTest.kt | 6 +- .../settings/channel/ChannelViewModel.kt | 4 +- 18 files changed, 252 insertions(+), 104 deletions(-) create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionAware.kt delete mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt 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 5acd0b1a06..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, 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/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..bbd3617088 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.radio + +import dev.mokkery.MockMode +import dev.mokkery.mock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.Node +import org.meshtastic.core.repository.MeshLocationManager +import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.testing.FakeNodeRepository +import org.meshtastic.core.testing.FakeRadioController +import org.meshtastic.core.testing.FakeServiceRepository +import org.meshtastic.core.testing.FakeUiPrefs +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position +import org.meshtastic.proto.User +import org.meshtastic.sdk.DeviceStorage +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.StorageProvider +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorage +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SdkStateBridgeTest { + + @Test + fun `went offline marks node offline in repository`() = runTest { + val remoteNode = NodeId(0x22222222) + val staleHeartbeatMs = Clock.System.now().toEpochMilliseconds() - 5.seconds.inWholeMilliseconds + val nodeRepository = + FakeNodeRepository().apply { + setNodes( + listOf( + Node( + num = remoteNode.raw, + user = User(id = "!22222222", long_name = "Test Node"), + lastHeard = (Clock.System.now().toEpochMilliseconds() / 1000).toInt(), + ), + ), + ) + } + val (_, client) = connectedClient(SeededHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs))) + buildBridge(client, nodeRepository) + + client.connect() + runCurrent() + advanceTimeBy(30.seconds) + runCurrent() + + val updated = nodeRepository.nodeDBbyNum.value.getValue(remoteNode.raw) + assertTrue(updated.lastHeard <= (staleHeartbeatMs / 1000).toInt()) + assertFalse(updated.isOnline) + + client.disconnect() + } + + @Test + fun `came online marks node online in repository`() = runTest { + val remoteNode = NodeId(0x33333333) + val staleHeartbeatMs = Clock.System.now().toEpochMilliseconds() - 5.seconds.inWholeMilliseconds + val nodeRepository = + FakeNodeRepository().apply { + setNodes( + listOf( + Node( + num = remoteNode.raw, + user = User(id = "!33333333", long_name = "Test Node"), + lastHeard = (staleHeartbeatMs / 1000).toInt(), + ), + ), + ) + } + val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs))) + buildBridge(client, nodeRepository) + + client.connect() + runCurrent() + advanceTimeBy(30.seconds) + runCurrent() + + transport.injectPacket( + MeshPacket( + from = remoteNode.raw, + to = 0, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP), + ), + ) + runCurrent() + + val updated = nodeRepository.nodeDBbyNum.value.getValue(remoteNode.raw) + assertTrue(updated.lastHeard > (staleHeartbeatMs / 1000).toInt()) + assertTrue(updated.isOnline) + + client.disconnect() + } + + private fun TestScope.connectedClient( + storage: StorageProvider, + myNodeNum: Int = 0x11111111, + presenceTimeout: Duration = 1.seconds, + ): Pair { + val transport = FakeRadioTransport(identity = TransportIdentity("fake:state-bridge"), autoHandshake = true, nodeNum = myNodeNum) + val client = + RadioClient.Builder() + .transport(transport) + .storage(storage) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .presenceTimeout(presenceTimeout) + .build() + return transport to client + } + + private fun TestScope.buildBridge( + client: RadioClient, + nodeRepository: FakeNodeRepository, + ): SdkStateBridge = + SdkStateBridge( + accessor = TestRadioClientAccessor(client), + serviceRepository = FakeServiceRepository(), + nodeRepository = nodeRepository, + packetRepository = lazyOf(mock(MockMode.autofill)), + locationManager = NoOpLocationManager, + uiPrefs = FakeUiPrefs(), + radioController = FakeRadioController(), + dispatchers = CoroutineDispatchers( + io = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher, + main = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher, + default = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher, + ), + ) + + private class TestRadioClientAccessor(client: RadioClient) : RadioClientAccessor { + override val client = MutableStateFlow(client) + + override fun rebuildAndConnectAsync() = Unit + + override fun disconnect() = Unit + } + + private object NoOpLocationManager : MeshLocationManager { + override fun start(scope: CoroutineScope, sendPositionFn: (Position) -> Unit) = Unit + + override fun stop() = Unit + } +} + +private class SeededHeartbeatStorageProvider( + private val heartbeats: Map, +) : StorageProvider { + override suspend fun activate(identity: TransportIdentity): DeviceStorage = + InMemoryStorage().also { storage -> + heartbeats.forEach { (nodeId, heartbeatMs) -> + storage.saveHeartbeat(nodeId, heartbeatMs) + } + } +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionAware.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionAware.kt new file mode 100644 index 0000000000..5e5cb3bc3a --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionAware.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import kotlinx.coroutines.flow.StateFlow + +/** Provides read-only access to the app's connection state. */ +interface ConnectionAware { + val connectionState: StateFlow +} 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 index 6554a7da6c..c12979e213 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt @@ -20,7 +20,7 @@ import org.meshtastic.proto.Channel import org.meshtastic.proto.Config /** Focused interface for local device configuration and edit sessions. */ -interface DeviceAdmin { +interface DeviceAdmin : ConnectionAware { suspend fun setLocalConfig(config: Config) suspend fun setLocalChannel(channel: Channel) suspend fun beginEditSettings(destNum: Int) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt index 1e42ec8203..8cd8492055 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt @@ -17,7 +17,7 @@ package org.meshtastic.core.model /** Focused interface for device lifecycle control. */ -interface DeviceControl { +interface DeviceControl : ConnectionAware { suspend fun reboot(destNum: Int, packetId: Int) suspend fun rebootToDfu(nodeNum: Int) suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt index 897c2501c9..1eaf22ce6c 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt @@ -17,7 +17,7 @@ package org.meshtastic.core.model /** Focused interface for sending messages over the mesh. */ -interface MessageSender { +interface MessageSender : ConnectionAware { suspend fun sendMessage(packet: DataPacket) fun getPacketId(): Int } 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 a79c745baa..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 @@ -26,18 +26,6 @@ import org.meshtastic.proto.ClientNotification * This super-interface remains for backward compatibility with existing injections. */ interface RadioController : MessageSender, DeviceAdmin, RemoteAdmin, DeviceControl, DataRequester { - /** - * Canonical app-level connection state, delegated from [ServiceRepository][connectionState]. - * - * This exposes the same single source of truth as `ServiceRepository.connectionState`, surfaced through the - * controller interface for convenience in feature modules and ViewModels that depend on [RadioController] rather - * than [ServiceRepository] directly. - * - * This is **not** the transport-level state — it reflects the fully reconciled app-level state including handshake - * progress and device sleep policy. - */ - val connectionState: StateFlow - /** * Flow of notifications from the radio client. * 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 7d35a8e318..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 = packet.from, - to = packet.to, - time = packet.rx_time * 1000L, - id = packet.id, - dataType = decoded.portnum.value, - bytes = decoded.payload.toByteArray().toByteString(), - hopLimit = packet.hop_limit, - channel = if (packet.pki_encrypted == true) DataPacket.PKC_CHANNEL_INDEX else packet.channel, - wantAck = packet.want_ack == true, - hopStart = packet.hop_start, - snr = packet.rx_snr, - rssi = packet.rx_rssi, - replyId = decoded.reply_id, - relayNode = packet.relay_node, - viaMqtt = packet.via_mqtt == true, - emoji = decoded.emoji, - transportMechanism = packet.transport_mechanism.value, - ) - } -} 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/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 455236e19c..153c91a21e 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 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/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..19f6843c57 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,9 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.model.DataRequester +import org.meshtastic.core.model.MessageSender import org.meshtastic.core.model.Position -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText @@ -48,7 +49,8 @@ import org.meshtastic.core.ui.util.SnackbarManager @Single(binds = [NodeRequestActions::class]) class CommonNodeRequestActions constructor( - private val radioController: RadioController, + private val dataRequester: DataRequester, + private val messageSender: MessageSender, private val snackbarManager: SnackbarManager, ) : NodeRequestActions { @@ -65,7 +67,7 @@ constructor( override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(ioDispatcher) { Logger.i { "Requesting UserInfo for '$destNum'" } - radioController.requestUserInfo(destNum) + dataRequester.requestUserInfo(destNum) showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName)) } } @@ -73,8 +75,8 @@ constructor( override fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(ioDispatcher) { Logger.i { "Requesting NeighborInfo for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestNeighborInfo(packetId, destNum) + val packetId = messageSender.getPacketId() + dataRequester.requestNeighborInfo(packetId, destNum) _lastRequestNeighborTimes.update { it + (destNum to nowMillis) } showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName)) } @@ -83,7 +85,7 @@ constructor( override fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) { scope.launch(ioDispatcher) { Logger.i { "Requesting position for '$destNum'" } - radioController.requestPosition(destNum, position) + dataRequester.requestPosition(destNum, position) showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName)) } } @@ -91,8 +93,8 @@ constructor( override fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) { scope.launch(ioDispatcher) { Logger.i { "Requesting telemetry for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestTelemetry(packetId, destNum, type.ordinal) + val packetId = messageSender.getPacketId() + dataRequester.requestTelemetry(packetId, destNum, type.ordinal) val typeRes = when (type) { @@ -112,8 +114,8 @@ constructor( override fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(ioDispatcher) { Logger.i { "Requesting traceroute for '$destNum'" } - val packetId = radioController.getPacketId() - radioController.requestTraceroute(packetId, destNum) + val packetId = messageSender.getPacketId() + dataRequester.requestTraceroute(packetId, destNum) _lastTracerouteTime.value = nowMillis showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName)) } 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..1c48540f1c 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,9 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.getString import org.koin.core.annotation.Single import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.model.DeviceControl +import org.meshtastic.core.model.MessageSender import org.meshtastic.core.model.Node -import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository @@ -47,7 +48,8 @@ open class NodeManagementActions constructor( private val nodeRepository: NodeRepository, private val serviceRepository: ServiceRepository, - private val radioController: RadioController, + private val deviceControl: DeviceControl, + private val messageSender: MessageSender, private val alertManager: AlertManager, ) { open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) { @@ -64,8 +66,8 @@ constructor( open fun removeNode(scope: CoroutineScope, nodeNum: Int) { scope.launch(ioDispatcher) { Logger.i { "Removing node '$nodeNum'" } - val packetId = radioController.getPacketId() - radioController.removeByNodenum(packetId, nodeNum) + val packetId = messageSender.getPacketId() + deviceControl.removeByNodenum(packetId, nodeNum) nodeRepository.deleteNode(nodeNum) } } 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 0d54dfeadb..2f0ab1c809 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,7 +29,7 @@ import org.koin.core.annotation.KoinViewModel import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeSortOption -import org.meshtastic.core.model.RadioController +import org.meshtastic.core.model.DeviceAdmin import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioPrefs @@ -47,7 +47,7 @@ class NodeListViewModel( private val nodeRepository: NodeRepository, private val radioConfigRepository: RadioConfigRepository, private val serviceRepository: ServiceRepository, - private val radioController: RadioController, + private val radioController: DeviceAdmin, private val radioPrefs: RadioPrefs, val nodeManagementActions: NodeManagementActions, private val getFilteredNodesUseCase: GetFilteredNodesUseCase, 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..3233747da6 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,8 @@ class NodeManagementActionsTest { NodeManagementActions( nodeRepository = nodeRepository, serviceRepository = serviceRepository, - radioController = radioController, + deviceControl = radioController, + messageSender = radioController, alertManager = alertManager, ) @@ -78,7 +79,8 @@ class NodeManagementActionsTest { NodeManagementActions( nodeRepository = nodeRepository, serviceRepository = serviceRepository, - radioController = radioController, + deviceControl = radioController, + messageSender = radioController, alertManager = realAlertManager, ) val node = Node(num = 123, user = User(long_name = "Test Node")) 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() { From 7683db0c572114d98a127e466e5538060407f0e6 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 15:37:35 -0500 Subject: [PATCH 24/53] =?UTF-8?q?feat:=20deep=20SDK=20integration=20?= =?UTF-8?q?=E2=80=94=20retry=20delivery,=20Store-and-Forward=20API,=20cong?= =?UTF-8?q?estion=20surfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I1 — RetryPolicy in MessageDeliveryTracker: - track() now accepts optional RetryPolicy (default: ExponentialBackoff 3 attempts) - Failed sends automatically retry before marking ERROR - UI sees ENROUTE during retry attempts I2 — Store-and-Forward SDK consumption: - SdkStateBridge observes storeForward.events and servers - ServiceRepository exposes storeForwardServers StateFlow - SdkRadioController.requestStoreForwardHistory() delegates to SDK - HistoryManagerImpl uses SDK path instead of manual packet construction I3 — Congestion level surfacing: - SdkStateBridge handles MeshEvent.CongestionWarning - ServiceRepository.congestionLevel StateFlow exposed to UI - Cleared on disconnect Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/manager/HistoryManagerImpl.kt | 55 ++++++----------- .../manager/StoreForwardPacketHandlerImpl.kt | 7 ++- .../core/data/radio/MessageDeliveryTracker.kt | 61 +++++++++++++------ .../core/data/radio/SdkRadioController.kt | 19 ++++++ .../core/data/radio/SdkStateBridge.kt | 54 ++++++++++++++++ .../data/manager/HistoryManagerImplTest.kt | 31 ---------- .../meshtastic/core/model/DataRequester.kt | 1 + core/repository/build.gradle.kts | 1 + .../core/repository/ServiceRepository.kt | 13 ++++ .../core/service/ServiceRepositoryImpl.kt | 20 ++++++ .../core/testing/FakeRadioController.kt | 7 +++ .../core/testing/FakeServiceRepository.kt | 19 ++++++ 12 files changed, 200 insertions(+), 88 deletions(-) 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 index 7ea4d7cf09..aed0605310 100644 --- 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 @@ -17,20 +17,21 @@ package org.meshtastic.core.data.manager import co.touchlab.kermit.Logger -import okio.ByteString.Companion.toByteString +import kotlinx.coroutines.CoroutineScope +import org.koin.core.annotation.Named import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.safeCatching +import org.meshtastic.core.common.util.handledLaunch +import org.meshtastic.core.model.RadioController 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 { +class HistoryManagerImpl( + private val meshPrefs: MeshPrefs, + private val radioController: RadioController, + @Named("ServiceScope") private val scope: CoroutineScope, +) : HistoryManager { companion object { private const val HISTORY_TAG = "HistoryReplay" @@ -38,20 +39,6 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan 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 @@ -81,32 +68,26 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan return } - val lastRequest = meshPrefs.getStoreForwardLastRequest(address).value + val lastRequest = meshPrefs.getStoreForwardLastRequest(address).value.takeIf { it > 0 } 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", + "since=${lastRequest ?: "all"} window=$window max=$max via=sdk", ) - 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, - ), - ) + scope.handledLaunch { + val accepted = radioController.requestStoreForwardHistory(since = lastRequest) + if (!accepted) { + logger.w { + "requestHistory failed trigger=$trigger transport=$transport addr=$address since=${lastRequest ?: "all"}" + } + } } - .onFailure { ex -> logger.w(ex) { "requestHistory failed" } } } override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index daf1b7f814..162efb0488 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -37,7 +37,12 @@ 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. */ +/** + * Implementation of [StoreForwardPacketHandler] that handles both legacy S&F and SF++ packets. + * + * Legacy Store-and-Forward discovery/history is now exposed through `RadioClient.storeForward`, but we still keep + * this parser for backward compatibility and for SF++ handling that the SDK does not fully replace yet. + */ @Single class StoreForwardPacketHandlerImpl( private val nodeRepository: NodeRepository, 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 index e3237143ae..d54a1d8578 100644 --- 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 @@ -17,6 +17,7 @@ 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 @@ -29,7 +30,11 @@ 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. @@ -43,44 +48,62 @@ class MessageDeliveryTracker( 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 state transitions and updates message status in the repository. + * Observes intermediate state transitions and resolves the terminal status via SDK retries. */ - fun track(packetId: Int, handle: MessageHandle) { + fun track(packetId: Int, handle: MessageHandle, policy: RetryPolicy = defaultRetryPolicy) { scope.launch { activeHandlesMutex.withLock { activeHandles[packetId] = handle } val repository = packetRepository.value - handle.state - .onEach { state -> - val status = mapSendState(state) - Logger.d { "[DeliveryTracker] Packet $packetId → $status" } - repository.updateMessageStatus(packetId, status) - } - .first { state -> - val terminal = state.isTerminal() - if (terminal) { - activeHandlesMutex.withLock { - if (activeHandles[packetId] === handle) { - activeHandles.remove(packetId) - } - } + 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) } - terminal } + } } } - private fun mapSendState(state: SendState): MessageStatus = when (state) { + 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.ERROR + 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 = diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 2905a2a987..5ad90a19ad 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -437,6 +437,25 @@ class SdkRadioController( 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 beginEditSettings(destNum: Int) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 943c8c142f..5a114e842f 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -20,9 +20,11 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +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 kotlinx.coroutines.launch import okio.ByteString.Companion.toByteString @@ -50,6 +52,7 @@ import org.meshtastic.sdk.ConnectionState as SdkConnectionState import org.meshtastic.sdk.MeshEvent import org.meshtastic.sdk.NodeChange import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.StoreForwardEvent /** * Bridges SDK reactive flows into the repository layer and routes [ServiceAction]s @@ -151,6 +154,12 @@ class SdkStateBridge( 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" } @@ -158,6 +167,51 @@ class SdkStateBridge( } .launchIn(scope) + // ── Store-and-Forward (server discovery + replay lifecycle) ─────────── + 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 { event -> + 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)}" + } + } + } + } + .launchIn(scope) + // ── ServiceAction routing (replaces MeshServiceOrchestrator dispatch) ─ serviceRepository.serviceAction .onEach { action -> handleServiceAction(action) } 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 index 4223b47f44..ec62f687cf 100644 --- 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 @@ -16,42 +16,11 @@ */ 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) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt index 981e5d8a84..59d5e04bb7 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt @@ -23,4 +23,5 @@ interface DataRequester { suspend fun requestTraceroute(requestId: Int, destNum: Int) suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) suspend fun requestNeighborInfo(requestId: Int, destNum: Int) + suspend fun requestStoreForwardHistory(since: Int? = null, serverNodeNum: Int? = null): Boolean } 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/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index b41b71cda1..4ad665c917 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 @@ -25,6 +25,7 @@ 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.sdk.CongestionLevel /** * Interface for managing background service state, connection status, and mesh events. @@ -54,6 +55,9 @@ interface ServiceRepository { */ val connectionState: StateFlow + /** Current mesh congestion level, null when unknown or disconnected. */ + val congestionLevel: StateFlow + /** * Updates the canonical app-level connection state. * @@ -64,6 +68,9 @@ interface ServiceRepository { */ fun setConnectionState(connectionState: ConnectionState) + /** Sets the current mesh congestion level. */ + fun setCongestionLevel(level: CongestionLevel?) + /** * Reactive flow of high-level client notifications. * @@ -81,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. * 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 c08c5bdf02..f38c574bcb 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,6 +24,7 @@ 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 @@ -32,6 +33,7 @@ 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.sdk.CongestionLevel /** * Platform-agnostic implementation of [ServiceRepository]. @@ -50,6 +52,16 @@ open class ServiceRepositoryImpl : 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) @@ -65,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 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..515beeab2a 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 @@ -47,6 +47,7 @@ class FakeRadioController : val sentSharedContacts = mutableListOf() var throwOnSend: Boolean = false var lastSetDeviceAddress: String? = null + var lastStoreForwardHistoryRequest: Pair? = null var beginEditSettingsCalled = false var commitEditSettingsCalled = false var startProvideLocationCalled = false @@ -59,6 +60,7 @@ class FakeRadioController : sentSharedContacts.clear() throwOnSend = false lastSetDeviceAddress = null + lastStoreForwardHistoryRequest = null beginEditSettingsCalled = false commitEditSettingsCalled = false startProvideLocationCalled = false @@ -140,6 +142,11 @@ class FakeRadioController : override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {} + override suspend fun requestStoreForwardHistory(since: Int?, serverNodeNum: Int?): Boolean { + lastStoreForwardHistoryRequest = since to serverNodeNum + return true + } + override suspend fun beginEditSettings(destNum: Int) { beginEditSettingsCalled = 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 eacbf4ef58..6c55cdaf4d 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,6 +22,7 @@ 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 @@ -29,6 +30,7 @@ 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.sdk.CongestionLevel @Suppress("TooManyFunctions") class FakeServiceRepository : ServiceRepository { @@ -38,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) @@ -51,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 From 64464196b04fc8ab1112ffd6ed894e17041bdcc0 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 16:06:29 -0500 Subject: [PATCH 25/53] feat: SFPP delegation to SDK, NeighborInfo SDK model, congestion + S&F badges - SdkStateBridge: handle SfppLinkProvided/SfppCanonAnnounced events from SDK - StoreForwardPacketHandlerImpl: SFPP parsing removed (SDK-owned) - NeighborInfoHandlerImpl: delegate formatting to SDK NeighborInfo.fromProto() - NodeStatusIcons: CongestionBadge (yellow/orange/red for MEDIUM/HIGH/CRITICAL) - NodeStatusIcons: StoreForwardBadge (blue cloud icon for S&F servers) - NodeListViewModel: expose congestionLevel + storeForwardServers flows - Tests updated for SFPP bridge coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/manager/NeighborInfoHandlerImpl.kt | 27 ++- .../manager/StoreForwardPacketHandlerImpl.kt | 93 +---------- .../core/data/radio/SdkStateBridge.kt | 23 +++ .../StoreForwardPacketHandlerImplTest.kt | 154 ++---------------- .../core/data/radio/SdkStateBridgeTest.kt | 90 +++++++++- .../feature/node/component/NodeItem.kt | 9 + .../feature/node/component/NodeStatusIcons.kt | 55 +++++++ .../feature/node/list/NodeListScreen.kt | 4 + .../feature/node/list/NodeListViewModel.kt | 5 + 9 files changed, 216 insertions(+), 244 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 91c68b1aac..a74dbd8495 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -28,6 +28,7 @@ import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NeighborInfo +import org.meshtastic.sdk.NeighborInfo as SdkNeighborInfo @Single class NeighborInfoHandlerImpl( @@ -47,37 +48,33 @@ class NeighborInfoHandlerImpl( 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 == nodeRepository.myNodeNum.value) { lastNeighborInfo = ni Logger.d { "Stored last neighbor info from connected radio" } } - // Update Node DB - nodeRepository.nodeDBbyNodeNum[from]?.let { /* SDK client.nodes is canonical source */ } - - // 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 formatted = + SdkNeighborInfo + .fromProto( + reportingNode = from, + neighborNodeIds = ni.neighbors.map { it.node_id }, + snrValues = ni.neighbors.map { it.snr }, + ).format { nodeId -> + val user = nodeRepository.getUser(nodeId.raw) + "${user.long_name} (${user.short_name})" + } 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" + "$formatted\nDuration: ${NumberFormatter.format(seconds, 1)} s" } else { formatted } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index 162efb0488..39fbc3b7ba 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -19,13 +19,9 @@ 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.NodeRepository @@ -34,14 +30,12 @@ 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. + * Implementation of [StoreForwardPacketHandler] that keeps legacy S&F parsing for backward compatibility. * - * Legacy Store-and-Forward discovery/history is now exposed through `RadioClient.storeForward`, but we still keep - * this parser for backward compatibility and for SF++ handling that the SDK does not fully replace yet. + * SF++ parsing/status updates are now delegated to the SDK and consumed via [org.meshtastic.core.data.radio.SdkStateBridge]. */ @Single class StoreForwardPacketHandlerImpl( @@ -58,89 +52,8 @@ class StoreForwardPacketHandlerImpl( 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.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=${nodeRepository.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 = nodeRepository.myNodeNum.value ?: 0, - ) - } - } - - 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, - ) - } - } + Logger.d { "SFPP packet received from=${packet.from} (handled by SDK)" } } private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 5a114e842f..d696871f90 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -33,6 +33,7 @@ import org.meshtastic.core.common.util.nowSeconds 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.RadioController import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.model.service.ServiceAction @@ -208,6 +209,28 @@ class SdkStateBridge( "[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, + ) + } + else -> Logger.d { "[SdkBridge] S&F event: $event" } } } .launchIn(scope) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index f842545040..75e37318f7 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -17,19 +17,16 @@ 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.VerifyMode 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 @@ -40,7 +37,6 @@ 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 @@ -61,8 +57,6 @@ class StoreForwardPacketHandlerImplTest { @BeforeTest fun setUp() { - every { nodeRepository.myNodeNum } returns MutableStateFlow(myNodeNum) - handler = StoreForwardPacketHandlerImpl( nodeRepository = nodeRepository, @@ -78,11 +72,6 @@ class StoreForwardPacketHandlerImplTest { 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, @@ -200,138 +189,27 @@ class StoreForwardPacketHandlerImplTest { // 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()) } - } - - // ---------- 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 ---------- + // ---------- SF++: delegated to SDK ---------- @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 + fun `handleStoreForwardPlusPlus logs only and leaves repository untouched`() = testScope.runTest { + val packet = + MeshPacket( + from = 999, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = "ignored".encodeToByteArray().toByteString(), + ), ) - val packet = makeSfppPacket(999, sfpp) handler.handleStoreForwardPlusPlus(packet) advanceUntilIdle() - verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) } + verifySuspend(mode = VerifyMode.exactly(0)) { + packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) + } + verifySuspend(mode = VerifyMode.exactly(0)) { + packetRepository.updateSFPPStatusByHash(any(), any(), any()) + } } } 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 index bbd3617088..edea8eb0d7 100644 --- 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 @@ -17,7 +17,9 @@ 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 @@ -25,7 +27,9 @@ 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 @@ -37,6 +41,7 @@ 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 @@ -126,6 +131,71 @@ class SdkStateBridgeTest { client.disconnect() } + @Test + fun `sfpp link provided updates packet repository`() = runTest { + val packetRepository = mock(MockMode.autofill) + val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(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(SeededHeartbeatStorageProvider(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() + } + private fun TestScope.connectedClient( storage: StorageProvider, myNodeNum: Int = 0x11111111, @@ -146,12 +216,13 @@ class SdkStateBridgeTest { private fun TestScope.buildBridge( client: RadioClient, nodeRepository: FakeNodeRepository, + packetRepository: PacketRepository = mock(MockMode.autofill), ): SdkStateBridge = SdkStateBridge( accessor = TestRadioClientAccessor(client), serviceRepository = FakeServiceRepository(), nodeRepository = nodeRepository, - packetRepository = lazyOf(mock(MockMode.autofill)), + packetRepository = lazyOf(packetRepository), locationManager = NoOpLocationManager, uiPrefs = FakeUiPrefs(), radioController = FakeRadioController(), @@ -162,6 +233,23 @@ class SdkStateBridgeTest { ), ) + 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) 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..f79e266d0d 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.sdk.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 de6e547e56..40ca70da84 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 @@ -48,11 +48,17 @@ import org.meshtastic.core.resources.mute_always 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.sdk.CongestionLevel @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -62,14 +68,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 +90,9 @@ fun NodeStatusIcons( tint = contentColor, ) } + if (isStoreForwardServer) { + StoreForwardBadge() + } if (isMuted && !isThisNode) { StatusBadge( imageVector = MeshtasticIcons.VolumeOff, @@ -125,6 +139,47 @@ private fun ThisNodeStatusBadge(connectionState: ConnectionState, deviceType: De } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun StoreForwardBadge() { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text("Store & Forward server") } }, + state = rememberTooltipState(), + ) { + Icon( + imageVector = MeshtasticIcons.CloudDownload, + contentDescription = "Store & Forward server", + 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 + } + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), + tooltip = { PlainTooltip { Text("Channel: ${level.name}") } }, + state = rememberTooltipState(), + ) { + Icon( + imageVector = MeshtasticIcons.Warning, + contentDescription = "Congestion: ${level.name}", + 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/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index fbb81101a3..7368023826 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() @@ -203,11 +205,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 2f0ab1c809..bf173df284 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 @@ -39,6 +39,7 @@ 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.sdk.CongestionLevel @Suppress("LongParameterList") @KoinViewModel @@ -62,6 +63,10 @@ class NodeListViewModel( val connectionState = serviceRepository.connectionState + val congestionLevel: StateFlow = serviceRepository.congestionLevel + + val storeForwardServers: StateFlow> = serviceRepository.storeForwardServers + val deviceType: StateFlow = radioPrefs.devAddr .map { address -> address?.let { DeviceType.fromAddress(it) } } From c712d7ef6890e8d46e7b5d148f45493a2fe27f72 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 16:46:54 -0500 Subject: [PATCH 26/53] chore: remove dead SfppHasher + unused NodeRepository injection - Delete SfppHasher wrapper (SDK SfppHash handles SFPP hashing) - Remove unused NodeRepository from StoreForwardPacketHandlerImpl - Update tests to match constructor change Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manager/StoreForwardPacketHandlerImpl.kt | 2 - .../StoreForwardPacketHandlerImplTest.kt | 3 - .../meshtastic/core/model/util/SfppHasher.kt | 25 ------ .../core/model/util/SfppHasherTest.kt | 87 ------------------- 4 files changed, 117 deletions(-) delete mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt delete mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index 39fbc3b7ba..7fef493f83 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -24,7 +24,6 @@ import org.koin.core.annotation.Single import org.meshtastic.core.model.DataPacket import org.meshtastic.core.repository.HistoryManager import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.StoreForwardPacketHandler import org.meshtastic.proto.MeshPacket @@ -39,7 +38,6 @@ import kotlin.time.Duration.Companion.milliseconds */ @Single class StoreForwardPacketHandlerImpl( - private val nodeRepository: NodeRepository, private val packetRepository: Lazy, private val historyManager: HistoryManager, private val dataHandler: Lazy, diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index 75e37318f7..5bbce6fc95 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -31,7 +31,6 @@ 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.NodeRepository import org.meshtastic.core.repository.PacketRepository import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket @@ -43,7 +42,6 @@ import kotlin.test.Test @OptIn(ExperimentalCoroutinesApi::class) class StoreForwardPacketHandlerImplTest { - private val nodeRepository = mock(MockMode.autofill) private val packetRepository = mock(MockMode.autofill) private val historyManager = mock(MockMode.autofill) private val dataHandler = mock(MockMode.autofill) @@ -59,7 +57,6 @@ class StoreForwardPacketHandlerImplTest { fun setUp() { handler = StoreForwardPacketHandlerImpl( - nodeRepository = nodeRepository, packetRepository = lazy { packetRepository }, historyManager = historyManager, dataHandler = lazy { dataHandler }, 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 3a6caf5425..0000000000 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt +++ /dev/null @@ -1,25 +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 org.meshtastic.sdk.SfppHash - -/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */ -object SfppHasher { - fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = - SfppHash.compute(encryptedPayload, to, from, id) -} 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()) - } -} From 31f792c71e88c2e25dcf5295716c09b612db96a2 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 17:22:54 -0500 Subject: [PATCH 27/53] fix: remove SFPP vestige, resource-back badge strings, add bridge tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove handleStoreForwardPlusPlus() from interface + impl (dead code post-SDK cutover) - Move StoreForwardBadge/CongestionBadge hardcoded strings to string resources (i18n) - Add SdkStateBridge tests: congestion warning → ServiceRepository, S&F server propagation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manager/StoreForwardPacketHandlerImpl.kt | 6 +- .../StoreForwardPacketHandlerImplTest.kt | 24 ------- .../core/data/radio/SdkStateBridgeTest.kt | 63 ++++++++++++++++++- .../repository/StoreForwardPacketHandler.kt | 9 +-- .../composeResources/values/strings.xml | 2 + .../feature/node/component/NodeStatusIcons.kt | 12 ++-- 6 files changed, 74 insertions(+), 42 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt index 7fef493f83..503a045ad6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt @@ -34,7 +34,7 @@ import kotlin.time.Duration.Companion.milliseconds /** * Implementation of [StoreForwardPacketHandler] that keeps legacy S&F parsing for backward compatibility. * - * SF++ parsing/status updates are now delegated to the SDK and consumed via [org.meshtastic.core.data.radio.SdkStateBridge]. + * SF++ parsing/status updates are delegated to the SDK and consumed via [org.meshtastic.core.data.radio.SdkStateBridge]. */ @Single class StoreForwardPacketHandlerImpl( @@ -50,10 +50,6 @@ class StoreForwardPacketHandlerImpl( handleReceivedStoreAndForward(dataPacket, u, myNodeNum) } - override fun handleStoreForwardPlusPlus(packet: MeshPacket) { - Logger.d { "SFPP packet received from=${packet.from} (handled by SDK)" } - } - private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) { val lastRequest = s.history?.last_request ?: 0 Logger.d { "StoreAndForward from=${dataPacket.from} lastRequest=$lastRequest" } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt index 5bbce6fc95..215ac6c3fa 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt @@ -185,28 +185,4 @@ class StoreForwardPacketHandlerImplTest { advanceUntilIdle() // No crash — falls through to else branch } - - // ---------- SF++: delegated to SDK ---------- - - @Test - fun `handleStoreForwardPlusPlus logs only and leaves repository untouched`() = testScope.runTest { - val packet = - MeshPacket( - from = 999, - decoded = Data( - portnum = PortNum.STORE_FORWARD_APP, - payload = "ignored".encodeToByteArray().toByteString(), - ), - ) - - handler.handleStoreForwardPlusPlus(packet) - advanceUntilIdle() - - verifySuspend(mode = VerifyMode.exactly(0)) { - packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) - } - verifySuspend(mode = VerifyMode.exactly(0)) { - packetRepository.updateSFPPStatusByHash(any(), any(), any()) - } - } } 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 index edea8eb0d7..2b9dc98719 100644 --- 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 @@ -51,6 +51,7 @@ 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 @@ -196,6 +197,65 @@ class SdkStateBridgeTest { client.disconnect() } + @Test + fun `congestion warning updates service repository congestion level`() = runTest { + val serviceRepo = FakeServiceRepository() + val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(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(SeededHeartbeatStorageProvider(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, @@ -217,10 +277,11 @@ class SdkStateBridgeTest { client: RadioClient, nodeRepository: FakeNodeRepository, packetRepository: PacketRepository = mock(MockMode.autofill), + serviceRepository: FakeServiceRepository = FakeServiceRepository(), ): SdkStateBridge = SdkStateBridge( accessor = TestRadioClientAccessor(client), - serviceRepository = FakeServiceRepository(), + serviceRepository = serviceRepository, nodeRepository = nodeRepository, packetRepository = lazyOf(packetRepository), locationManager = NoOpLocationManager, 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 index e884a8d3c3..b7fefddae6 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt @@ -19,7 +19,7 @@ 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 for handling Store & Forward (legacy) packets. */ interface StoreForwardPacketHandler { /** * Handles a legacy Store & Forward packet. @@ -29,11 +29,4 @@ interface StoreForwardPacketHandler { * @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/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 0f3cf4e28a..a48e9c57a7 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1144,6 +1144,8 @@ Store & Forward Store & Forward Config Store & Forward enabled + Store & Forward server + Channel: %1$s Subred Super deep sleep duration Supported 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 40ca70da84..a5b6faf5ab 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,12 +39,14 @@ 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 @@ -142,14 +144,15 @@ 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("Store & Forward server") } }, + tooltip = { PlainTooltip { Text(text) } }, state = rememberTooltipState(), ) { Icon( imageVector = MeshtasticIcons.CloudDownload, - contentDescription = "Store & Forward server", + contentDescription = text, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.StatusBlue, ) @@ -166,14 +169,15 @@ private fun CongestionBadge(level: CongestionLevel) { 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("Channel: ${level.name}") } }, + tooltip = { PlainTooltip { Text(tooltipText) } }, state = rememberTooltipState(), ) { Icon( imageVector = MeshtasticIcons.Warning, - contentDescription = "Congestion: ${level.name}", + contentDescription = tooltipText, modifier = Modifier.size(24.dp), tint = color, ) From b874873f108dcdf32931f076207649c7e22d5e73 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 18:03:21 -0500 Subject: [PATCH 28/53] refactor: delete 6 dead packet handlers post-SDK cutover (-1420 lines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove handler interfaces, implementations, and tests that have zero production callers after the SDK became authoritative for protocol handling: - TelemetryPacketHandler/Impl — SDK owns telemetry via NodeChange.Updated - StoreForwardPacketHandler/Impl — SDK owns S&F lifecycle + SFPP - NeighborInfoHandler/Impl — SDK owns NeighborInfo model - TracerouteHandler/Impl — SDK owns traceroute via AdminResult flow - MeshDataHandler/MessagePersistenceHandler — handleReceivedData was no-op - HistoryManager/Impl — only caller was deleted StoreForwardPacketHandlerImpl Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/manager/HistoryManagerImpl.kt | 105 --------- .../data/manager/MessagePersistenceHandler.kt | 193 ----------------- .../data/manager/NeighborInfoHandlerImpl.kt | 88 -------- .../manager/StoreForwardPacketHandlerImpl.kt | 98 --------- .../manager/TelemetryPacketHandlerImpl.kt | 167 --------------- .../data/manager/TracerouteHandlerImpl.kt | 118 ----------- .../data/manager/HistoryManagerImplTest.kt | 39 ---- .../StoreForwardPacketHandlerImplTest.kt | 188 ---------------- .../manager/TelemetryPacketHandlerImplTest.kt | 200 ------------------ .../core/repository/HistoryManager.kt | 46 ---- .../core/repository/MeshDataHandler.kt | 43 ---- .../core/repository/NeighborInfoHandler.kt | 36 ---- .../repository/StoreForwardPacketHandler.kt | 32 --- .../core/repository/TelemetryPacketHandler.kt | 32 --- .../core/repository/TracerouteHandler.kt | 35 --- 15 files changed, 1420 deletions(-) delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt delete mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt delete mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshDataHandler.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NeighborInfoHandler.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TelemetryPacketHandler.kt delete mode 100644 core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/TracerouteHandler.kt 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 aed0605310..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt +++ /dev/null @@ -1,105 +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 org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.model.RadioController -import org.meshtastic.core.repository.HistoryManager -import org.meshtastic.core.repository.MeshPrefs -import org.meshtastic.proto.ModuleConfig - -@Single -class HistoryManagerImpl( - private val meshPrefs: MeshPrefs, - private val radioController: RadioController, - @Named("ServiceScope") private val scope: CoroutineScope, -) : 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 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.takeIf { it > 0 } - val (window, max) = - resolveHistoryRequestParameters( - storeForwardConfig?.history_return_window ?: 0, - storeForwardConfig?.history_return_max ?: 0, - ) - - historyLog( - "requestHistory trigger=$trigger transport=$transport addr=$address " + - "since=${lastRequest ?: "all"} window=$window max=$max via=sdk", - ) - - scope.handledLaunch { - val accepted = radioController.requestStoreForwardHistory(since = lastRequest) - if (!accepted) { - logger.w { - "requestHistory failed trigger=$trigger transport=$transport addr=$address since=${lastRequest ?: "all"}" - } - } - } - } - - 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/MessagePersistenceHandler.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.kt deleted file mode 100644 index 3d4dd73e2b..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MessagePersistenceHandler.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.data.manager - -import co.touchlab.kermit.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.handledLaunch -import org.meshtastic.core.common.util.nowMillis -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.MeshServiceNotifications -import org.meshtastic.core.repository.MessageFilter -import org.meshtastic.core.repository.NodeRepository -import org.meshtastic.core.repository.Notification -import org.meshtastic.core.repository.NotificationManager -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.RadioConfigRepository -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.critical_alert -import org.meshtastic.core.resources.getStringSuspend -import org.meshtastic.core.resources.unknown_username -import org.meshtastic.core.resources.waypoint_received -import org.meshtastic.proto.PortNum - -/** - * SDK-era implementation of [MeshDataHandler] focused on message persistence and notifications. - * - * The full packet-routing logic (handleReceivedData) is no longer needed — the SDK's packet flow - * is consumed directly by VMs and SdkStateBridge. This class retains only [rememberDataPacket] - * which is called by [StoreForwardPacketHandlerImpl] to persist forwarded messages. - */ -@Single -class MessagePersistenceHandler( - private val nodeRepository: NodeRepository, - private val packetRepository: Lazy, - private val notificationManager: NotificationManager, - private val serviceNotifications: MeshServiceNotifications, - private val radioConfigRepository: RadioConfigRepository, - private val messageFilter: MessageFilter, - @Named("ServiceScope") private val scope: CoroutineScope, -) : MeshDataHandler { - - private val rememberDataType = - setOf( - PortNum.TEXT_MESSAGE_APP.value, - PortNum.ALERT_APP.value, - PortNum.WAYPOINT_APP.value, - PortNum.NODE_STATUS_APP.value, - ) - - override fun handleReceivedData( - packet: org.meshtastic.proto.MeshPacket, - myNodeNum: Int, - logUuid: String?, - logInsertJob: kotlinx.coroutines.Job?, - ) { - // No-op: Incoming packet routing is handled by SdkStateBridge / VM packet observers. - // This method exists only to satisfy the MeshDataHandler interface contract. - } - - override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) { - if (dataPacket.dataType !in rememberDataType) return - val fromLocal = dataPacket.from == DataPacket.LOCAL || dataPacket.from == myNodeNum - val toBroadcast = dataPacket.to == DataPacket.BROADCAST - val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from - - val contactKey = "${dataPacket.channel}${DataPacket.nodeNumToId(contactId)}" - - scope.handledLaunch { - packetRepository.value.apply { - val existingPackets = findPacketsWithId(dataPacket.id) - if (existingPackets.isNotEmpty()) { - Logger.d { - "Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " + - "to=${dataPacket.to} contactKey=$contactKey" + - " (already have ${existingPackets.size} packet(s))" - } - return@handledLaunch - } - - val isFiltered = shouldFilterMessage(dataPacket, contactKey) - - insert( - dataPacket, - myNodeNum, - contactKey, - nowMillis, - read = fromLocal || isFiltered, - filtered = isFiltered, - ) - if (!isFiltered) { - handlePacketNotification(dataPacket, contactKey, updateNotification) - } - } - } - } - - @Suppress("ReturnCount") - private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean { - val isIgnored = nodeRepository.nodeDBbyNum.value[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 = nodeRepository.nodeDBbyNum.value[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.LOCAL) { - return nodeRepository.ourNodeInfo.value?.user?.long_name ?: getStringSuspend(Res.string.unknown_username) - } - return nodeRepository.nodeDBbyNum.value[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.BROADCAST) { - radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name - } else { - null - } - serviceNotifications.updateMessageNotification( - contactKey, - getSenderName(dataPacket), - message, - dataPacket.to == DataPacket.BROADCAST, - channelName, - isSilent, - ) - } - - PortNum.WAYPOINT_APP.value -> { - val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name) - notificationManager.dispatch( - Notification( - title = getSenderName(dataPacket), - message = message, - category = Notification.Category.Message, - contactKey = contactKey, - isSilent = isSilent, - ), - ) - } - - else -> return - } - } -} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt deleted file mode 100644 index a74dbd8495..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ /dev/null @@ -1,88 +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.NodeRepository -import org.meshtastic.core.repository.ServiceRepository -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo -import org.meshtastic.sdk.NeighborInfo as SdkNeighborInfo - -@Single -class NeighborInfoHandlerImpl( - private val nodeRepository: NodeRepository, - private val serviceRepository: ServiceRepository, -) : 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) - - val from = packet.from - if (from == nodeRepository.myNodeNum.value) { - lastNeighborInfo = ni - Logger.d { "Stored last neighbor info from connected radio" } - } - - val requestId = packet.decoded?.request_id ?: 0 - val start = startTimes.value[requestId] - startTimes.update { it.remove(requestId) } - - val formatted = - SdkNeighborInfo - .fromProto( - reportingNode = from, - neighborNodeIds = ni.neighbors.map { it.node_id }, - snrValues = ni.neighbors.map { it.snr }, - ).format { nodeId -> - val user = nodeRepository.getUser(nodeId.raw) - "${user.long_name} (${user.short_name})" - } - - 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\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/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt deleted file mode 100644 index 503a045ad6..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt +++ /dev/null @@ -1,98 +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 org.koin.core.annotation.Named -import org.koin.core.annotation.Single -import org.meshtastic.core.model.DataPacket -import org.meshtastic.core.repository.HistoryManager -import org.meshtastic.core.repository.MeshDataHandler -import org.meshtastic.core.repository.PacketRepository -import org.meshtastic.core.repository.StoreForwardPacketHandler -import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.PortNum -import org.meshtastic.proto.StoreAndForward -import kotlin.time.Duration.Companion.milliseconds - -/** - * Implementation of [StoreForwardPacketHandler] that keeps legacy S&F parsing for backward compatibility. - * - * SF++ parsing/status updates are delegated to the SDK and consumed via [org.meshtastic.core.data.radio.SdkStateBridge]. - */ -@Single -class StoreForwardPacketHandlerImpl( - private val packetRepository: Lazy, - 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) - } - - 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.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 57be6f7317..0000000000 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +++ /dev/null @@ -1,167 +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.NodeRepository -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 nodeRepository: NodeRepository, - 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) - // Note: Local telemetry notification update was previously handled by - // MeshConnectionManager.updateTelemetry(), now managed via SDK flows. - - nodeRepository.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/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 ec62f687cf..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.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.data.manager - -import kotlin.test.Test -import kotlin.test.assertEquals - -class HistoryManagerImplTest { - - @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/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt deleted file mode 100644 index 215ac6c3fa..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt +++ /dev/null @@ -1,188 +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.VerifyMode -import dev.mokkery.verify -import dev.mokkery.verifySuspend -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.HistoryManager -import org.meshtastic.core.repository.MeshDataHandler -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.StoreAndForward -import kotlin.test.BeforeTest -import kotlin.test.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class StoreForwardPacketHandlerImplTest { - - private val packetRepository = 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() { - handler = - StoreForwardPacketHandlerImpl( - packetRepository = lazy { packetRepository }, - 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 makeDataPacket(from: Int): DataPacket = DataPacket( - id = 1, - time = 1700000000000L, - to = DataPacket.BROADCAST, - from = 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 - } -} 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 6c013b6972..0000000000 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt +++ /dev/null @@ -1,200 +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.NodeRepository -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 nodeRepository = 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( - nodeRepository = nodeRepository, - 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.BROADCAST, - from = from, - bytes = null, - dataType = PortNum.TELEMETRY_APP.value, - ) - - // ---------- Device metrics from local node ---------- - - @Test - fun `local device metrics updates node`() = testScope.runTest { - val telemetry = - Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.1f)) - val packet = makeTelemetryPacket(myNodeNum, telemetry) - val dataPacket = makeDataPacket(myNodeNum) - - handler.handleTelemetry(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { nodeRepository.updateNode(myNodeNum, any(), any(), any()) } - } - - // ---------- Device metrics from remote node ---------- - - @Test - fun `remote device metrics updates node`() = testScope.runTest { - val telemetry = - Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 90, voltage = 4.2f)) - val packet = makeTelemetryPacket(remoteNodeNum, telemetry) - val dataPacket = makeDataPacket(remoteNodeNum) - - handler.handleTelemetry(packet, dataPacket, myNodeNum) - advanceUntilIdle() - - verify { nodeRepository.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 { nodeRepository.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 { nodeRepository.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 { nodeRepository.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/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/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/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/StoreForwardPacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.kt deleted file mode 100644 index b7fefddae6..0000000000 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/StoreForwardPacketHandler.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 Store & Forward (legacy) 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) -} 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?) -} From 35f03735ab7baddf9ba042b191e3c10ba7cd363d Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 22:05:25 -0500 Subject: [PATCH 29/53] refactor: use SDK remote admin API, eliminate sendRemoteAdmin - Replace all isLocalNode/sendRemoteAdmin patterns with client.admin.forNode(NodeId(destNum)).method() - Use client.sendReaction() instead of manual MeshPacket construction - Use client.admin.forNode(dest).getDeviceMetadata() for remote metadata - Delete sendRemoteAdmin() and isLocalNode() helpers - Remove unused imports (AdminMessage, Data, MeshPacket from SdkStateBridge) Net: -131 lines, all admin ops now go through SDK's typed API with proper ACK tracking and session-key retry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/radio/SdkRadioController.kt | 188 ++++-------------- .../core/data/radio/SdkStateBridge.kt | 39 +--- 2 files changed, 48 insertions(+), 179 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 5ad90a19ad..b0a389c361 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -173,38 +173,22 @@ class SdkRadioController( override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.setOwner(user) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(set_owner = user)) - } + c.admin.forNode(NodeId(destNum)).setOwner(user) } override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.setConfig(config) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(set_config = config)) - } + c.admin.forNode(NodeId(destNum)).setConfig(config) } override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.setModuleConfig(config) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(set_module_config = config)) - } + c.admin.forNode(NodeId(destNum)).setModuleConfig(config) } override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.setChannel(channel) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(set_channel = channel)) - } + c.admin.forNode(NodeId(destNum)).setChannel(channel) } override suspend fun setFixedPosition(destNum: Int, position: Position) { @@ -215,162 +199,88 @@ class SdkRadioController( altitude = position.altitude, time = position.time, ) - if (isLocalNode(destNum)) { - c.admin.setFixedPosition(protoPos) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(set_fixed_position = protoPos)) - } + c.admin.forNode(NodeId(destNum)).setFixedPosition(protoPos) } override suspend fun setRingtone(destNum: Int, ringtone: String) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.setRingtone(ringtone) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(set_ringtone_message = ringtone)) - } + c.admin.forNode(NodeId(destNum)).setRingtone(ringtone) } override suspend fun setCannedMessages(destNum: Int, messages: String) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.setCannedMessages(messages) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(set_canned_message_module_messages = messages)) - } + c.admin.forNode(NodeId(destNum)).setCannedMessages(messages) } // ── Remote admin (getters) ────────────────────────────────────────────── override suspend fun getOwner(destNum: Int, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.getOwner() - } else { - sendRemoteAdmin(c, destNum, AdminMessage(get_owner_request = true), wantResponse = true) - } + c.admin.forNode(NodeId(destNum)).getOwner() } override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { val c = requireClient() val type = AdminMessage.ConfigType.fromValue(configType) ?: return - if (isLocalNode(destNum)) { - c.admin.getConfig(type) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(get_config_request = type), wantResponse = true) - } + c.admin.forNode(NodeId(destNum)).getConfig(type) } override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) { val c = requireClient() val type = AdminMessage.ModuleConfigType.fromValue(moduleConfigType) ?: return - if (isLocalNode(destNum)) { - c.admin.getModuleConfig(type) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(get_module_config_request = type), wantResponse = true) - } + c.admin.forNode(NodeId(destNum)).getModuleConfig(type) } override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.getChannel(ChannelIndex(index)) - } else { - sendRemoteAdmin(c, destNum, AdminMessage(get_channel_request = index + 1), wantResponse = true) - } + c.admin.forNode(NodeId(destNum)).getChannel(ChannelIndex(index)) } override suspend fun getRingtone(destNum: Int, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.getRingtone() - } else { - sendRemoteAdmin(c, destNum, AdminMessage(get_ringtone_request = true), wantResponse = true) - } + c.admin.forNode(NodeId(destNum)).getRingtone() } override suspend fun getCannedMessages(destNum: Int, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.getCannedMessages() - } else { - sendRemoteAdmin( - c, - destNum, - AdminMessage(get_canned_message_module_messages_request = true), - wantResponse = true, - ) - } + c.admin.forNode(NodeId(destNum)).getCannedMessages() } override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.getDeviceConnectionStatus() - } else { - sendRemoteAdmin( - c, - destNum, - AdminMessage(get_device_connection_status_request = true), - wantResponse = true, - ) - } + c.admin.forNode(NodeId(destNum)).getDeviceConnectionStatus() } // ── Lifecycle commands ─────────────────────────────────────────────────── override suspend fun reboot(destNum: Int, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.reboot() - } else { - sendRemoteAdmin(c, destNum, AdminMessage(reboot_seconds = 0)) - } + c.admin.forNode(NodeId(destNum)).reboot() } override suspend fun rebootToDfu(nodeNum: Int) { val c = requireClient() - if (isLocalNode(nodeNum)) { - c.admin.enterDfuMode() - } else { - sendRemoteAdmin(c, nodeNum, AdminMessage(enter_dfu_mode_request = true)) - } + c.admin.forNode(NodeId(nodeNum)).enterDfuMode() } override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.rebootOta() - } else { - sendRemoteAdmin(c, destNum, AdminMessage(reboot_ota_seconds = 0)) - } + c.admin.forNode(NodeId(destNum)).rebootOta() } override suspend fun shutdown(destNum: Int, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.shutdown() - } else { - sendRemoteAdmin(c, destNum, AdminMessage(shutdown_seconds = 0)) - } + c.admin.forNode(NodeId(destNum)).shutdown() } override suspend fun factoryReset(destNum: Int, packetId: Int) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.factoryReset() - } else { - sendRemoteAdmin(c, destNum, AdminMessage(factory_reset_config = 1)) - } + c.admin.forNode(NodeId(destNum)).factoryReset() } override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) { val c = requireClient() - if (isLocalNode(destNum)) { - c.admin.nodeDbReset() - } else { - sendRemoteAdmin(c, destNum, AdminMessage(nodedb_reset = true)) - } + c.admin.forNode(NodeId(destNum)).nodeDbReset() } override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) { @@ -459,17 +369,27 @@ class SdkRadioController( // ── Edit settings (transactional) ─────────────────────────────────────── override suspend fun beginEditSettings(destNum: Int) { - val c = client ?: return - val msg = AdminMessage(begin_edit_settings = true) - val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum) - sendRemoteAdmin(c, target.raw, msg) + val c = requireClient() + val target = resolveTarget(c, destNum) + val payload = AdminMessage.ADAPTER.encode(AdminMessage(begin_edit_settings = true)) + c.send( + portnum = PortNum.ADMIN_APP, + payload = payload, + to = target, + wantAck = false, + ) } override suspend fun commitEditSettings(destNum: Int) { - val c = client ?: return - val msg = AdminMessage(commit_edit_settings = true) - val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum) - sendRemoteAdmin(c, target.raw, msg) + val c = requireClient() + val target = resolveTarget(c, destNum) + val payload = AdminMessage.ADAPTER.encode(AdminMessage(commit_edit_settings = true)) + c.send( + portnum = PortNum.ADMIN_APP, + payload = payload, + to = target, + wantAck = true, + ) } // ── Utility ───────────────────────────────────────────────────────────── @@ -490,34 +410,8 @@ class SdkRadioController( // ── Private helpers ───────────────────────────────────────────────────── - private fun isLocalNode(destNum: Int): Boolean { - if (destNum == 0) return true - val ownNum = client?.ownNode?.value?.num ?: return true - return destNum == ownNum - } - - private suspend fun sendRemoteAdmin( - c: RadioClient, - destNum: Int, - adminMsg: AdminMessage, - wantResponse: Boolean = false, - ) { - val payload = AdminMessage.ADAPTER.encode(adminMsg).toByteString() - try { - c.send( - MeshPacket( - to = destNum, - want_ack = true, - decoded = Data( - portnum = PortNum.ADMIN_APP, - payload = payload, - want_response = wantResponse, - ), - ), - ) - } catch (e: Exception) { - Logger.e(e) { "sendRemoteAdmin to $destNum failed" } - throw e - } + private fun resolveTarget(c: RadioClient, destNum: Int): NodeId { + if (destNum == 0) return NodeId(c.ownNode.value?.num ?: 0) + return NodeId(destNum) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index d696871f90..4a6e2594dd 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.di.CoroutineDispatchers @@ -42,13 +41,11 @@ 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.AdminMessage import org.meshtastic.proto.ClientNotification -import org.meshtastic.proto.Data -import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum 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.MeshEvent import org.meshtastic.sdk.NodeChange @@ -330,18 +327,11 @@ class SdkStateBridge( val channel = action.contactKey[0].digitToInt() val destId = action.contactKey.substring(1) val destNum = runCatching { DataPacket.parseNodeNum(destId) }.getOrDefault(DataPacket.BROADCAST) - client.send( - MeshPacket( - to = destNum, - channel = channel, - want_ack = true, - decoded = Data( - portnum = PortNum.TEXT_MESSAGE_APP, - payload = action.emoji.encodeToByteArray().toByteString(), - emoji = EMOJI_INDICATOR, - reply_id = action.replyId, - ), - ), + client.sendReaction( + emoji = action.emoji, + to = NodeId(destNum), + channel = ChannelIndex(channel), + replyId = action.replyId, ) } @@ -365,27 +355,12 @@ class SdkStateBridge( } is ServiceAction.GetDeviceMetadata -> { - val payload = AdminMessage.ADAPTER.encode( - AdminMessage(get_device_metadata_request = true), - ).toByteString() - client.send( - MeshPacket( - to = action.destNum, - want_ack = true, - decoded = Data( - portnum = PortNum.ADMIN_APP, - payload = payload, - want_response = true, - ), - ), - ) + client.admin.forNode(NodeId(action.destNum)).getDeviceMetadata() } } } companion object { - private const val EMOJI_INDICATOR = 1 - private fun mapConnectionState(sdkState: SdkConnectionState): AppConnectionState = when (sdkState) { is SdkConnectionState.Disconnected -> AppConnectionState.Disconnected is SdkConnectionState.Connecting -> AppConnectionState.Connecting(attempt = sdkState.attempt) From 6e5b159014694431d6e9c7df7e8ec5a317c95317 Mon Sep 17 00:00:00 2001 From: James Rich Date: Tue, 5 May 2026 22:19:38 -0500 Subject: [PATCH 30/53] refactor: eliminate MeshPacket/Data imports from radio layer - sendMessage() now uses client.send(portnum, payload, to, channel, ...) - requestUserInfo() now uses client.requestNodeInfo(NodeId) - SdkStateBridge position publishing calls SDK directly (removes intermediate DataPacket + RadioController dependency) - Remove unused imports: MeshPacket, Data, okio.toByteString - Remove unused radioController dependency from SdkStateBridge Net: SdkRadioController no longer imports MeshPacket or Data proto. Only AdminMessage remains (for beginEditSettings/commitEditSettings). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/radio/SdkRadioController.kt | 37 +++++-------------- .../core/data/radio/SdkStateBridge.kt | 14 +++---- .../core/data/radio/SdkStateBridgeTest.kt | 2 - 3 files changed, 15 insertions(+), 38 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index b0a389c361..7c05f90182 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.data.radio import co.touchlab.kermit.Logger import kotlinx.atomicfu.atomic import kotlinx.coroutines.flow.StateFlow -import okio.ByteString.Companion.toByteString import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket @@ -38,8 +37,6 @@ import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.ClientNotification import org.meshtastic.proto.Config -import org.meshtastic.proto.Data -import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.PortNum import org.meshtastic.proto.SharedContact @@ -110,22 +107,16 @@ class SdkRadioController( Logger.w { "sendMessage: no client, dropping packet" } return } - val destNum = packet.to val packetId = packet.id.takeIf { it != 0 } ?: getPacketId() - val meshPacket = MeshPacket( - id = packetId, - to = destNum, - channel = packet.channel, - want_ack = packet.wantAck, - hop_limit = packet.hopLimit, - decoded = Data( - portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP, - payload = packet.bytes ?: okio.ByteString.EMPTY, - want_response = false, - ), - ) try { - val handle = c.send(meshPacket) + 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) { @@ -308,17 +299,7 @@ class SdkRadioController( override suspend fun requestUserInfo(destNum: Int) { val c = client ?: return - c.send( - MeshPacket( - to = destNum, - want_ack = true, - decoded = Data( - portnum = PortNum.NODEINFO_APP, - payload = byteArrayOf().toByteString(), - want_response = true, - ), - ), - ) + c.requestNodeInfo(NodeId(destNum)) } override suspend fun requestTraceroute(requestId: Int, destNum: Int) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 4a6e2594dd..dbc42267b0 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -33,7 +33,6 @@ 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.RadioController import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.MeshLocationManager @@ -72,7 +71,6 @@ class SdkStateBridge( private val packetRepository: Lazy, private val locationManager: MeshLocationManager, private val uiPrefs: UiPrefs, - private val radioController: RadioController, private val dispatchers: CoroutineDispatchers, ) { private val scope = CoroutineScope(SupervisorJob() + dispatchers.default) @@ -249,13 +247,13 @@ class SdkStateBridge( if (shouldProvide) { locationManager.start(scope) { pos -> scope.launch { - val packet = DataPacket( - bytes = okio.ByteString.of( - *org.meshtastic.proto.Position.ADAPTER.encode(pos), - ), - dataType = PortNum.POSITION_APP.value, + val c = accessor.client.value ?: return@launch + val posBytes = org.meshtastic.proto.Position.ADAPTER.encode(pos) + c.send( + portnum = PortNum.POSITION_APP, + payload = posBytes, + wantAck = false, ) - radioController.sendMessage(packet) } } } else { 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 index 2b9dc98719..671b736536 100644 --- 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 @@ -34,7 +34,6 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.repository.MeshLocationManager import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.testing.FakeNodeRepository -import org.meshtastic.core.testing.FakeRadioController import org.meshtastic.core.testing.FakeServiceRepository import org.meshtastic.core.testing.FakeUiPrefs import org.meshtastic.proto.Data @@ -286,7 +285,6 @@ class SdkStateBridgeTest { packetRepository = lazyOf(packetRepository), locationManager = NoOpLocationManager, uiPrefs = FakeUiPrefs(), - radioController = FakeRadioController(), dispatchers = CoroutineDispatchers( io = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher, main = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher, From 140e062eee15e5b7070b3d1850f0b56c4eb6bd29 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 10:50:02 -0500 Subject: [PATCH 31/53] refactor: eliminate ProcessRadioResponseUseCase and packet-ID correlation Replace the manual packet-ID tracking and meshPacketFlow subscription in RadioConfigViewModel with direct typed returns from the SDK via RadioConfigUseCase. The ViewModel now awaits typed results (User, Config, ModuleConfig, channels, etc.) from suspend calls and maps AdminException to UI error states. Key changes: - Delete ProcessRadioResponseUseCase (130 lines of manual proto decode) - Remove requestIds state, registerRequestId, processPacketResponse, sendAdminRequest, and meshPacketFlow subscription from ViewModel - Rewrite setResponseStateLoading to use direct coroutine calls - Admin actions (reboot/shutdown/etc.) fire directly without session key preflight (SDK handles retryOnSessionExpiry transparently) - All setters (setConfig, setModuleConfig, setOwner, updateChannels) no longer return/track packetIds - Remove messageSender dependency from NodeManagementActions - Update InstallProfileUseCase to use editSettings {} receiver pattern - Update all callers: CleanNodeDatabaseUseCase, Esp32OtaUpdateHandler, NodeManagementActions - Rewrite RadioConfigViewModelTest for new direct-await semantics - Update RadioConfigUseCaseTest and InstallProfileUseCaseTest Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/radio/SdkRadioController.kt | 128 +++--- .../usecase/settings/AdminActionsUseCase.kt | 54 +-- .../settings/CleanNodeDatabaseUseCase.kt | 3 +- .../usecase/settings/InstallProfileUseCase.kt | 141 ++---- .../settings/ProcessRadioResponseUseCase.kt | 130 ------ .../usecase/settings/RadioConfigUseCase.kt | 179 ++------ .../settings/InstallProfileUseCaseTest.kt | 8 +- .../settings/RadioConfigUseCaseTest.kt | 4 +- .../meshtastic/core/model/AdminException.kt | 42 ++ .../org/meshtastic/core/model/DeviceAdmin.kt | 12 +- .../meshtastic/core/model/DeviceAdminEdit.kt | 36 ++ .../meshtastic/core/model/DeviceControl.kt | 18 +- .../org/meshtastic/core/model/RemoteAdmin.kt | 59 ++- .../core/testing/FakeRadioController.kt | 60 +-- .../firmware/ota/Esp32OtaUpdateHandler.kt | 2 +- .../node/detail/NodeManagementActions.kt | 5 +- .../node/detail/NodeManagementActionsTest.kt | 2 - .../settings/radio/RadioConfigViewModel.kt | 433 +++++------------- .../radio/RadioConfigViewModelTest.kt | 236 +++------- 19 files changed, 555 insertions(+), 997 deletions(-) delete mode 100644 core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 7c05f90182..a022e83434 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -20,10 +20,12 @@ 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 @@ -37,6 +39,7 @@ 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 @@ -162,24 +165,24 @@ class SdkRadioController( // ── Remote admin (config/owner/channel) ───────────────────────────────── - override suspend fun setOwner(destNum: Int, user: User, packetId: Int) { + override suspend fun setOwner(destNum: Int, user: User) { val c = requireClient() - c.admin.forNode(NodeId(destNum)).setOwner(user) + c.admin.forNode(NodeId(destNum)).setOwner(user).unwrap() } - override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) { + override suspend fun setConfig(destNum: Int, config: Config) { val c = requireClient() - c.admin.forNode(NodeId(destNum)).setConfig(config) + c.admin.forNode(NodeId(destNum)).setConfig(config).unwrap() } - override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) { + override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig) { val c = requireClient() - c.admin.forNode(NodeId(destNum)).setModuleConfig(config) + c.admin.forNode(NodeId(destNum)).setModuleConfig(config).unwrap() } - override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) { + override suspend fun setRemoteChannel(destNum: Int, channel: Channel) { val c = requireClient() - c.admin.forNode(NodeId(destNum)).setChannel(channel) + c.admin.forNode(NodeId(destNum)).setChannel(channel).unwrap() } override suspend fun setFixedPosition(destNum: Int, position: Position) { @@ -205,78 +208,85 @@ class SdkRadioController( // ── Remote admin (getters) ────────────────────────────────────────────── - override suspend fun getOwner(destNum: Int, packetId: Int) { + override suspend fun getOwner(destNum: Int): User { val c = requireClient() - c.admin.forNode(NodeId(destNum)).getOwner() + return c.admin.forNode(NodeId(destNum)).getOwner().unwrap() } - override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) { + override suspend fun getConfig(destNum: Int, configType: Int): Config { val c = requireClient() - val type = AdminMessage.ConfigType.fromValue(configType) ?: return - c.admin.forNode(NodeId(destNum)).getConfig(type) + 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, packetId: Int) { + override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): ModuleConfig { val c = requireClient() - val type = AdminMessage.ModuleConfigType.fromValue(moduleConfigType) ?: return - c.admin.forNode(NodeId(destNum)).getModuleConfig(type) + 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, packetId: Int) { + override suspend fun getChannel(destNum: Int, index: Int): Channel { val c = requireClient() - c.admin.forNode(NodeId(destNum)).getChannel(ChannelIndex(index)) + return c.admin.forNode(NodeId(destNum)).getChannel(ChannelIndex(index)).unwrap() } - override suspend fun getRingtone(destNum: Int, packetId: Int) { + override suspend fun listChannels(destNum: Int): List { val c = requireClient() - c.admin.forNode(NodeId(destNum)).getRingtone() + return c.admin.forNode(NodeId(destNum)).listChannels().unwrap() } - override suspend fun getCannedMessages(destNum: Int, packetId: Int) { + override suspend fun getRingtone(destNum: Int): String { val c = requireClient() - c.admin.forNode(NodeId(destNum)).getCannedMessages() + return c.admin.forNode(NodeId(destNum)).getRingtone().unwrap() } - override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) { + override suspend fun getCannedMessages(destNum: Int): String { val c = requireClient() - c.admin.forNode(NodeId(destNum)).getDeviceConnectionStatus() + 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, packetId: Int) { + override suspend fun reboot(destNum: Int) { val c = requireClient() - c.admin.forNode(NodeId(destNum)).reboot() + c.admin.forNode(NodeId(destNum)).reboot().unwrap() } override suspend fun rebootToDfu(nodeNum: Int) { val c = requireClient() - c.admin.forNode(NodeId(nodeNum)).enterDfuMode() + c.admin.forNode(NodeId(nodeNum)).enterDfuMode().unwrap() } - override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) { + override suspend fun requestRebootOta(destNum: Int, mode: Int, hash: ByteArray?) { val c = requireClient() - c.admin.forNode(NodeId(destNum)).rebootOta() + c.admin.forNode(NodeId(destNum)).rebootOta().unwrap() } - override suspend fun shutdown(destNum: Int, packetId: Int) { + override suspend fun shutdown(destNum: Int) { val c = requireClient() - c.admin.forNode(NodeId(destNum)).shutdown() + c.admin.forNode(NodeId(destNum)).shutdown().unwrap() } - override suspend fun factoryReset(destNum: Int, packetId: Int) { + override suspend fun factoryReset(destNum: Int) { val c = requireClient() - c.admin.forNode(NodeId(destNum)).factoryReset() + c.admin.forNode(NodeId(destNum)).factoryReset().unwrap() } - override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) { + override suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean) { val c = requireClient() - c.admin.forNode(NodeId(destNum)).nodeDbReset() + c.admin.forNode(NodeId(destNum)).nodeDbReset().unwrap() } - override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) { + override suspend fun removeByNodenum(nodeNum: Int) { val c = requireClient() - c.admin.removeNode(NodeId(nodeNum)) + c.admin.removeNode(NodeId(nodeNum)).unwrap() } // ── Data requests ─────────────────────────────────────────────────────── @@ -349,28 +359,19 @@ class SdkRadioController( // ── Edit settings (transactional) ─────────────────────────────────────── - override suspend fun beginEditSettings(destNum: Int) { - val c = requireClient() - val target = resolveTarget(c, destNum) - val payload = AdminMessage.ADAPTER.encode(AdminMessage(begin_edit_settings = true)) - c.send( - portnum = PortNum.ADMIN_APP, - payload = payload, - to = target, - wantAck = false, - ) - } - - override suspend fun commitEditSettings(destNum: Int) { + override suspend fun editSettings(destNum: Int, block: suspend DeviceAdminEdit.() -> Unit) { val c = requireClient() - val target = resolveTarget(c, destNum) - val payload = AdminMessage.ADAPTER.encode(AdminMessage(commit_edit_settings = true)) - c.send( - portnum = PortNum.ADMIN_APP, - payload = payload, - to = target, - wantAck = true, - ) + 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 ───────────────────────────────────────────────────────────── @@ -391,8 +392,13 @@ class SdkRadioController( // ── Private helpers ───────────────────────────────────────────────────── - private fun resolveTarget(c: RadioClient, destNum: Int): NodeId { - if (destNum == 0) return NodeId(c.ownNode.value?.num ?: 0) - return NodeId(destNum) + /** 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.Failed -> throw AdminException.RoutingError(routingError.name) } } 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/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/RadioConfigUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt index 8d83f5aee9..d252d1f364 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 @@ -47,8 +47,8 @@ class RadioConfigUseCaseTest { @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/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..431989480e --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt @@ -0,0 +1,42 @@ +/* + * 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") +} 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 index c12979e213..c37d30d140 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdmin.kt @@ -23,6 +23,14 @@ import org.meshtastic.proto.Config interface DeviceAdmin : ConnectionAware { suspend fun setLocalConfig(config: Config) suspend fun setLocalChannel(channel: Channel) - suspend fun beginEditSettings(destNum: Int) - suspend fun commitEditSettings(destNum: Int) + + /** + * 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/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.kt new file mode 100644 index 0000000000..88644fef66 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceAdminEdit.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 +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.User + +/** + * Receiver interface for batched admin writes inside an [DeviceAdmin.editSettings] block. + * + * 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. + */ +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/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt index 8cd8492055..7275e14f0d 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceControl.kt @@ -16,13 +16,17 @@ */ package org.meshtastic.core.model -/** Focused interface for device lifecycle control. */ +/** + * 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, packetId: Int) + suspend fun reboot(destNum: Int) suspend fun rebootToDfu(nodeNum: Int) - suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) - suspend fun shutdown(destNum: Int, packetId: Int) - suspend fun factoryReset(destNum: Int, packetId: Int) - suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) - suspend fun removeByNodenum(packetId: Int, 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/RemoteAdmin.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt index 0773e4da4e..a743b9a94b 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RemoteAdmin.kt @@ -18,23 +18,58 @@ 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. */ +/** + * Focused interface for remote node administration. + * + * Methods suspend until the device responds. On failure, they throw [AdminException]. + */ interface RemoteAdmin { - suspend fun setOwner(destNum: Int, user: User, packetId: Int) - suspend fun setConfig(destNum: Int, config: Config, packetId: Int) - suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) - suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) + /** 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) - suspend fun getOwner(destNum: Int, packetId: Int) - suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) - suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) - suspend fun getChannel(destNum: Int, index: Int, packetId: Int) - suspend fun getRingtone(destNum: Int, packetId: Int) - suspend fun getCannedMessages(destNum: Int, packetId: Int) - suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) + + /** 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/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index 515beeab2a..d891284e38 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,13 @@ 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.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 @@ -48,8 +50,7 @@ class FakeRadioController : var throwOnSend: Boolean = false var lastSetDeviceAddress: String? = null var lastStoreForwardHistoryRequest: Pair? = null - var beginEditSettingsCalled = false - var commitEditSettingsCalled = false + var editSettingsCalled = false var startProvideLocationCalled = false var stopProvideLocationCalled = false @@ -61,8 +62,7 @@ class FakeRadioController : throwOnSend = false lastSetDeviceAddress = null lastStoreForwardHistoryRequest = null - beginEditSettingsCalled = false - commitEditSettingsCalled = false + editSettingsCalled = false startProvideLocationCalled = false stopProvideLocationCalled = false } @@ -90,13 +90,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) {} @@ -104,33 +104,36 @@ 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) {} @@ -147,12 +150,15 @@ class FakeRadioController : return true } - override suspend fun beginEditSettings(destNum: Int) { - beginEditSettingsCalled = 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 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/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 1c48540f1c..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 @@ -23,7 +23,6 @@ import org.jetbrains.compose.resources.getString import org.koin.core.annotation.Single import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.model.DeviceControl -import org.meshtastic.core.model.MessageSender import org.meshtastic.core.model.Node import org.meshtastic.core.model.service.ServiceAction import org.meshtastic.core.repository.NodeRepository @@ -49,7 +48,6 @@ constructor( private val nodeRepository: NodeRepository, private val serviceRepository: ServiceRepository, private val deviceControl: DeviceControl, - private val messageSender: MessageSender, private val alertManager: AlertManager, ) { open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) { @@ -66,8 +64,7 @@ constructor( open fun removeNode(scope: CoroutineScope, nodeNum: Int) { scope.launch(ioDispatcher) { Logger.i { "Removing node '$nodeNum'" } - val packetId = messageSender.getPacketId() - deviceControl.removeByNodenum(packetId, nodeNum) + deviceControl.removeByNodenum(nodeNum) nodeRepository.deleteNode(nodeNum) } } 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 3233747da6..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 @@ -47,7 +47,6 @@ class NodeManagementActionsTest { nodeRepository = nodeRepository, serviceRepository = serviceRepository, deviceControl = radioController, - messageSender = radioController, alertManager = alertManager, ) @@ -80,7 +79,6 @@ class NodeManagementActionsTest { nodeRepository = nodeRepository, serviceRepository = serviceRepository, deviceControl = radioController, - messageSender = radioController, alertManager = realAlertManager, ) val node = Node(num = 123, user = User(long_name = "Test Node")) 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 f9258deb12..3768eaba8c 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,7 +21,6 @@ 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.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -41,9 +40,8 @@ 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.model.AdminException import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.MqttConnectionState import org.meshtastic.core.model.MqttProbeStatus @@ -70,7 +68,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 @@ -81,10 +78,8 @@ 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 /** Data class that represents the current RadioConfig state. */ data class RadioConfigState( @@ -125,7 +120,6 @@ open class RadioConfigViewModel( 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, @@ -189,7 +183,6 @@ open class RadioConfigViewModel( val destNode: StateFlow get() = _destNode - private val requestIds = MutableStateFlow(hashSetOf()) private val _radioConfigState = MutableStateFlow(RadioConfigState()) val radioConfigState: StateFlow = _radioConfigState @@ -239,8 +232,6 @@ 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) } } @@ -283,8 +274,7 @@ open class RadioConfigViewModel( val destNum = destNode.value?.num ?: return safeLaunch(tag = "setOwner") { _radioConfigState.update { it.copy(userConfig = user) } - val packetId = radioConfigUseCase.setOwner(destNum, user) - registerRequestId(packetId) + radioConfigUseCase.setOwner(destNum, user) } } @@ -292,8 +282,7 @@ open class RadioConfigViewModel( val destNum = destNode.value?.num ?: return getChannelList(new, old).forEach { channel -> safeLaunch(tag = "setRemoteChannel") { - val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel) - registerRequestId(packetId) + radioConfigUseCase.setRemoteChannel(destNum, channel) } } @@ -324,8 +313,7 @@ open class RadioConfigViewModel( ), ) } - val packetId = radioConfigUseCase.setConfig(destNum, config) - registerRequestId(packetId) + radioConfigUseCase.setConfig(destNum, config) } } @@ -357,8 +345,7 @@ open class RadioConfigViewModel( ), ) } - val packetId = radioConfigUseCase.setModuleConfig(destNum, config) - registerRequestId(packetId) + radioConfigUseCase.setModuleConfig(destNum, config) } } @@ -374,47 +361,6 @@ open class RadioConfigViewModel( 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) - } - } - } - fun setFixedPosition(position: Position) { val destNum = destNode.value?.num ?: return safeLaunch(tag = "setFixedPosition") { radioConfigUseCase.setFixedPosition(destNum, position) } @@ -457,7 +403,6 @@ open class RadioConfigViewModel( } fun clearPacketResponse() { - requestIds.value = hashSetOf() _radioConfigState.update { it.copy(responseState = ResponseState.Empty) } } @@ -466,97 +411,146 @@ open class RadioConfigViewModel( _radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) } - when (route) { - ConfigRoute.USER -> - safeLaunch(tag = "getOwner") { - val packetId = radioConfigUseCase.getOwner(destNum) - registerRequestId(packetId) - } + 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()) } } + private fun AdminException.toUiText(): UiText = when (this) { + is AdminException.Timeout -> UiText.Resource(Res.string.timeout) + else -> UiText.DynamicString(message ?: "Admin request failed") + } + fun shouldReportLocation(nodeNum: Int?) = mapConsentPrefs.shouldReportLocation(nodeNum) fun setShouldReportLocation(nodeNum: Int?, shouldReportLocation: Boolean) { mapConsentPrefs.setShouldReportLocation(nodeNum, shouldReportLocation) } - 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 - } - } - } - 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 } } } @@ -570,195 +564,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/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index affa6edf30..213fda4f95 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,9 +40,8 @@ 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.model.AdminException import org.meshtastic.core.model.Node import org.meshtastic.core.repository.AnalyticsPrefs import org.meshtastic.core.repository.FileService @@ -55,9 +53,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 @@ -65,7 +63,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 @@ -93,11 +90,9 @@ class RadioConfigViewModelTest { 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 @@ -115,15 +110,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() } @@ -148,7 +140,6 @@ class RadioConfigViewModelTest { installProfileUseCase = installProfileUseCase, radioConfigUseCase = radioConfigUseCase, adminActionsUseCase = adminActionsUseCase, - processRadioResponseUseCase = processRadioResponseUseCase, locationService = locationService, fileService = fileService, mqttManager = mqttManager, @@ -161,7 +152,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) @@ -193,87 +184,98 @@ class RadioConfigViewModelTest { } @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()) } } @@ -295,7 +297,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) @@ -334,15 +336,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) } @@ -354,7 +353,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) @@ -404,125 +403,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) } } From 3fb45e05bd59195aa137ba8061f7ef66f0977c22 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 11:32:38 -0500 Subject: [PATCH 32/53] fix: architecture review P0/P1 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P0: setDeviceAddress now persists address to RadioPrefs before reconnect - P0: Document nodedbReset firmware limitation (preserveFavorites is local-only) - P1: Add writeAction helper for AdminException → sendError in RadioConfigViewModel - P1: Wrap CommonNodeRequestActions scope.launch with runCatching for crash safety - P1: Create CongestionLevel typealias in core/model to decouple feature modules from SDK - P1: Cancel prior loadJob in setResponseStateLoading to prevent stale results Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/radio/SdkRadioController.kt | 6 + .../meshtastic/core/model/CongestionLevel.kt | 25 ++++ .../core/repository/ServiceRepository.kt | 2 +- .../core/service/ServiceRepositoryImpl.kt | 2 +- .../core/testing/FakeServiceRepository.kt | 2 +- .../feature/node/component/NodeItem.kt | 2 +- .../feature/node/component/NodeStatusIcons.kt | 2 +- .../node/detail/CommonNodeRequestActions.kt | 70 ++++++----- .../feature/node/list/NodeListViewModel.kt | 2 +- .../settings/radio/RadioConfigViewModel.kt | 116 +++++++++--------- 10 files changed, 138 insertions(+), 91 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/CongestionLevel.kt diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index a022e83434..31d9a11345 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -34,6 +34,7 @@ import org.meshtastic.core.model.RadioController import org.meshtastic.core.model.RemoteAdmin 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 @@ -77,6 +78,7 @@ class SdkRadioController( private val nodeRepository: NodeRepository, private val locationManager: MeshLocationManager, private val deliveryTracker: MessageDeliveryTracker, + private val radioPrefs: RadioPrefs, ) : RadioController { private val packetIdCounter = atomic(1) @@ -281,6 +283,9 @@ class SdkRadioController( 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() } @@ -387,6 +392,7 @@ class SdkRadioController( } override fun setDeviceAddress(address: String) { + radioPrefs.setDevAddr(address) accessor.rebuildAndConnectAsync() } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/CongestionLevel.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/CongestionLevel.kt new file mode 100644 index 0000000000..9f2afe5d0d --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/CongestionLevel.kt @@ -0,0 +1,25 @@ +/* + * 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 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/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ServiceRepository.kt index 4ad665c917..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 @@ -25,7 +25,7 @@ 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.sdk.CongestionLevel +import org.meshtastic.core.model.CongestionLevel /** * Interface for managing background service state, connection status, and mesh events. 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 f38c574bcb..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 @@ -33,7 +33,7 @@ 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.sdk.CongestionLevel +import org.meshtastic.core.model.CongestionLevel /** * Platform-agnostic implementation of [ServiceRepository]. 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 6c55cdaf4d..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 @@ -30,7 +30,7 @@ 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.sdk.CongestionLevel +import org.meshtastic.core.model.CongestionLevel @Suppress("TooManyFunctions") class FakeServiceRepository : ServiceRepository { 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 f79e266d0d..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,7 +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.sdk.CongestionLevel +import org.meshtastic.core.model.CongestionLevel private const val ACTIVE_ALPHA = 0.5f private const val INACTIVE_ALPHA = 0.2f 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 a5b6faf5ab..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 @@ -60,7 +60,7 @@ 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.sdk.CongestionLevel +import org.meshtastic.core.model.CongestionLevel @OptIn(ExperimentalMaterial3Api::class) @Composable 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 19f6843c57..cfd95ac8a4 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 @@ -66,58 +66,68 @@ constructor( override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) { scope.launch(ioDispatcher) { - Logger.i { "Requesting UserInfo for '$destNum'" } - dataRequester.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 = messageSender.getPacketId() - dataRequester.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'" } + val packetId = messageSender.getPacketId() + dataRequester.requestNeighborInfo(packetId, destNum) + _lastRequestNeighborTimes.update { it + (destNum to nowMillis) } + showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName)) + }.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'" } - dataRequester.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 = messageSender.getPacketId() - dataRequester.requestTelemetry(packetId, destNum, type.ordinal) + runCatching { + Logger.i { "Requesting telemetry for '$destNum'" } + val packetId = messageSender.getPacketId() + dataRequester.requestTelemetry(packetId, destNum, type.ordinal) - 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.HOST -> Res.string.request_host_metrics + TelemetryType.PAX -> Res.string.request_pax_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 = messageSender.getPacketId() - dataRequester.requestTraceroute(packetId, destNum) - _lastTracerouteTime.value = nowMillis - showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName)) + runCatching { + Logger.i { "Requesting traceroute for '$destNum'" } + val packetId = messageSender.getPacketId() + dataRequester.requestTraceroute(packetId, 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/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt index bf173df284..ef4b63b4b2 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 @@ -39,7 +39,7 @@ 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.sdk.CongestionLevel +import org.meshtastic.core.model.CongestionLevel @Suppress("LongParameterList") @KoinViewModel 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 3768eaba8c..bd2008d008 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 @@ -186,6 +186,8 @@ open class RadioConfigViewModel( private val _radioConfigState = MutableStateFlow(RadioConfigState()) val radioConfigState: StateFlow = _radioConfigState + private var loadJob: Job? = null + fun setPreserveFavorites(preserveFavorites: Boolean) { _radioConfigState.update { it.copy(nodeDbResetPreserveFavorites = preserveFavorites) } } @@ -272,18 +274,14 @@ open class RadioConfigViewModel( fun setOwner(user: User) { val destNum = destNode.value?.num ?: return - safeLaunch(tag = "setOwner") { - _radioConfigState.update { it.copy(userConfig = user) } - radioConfigUseCase.setOwner(destNum, user) - } + _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") { - radioConfigUseCase.setRemoteChannel(destNum, channel) - } + writeAction("setRemoteChannel") { radioConfigUseCase.setRemoteChannel(destNum, channel) } } if (destNum == myNodeNum) { @@ -297,78 +295,74 @@ 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, - ), - ) - } - radioConfigUseCase.setConfig(destNum, config) + _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, - ), - ) - } - radioConfigUseCase.setModuleConfig(destNum, config) + _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) } + 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) } } fun importProfile(uri: CommonUri, onResult: (DeviceProfile) -> Unit) { @@ -411,7 +405,8 @@ open class RadioConfigViewModel( _radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) } - viewModelScope.launch { + loadJob?.cancel() + loadJob = viewModelScope.launch { try { when (route) { ConfigRoute.USER -> { @@ -539,6 +534,17 @@ open class RadioConfigViewModel( else -> UiText.DynamicString(message ?: "Admin request failed") } + /** 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) { From f4c6cee3329ff0dcd288ae82e4c7ca27abec1854 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 12:04:07 -0500 Subject: [PATCH 33/53] feat: typed telemetry dispatch + MeshTopology service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace magic int dispatch in requestTelemetry with TelemetryType enum - Update DataRequester interface: remove requestId param, use TelemetryType directly - Add HEALTH and TRAFFIC_MANAGEMENT to TelemetryType, remove stale PAX variant - Create MeshTopologyService wrapping SDK's MeshTopology with thread-safe Mutex - Wire NeighborInfo packet ingestion in SdkStateBridge → topology graph - Clear topology on node snapshot (reconnect) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/radio/MeshTopologyService.kt | 93 +++++++++++++++++++ .../core/data/radio/SdkRadioController.kt | 22 ++--- .../core/data/radio/SdkStateBridge.kt | 23 +++++ .../core/data/radio/SdkStateBridgeTest.kt | 1 + .../meshtastic/core/model/DataRequester.kt | 2 +- .../meshtastic/core/model/TelemetryType.kt | 9 +- .../core/testing/FakeRadioController.kt | 3 +- .../component/TelemetricActionsSection.kt | 2 +- .../node/detail/CommonNodeRequestActions.kt | 6 +- .../feature/node/metrics/PaxMetrics.kt | 4 +- .../feature/widget/RefreshLocalStatsAction.kt | 4 +- 11 files changed, 147 insertions(+), 22 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/MeshTopologyService.kt 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..6554a57d61 --- /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/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 31d9a11345..2ce8c5302c 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -32,6 +32,7 @@ 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 @@ -322,19 +323,18 @@ class SdkRadioController( c.routing.traceRoute(NodeId(destNum)) } - override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) { + override suspend fun requestTelemetry(destNum: Int, type: TelemetryType) { val c = requireClient() val node = NodeId(destNum) - when (typeValue) { - 0 -> c.telemetry.requestDevice(node) - 1 -> c.telemetry.requestEnvironment(node) - 2 -> c.telemetry.requestAirQuality(node) - 3 -> c.telemetry.requestPower(node) - 4 -> c.telemetry.requestLocalStats() - 5 -> c.telemetry.requestHealth(node) - 6 -> c.telemetry.requestHost(node) - 7 -> c.telemetry.requestTrafficManagement(node) - else -> Logger.w { "Unknown telemetry type: $typeValue" } + 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) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index dbc42267b0..7beb100d84 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob 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 @@ -47,6 +48,7 @@ import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.ChannelIndex import org.meshtastic.sdk.ConnectionState as SdkConnectionState import org.meshtastic.sdk.MeshEvent +import org.meshtastic.sdk.NeighborInfo import org.meshtastic.sdk.NodeChange import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.StoreForwardEvent @@ -70,6 +72,7 @@ class SdkStateBridge( private val nodeRepository: NodeRepository, private val packetRepository: Lazy, private val locationManager: MeshLocationManager, + private val topologyService: MeshTopologyService, private val uiPrefs: UiPrefs, private val dispatchers: CoroutineDispatchers, ) { @@ -95,6 +98,7 @@ class SdkStateBridge( when (change) { is NodeChange.Snapshot -> { nodeRepository.clear() + topologyService.clear() change.nodes.forEach { (_, nodeInfo) -> nodeRepository.installNodeInfo(nodeInfo, withBroadcast = false) } @@ -139,6 +143,25 @@ class SdkStateBridge( .onEach { packet -> serviceRepository.emitMeshPacket(packet) } .launchIn(scope) + // ── Topology: ingest NeighborInfo packets into MeshTopology graph ──── + accessor.client + .flatMapLatest { client -> client?.packets ?: flowOf() } + .filter { it.decoded?.portnum == PortNum.NEIGHBORINFO_APP } + .onEach { packet -> + val payload = packet.decoded?.payload?.toByteArray() ?: return@onEach + 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" } } + } + .launchIn(scope) + // ── Events (notifications, security, backpressure) ────────────────── accessor.client .flatMapLatest { client -> client?.events ?: flowOf() } 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 index 671b736536..b987d9f5aa 100644 --- 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 @@ -284,6 +284,7 @@ class SdkStateBridgeTest { nodeRepository = nodeRepository, packetRepository = lazyOf(packetRepository), locationManager = NoOpLocationManager, + topologyService = MeshTopologyService(), uiPrefs = FakeUiPrefs(), dispatchers = CoroutineDispatchers( io = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt index 59d5e04bb7..76c693e208 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt @@ -21,7 +21,7 @@ interface DataRequester { suspend fun requestPosition(destNum: Int, currentPosition: Position) suspend fun requestUserInfo(destNum: Int) suspend fun requestTraceroute(requestId: Int, destNum: Int) - suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) + suspend fun requestTelemetry(destNum: Int, type: TelemetryType) suspend fun requestNeighborInfo(requestId: Int, destNum: Int) suspend fun requestStoreForwardHistory(since: Int? = null, serverNodeNum: Int? = null): Boolean } 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/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt index d891284e38..8579fd7ff7 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 @@ -22,6 +22,7 @@ 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 @@ -141,7 +142,7 @@ class FakeRadioController : override suspend fun requestTraceroute(requestId: Int, 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) {} 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 cfd95ac8a4..7a60ac29be 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 @@ -100,8 +100,7 @@ constructor( scope.launch(ioDispatcher) { runCatching { Logger.i { "Requesting telemetry for '$destNum'" } - val packetId = messageSender.getPacketId() - dataRequester.requestTelemetry(packetId, destNum, type.ordinal) + dataRequester.requestTelemetry(destNum, type) val typeRes = when (type) { @@ -110,8 +109,9 @@ constructor( 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.PAX -> Res.string.request_pax_metrics + TelemetryType.TRAFFIC_MANAGEMENT -> Res.string.request_device_metrics } showFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)) 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/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt index b382ba4ab7..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 @@ -41,7 +41,7 @@ class RefreshLocalStatsAction : return } - radioController.requestTelemetry(myNodeNum.hashCode(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal) - radioController.requestTelemetry(myNodeNum.hashCode() + 1, myNodeNum, TelemetryType.DEVICE.ordinal) + radioController.requestTelemetry(myNodeNum, TelemetryType.LOCAL_STATS) + radioController.requestTelemetry(myNodeNum, TelemetryType.DEVICE) } } From 0b5791a61d8685100b5b341a3a2f5e226ba9768d Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 12:23:25 -0500 Subject: [PATCH 34/53] refactor: remove getPacketId() from public interface - Remove getPacketId() from MessageSender interface (SDK owns packet IDs) - Remove requestId parameter from requestTraceroute/requestNeighborInfo - Make getPacketId() private in SdkRadioController (still used internally for delivery tracking correlation) - Replace radioController.getPacketId() in map with local Random.nextInt() for waypoint ID generation (only needs uniqueness, not SDK correlation) - Remove messageSender dependency from CommonNodeRequestActions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/core/data/radio/SdkRadioController.kt | 6 +++--- .../domain/usecase/settings/RadioConfigUseCaseTest.kt | 2 -- .../kotlin/org/meshtastic/core/model/DataRequester.kt | 4 ++-- .../kotlin/org/meshtastic/core/model/MessageSender.kt | 1 - .../org/meshtastic/core/testing/FakeRadioController.kt | 6 ++---- .../kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt | 3 ++- .../feature/node/detail/CommonNodeRequestActions.kt | 8 ++------ 7 files changed, 11 insertions(+), 19 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 2ce8c5302c..219ccc0988 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -318,7 +318,7 @@ class SdkRadioController( c.requestNodeInfo(NodeId(destNum)) } - override suspend fun requestTraceroute(requestId: Int, destNum: Int) { + override suspend fun requestTraceroute(destNum: Int) { val c = requireClient() c.routing.traceRoute(NodeId(destNum)) } @@ -338,7 +338,7 @@ class SdkRadioController( } } - override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) { + override suspend fun requestNeighborInfo(destNum: Int) { val c = requireClient() c.routing.requestNeighborInfo(NodeId(destNum)) } @@ -381,7 +381,7 @@ class SdkRadioController( // ── Utility ───────────────────────────────────────────────────────────── - override fun getPacketId(): Int = packetIdCounter.getAndIncrement() + private fun getPacketId(): Int = packetIdCounter.getAndIncrement() override fun startProvideLocation() { // Location provision is managed at the app level; no-op here 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 d252d1f364..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,8 +41,6 @@ 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 diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt index 76c693e208..69564bdaaa 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataRequester.kt @@ -20,8 +20,8 @@ package org.meshtastic.core.model interface DataRequester { suspend fun requestPosition(destNum: Int, currentPosition: Position) suspend fun requestUserInfo(destNum: Int) - suspend fun requestTraceroute(requestId: Int, destNum: Int) + suspend fun requestTraceroute(destNum: Int) suspend fun requestTelemetry(destNum: Int, type: TelemetryType) - suspend fun requestNeighborInfo(requestId: Int, destNum: Int) + 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/MessageSender.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt index 1eaf22ce6c..b89d079164 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MessageSender.kt @@ -19,5 +19,4 @@ package org.meshtastic.core.model /** Focused interface for sending messages over the mesh. */ interface MessageSender : ConnectionAware { suspend fun sendMessage(packet: DataPacket) - fun getPacketId(): Int } 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 8579fd7ff7..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 @@ -140,11 +140,11 @@ class FakeRadioController : override suspend fun requestUserInfo(destNum: Int) {} - override suspend fun requestTraceroute(requestId: Int, destNum: Int) {} + override suspend fun requestTraceroute(destNum: 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 requestStoreForwardHistory(since: Int?, serverNodeNum: Int?): Boolean { lastStoreForwardHistoryRequest = since to serverNodeNum @@ -162,8 +162,6 @@ class FakeRadioController : block(edit) } - override fun getPacketId(): Int = 1 - override fun startProvideLocation() { startProvideLocationCalled = true } 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 153c91a21e..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 @@ -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/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 7a60ac29be..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 @@ -27,7 +27,6 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.DataRequester -import org.meshtastic.core.model.MessageSender import org.meshtastic.core.model.Position import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.resources.Res @@ -50,7 +49,6 @@ import org.meshtastic.core.ui.util.SnackbarManager class CommonNodeRequestActions constructor( private val dataRequester: DataRequester, - private val messageSender: MessageSender, private val snackbarManager: SnackbarManager, ) : NodeRequestActions { @@ -78,8 +76,7 @@ constructor( scope.launch(ioDispatcher) { runCatching { Logger.i { "Requesting NeighborInfo for '$destNum'" } - val packetId = messageSender.getPacketId() - dataRequester.requestNeighborInfo(packetId, 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" } } @@ -123,8 +120,7 @@ constructor( scope.launch(ioDispatcher) { runCatching { Logger.i { "Requesting traceroute for '$destNum'" } - val packetId = messageSender.getPacketId() - dataRequester.requestTraceroute(packetId, 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" } } From 0b6d8eca085c11752a773db91decd33642d80669 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 12:24:10 -0500 Subject: [PATCH 35/53] chore: remove unused core.service dependencies from 5 feature modules Only feature/messaging actually imports from core.service (SendMessageWorker). The other 5 feature modules (connections, firmware, map, node, settings) had vestigial dependency declarations from pre-migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- feature/connections/build.gradle.kts | 1 - feature/firmware/build.gradle.kts | 1 - feature/map/build.gradle.kts | 1 - feature/node/build.gradle.kts | 1 - feature/settings/build.gradle.kts | 1 - 5 files changed, 5 deletions(-) diff --git a/feature/connections/build.gradle.kts b/feature/connections/build.gradle.kts index 09447942c2..9fcf6112fd 100644 --- a/feature/connections/build.gradle.kts +++ b/feature/connections/build.gradle.kts @@ -38,7 +38,6 @@ 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) 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/map/build.gradle.kts b/feature/map/build.gradle.kts index 42506ea334..6456caca15 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -37,7 +37,6 @@ kotlin { implementation(projects.core.navigation) implementation(projects.core.prefs) implementation(projects.core.proto) - implementation(projects.core.service) implementation(projects.core.resources) implementation(projects.core.ui) implementation(projects.core.di) diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index c909082806..ca55cd53b9 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -40,7 +40,6 @@ kotlin { 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) 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) From 6487d1316dcb83560916f99b7495e643bb68649b Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 12:37:43 -0500 Subject: [PATCH 36/53] refactor: remove dead deps + add Compose stability annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused feature/node → feature/map dependency (nothing imported) - Add @Immutable to RadioConfigState, DiscoveredDevices, DeviceListEntry to prevent unnecessary Compose recompositions from unstable inference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/feature/connections/model/DeviceListEntry.kt | 1 + .../meshtastic/feature/connections/model/DiscoveredDevices.kt | 1 + feature/node/build.gradle.kts | 1 - .../meshtastic/feature/settings/radio/RadioConfigViewModel.kt | 1 + 4 files changed, 3 insertions(+), 1 deletion(-) 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/node/build.gradle.kts b/feature/node/build.gradle.kts index ca55cd53b9..c8018e59c0 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -42,7 +42,6 @@ kotlin { implementation(projects.core.resources) 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/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 bd2008d008..69c9d74c22 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 @@ -82,6 +82,7 @@ import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User /** Data class that represents the current RadioConfig state. */ +@androidx.compose.runtime.Immutable data class RadioConfigState( val isLocal: Boolean = false, val connected: Boolean = false, From 5dd5ebc07420871910a55a32886dc888d428fb12 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 12:43:55 -0500 Subject: [PATCH 37/53] chore: remove more dead dependencies + stability annotations - feature/map: remove unused core.data, core.database, core.datastore; add missing core.common, core.repository (actual dependencies) - feature/node: remove unused core.datastore - feature/connections: remove unused core.database - Add @Immutable to NodesUiState, NodeFilterState Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- feature/connections/build.gradle.kts | 1 - feature/map/build.gradle.kts | 5 ++--- feature/node/build.gradle.kts | 1 - .../org/meshtastic/feature/node/list/NodeListViewModel.kt | 2 ++ 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/feature/connections/build.gradle.kts b/feature/connections/build.gradle.kts index 9fcf6112fd..e22f03a394 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) diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index 6456caca15..82488f6be5 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -30,13 +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.repository) implementation(projects.core.resources) implementation(projects.core.ui) implementation(projects.core.di) diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index c8018e59c0..f17e2795fd 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -33,7 +33,6 @@ 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) 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 ef4b63b4b2..29566c367f 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 @@ -159,6 +159,7 @@ class NodeListViewModel( } } +@androidx.compose.runtime.Immutable data class NodesUiState( val sort: NodeSortOption = NodeSortOption.LAST_HEARD, val filter: NodeFilterState = NodeFilterState(), @@ -166,6 +167,7 @@ data class NodesUiState( val tempInFahrenheit: Boolean = false, ) +@androidx.compose.runtime.Immutable data class NodeFilterState( val filterText: String = "", val includeUnknown: Boolean = false, From b3542c76aaf8441e0de1e77e91bdd59fe2c8178d Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 12:46:42 -0500 Subject: [PATCH 38/53] fix: remove circular StateFlow observation in RadioConfigViewModel The combine(serviceRepository.connectionState, radioConfigState) pattern subscribed to the ViewModel's own output StateFlow, causing redundant re-evaluations on every state change. Replace with direct observation of the source connectionState flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../feature/settings/radio/RadioConfigViewModel.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 69c9d74c22..20089e9bde 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 @@ -235,9 +235,10 @@ open class RadioConfigViewModel( .onEach { manifest -> _radioConfigState.update { it.copy(fileManifest = manifest) } } .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 -> From 07214bd3079a2b2ece5ca1ed270ac4ae95cd15e7 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 13:18:49 -0500 Subject: [PATCH 39/53] refactor: decouple feature/connections from feature/settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move ResponseState to core/model - Move PacketResponseStateDialog to core/ui/component - Create RadioConfigStateProvider interface in core/model - RadioConfigViewModel implements RadioConfigStateProvider - ConnectionsScreen accepts interface instead of concrete VM - ConnectionsNavigation receives provider via lambda from app/desktop - Remove projects.feature.settings dependency from connections build This eliminates a feature→feature dependency, improving build parallelism and enforcing proper module boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main/kotlin/org/meshtastic/app/ui/Main.kt | 3 +- .../app/ui/NavigationAssemblyTest.kt | 12 +++++- .../core/model/RadioConfigStateProvider.kt | 38 +++++++++++++++++++ .../meshtastic/core/model}/ResponseState.kt | 2 +- .../component/PacketResponseStateDialog.kt | 5 +-- .../desktop/navigation/DesktopNavigation.kt | 4 +- feature/connections/build.gradle.kts | 1 - .../navigation/ConnectionsNavigation.kt | 12 ++++-- .../connections/ui/ConnectionsScreen.kt | 25 ++++++------ .../feature/settings/AdministrationScreen.kt | 4 +- .../settings/radio/RadioConfigViewModel.kt | 28 +++++++++++++- .../radio/channel/ChannelConfigScreen.kt | 4 +- .../settings/radio/channel/ChannelScreen.kt | 2 +- .../radio/component/LoadingOverlay.kt | 2 +- .../radio/component/RadioConfigScreenList.kt | 3 +- 15 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioConfigStateProvider.kt rename {feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio => core/model/src/commonMain/kotlin/org/meshtastic/core/model}/ResponseState.kt (96%) rename {feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio => core/ui/src/commonMain/kotlin/org/meshtastic/core/ui}/component/PacketResponseStateDialog.kt (97%) 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/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/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/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/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/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/feature/connections/build.gradle.kts b/feature/connections/build.gradle.kts index e22f03a394..99779604cb 100644 --- a/feature/connections/build.gradle.kts +++ b/feature/connections/build.gradle.kts @@ -40,7 +40,6 @@ kotlin { implementation(projects.core.ui) implementation(projects.core.ble) implementation(projects.core.network) - implementation(projects.feature.settings) implementation(libs.jetbrains.navigation3.ui) } 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 0a180fd4bd..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) } }, ) @@ -264,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/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/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 20089e9bde..d2a06d693f 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 @@ -22,12 +22,15 @@ import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.Job 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 @@ -48,6 +51,7 @@ 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 @@ -80,6 +84,7 @@ import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.User +import org.meshtastic.core.model.ResponseState /** Data class that represents the current RadioConfig state. */ @androidx.compose.runtime.Immutable @@ -124,7 +129,7 @@ open class RadioConfigViewModel( private val locationService: LocationService, private val fileService: FileService, private val mqttManager: MqttManager, -) : ViewModel() { +) : ViewModel(), RadioConfigStateProvider { val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed fun toggleAnalyticsAllowed() { @@ -398,10 +403,29 @@ open class RadioConfigViewModel( safeLaunch(tag = "installProfile") { installProfileUseCase(destNum, protobuf, destNode.value?.user) } } - fun clearPacketResponse() { + // 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 } + .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) + } + + override fun clearPacketResponse() { _radioConfigState.update { it.copy(responseState = ResponseState.Empty) } } + // endregion + fun setResponseStateLoading(route: Enum<*>) { val destNum = destNumFlow.value ?: destNode.value?.num ?: return 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..b99de72484 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 @@ -95,7 +95,7 @@ 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.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 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 From 44b2a8c98aab9f3931849135ab06334fbbf0bbde Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 13:23:41 -0500 Subject: [PATCH 40/53] refactor: extract MqttProbeCoordinator and ProfileCoordinator from RadioConfigViewModel Extract self-contained logic into plain coordinator classes: - MqttProbeCoordinator: MQTT broker probe state + cancellation - ProfileCoordinator: import/export/install profile file I/O RadioConfigViewModel delegates to coordinators while retaining state ownership and config-loading orchestration (its core job). Reduces VM body by ~60 lines of implementation detail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../settings/radio/MqttProbeCoordinator.kt | 72 +++++++++++++++ .../settings/radio/ProfileCoordinator.kt | 91 +++++++++++++++++++ .../settings/radio/RadioConfigViewModel.kt | 55 +++-------- .../radio/RadioConfigViewModelTest.kt | 1 + 4 files changed, 179 insertions(+), 40 deletions(-) create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/MqttProbeCoordinator.kt create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/ProfileCoordinator.kt 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 d2a06d693f..348a895a99 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 @@ -24,7 +24,6 @@ import kotlinx.coroutines.Job 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 @@ -37,7 +36,6 @@ 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 @@ -142,39 +140,25 @@ open class RadioConfigViewModel( 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")) @@ -372,35 +356,26 @@ open class RadioConfigViewModel( 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 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 213fda4f95..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 @@ -43,6 +43,7 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase 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 From 046efd50ddce50c4bb05c1579b005cf473d60d16 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 13:26:26 -0500 Subject: [PATCH 41/53] refactor: replace string-based route with typed Enum in RadioConfigState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change RadioConfigState.route from String to Enum<*>? - ChannelScreen resolves navigation target via typed cast instead of string lookup through getNavRouteFrom() - Delete dead SettingsNavUtils.kt (getNavRouteFrom now unused) - Eliminates fragile string→enum round-trip that could silently fail Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../settings/navigation/SettingsNavUtils.kt | 22 ------------------- .../settings/radio/RadioConfigViewModel.kt | 6 ++--- .../settings/radio/channel/ChannelScreen.kt | 8 ++++--- 3 files changed, 8 insertions(+), 28 deletions(-) delete mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavUtils.kt diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavUtils.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavUtils.kt deleted file mode 100644 index 93e5763ef2..0000000000 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavUtils.kt +++ /dev/null @@ -1,22 +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.feature.settings.navigation - -import org.meshtastic.core.navigation.Route - -fun getNavRouteFrom(routeName: String): Route? = - ConfigRoute.entries.find { it.name == routeName }?.route ?: ModuleRoute.entries.find { it.name == routeName }?.route 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 348a895a99..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 @@ -89,7 +89,7 @@ import org.meshtastic.core.model.ResponseState 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(), @@ -385,7 +385,7 @@ open class RadioConfigViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ResponseState.Empty) override val pendingRouteName: StateFlow = - _radioConfigState.map { it.route } + _radioConfigState.map { it.route?.name.orEmpty() } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "") override fun requestConfigLoad(routeName: String) { @@ -404,7 +404,7 @@ open class RadioConfigViewModel( 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()) } loadJob?.cancel() loadJob = viewModelScope.launch { 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 b99de72484..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,7 +93,7 @@ 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.core.ui.component.PacketResponseStateDialog import org.meshtastic.proto.ChannelSet @@ -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) } }, ) From 52aaa4d926711119660624471f54806950dba354 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 13:52:20 -0500 Subject: [PATCH 42/53] feat: handle AdminResult.RateLimited from SDK, update MeshTopology API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AdminException.RateLimited to domain exception hierarchy - Handle AdminResult.RateLimited in SdkRadioController.unwrap() - Update MeshTopologyService for SDK's new suspend API: topology.nodes → topology.nodes(), topology.edgeCount → edgeCount() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/core/data/radio/MeshTopologyService.kt | 4 ++-- .../org/meshtastic/core/data/radio/SdkRadioController.kt | 1 + .../kotlin/org/meshtastic/core/model/AdminException.kt | 3 +++ 3 files changed, 6 insertions(+), 2 deletions(-) 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 index 6554a57d61..a9d72dcda7 100644 --- 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 @@ -56,7 +56,7 @@ class MeshTopologyService { mutex.withLock { topology.addNeighborInfo(info) _edges.value = topology.allEdges() - _nodeCount.value = topology.nodes.size + _nodeCount.value = topology.nodes().size } Logger.d { "[Topology] Ingested neighbors from ${info.nodeId}: ${info.neighbors.size} edges" } } @@ -66,7 +66,7 @@ class MeshTopologyService { mutex.withLock { topology.removeNode(nodeId) _edges.value = topology.allEdges() - _nodeCount.value = topology.nodes.size + _nodeCount.value = topology.nodes().size } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt index 219ccc0988..fb32e0af70 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkRadioController.kt @@ -405,6 +405,7 @@ class SdkRadioController( 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/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt index 431989480e..0d188d84fc 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/AdminException.kt @@ -39,4 +39,7 @@ sealed class AdminException(message: String) : Exception(message) { /** 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") } From fa6d625d90ee41eedbea9bd3cc2f252b1ed9e80d Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:02:03 -0500 Subject: [PATCH 43/53] perf: Phase 4 Android performance optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frequentEmojis: computed List → cached StateFlow (avoids parse+sort on every recomposition) - SdkNodeRepositoryImpl: SharingStarted.Eagerly → WhileSubscribed(5_000) for ourNodeInfo, myId, localStats - PacketRepositoryImpl: deduplicate flatMapLatest chains via shared combine(myNodeNumFlow, currentDb) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/repository/PacketRepositoryImpl.kt | 32 +++++++++++-------- .../data/repository/SdkNodeRepositoryImpl.kt | 6 ++-- .../meshtastic/feature/messaging/Message.kt | 2 +- .../feature/messaging/MessageViewModel.kt | 27 +++++++++------- .../feature/messaging/MessageViewModelTest.kt | 3 +- 5 files changed, 39 insertions(+), 31 deletions(-) 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 d976996604..22c9a0c578 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,7 @@ 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 @@ -61,12 +62,15 @@ class PacketRepositoryImpl( .map { it?.myNodeNum ?: 0 } .distinctUntilChanged() - override fun getWaypoints(): Flow> = myNodeNumFlow - .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllWaypointsFlow(num) } } + /** 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> = myNodeNumFlow - .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys(num) } } + override fun getContacts(): Flow> = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().getContactKeys(num) } .map { map -> map.mapValues { it.value.data } } override fun getContactsPaged(): Flow> = Pager( @@ -87,17 +91,17 @@ class PacketRepositoryImpl( override suspend fun getUnreadCount(contact: String): Int = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(currentMyNodeNum, contact) } - override fun getUnreadCountFlow(contact: String): Flow = myNodeNumFlow - .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountFlow(num, contact) } } + override fun getUnreadCountFlow(contact: String): Flow = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().getUnreadCountFlow(num, contact) } - override fun getFirstUnreadMessageUuid(contact: String): Flow = myNodeNumFlow - .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(num, contact) } } + override fun getFirstUnreadMessageUuid(contact: String): Flow = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().getFirstUnreadMessageUuid(num, contact) } - override fun hasUnreadMessages(contact: String): Flow = myNodeNumFlow - .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(num, contact) } } + override fun hasUnreadMessages(contact: String): Flow = numAndDb + .flatMapLatest { (num, db) -> db.packetDao().hasUnreadMessages(num, contact) } - override fun getUnreadCountTotal(): Flow = myNodeNumFlow - .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal(num) } } + 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(currentMyNodeNum, contact, timestamp) } @@ -463,8 +467,8 @@ class PacketRepositoryImpl( suspend fun updateReaction(reaction: RoomReaction) = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(reaction) } - override fun getFilteredCountFlow(contactKey: String): Flow = myNodeNumFlow - .flatMapLatest { num -> dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFilteredCountFlow(num, 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(currentMyNodeNum, contactKey) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt index 1eb28ecdec..5914593f85 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -99,15 +99,15 @@ class SdkNodeRepositoryImpl( override val ourNodeInfo: StateFlow = combine(_nodeDBbyNum, _myNodeNum) { db, myNum -> myNum?.let { db[it] } } - .stateIn(scope, SharingStarted.Eagerly, null) + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) override val myId: StateFlow = ourNodeInfo.map { it?.user?.id } - .stateIn(scope, SharingStarted.Eagerly, null) + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) override val localStats: StateFlow = localStatsDataSource.localStatsFlow - .stateIn(scope, SharingStarted.Eagerly, LocalStats()) + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), LocalStats()) override fun updateLocalStats(stats: LocalStats) { scope.launch { localStatsDataSource.setLocalStats(stats) } 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 d3c64bfde4..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 @@ -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/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt index 85b746c05d..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 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) } From 604ce68415352a3a6c5f1fd29aa61c60e378de23 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:12:22 -0500 Subject: [PATCH 44/53] feat: set_ignored_node side effects mirror firmware behavior When ignoring a node, wipe position, deviceMetrics, and publicKey locally to match what firmware does on the device side. Un-ignoring just clears the isIgnored flag (device will re-populate on next heard packet). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../meshtastic/core/data/radio/SdkStateBridge.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 7beb100d84..516303c1a2 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -42,7 +42,9 @@ import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.UiPrefs import org.meshtastic.proto.ClientNotification +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 @@ -327,7 +329,19 @@ class SdkStateBridge( val newIgnored = !node.isIgnored val result = runCatching { client.admin.setIgnored(NodeId(node.num), newIgnored) } if (result.isSuccess) { - nodeRepository.updateNode(node.num) { it.copy(isIgnored = newIgnored) } + nodeRepository.updateNode(node.num) { n -> + if (newIgnored) { + // Mirror firmware behavior: wipe position, device_metrics, zero public_key + 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}" } From 4340fc5045ffaf914c57cca698485408af1ca48b Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:15:01 -0500 Subject: [PATCH 45/53] feat: handle NODE_STATUS_APP packets to populate node status string Parse UTF-8 payload from NODE_STATUS_APP (PortNum=36) and update the node's nodeStatus field. Already displayed in NodeItem and NodeDetailsSection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/core/data/radio/SdkStateBridge.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index 516303c1a2..dde0c355c2 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -164,6 +164,16 @@ class SdkStateBridge( } .launchIn(scope) + // ── NODE_STATUS_APP: update node status string ─────────────────────── + accessor.client + .flatMapLatest { client -> client?.packets ?: flowOf() } + .filter { it.decoded?.portnum == PortNum.NODE_STATUS_APP } + .onEach { packet -> + val status = packet.decoded?.payload?.utf8() ?: return@onEach + nodeRepository.updateNode(packet.from) { it.copy(nodeStatus = status) } + } + .launchIn(scope) + // ── Events (notifications, security, backpressure) ────────────────── accessor.client .flatMapLatest { client -> client?.events ?: flowOf() } From 839d43ddf189618b108123e35f8cf1294d53ebac Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:24:17 -0500 Subject: [PATCH 46/53] Refactor SdkStateBridge bridges Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/radio/SdkEventBridge.kt | 60 +++++ .../core/data/radio/SdkNodeBridge.kt | 99 ++++++++ .../core/data/radio/SdkPacketBridge.kt | 118 +++++++++ .../core/data/radio/SdkStateBridge.kt | 227 +++--------------- .../core/data/radio/SdkTopologyBridge.kt | 51 ++++ 5 files changed, 356 insertions(+), 199 deletions(-) create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkEventBridge.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkNodeBridge.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkPacketBridge.kt create mode 100644 core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkTopologyBridge.kt 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..3c4b1c2f9e --- /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) + } + + private 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..106a02f7dd --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkNodeBridge.kt @@ -0,0 +1,99 @@ +/* + * 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.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 { packet -> + val status = packet.decoded?.payload?.utf8() ?: return@onEach + nodeRepository.updateNode(packet.from) { it.copy(nodeStatus = status) } + } + .launchIn(scope) + } + + private 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) + } + } + + 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..97e5b4aa77 --- /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) + } + + private 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/SdkStateBridge.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt index dde0c355c2..a506394f51 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkStateBridge.kt @@ -20,28 +20,22 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -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.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.koin.core.annotation.Single -import org.meshtastic.core.common.util.nowSeconds 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.util.onlineTimeThreshold 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.ClientNotification import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.PortNum import org.meshtastic.proto.Position @@ -49,11 +43,7 @@ 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.MeshEvent -import org.meshtastic.sdk.NeighborInfo -import org.meshtastic.sdk.NodeChange import org.meshtastic.sdk.NodeId -import org.meshtastic.sdk.StoreForwardEvent /** * Bridges SDK reactive flows into the repository layer and routes [ServiceAction]s @@ -62,215 +52,59 @@ import org.meshtastic.sdk.StoreForwardEvent * 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. - * - * **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientAccessor.client] - * and starts/stops collection as clients come and go. */ @Single -@Suppress("TooManyFunctions") class SdkStateBridge( private val accessor: RadioClientAccessor, private val serviceRepository: ServiceRepository, private val nodeRepository: NodeRepository, private val packetRepository: Lazy, private val locationManager: MeshLocationManager, - private val topologyService: MeshTopologyService, + 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 { - startBridge() + bind() } - private fun startBridge() { + 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" } + } - // ── Connection state ──────────────────────────────────────────────── + private fun bindConnectionState() { accessor.client .flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) } .onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) } .launchIn(scope) + } - // ── Node updates (position, telemetry, user all included in NodeInfo) ─ - accessor.client - .flatMapLatest { client -> client?.nodes ?: flowOf() } - .onEach { change -> - 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 -> { - 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())) - } - } - } - is 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())) - } - } - } - } - } - .launchIn(scope) - - // ── Own node identity ─────────────────────────────────────────────── - accessor.client - .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } - .onEach { ownNode -> if (ownNode != null) nodeRepository.setMyNodeNum(ownNode.num) } - .launchIn(scope) - - // ── Raw packet forward (for RadioConfigViewModel + TAK) ───────────── - accessor.client - .flatMapLatest { client -> client?.packets ?: flowOf() } - .onEach { packet -> serviceRepository.emitMeshPacket(packet) } - .launchIn(scope) - - // ── Topology: ingest NeighborInfo packets into MeshTopology graph ──── - accessor.client - .flatMapLatest { client -> client?.packets ?: flowOf() } - .filter { it.decoded?.portnum == PortNum.NEIGHBORINFO_APP } - .onEach { packet -> - val payload = packet.decoded?.payload?.toByteArray() ?: return@onEach - 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" } } - } - .launchIn(scope) - - // ── NODE_STATUS_APP: update node status string ─────────────────────── - accessor.client - .flatMapLatest { client -> client?.packets ?: flowOf() } - .filter { it.decoded?.portnum == PortNum.NODE_STATUS_APP } - .onEach { packet -> - val status = packet.decoded?.payload?.utf8() ?: return@onEach - nodeRepository.updateNode(packet.from) { it.copy(nodeStatus = status) } - } - .launchIn(scope) - - // ── Events (notifications, security, backpressure) ────────────────── - accessor.client - .flatMapLatest { client -> client?.events ?: flowOf() } - .onEach { event -> - 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" } - } - } - .launchIn(scope) - - // ── Store-and-Forward (server discovery + replay lifecycle) ─────────── - 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 { event -> - 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, - ) - } - else -> Logger.d { "[SdkBridge] S&F event: $event" } - } - } - .launchIn(scope) - - // ── ServiceAction routing (replaces MeshServiceOrchestrator dispatch) ─ + private fun bindServiceActions() { serviceRepository.serviceAction .onEach { action -> handleServiceAction(action) } .launchIn(scope) + } - // ── Location publishing ───────────────────────────────────────────── + private fun bindLocationPublishing() { accessor.client .flatMapLatest { client -> client?.ownNode ?: flowOf(null) } .onEach { ownNode -> @@ -282,9 +116,9 @@ class SdkStateBridge( if (shouldProvide) { locationManager.start(scope) { pos -> scope.launch { - val c = accessor.client.value ?: return@launch + val client = accessor.client.value ?: return@launch val posBytes = org.meshtastic.proto.Position.ADAPTER.encode(pos) - c.send( + client.send( portnum = PortNum.POSITION_APP, payload = posBytes, wantAck = false, @@ -299,12 +133,8 @@ class SdkStateBridge( } } .launchIn(scope) - - Logger.i { "SdkStateBridge started — SDK owns transport + ServiceAction dispatch" } } - // ── ServiceAction handling ─────────────────────────────────────────────── - private suspend fun handleServiceAction(action: ServiceAction) { val client = accessor.client.value if (client == null) { @@ -341,7 +171,6 @@ class SdkStateBridge( if (result.isSuccess) { nodeRepository.updateNode(node.num) { n -> if (newIgnored) { - // Mirror firmware behavior: wipe position, device_metrics, zero public_key n.copy( isIgnored = true, position = Position(), 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..cfe3124444 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/radio/SdkTopologyBridge.kt @@ -0,0 +1,51 @@ +/* + * 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.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 { packet -> + val payload = packet.decoded?.payload?.toByteArray() ?: return@onEach + 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" } } + } + .launchIn(scope) + } +} From 6450c69820286968472475305d0338b5fd9810bb Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:25:03 -0500 Subject: [PATCH 47/53] Add distance-based node filter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/common/util/LocationUtils.kt | 4 + .../core/common/util/LocationUtilsTest.kt | 6 ++ .../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 17 ++++ .../core/repository/AppPreferences.kt | 4 + .../composeResources/values/strings.xml | 4 + .../core/testing/FakeAppPreferences.kt | 6 ++ .../node/component/NodeFilterTextField.kt | 77 +++++++++++++++++++ .../domain/usecase/GetFilteredNodesUseCase.kt | 40 +++++++--- .../node/list/NodeFilterPreferences.kt | 5 ++ .../feature/node/list/NodeListScreen.kt | 2 + .../feature/node/list/NodeListViewModel.kt | 7 +- .../usecase/GetFilteredNodesUseCaseTest.kt | 32 +++++++- .../node/list/NodeListViewModelTest.kt | 1 + 13 files changed, 193 insertions(+), 12 deletions(-) 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 74810daac9..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 @@ -38,6 +38,10 @@ object GPSFormat { fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double = PositionUtils.distance(latitudeA, longitudeA, latitudeB, longitudeB) +/** @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. * 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/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/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/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index a48e9c57a7..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 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/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/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 7368023826..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 @@ -183,6 +183,8 @@ fun NodeListScreen( ignoredNodeCount = ignoredNodeCount, excludeMqtt = state.filter.excludeMqtt, onToggleExcludeMqtt = { viewModel.nodeFilterPreferences.toggleExcludeMqtt() }, + maxDistanceKm = state.filter.maxDistanceKm, + onMaxDistanceKmChange = viewModel.nodeFilterPreferences::setMaxDistanceKm, ) } 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 29566c367f..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 @@ -94,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, @@ -107,6 +108,7 @@ class NodeListViewModel( onlyDirect = filterToggles.onlyDirect, showIgnored = filterToggles.showIgnored, excludeMqtt = excludeMqtt, + maxDistanceKm = maxDistanceKm, ) } val nodesUiState: StateFlow = @@ -176,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/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 df4f8962cb..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 @@ -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()) From 6490fc30e8919d0d9a3d38c28ec1e93a28700303 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 14:58:16 -0500 Subject: [PATCH 48/53] test: comprehensive bridge unit tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/radio/SdkEventBridge.kt | 2 +- .../core/data/radio/SdkNodeBridge.kt | 13 +- .../core/data/radio/SdkPacketBridge.kt | 2 +- .../core/data/radio/SdkTopologyBridge.kt | 29 +- .../core/data/radio/SdkEventBridgeTest.kt | 141 ++++++++ .../core/data/radio/SdkNodeBridgeTest.kt | 336 ++++++++++++++++++ .../core/data/radio/SdkPacketBridgeTest.kt | 268 ++++++++++++++ .../core/data/radio/SdkTopologyBridgeTest.kt | 146 ++++++++ 8 files changed, 917 insertions(+), 20 deletions(-) create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkEventBridgeTest.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkNodeBridgeTest.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkPacketBridgeTest.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkTopologyBridgeTest.kt 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 index 3c4b1c2f9e..899ed69579 100644 --- 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 @@ -36,7 +36,7 @@ internal class SdkEventBridge( .launchIn(scope) } - private fun handleEvent(event: MeshEvent) { + internal fun handleEvent(event: MeshEvent) { when (event) { is MeshEvent.DeviceRebooted -> { Logger.i { "[SdkBridge] Device rebooted" } 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 index 106a02f7dd..5d50f178d2 100644 --- 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 @@ -28,6 +28,7 @@ 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 @@ -49,14 +50,11 @@ internal class SdkNodeBridge( accessor.client .flatMapLatest { client -> client?.packets ?: emptyFlow() } .filter { it.decoded?.portnum == PortNum.NODE_STATUS_APP } - .onEach { packet -> - val status = packet.decoded?.payload?.utf8() ?: return@onEach - nodeRepository.updateNode(packet.from) { it.copy(nodeStatus = status) } - } + .onEach(::handleNodeStatusPacket) .launchIn(scope) } - private suspend fun handleNodeChange(change: NodeChange) { + internal suspend fun handleNodeChange(change: NodeChange) { when (change) { is NodeChange.Snapshot -> { nodeRepository.clear() @@ -75,6 +73,11 @@ internal class SdkNodeBridge( } } + 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 { 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 index 97e5b4aa77..5ec74bac55 100644 --- 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 @@ -57,7 +57,7 @@ internal class SdkPacketBridge( .launchIn(scope) } - private suspend fun handleStoreForwardEvent(event: StoreForwardEvent) { + internal suspend fun handleStoreForwardEvent(event: StoreForwardEvent) { when (event) { is StoreForwardEvent.ServerDiscovered -> { Logger.i { 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 index cfe3124444..8a84b4e06b 100644 --- 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 @@ -23,6 +23,7 @@ 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 @@ -33,19 +34,21 @@ internal class SdkTopologyBridge( accessor.client .flatMapLatest { client -> client?.packets ?: emptyFlow() } .filter { it.decoded?.portnum == PortNum.NEIGHBORINFO_APP } - .onEach { packet -> - val payload = packet.decoded?.payload?.toByteArray() ?: return@onEach - 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" } } - } + .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/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/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/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(), + ), + ) +} From 37be51bed9f207786c090cf5572cbd6dd23f361b Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 15:02:08 -0500 Subject: [PATCH 49/53] test: MeshTopologyService and MessageDeliveryTracker tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../data/radio/MeshTopologyServiceTest.kt | 172 +++++++++ .../data/radio/MessageDeliveryTrackerTest.kt | 326 ++++++++++++++++++ .../core/data/radio/SdkStateBridgeTest.kt | 14 +- 3 files changed, 505 insertions(+), 7 deletions(-) create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/MeshTopologyServiceTest.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/MessageDeliveryTrackerTest.kt 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/SdkStateBridgeTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkStateBridgeTest.kt index b987d9f5aa..6c26e6e842 100644 --- 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 @@ -76,7 +76,7 @@ class SdkStateBridgeTest { ), ) } - val (_, client) = connectedClient(SeededHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs))) + val (_, client) = connectedClient(StateBridgeHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs))) buildBridge(client, nodeRepository) client.connect() @@ -107,7 +107,7 @@ class SdkStateBridgeTest { ), ) } - val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs))) + val (transport, client) = connectedClient(StateBridgeHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs))) buildBridge(client, nodeRepository) client.connect() @@ -134,7 +134,7 @@ class SdkStateBridgeTest { @Test fun `sfpp link provided updates packet repository`() = runTest { val packetRepository = mock(MockMode.autofill) - val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(emptyMap())) + val (transport, client) = connectedClient(StateBridgeHeartbeatStorageProvider(emptyMap())) buildBridge(client, FakeNodeRepository(), packetRepository) client.connect() @@ -170,7 +170,7 @@ class SdkStateBridgeTest { @Test fun `sfpp canon announce updates packet repository by hash`() = runTest { val packetRepository = mock(MockMode.autofill) - val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(emptyMap())) + val (transport, client) = connectedClient(StateBridgeHeartbeatStorageProvider(emptyMap())) buildBridge(client, FakeNodeRepository(), packetRepository) client.connect() @@ -199,7 +199,7 @@ class SdkStateBridgeTest { @Test fun `congestion warning updates service repository congestion level`() = runTest { val serviceRepo = FakeServiceRepository() - val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(emptyMap())) + val (transport, client) = connectedClient(StateBridgeHeartbeatStorageProvider(emptyMap())) buildBridge(client, FakeNodeRepository(), serviceRepository = serviceRepo) client.connect() @@ -231,7 +231,7 @@ class SdkStateBridgeTest { @Test fun `store forward server list propagates to service repository`() = runTest { val serviceRepo = FakeServiceRepository() - val (transport, client) = connectedClient(SeededHeartbeatStorageProvider(emptyMap())) + val (transport, client) = connectedClient(StateBridgeHeartbeatStorageProvider(emptyMap())) buildBridge(client, FakeNodeRepository(), serviceRepository = serviceRepo) client.connect() @@ -325,7 +325,7 @@ class SdkStateBridgeTest { } } -private class SeededHeartbeatStorageProvider( +private class StateBridgeHeartbeatStorageProvider( private val heartbeats: Map, ) : StorageProvider { override suspend fun activate(identity: TransportIdentity): DeviceStorage = From 2d299f7e2121c995ba35fc4ca7e97242e679755c Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 15:06:13 -0500 Subject: [PATCH 50/53] test: SdkRadioController and PacketRepository coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../repository/PacketRepositoryImplTest.kt | 32 ++ .../core/data/radio/SdkRadioControllerTest.kt | 435 ++++++++++++++++++ .../repository/CommonPacketRepositoryTest.kt | 244 +++++++++- 3 files changed, 698 insertions(+), 13 deletions(-) create mode 100644 core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkRadioControllerTest.kt diff --git a/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt new file mode 100644 index 0000000000..b4d0603eb6 --- /dev/null +++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt @@ -0,0 +1,32 @@ +/* + * 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 kotlin.test.BeforeTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PacketRepositoryImplTest : CommonPacketRepositoryTest() { + + @BeforeTest + fun setUp() { + setupRepo() + } +} 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/repository/CommonPacketRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonPacketRepositoryTest.kt index cffa154c9b..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,55 +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 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) - private val nodeRepository = FakeNodeRepository() + 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, 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" + val packet = DataPacket(to = DataPacket.BROADCAST, bytes = ByteString.EMPTY, dataType = PortNum.TEXT_MESSAGE_APP.value, id = 123) - // Set the current node number so PacketRepositoryImpl can pass it to queries - nodeRepository.setMyNodeInfo(org.meshtastic.core.testing.TestDataFactory.createMyNodeInfo(myNodeNum = myNodeNum)) + repository.savePacket(myNodeNum, broadcastContact, packet, 1000L) - val packet = DataPacket(to = DataPacket.BROADCAST, bytes = okio.ByteString.EMPTY, dataType = 1, id = 123) + assertEquals(1, repository.getMessageCount(broadcastContact)) + } - repository.savePacket(myNodeNum, contact, packet, 1000L) + @Test + fun `clearAllUnreadCounts works with real DB`() = runTest(testDispatcher) { + repository.clearAllUnreadCounts() + } - // Verify it was saved. - val count = repository.getMessageCount(contact) - assertEquals(1, count) + @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 `clearAllUnreadCounts works with real DB`() = runTest(testDispatcher) { + 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() - // No exception thrown + + 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())) } From aacd7895505e74e6f0e4d280b02c833a4d10271a Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 16:15:11 -0500 Subject: [PATCH 51/53] =?UTF-8?q?Add=20integration=20tests=20for=20SDK?= =?UTF-8?q?=E2=86=92Bridge=E2=86=92Repository=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 integration tests covering: - T4a: SDK packets/events propagate through bridges to repositories - T4b: Connection lifecycle state transitions (connect/disconnect/reconnect) - T4c: Error resilience (malformed protos, unknown ports, rapid fire, empty payloads) Tests verify the full chain from FakeRadioTransport through RadioClient, SdkStateBridge orchestration, and into service/node/topology repositories. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/data/radio/SdkIntegrationTest.kt | 520 ++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/radio/SdkIntegrationTest.kt 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, + ), + ) +} From 813316110e4da69f4d58c5be65b94e75bef0ce21 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 17:41:19 -0500 Subject: [PATCH 52/53] chore: KDoc cleanup, stale comments, and cruft removal - Add KDoc to 3 JSON datasource interfaces and 7 repository implementations - Remove IndoorAirQuality stub comments - Remove commented-out WRITE_EXTERNAL_STORAGE permission from manifest - Update foreground service comment to architecture note (Android 14+) - Remove redundant inline comments in PacketDao.kt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 6 +- .../AppMetadataRepositoryImplTest.kt | 208 ++++++++++++ .../DeviceHardwareRepositoryImplTest.kt | 308 ++++++++++++++++++ .../FirmwareReleaseRepositoryImplTest.kt | 297 +++++++++++++++++ .../BootloaderOtaQuirksJsonDataSource.kt | 4 + .../DeviceHardwareJsonDataSource.kt | 4 + .../FirmwareReleaseJsonDataSource.kt | 4 + .../repository/AppMetadataRepositoryImpl.kt | 26 +- .../DeviceHardwareRepositoryImpl.kt | 16 +- .../FirmwareReleaseRepositoryImpl.kt | 3 + .../data/repository/PacketRepositoryImpl.kt | 3 + .../QuickChatActionRepositoryImpl.kt | 3 + .../data/repository/SdkNodeRepositoryImpl.kt | 10 +- .../TracerouteSnapshotRepositoryImpl.kt | 3 + .../core/data/manager/MqttManagerImplTest.kt | 256 +++++++++++++++ .../core/database/dao/NodeMetadataDao.kt | 34 ++ .../meshtastic/core/database/dao/PacketDao.kt | 2 - .../core/ui/component/IndoorAirQuality.kt | 4 - .../core/ui/qr/ScannedQrCodeViewModelTest.kt | 141 ++++++++ .../core/ui/viewmodel/UIViewModelTest.kt | 285 ++++++++++++++++ 20 files changed, 1573 insertions(+), 44 deletions(-) create mode 100644 core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt create mode 100644 core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt create mode 100644 core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt create mode 100644 core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MqttManagerImplTest.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeViewModelTest.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModelTest.kt 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/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/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/repository/AppMetadataRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImpl.kt index b69d04db0c..faf5934463 100644 --- 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 @@ -25,6 +25,9 @@ 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, @@ -35,41 +38,28 @@ class AppMetadataRepositoryImpl( .map { list -> list.associate { it.num to it.toModel() } } override suspend fun setFavorite(nodeNum: Int, isFavorite: Boolean) { - ensureExists(nodeNum) - dbManager.withDb { it.nodeMetadataDao().setFavorite(nodeNum, isFavorite) } + dbManager.withDb { it.nodeMetadataDao().setFavoriteEnsuringExists(nodeNum, isFavorite) } } override suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean) { - ensureExists(nodeNum) - dbManager.withDb { it.nodeMetadataDao().setIgnored(nodeNum, isIgnored) } + dbManager.withDb { it.nodeMetadataDao().setIgnoredEnsuringExists(nodeNum, isIgnored) } } override suspend fun setMuted(nodeNum: Int, isMuted: Boolean) { - ensureExists(nodeNum) - dbManager.withDb { it.nodeMetadataDao().setMuted(nodeNum, isMuted) } + dbManager.withDb { it.nodeMetadataDao().setMutedEnsuringExists(nodeNum, isMuted) } } override suspend fun setNotes(nodeNum: Int, notes: String) { - ensureExists(nodeNum) - dbManager.withDb { it.nodeMetadataDao().setNotes(nodeNum, notes) } + dbManager.withDb { it.nodeMetadataDao().setNotesEnsuringExists(nodeNum, notes) } } override suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean) { - ensureExists(nodeNum) - dbManager.withDb { it.nodeMetadataDao().setManuallyVerified(nodeNum, verified) } + dbManager.withDb { it.nodeMetadataDao().setManuallyVerifiedEnsuringExists(nodeNum, verified) } } override suspend fun delete(nodeNum: Int) { dbManager.withDb { it.nodeMetadataDao().delete(nodeNum) } } - - private suspend fun ensureExists(nodeNum: Int) { - dbManager.withDb { db -> - if (db.nodeMetadataDao().getByNum(nodeNum) == null) { - db.nodeMetadataDao().upsert(NodeMetadataEntity(num = nodeNum)) - } - } - } } private fun NodeMetadataEntity.toModel() = NodeMetadata( 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/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt index 22c9a0c578..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 @@ -46,6 +46,9 @@ 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( 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 index 5914593f85..6bbdefc557 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/SdkNodeRepositoryImpl.kt @@ -58,15 +58,7 @@ import org.meshtastic.proto.NodeInfo as ProtoNodeInfo import org.meshtastic.proto.Position as ProtoPosition /** - * Unified node repository and manager — single source of truth for all mesh node state. - * - * Replaces the previous split between a write-operation layer (in-memory atomicfu maps) - * and `SdkNodeRepositoryImpl` (repository interface, StateFlows). Now uses a single StateFlow - * with metadata enrichment on every write. - * - * The SDK manages node persistence via its SqlDelight storage. This class stores the live node - * database in-memory, populated by SdkStateBridge from the SDK's NodeChange flow. - * Node metadata (favorites, notes, ignored, muted) persists via Room's node_metadata table. + * Maintains live mesh node state and exposes reactive node data for the app layer. */ @Single(binds = [NodeRepository::class, NodeIdLookup::class]) @Suppress("TooManyFunctions", "LongParameterList") 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/MqttManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MqttManagerImplTest.kt new file mode 100644 index 0000000000..66b1a6d2d4 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MqttManagerImplTest.kt @@ -0,0 +1,256 @@ +/* + * 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.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 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() + 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, 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/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeMetadataDao.kt index 35f1f06e6d..0fdaa9946c 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -28,6 +29,9 @@ 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> @@ -37,18 +41,48 @@ interface NodeMetadataDao { @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) 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 c7e136f9e4..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 @@ -263,7 +263,6 @@ interface PacketDao { @Transaction 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(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)) } @@ -272,7 +271,6 @@ interface PacketDao { @Transaction suspend fun updateMessageId(myNodeNum: Int, data: DataPacket, id: Int) { val new = data.copy(id = id) - // Match on key fields that identify the packet 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)) } 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/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(), + ) +} From d35dc5da0495154b09be3ab350f40f04b0cae970 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 6 May 2026 18:02:00 -0500 Subject: [PATCH 53/53] fix: update MqttManagerImplTest for nodeRepository constructor param After rebase, MqttManagerImpl gained a nodeRepository parameter. Updated test to mock and pass the new dependency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/core/data/manager/MqttManagerImplTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 66b1a6d2d4..294f9f06aa 100644 --- 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 @@ -35,6 +35,7 @@ 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 @@ -58,6 +59,7 @@ class MqttManagerImplTest { 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 @@ -73,6 +75,7 @@ class MqttManagerImplTest { mqttRepository = mock(MockMode.autofill) packetHandler = mock(MockMode.autofill) serviceRepository = FakeServiceRepository() + nodeRepository = mock(MockMode.autofill) publishCalls.clear() every { mqttRepository.connectionState } returns connectionStateFlow @@ -87,7 +90,7 @@ class MqttManagerImplTest { } every { packetHandler.sendToRadio(any()) } returns Unit - mqttManager = MqttManagerImpl(mqttRepository, packetHandler, serviceRepository, serviceScope) + mqttManager = MqttManagerImpl(mqttRepository, packetHandler, serviceRepository, nodeRepository, serviceScope) } @AfterTest