diff --git a/MIGRATION-REMAINING.md b/MIGRATION-REMAINING.md
new file mode 100644
index 0000000000..9174fd07b5
--- /dev/null
+++ b/MIGRATION-REMAINING.md
@@ -0,0 +1,162 @@
+# SDK Migration — Status & Remaining Work
+
+> Tracks progress of the Meshtastic-Android clean-break migration to meshtastic-sdk.
+> Updated: 2026-05-05
+
+---
+
+## Summary
+
+**Completed:** ~100% of the Clean Break migration. AIDL dropped, SDK is sole radio path,
+transport layer fully deleted, Desktop uses shared SDK bridge, dead infrastructure gone,
+POC ViewModels removed, NodeInfoReadDataSource eliminated, Room legacy tables dropped,
+NodeManager merged into SdkNodeRepositoryImpl, MeshActivity restored.
+
+**Remaining:** Optional VM parameter slimming and test coverage for new bridge code.
+
+**Net change:** 170 files changed, +4,601 / -16,963 lines (net -12,362 LOC removed)
+
+---
+
+## Architecture (current state)
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Feature VMs (NodeList, Settings, RadioConfig, Messages...) │
+│ inject: RadioController, NodeRepository, ServiceRepository │
+└───────────────────────────────┬──────────────────────────────┘
+ │
+┌───────────────────────────────▼──────────────────────────────┐
+│ SDK-Backed Adapters (core/data) │
+│ SdkRadioController, SdkStateBridge, SdkNodeRepositoryImpl │
+│ SdkPacketHandler, SdkRadioInterfaceService │
+│ MessagePersistenceHandler │
+└───────────────────────────────┬──────────────────────────────┘
+ │
+┌───────────────────────────────▼──────────────────────────────┐
+│ RadioClientAccessor (platform-specific providers) │
+│ Android: RadioClientProvider (app/) │
+│ Desktop: DesktopRadioClientProvider (desktop/) │
+└───────────────────────────────┬──────────────────────────────┘
+ │
+┌───────────────────────────────▼──────────────────────────────┐
+│ meshtastic-sdk │
+│ RadioClient → MeshEngine → Transport (BLE/TCP/Serial) │
+└──────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## What's Done
+
+### Infrastructure ✅
+- AIDL completely removed
+- SDK composite build integrated
+- `RadioClientProvider` (Android) + `DesktopRadioClientProvider` (Desktop)
+- `SdkClientLifecycle` bridges to service layer
+- SDK `sendRaw(ToRadio)` API added for MQTT/XModem
+
+### Transport Layer ✅
+- **Fully deleted:** BleRadioTransport, TcpRadioTransport, SerialRadioTransport, StreamTransport, HeartbeatSender, StreamFrameCodec, all transport factories, BleReconnectPolicy, TcpTransport
+- `RadioInterfaceService` slimmed to device-address surface only
+- `SdkRadioInterfaceService` created (thin adapter over RadioPrefs + RadioClientAccessor)
+- `NoopRadioInterfaceService` deleted (superseded by SdkRadioInterfaceService)
+- `JvmUsbScanner` migrated to SDK's `JvmSerialPorts.list()`
+
+### Pipeline ✅
+- **Fully deleted:** CommandSender, MeshRouter, MeshActionHandler, PacketHandlerImpl, MeshDataHandlerImpl, MeshConnectionManager, MeshConfigFlowManager, ServiceBroadcasts, DirectRadioControllerImpl, broadcast Constants.kt
+- `SdkRadioController` is sole RadioController impl
+- `SdkStateBridge` bridges SDK events → repositories
+- `SdkPacketHandler` routes MeshPackets via `client.send()`, raw ToRadio via `client.sendRaw()`
+
+### POC Code ✅
+- **Deleted:** SdkConfigViewModel, SdkMessagingViewModel, SdkTelemetryViewModel, RadioClientViewModel, SdkNodeListViewModel
+- All POC diagnostic logging removed from Main.kt
+- Dead test fakes removed (app/test/Fakes.kt)
+
+### Data Layer ✅
+- Room migration 38→39: NodeMetadata persistence
+- Room migration 39→40: DROP legacy `nodes`, `my_node`, `metadata` tables
+- `SdkNodeRepositoryImpl` implements unified NodeRepository + NodeIdLookup
+- SDK storage (SqlDelight) is source of truth for node data
+- `AppMetadataRepository` provides firmware/hardware/model info
+- NodeManagerImpl deleted — logic merged into SdkNodeRepositoryImpl
+
+### Desktop ✅
+- Fully cut over to SDK via shared KMP bridge
+- `DesktopRadioClientProvider` manages TCP/Serial transport
+- No transport stubs needed — SDK handles everything
+
+### NodeManager Merge ✅
+- NodeManager interface eliminated — all methods merged into NodeRepository
+- `SdkNodeRepositoryImpl` now binds [NodeRepository, NodeIdLookup]
+- Single in-memory StateFlow — no duplicate maps
+- Metadata enrichment on every write (favorites, notes, ignore, mute)
+- `NodeManagerImpl.kt` deleted (377 LOC)
+- `NodeManager.kt` interface deleted (82 LOC)
+
+### MeshActivity Restoration ✅
+- `meshActivityFlow` added to ServiceRepository interface
+- Emit `Send` from SdkPacketHandler.sendToRadio() and SdkRadioController.sendMessage()
+- Emit `Receive` from ServiceRepositoryImpl.emitMeshPacket()
+- UIViewModel.meshActivity wired to serviceRepository.meshActivityFlow
+- Connection icon animation fully functional
+
+### Dead Code Removal ✅
+- Removed 7 dead methods from NodeManager/NodeRepository interfaces (~220 LOC)
+- Deleted `NodeInfo` data class (kept MeshUser, Position, DeviceMetrics, EnvironmentMetrics)
+- Renamed `NodeInfo.kt` → `MeshModels.kt`
+- Removed dead `nodeManager` parameter from MeshServiceOrchestrator
+
+### Error Handling ✅
+- SdkStateBridge: ServiceAction dispatch wrapped in try/catch (prevents bridge death)
+- Favorite/Ignore/Mute: local state update only applied on admin call success
+- SdkRadioController: sendMessage + sendRemoteAdmin log errors before re-throwing
+- ImportContact: guarded with runCatching
+
+### UseCases Deleted ✅
+- ProcessRadioResponse (tests only — impl kept, has real packet parsing logic)
+- AdminActions (tests only — impl kept, has real reboot/reset logic)
+- SetMeshLogSettings (tests only — impl kept)
+- CleanNodeDatabase (tests only — impl kept)
+- IsOtaCapable (tests only — impl kept)
+- EnsureRemoteAdminSession (tests only — impl kept, complex concurrency)
+
+---
+
+## What Remains (optional, quality-of-life)
+
+### 1. VM Parameter Slimming
+VMs currently inject SDK-backed adapters (RadioController, NodeRepository, etc.)
+which work correctly. Direct SDK injection would reduce params but isn't required:
+
+| VM | Current Params | Could Be |
+|----|---------------|----------|
+| RadioConfigVM | 15 | 8-10 |
+| SettingsVM | 12 | 8-10 |
+| MessageVM | 12 | 6-8 |
+| NodeListVM | 9 | 5-6 |
+| NodeDetailVM | 7 | 4-5 |
+
+### 2. Test Coverage
+- New code (`SdkRadioInterfaceService`, `SdkPacketHandler`, `MessagePersistenceHandler`)
+ has no dedicated tests yet (existing integration tests cover happy paths)
+- UseCase tests were deleted with the impls — should add back for kept impls
+
+---
+
+## What STAYS (permanent architecture)
+
+These components are NOT migration candidates:
+
+- `PacketRepository` — message persistence (SDK doesn't persist chat history)
+- `MeshLogRepository` — debug logging (app-local)
+- `QuickChatActionRepository` — quick-chat templates
+- `DeviceHardwareRepository` / `FirmwareReleaseRepository` — GitHub API
+- `NodeMetadataDao` / `AppMetadataRepository` — favorites, notes, ignore, mute
+- `MeshServiceOrchestrator` — TAK lifecycle, notifications, DB init, widget
+- `SdkStateBridge` — SDK → repository bridging, notifications, TAK dispatch
+- `MqttManager` / `HistoryManager` / `XModemManager` — real features
+- `TelemetryPacketHandler` / `NeighborInfoHandler` / `TracerouteHandler` — packet processors
+- `ContactSettings` — per-contact mute/read state
+- `SessionManager` — per-node admin session passkey management
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 45de5390d9..3736ca1b79 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -225,6 +225,17 @@ dependencies {
implementation(projects.feature.wifiProvision)
implementation(projects.feature.widget)
+ // Meshtastic SDK (composite build — sourced from ../meshtastic-sdk)
+ implementation(libs.sdk.core)
+ implementation(libs.sdk.proto)
+ implementation(libs.sdk.transport.ble)
+ implementation(libs.sdk.transport.tcp)
+ implementation(libs.sdk.transport.serial)
+ implementation(libs.sdk.storage.sqldelight)
+ // Kable Peripheral type is used directly in RadioClientProvider via BlePeripheralFactory
+ implementation(libs.kable.core)
+ testImplementation(libs.sdk.testing)
+
implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index da85fc9500..65040e2dc7 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -431,10 +431,10 @@ fun MapView(
}
}
- fun getUsername(id: String?) = if (id == DataPacket.ID_LOCAL || (myId != null && id == myId)) {
+ fun getUsername(id: Int) = if (id == DataPacket.LOCAL || id == mapViewModel.myNodeNum) {
getString(Res.string.you)
} else {
- mapViewModel.getUser(id).long_name
+ mapViewModel.getUser(DataPacket.nodeNumToId(id)).long_name
}
@Suppress("MagicNumber")
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
index eefd9df435..90ea43d40c 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -37,7 +37,7 @@ class MapViewModel(
mapPrefs: MapPrefs,
packetRepository: PacketRepository,
nodeRepository: NodeRepository,
- radioController: RadioController,
+ radioController: MessageSender,
radioConfigRepository: RadioConfigRepository,
buildConfigProvider: BuildConfigProvider,
savedStateHandle: SavedStateHandle,
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index 8a4a798a81..9a05e6d870 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -50,7 +50,7 @@ import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.model.MessageSender
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
@@ -89,7 +89,7 @@ class MapViewModel(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioConfigRepository: RadioConfigRepository,
- radioController: RadioController,
+ radioController: MessageSender,
private val customTileProviderRepository: CustomTileProviderRepository,
uiPrefs: UiPrefs,
savedStateHandle: SavedStateHandle,
@@ -671,7 +671,7 @@ class MapViewModel(
}
override fun getUser(userId: String?) =
- nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.ID_BROADCAST)
+ nodeRepository.getUser(userId ?: org.meshtastic.core.model.DataPacket.nodeNumToId(org.meshtastic.core.model.DataPacket.BROADCAST))
}
enum class LayerType {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bc5dab9d31..c3e1bab7a0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -67,11 +67,7 @@
-->
-
-
-
+
diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
index 09c867a328..e6bff8de17 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
@@ -44,6 +44,7 @@ import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.service.worker.MeshLogCleanupWorker
import org.meshtastic.feature.widget.LocalStatsWidgetReceiver
+import org.meshtastic.sdk.storage.sqldelight.AndroidContextHolder
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
@@ -63,6 +64,10 @@ open class MeshUtilApplication :
super.onCreate()
ContextServices.app = this
+ // Must be set before startKoin so SqlDelightStorageProvider (used by RadioClientProvider)
+ // can resolve applicationContext internally.
+ AndroidContextHolder.context = applicationContext
+
startKoin {
androidContext(this@MeshUtilApplication)
workManagerFactory()
diff --git a/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt
new file mode 100644
index 0000000000..620a55e3f2
--- /dev/null
+++ b/app/src/main/kotlin/org/meshtastic/app/radio/RadioClientProvider.kt
@@ -0,0 +1,153 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.radio
+
+import android.content.Context
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.koin.core.annotation.Single
+import org.meshtastic.core.data.radio.RadioClientAccessor
+import org.meshtastic.core.model.InterfaceId
+import org.meshtastic.core.repository.RadioPrefs
+import org.meshtastic.core.service.SdkClientLifecycle
+import org.meshtastic.sdk.AutoReconnectConfig
+import org.meshtastic.sdk.RadioClient
+import org.meshtastic.sdk.RadioTransport
+import org.meshtastic.sdk.storage.sqldelight.SqlDelightStorageProvider
+import org.meshtastic.sdk.transport.ble.BleTransport
+import org.meshtastic.sdk.transport.serial.AndroidSerialPorts
+import org.meshtastic.sdk.transport.tcp.TcpTransport
+
+/**
+ * Holds the active [RadioClient] and orchestrates connect/disconnect lifecycle.
+ *
+ * This is the SDK integration point for the POC. The [RadioClient] is exposed as a [StateFlow] so ViewModels and the
+ * service can react to connection changes with `flatMapLatest`.
+ */
+@Single(binds = [SdkClientLifecycle::class, RadioClientAccessor::class])
+class RadioClientProvider(
+ private val context: Context,
+ private val radioPrefs: RadioPrefs,
+) : SdkClientLifecycle, RadioClientAccessor {
+ private val _client = MutableStateFlow(null)
+
+ /** Active [RadioClient], or `null` when disconnected or between connections. */
+ override val client: StateFlow = _client.asStateFlow()
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val mutex = Mutex()
+
+ /**
+ * Tear down the existing client (if any) and build + connect a new one using the current saved radio address from
+ * [RadioPrefs].
+ *
+ * Supports BLE (`x` prefix), TCP (`t` prefix, format `tHOST:PORT`), and Serial (`s` prefix, format `sPORTNAME`).
+ */
+ suspend fun rebuildAndConnect() = mutex.withLock {
+ val rawAddress =
+ radioPrefs.devAddr.value
+ ?: run {
+ Logger.w { "RadioClientProvider: no saved device address — skipping connect" }
+ return@withLock
+ }
+
+ val interfaceChar = rawAddress.firstOrNull() ?: run {
+ Logger.w { "RadioClientProvider: empty address — skipping connect" }
+ return@withLock
+ }
+ val addressPayload = rawAddress.substring(1) // strip leading interface char
+
+ val transport: RadioTransport = when (InterfaceId.forIdChar(interfaceChar)) {
+ InterfaceId.BLUETOOTH -> {
+ Logger.i { "RadioClientProvider: building BLE transport for $addressPayload" }
+ BleTransport(addressPayload) { autoConnectIf { true } }
+ }
+
+ InterfaceId.TCP -> {
+ val (host, port) = parseTcpAddress(addressPayload)
+ Logger.i { "RadioClientProvider: building TCP transport for $host:$port" }
+ TcpTransport(host, port)
+ }
+
+ InterfaceId.SERIAL -> {
+ Logger.i { "RadioClientProvider: building Serial transport for $addressPayload" }
+ AndroidSerialPorts.init(context)
+ AndroidSerialPorts.open(addressPayload)
+ }
+
+ InterfaceId.MOCK, InterfaceId.NOP, null -> {
+ Logger.w { "RadioClientProvider: unsupported transport type '$interfaceChar' ($rawAddress)" }
+ return@withLock
+ }
+ }
+
+ // Clear first so observers see null during teardown
+ val old = _client.value
+ _client.value = null
+ old?.let { runCatching { it.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect old client" } } }
+
+ val newClient =
+ RadioClient.Builder()
+ .transport(transport)
+ .storage(SqlDelightStorageProvider(baseDir = context.filesDir.absolutePath))
+ .autoReconnect(AutoReconnectConfig()) // enabled=true; Disabled is SDK default — must opt in
+ .build()
+
+ _client.value = newClient
+ newClient.connect()
+
+ Logger.i { "RadioClientProvider: client connected via ${InterfaceId.forIdChar(interfaceChar)}" }
+ }
+
+ /** Fire-and-forget version of [rebuildAndConnect] for non-suspending call sites. */
+ override fun rebuildAndConnectAsync() {
+ scope.launch {
+ runCatching { rebuildAndConnect() }.onFailure { e -> Logger.e(e) { "RadioClientProvider: connect failed" } }
+ }
+ }
+
+ /** Disconnect and clear the active client. */
+ override fun disconnect() {
+ scope.launch {
+ mutex.withLock {
+ val c = _client.value ?: return@withLock
+ _client.value = null
+ runCatching { c.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect" } }
+ }
+ }
+ }
+
+ companion object {
+ private const val DEFAULT_TCP_PORT = 4403
+
+ /** Parse "host:port" or "host" (uses default port 4403). */
+ private fun parseTcpAddress(payload: String): Pair {
+ val parts = payload.split(":")
+ val host = parts[0]
+ val port = parts.getOrNull(1)?.toIntOrNull() ?: DEFAULT_TCP_PORT
+ return host to port
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
index 46409b14eb..767cff54df 100644
--- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
@@ -44,6 +44,7 @@ import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
+import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
import org.meshtastic.feature.node.navigation.nodesGraph
@@ -86,7 +87,7 @@ fun MainScreen() {
)
mapGraph(backStack)
channelsGraph(backStack)
- connectionsGraph(backStack)
+ connectionsGraph(backStack) { koinViewModel() }
settingsGraph(backStack)
firmwareGraph(backStack)
wifiProvisionGraph(backStack)
diff --git a/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.kt b/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.kt
new file mode 100644
index 0000000000..ced0b29219
--- /dev/null
+++ b/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProvider.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.radio
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import org.meshtastic.sdk.RadioClient
+import org.meshtastic.sdk.TransportIdentity
+import org.meshtastic.sdk.testing.FakeRadioTransport
+import org.meshtastic.sdk.testing.InMemoryStorageProvider
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Test-only RadioClient setup using FakeRadioTransport.
+ * Provides deterministic handshake and packet injection for integration tests.
+ */
+class TestRadioClientProvider(
+ val nodeNum: Int = 1,
+ coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default,
+) {
+ val transport = FakeRadioTransport(
+ identity = TransportIdentity("fake:test-radio-provider"),
+ autoHandshake = true,
+ nodeNum = nodeNum,
+ )
+
+ val client: RadioClient = RadioClient.Builder()
+ .transport(transport)
+ .storage(InMemoryStorageProvider())
+ .autoSyncTimeOnConnect(false)
+ .coroutineContext(coroutineContext)
+ .build()
+
+ suspend fun connect() {
+ client.connect()
+ }
+
+ suspend fun disconnect() {
+ client.disconnect()
+ }
+}
diff --git a/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProviderTest.kt b/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProviderTest.kt
new file mode 100644
index 0000000000..5216ec2e72
--- /dev/null
+++ b/app/src/test/kotlin/org/meshtastic/app/radio/TestRadioClientProviderTest.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.radio
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.io.bytestring.ByteString
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.FromRadio
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.NodeInfo
+import org.meshtastic.proto.PortNum
+import org.meshtastic.proto.User
+import org.meshtastic.sdk.ConnectionState
+import org.meshtastic.sdk.Frame
+import org.meshtastic.sdk.NodeChange
+import org.meshtastic.sdk.NodeId
+import org.meshtastic.sdk.TransportState
+import org.meshtastic.sdk.decodeAsNodeInfo
+import org.meshtastic.sdk.testing.FakeRadioTransport
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class TestRadioClientProviderTest {
+
+ @Test
+ fun connectInjectNodeAndDisconnect() = runTest {
+ val provider = TestRadioClientProvider(coroutineContext = backgroundScope.coroutineContext)
+
+ provider.connect()
+ assertEquals(ConnectionState.Connected, provider.client.connection.value)
+
+ val nodeInfo = NodeInfo(
+ num = 0x1234,
+ user = User(
+ id = "!00001234",
+ long_name = "Test Node",
+ short_name = "TN",
+ ),
+ )
+
+ val packetAwaiter = backgroundScope.async {
+ provider.client.packets.first { packet ->
+ packet.from == nodeInfo.num && packet.decodeAsNodeInfo()?.num == nodeInfo.num
+ }
+ }
+ runCurrent()
+
+ provider.transport.injectPacket(
+ MeshPacket(
+ from = nodeInfo.num,
+ to = provider.nodeNum,
+ decoded = Data(
+ portnum = PortNum.NODEINFO_APP,
+ payload = NodeInfo.ADAPTER.encode(nodeInfo).toByteString(),
+ ),
+ ),
+ )
+ runCurrent()
+ runCurrent()
+
+ assertEquals(nodeInfo.num, packetAwaiter.await().decodeAsNodeInfo()?.num)
+
+ val nodeAwaiter = backgroundScope.async {
+ provider.client.nodes.first { change ->
+ change is NodeChange.Added && change.node.num == nodeInfo.num
+ }
+ }
+ runCurrent()
+
+ provider.transport.injectNodeInfo(nodeInfo)
+ runCurrent()
+ runCurrent()
+
+ val added = assertIs(nodeAwaiter.await())
+ assertEquals(nodeInfo.num, added.node.num)
+ assertEquals(nodeInfo.user?.long_name, provider.client.nodeSnapshot()[NodeId(nodeInfo.num)]?.user?.long_name)
+
+ provider.disconnect()
+ assertEquals(ConnectionState.Disconnected, provider.client.connection.value)
+ assertEquals(TransportState.Disconnected, provider.transport.state.value)
+ }
+
+ private fun FakeRadioTransport.injectNodeInfo(nodeInfo: NodeInfo) {
+ val proto = FromRadio.ADAPTER.encode(FromRadio(node_info = nodeInfo))
+ val frame = ByteArray(4 + proto.size).apply {
+ this[0] = 0x94.toByte()
+ this[1] = 0xC3.toByte()
+ this[2] = (proto.size shr 8).toByte()
+ this[3] = (proto.size and 0xFF).toByte()
+ proto.copyInto(this, destinationOffset = 4)
+ }
+ injectFrame(Frame(ByteString(frame)))
+ }
+}
diff --git a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
deleted file mode 100644
index 0da77c5243..0000000000
--- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.service
-
-import dev.mokkery.MockMode
-import dev.mokkery.mock
-import org.meshtastic.core.model.Node
-import org.meshtastic.core.repository.MeshServiceNotifications
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.proto.ClientNotification
-import org.meshtastic.proto.Telemetry
-
-class Fakes {
- val service: RadioInterfaceService = mock(MockMode.autofill)
-}
-
-class FakeMeshServiceNotifications : MeshServiceNotifications {
- override fun clearNotifications() {}
-
- override fun initChannels() {}
-
- override fun updateServiceStateNotification(
- state: org.meshtastic.core.model.ConnectionState,
- telemetry: Telemetry?,
- ) {}
-
- override suspend fun updateMessageNotification(
- contactKey: String,
- name: String,
- message: String,
- isBroadcast: Boolean,
- channelName: String?,
- isSilent: Boolean,
- ) {}
-
- override suspend fun updateWaypointNotification(
- contactKey: String,
- name: String,
- message: String,
- waypointId: Int,
- isSilent: Boolean,
- ) {}
-
- override suspend fun updateReactionNotification(
- contactKey: String,
- name: String,
- emoji: String,
- isBroadcast: Boolean,
- channelName: String?,
- isSilent: Boolean,
- ) {}
-
- override fun showAlertNotification(contactKey: String, name: String, alert: String) {}
-
- override fun showNewNodeSeenNotification(node: Node) {}
-
- override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {}
-
- override fun showClientNotification(clientNotification: ClientNotification) {}
-
- override fun cancelMessageNotification(contactKey: String) {}
-
- override fun cancelLowBatteryNotification(node: Node) {}
-
- override fun clearClientNotification(notification: ClientNotification) {}
-}
diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
index 1fd4b39ce7..8bae44f757 100644
--- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
@@ -21,9 +21,12 @@ import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Test
import org.junit.runner.RunWith
+import org.meshtastic.core.model.RadioConfigStateProvider
+import org.meshtastic.core.model.ResponseState
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
@@ -49,7 +52,14 @@ class NavigationAssemblyTest {
nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow())
mapGraph(backStack)
channelsGraph(backStack)
- connectionsGraph(backStack)
+ connectionsGraph(backStack) {
+ object : RadioConfigStateProvider {
+ override val packetResponseState = MutableStateFlow>(ResponseState.Empty)
+ override val pendingRouteName = MutableStateFlow("")
+ override fun requestConfigLoad(routeName: String) {}
+ override fun clearPacketResponse() {}
+ }
+ }
settingsGraph(backStack)
firmwareGraph(backStack)
}
diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl
deleted file mode 100644
index b8a1640568..0000000000
--- a/core/api/src/main/aidl/org/meshtastic/core/model/DataPacket.aidl
+++ /dev/null
@@ -1,3 +0,0 @@
-package org.meshtastic.core.model;
-
-parcelable DataPacket;
\ No newline at end of file
diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl
deleted file mode 100644
index ba71539738..0000000000
--- a/core/api/src/main/aidl/org/meshtastic/core/model/MeshUser.aidl
+++ /dev/null
@@ -1,3 +0,0 @@
-package org.meshtastic.core.model;
-
-parcelable MeshUser;
\ No newline at end of file
diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl
deleted file mode 100644
index 1286d7c7fe..0000000000
--- a/core/api/src/main/aidl/org/meshtastic/core/model/MyNodeInfo.aidl
+++ /dev/null
@@ -1,3 +0,0 @@
-package org.meshtastic.core.model;
-
-parcelable MyNodeInfo;
\ No newline at end of file
diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl
deleted file mode 100644
index ab7c1c9261..0000000000
--- a/core/api/src/main/aidl/org/meshtastic/core/model/NodeInfo.aidl
+++ /dev/null
@@ -1,3 +0,0 @@
-package org.meshtastic.core.model;
-
-parcelable NodeInfo;
\ No newline at end of file
diff --git a/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl b/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl
deleted file mode 100644
index be49bd57a9..0000000000
--- a/core/api/src/main/aidl/org/meshtastic/core/model/Position.aidl
+++ /dev/null
@@ -1,3 +0,0 @@
-package org.meshtastic.core.model;
-
-parcelable Position;
\ No newline at end of file
diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
deleted file mode 100644
index f2307dd904..0000000000
--- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
+++ /dev/null
@@ -1,207 +0,0 @@
-package org.meshtastic.core.service;
-
-// Declare any non-default types here with import statements
-import org.meshtastic.core.model.DataPacket;
-import org.meshtastic.core.model.NodeInfo;
-import org.meshtastic.core.model.MeshUser;
-import org.meshtastic.core.model.Position;
-import org.meshtastic.core.model.MyNodeInfo;
-
-/**
-This is the public android API for talking to meshtastic radios.
-
-@deprecated The AIDL service integration is deprecated and will be removed in a future release.
- New integrations should connect via the built-in Local TAK Server on 127.0.0.1:8087 (TCP).
- Import the DataPackage from the TAK Config screen in the Meshtastic app to configure ATAK.
-
-To connect to meshtastic you should bind to it per https://developer.android.com/guide/components/bound-services
-
-The intent you use to reach the service should ideally use the action string:
-
- val intent = Intent("com.geeksville.mesh.Service")
-
-Or if using an explicit intent:
-
- val intent = Intent().apply {
- setClassName(
- "com.geeksville.mesh",
- "org.meshtastic.core.service.MeshService"
- )
- }
-
-In Android 11+ you *may* need to add the following to the client app's manifest to allow binding of the mesh service:
-
-
-
-For additional information, see https://developer.android.com/guide/topics/manifest/queries-element
-
-
-Once you have bound to the service you should register your broadcast receivers per https://developer.android.com/guide/components/broadcasts#context-registered-receivers
-
- // com.geeksville.mesh.x broadcast intents, where x is:
-
- // RECEIVED. - will **only** deliver packets for the specified port number. If a wellknown portnums.proto name for portnum is known it will be used
- // (i.e. com.geeksville.mesh.RECEIVED.TEXT_MESSAGE_APP) else the numeric portnum will be included as a base 10 integer (com.geeksville.mesh.RECEIVED.4403 etc...)
-
- // NODE_CHANGE for new IDs appearing or disappearing
- // CONNECTION_CHANGED for losing/gaining connection to the packet radio
- // MESSAGE_STATUS_CHANGED for any message status changes (for sent messages only, payload will contain a message ID and a MessageStatus)
-
-Note - these calls might throw RemoteException to indicate mesh error states
-*/
-interface IMeshService {
- /// Tell the service where to send its broadcasts of received packets
- /// This call is only required for manifest declared receivers. If your receiver is context-registered
- /// you don't need this.
- void subscribeReceiver(String packageName, String receiverName);
-
- /**
- * Set the user info for this node
- */
- void setOwner(in MeshUser user);
-
- void setRemoteOwner(in int requestId, in int destNum, in byte []payload);
- void getRemoteOwner(in int requestId, in int destNum);
-
- /// Return my unique user ID string
- String getMyId();
-
- /// Return a unique packet ID
- int getPacketId();
-
- /*
- Send a packet to a specified node name
-
- typ is defined in mesh.proto Data.Type. For now juse use 0 to mean opaque bytes.
-
- destId can be null to indicate "broadcast message"
-
- messageStatus and id of the provided message will be updated by this routine to indicate
- message send status and the ID that can be used to locate the message in the future
- */
- void send(inout DataPacket packet);
-
- /**
- Get the IDs of everyone on the mesh. You should also subscribe for NODE_CHANGE broadcasts.
- */
- List getNodes();
-
- /// This method is only intended for use in our GUI, so the user can set radio options
- /// It returns a DeviceConfig protobuf.
- byte []getConfig();
- /// It sets a Config protobuf via admin packet
- void setConfig(in byte []payload);
-
- /// Set and get a Config protobuf via admin packet
- void setRemoteConfig(in int requestId, in int destNum, in byte []payload);
- void getRemoteConfig(in int requestId, in int destNum, in int configTypeValue);
-
- /// Set and get a ModuleConfig protobuf via admin packet
- void setModuleConfig(in int requestId, in int destNum, in byte []payload);
- void getModuleConfig(in int requestId, in int destNum, in int moduleConfigTypeValue);
-
- /// Set and get the Ext Notification Ringtone string via admin packet
- void setRingtone(in int destNum, in String ringtone);
- void getRingtone(in int requestId, in int destNum);
-
- /// Set and get the Canned Message Messages string via admin packet
- void setCannedMessages(in int destNum, in String messages);
- void getCannedMessages(in int requestId, in int destNum);
-
- /// This method is only intended for use in our GUI, so the user can set radio options
- /// It sets a Channel protobuf via admin packet
- void setChannel(in byte []payload);
-
- /// Set and get a Channel protobuf via admin packet
- void setRemoteChannel(in int requestId, in int destNum, in byte []payload);
- void getRemoteChannel(in int requestId, in int destNum, in int channelIndex);
-
- /// Send beginEditSettings admin packet to nodeNum
- void beginEditSettings(in int destNum);
-
- /// Send commitEditSettings admin packet to nodeNum
- void commitEditSettings(in int destNum);
-
- /// delete a specific nodeNum from nodeDB
- void removeByNodenum(in int requestID, in int nodeNum);
-
- /// Send position packet with wantResponse to nodeNum
- void requestPosition(in int destNum, in Position position);
-
- /// Send setFixedPosition admin packet (or removeFixedPosition if Position is empty)
- void setFixedPosition(in int destNum, in Position position);
-
- /// Send traceroute packet with wantResponse to nodeNum
- void requestTraceroute(in int requestId, in int destNum);
-
- /// Send neighbor info packet with wantResponse to nodeNum
- void requestNeighborInfo(in int requestId, in int destNum);
-
- /// Send Shutdown admin packet to nodeNum
- void requestShutdown(in int requestId, in int destNum);
-
- /// Send Reboot admin packet to nodeNum
- void requestReboot(in int requestId, in int destNum);
-
- /// Send FactoryReset admin packet to nodeNum
- void requestFactoryReset(in int requestId, in int destNum);
-
- /// Send reboot to DFU admin packet
- void rebootToDfu(in int destNum);
-
- /// Send NodedbReset admin packet to nodeNum
- void requestNodedbReset(in int requestId, in int destNum, in boolean preserveFavorites);
-
- /// Returns a ChannelSet protobuf
- byte []getChannelSet();
-
- /**
- Is the packet radio currently connected to the phone? Returns a ConnectionState string.
- */
- String connectionState();
-
- /**
- * @deprecated For internal use only. External callers must not invoke this method;
- * it will be removed from the public API in a future release.
- */
- boolean setDeviceAddress(String deviceAddr);
-
- /// Get basic device hardware info about our connected radio. Will never return NULL. Will return NULL
- /// if no my node info is available (i.e. it will not throw an exception)
- MyNodeInfo getMyNodeInfo();
-
- /**
- * @deprecated No-op stub — firmware update is now handled entirely by the in-app OTA system.
- * This method will be removed from the public API in a future release.
- */
- void startFirmwareUpdate();
-
- /**
- * @deprecated Always returns {@code -4}, which is outside the documented range.
- * Firmware update progress is now tracked internally by the in-app OTA system.
- * This method will be removed from the public API in a future release.
- */
- int getUpdateStatus();
-
- /// Start providing location (from phone GPS) to mesh
- void startProvideLocation();
-
- /// Stop providing location (from phone GPS) to mesh
- void stopProvideLocation();
-
- /// Send request for node UserInfo
- void requestUserInfo(in int destNum);
-
- /// Request device connection status from the radio
- void getDeviceConnectionStatus(in int requestId, in int destNum);
-
- /// Send request for telemetry to nodeNum
- void requestTelemetry(in int requestId, in int destNum, in int type);
-
- /**
- * Tell the node to reboot into OTA mode for firmware update via BLE or WiFi (ESP32 only)
- * mode is 1 for BLE, 2 for WiFi
- * hash is the 32-byte firmware SHA256 hash (optional, can be null)
- */
- void requestRebootOta(in int requestId, in int destNum, in int mode, in byte []hash);
-}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BlePeripheralFactory.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BlePeripheralFactory.kt
new file mode 100644
index 0000000000..376100297a
--- /dev/null
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BlePeripheralFactory.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program, if not, see .
+ */
+package org.meshtastic.core.ble
+
+import com.juul.kable.Peripheral
+
+/**
+ * Build a Kable [Peripheral] for a previously saved BLE MAC address.
+ *
+ * Uses `autoConnect = true` (bonded-device path) since there is no live advertisement.
+ * Platform-specific MTU negotiation and threading strategy are applied via [platformConfig].
+ *
+ * Intended for use in [RadioClientProvider][org.meshtastic.app.radio.RadioClientProvider]
+ * when reconstructing a [BleTransport] from a persisted radio address.
+ *
+ * SDK gap F: [org.meshtastic.sdk.transport.ble.BleTransport] currently requires a caller-supplied
+ * [Peripheral] — it has no factory that accepts a MAC address string directly. This function
+ * bridges that gap on the Android side until the SDK exposes a convenience constructor.
+ */
+public fun buildPeripheralForSavedAddress(address: String): Peripheral {
+ val device = MeshtasticBleDevice(address)
+ return createPeripheral(address) {
+ platformConfig(device, autoConnect = { true })
+ }
+}
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 1927104b4e..6101977f41 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -15,6 +15,9 @@
* along with this program. If not, see .
*/
+import org.gradle.api.tasks.testing.Test
+import org.gradle.jvm.toolchain.JavaLanguageVersion
+
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.kotlin.parcelize)
@@ -34,6 +37,7 @@ kotlin {
commonMain.dependencies {
implementation(libs.kotlinx.atomicfu)
implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.sdk.core)
api(libs.kotlinx.datetime)
api(libs.okio)
api(libs.uri.kmp)
@@ -44,3 +48,11 @@ kotlin {
commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
}
}
+
+tasks.withType().configureEach {
+ javaLauncher.set(
+ javaToolchains.launcherFor {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ },
+ )
+}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt
index bdb13eac8a..85fc16cc6d 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/LocationUtils.kt
@@ -18,13 +18,8 @@
package org.meshtastic.core.common.util
-import kotlin.math.PI
-import kotlin.math.asin
-import kotlin.math.atan2
-import kotlin.math.cos
+import org.meshtastic.sdk.PositionUtils
import kotlin.math.pow
-import kotlin.math.sin
-import kotlin.math.sqrt
@Suppress("MagicNumber")
object GPSFormat {
@@ -39,30 +34,13 @@ object GPSFormat {
}
}
-private const val EARTH_RADIUS_METERS = 6371e3
-
-@Suppress("MagicNumber")
-private fun Double.toRadians(): Double = this * PI / 180.0
-
-@Suppress("MagicNumber")
-private fun Double.toDegrees(): Double = this * 180.0 / PI
-
/** @return distance in meters along the surface of the earth (ish) */
-@Suppress("MagicNumber")
-fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double {
- val lat1 = latitudeA.toRadians()
- val lon1 = longitudeA.toRadians()
- val lat2 = latitudeB.toRadians()
- val lon2 = longitudeB.toRadians()
-
- val dLat = lat2 - lat1
- val dLon = lon2 - lon1
+fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double =
+ PositionUtils.distance(latitudeA, longitudeA, latitudeB, longitudeB)
- val a = sin(dLat / 2).pow(2) + cos(lat1) * cos(lat2) * sin(dLon / 2).pow(2)
- val c = 2 * asin(sqrt(a))
-
- return EARTH_RADIUS_METERS * c
-}
+/** @return distance in kilometers along the surface of the earth (ish) */
+fun distanceKm(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double =
+ latLongToMeter(latitudeA, longitudeA, latitudeB, longitudeB) / 1000.0
/**
* Computes the bearing in degrees between two points on Earth.
@@ -73,18 +51,5 @@ fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, lon
* @param lon2 Longitude of the second point
* @return Bearing between the two points in degrees. A value of 0 means due north.
*/
-@Suppress("MagicNumber")
-fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
- val lat1Rad = lat1.toRadians()
- val lon1Rad = lon1.toRadians()
- val lat2Rad = lat2.toRadians()
- val lon2Rad = lon2.toRadians()
-
- val dLon = lon2Rad - lon1Rad
-
- val y = sin(dLon) * cos(lat2Rad)
- val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon)
- val bearing = atan2(y, x).toDegrees()
-
- return (bearing + 360) % 360
-}
+fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double =
+ PositionUtils.bearing(lat1, lon1, lat2, lon2)
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt
index db59a52d40..9f0d17dd27 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/LocationUtilsTest.kt
@@ -39,6 +39,12 @@ class LocationUtilsTest {
assertTrue(distance2 > 78000 && distance2 < 79000, "Distance was $distance2")
}
+ @Test
+ fun testDistanceKm() {
+ val distance = distanceKm(0.0, 0.0, 0.0, 1.0)
+ assertTrue(distance > 111 && distance < 112, "Distance was $distance")
+ }
+
@Test
fun testBearing() {
// North
diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts
index d167e1ffef..4dbd51f07a 100644
--- a/core/data/build.gradle.kts
+++ b/core/data/build.gradle.kts
@@ -44,6 +44,9 @@ kotlin {
implementation(projects.core.proto)
implementation(projects.core.takserver)
+ // Meshtastic SDK — shared RadioController and StateBridge implementations
+ api(libs.sdk.core)
+
implementation(libs.jetbrains.lifecycle.runtime)
implementation(libs.androidx.paging.common)
implementation(libs.kotlinx.serialization.json)
@@ -69,6 +72,7 @@ kotlin {
commonTest.dependencies {
implementation(projects.core.testing)
implementation(libs.kotlinx.coroutines.test)
+ implementation(libs.sdk.testing)
}
}
}
diff --git a/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt
new file mode 100644
index 0000000000..adf943844b
--- /dev/null
+++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/AppMetadataRepositoryImplTest.kt
@@ -0,0 +1,208 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.repository
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.repository.NodeMetadata
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import org.meshtastic.core.testing.setupTestContext
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+@OptIn(ExperimentalCoroutinesApi::class)
+class AppMetadataRepositoryImplTest {
+
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var repository: AppMetadataRepositoryImpl
+
+ @BeforeTest
+ fun setUp() {
+ setupTestContext()
+ dbProvider = FakeDatabaseProvider()
+ repository = AppMetadataRepositoryImpl(dbProvider)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ dbProvider.close()
+ }
+
+ @Test
+ fun `metadataByNum starts empty`() = runTest {
+ assertTrue(repository.metadataByNum.first().isEmpty())
+ }
+
+ @Test
+ fun `setFavorite creates missing metadata row`() = runTest {
+ repository.setFavorite(nodeNum = 101, isFavorite = true)
+ advanceUntilIdle()
+
+ assertEquals(
+ NodeMetadata(num = 101, isFavorite = true, notes = ""),
+ repository.metadataByNum.first().getValue(101),
+ )
+ }
+
+ @Test
+ fun `setIgnored creates missing metadata row`() = runTest {
+ repository.setIgnored(nodeNum = 102, isIgnored = true)
+ advanceUntilIdle()
+
+ assertEquals(
+ NodeMetadata(num = 102, isIgnored = true, notes = ""),
+ repository.metadataByNum.first().getValue(102),
+ )
+ }
+
+ @Test
+ fun `setMuted and setNotes preserve existing flags`() = runTest {
+ repository.setFavorite(nodeNum = 103, isFavorite = true)
+ repository.setMuted(nodeNum = 103, isMuted = true)
+ repository.setNotes(nodeNum = 103, notes = "Portable node")
+ advanceUntilIdle()
+
+ assertEquals(
+ NodeMetadata(num = 103, isFavorite = true, isMuted = true, notes = "Portable node"),
+ repository.metadataByNum.first().getValue(103),
+ )
+ }
+
+ @Test
+ fun `setManuallyVerified updates verification flag`() = runTest {
+ repository.setManuallyVerified(nodeNum = 104, verified = true)
+ advanceUntilIdle()
+
+ assertEquals(
+ NodeMetadata(num = 104, manuallyVerified = true, notes = ""),
+ repository.metadataByNum.first().getValue(104),
+ )
+ }
+
+ @Test
+ fun `repeated updates keep a single metadata entry per node`() = runTest {
+ repository.setFavorite(nodeNum = 105, isFavorite = true)
+ repository.setFavorite(nodeNum = 105, isFavorite = false)
+ repository.setNotes(nodeNum = 105, notes = "Updated")
+ advanceUntilIdle()
+
+ val metadata = repository.metadataByNum.first()
+ assertEquals(1, metadata.size)
+ assertEquals(NodeMetadata(num = 105, notes = "Updated"), metadata.getValue(105))
+ }
+
+ @Test
+ fun `delete removes existing metadata`() = runTest {
+ repository.setFavorite(nodeNum = 106, isFavorite = true)
+ advanceUntilIdle()
+
+ repository.delete(106)
+ advanceUntilIdle()
+
+ assertTrue(repository.metadataByNum.first().isEmpty())
+ }
+
+ @Test
+ fun `delete missing metadata is a no-op`() = runTest {
+ repository.delete(999)
+ advanceUntilIdle()
+
+ assertTrue(repository.metadataByNum.first().isEmpty())
+ }
+
+ @Test
+ fun `metadataByNum flow reflects create update and delete changes`() = runTest {
+ val created = backgroundScope.async(UnconfinedTestDispatcher(testScheduler)) {
+ repository.metadataByNum.drop(1).first { it[107]?.isFavorite == true }
+ }
+
+ repository.setFavorite(nodeNum = 107, isFavorite = true)
+ advanceUntilIdle()
+ assertEquals(NodeMetadata(num = 107, isFavorite = true, notes = ""), created.await().getValue(107))
+
+ val updated = backgroundScope.async(UnconfinedTestDispatcher(testScheduler)) {
+ repository.metadataByNum.drop(1).first { it[107]?.notes == "Flow note" }
+ }
+
+ repository.setNotes(nodeNum = 107, notes = "Flow note")
+ advanceUntilIdle()
+ assertEquals("Flow note", updated.await().getValue(107).notes)
+
+ val deleted = backgroundScope.async(UnconfinedTestDispatcher(testScheduler)) {
+ repository.metadataByNum.drop(1).first { it.isEmpty() }
+ }
+
+ repository.delete(107)
+ advanceUntilIdle()
+ assertTrue(deleted.await().isEmpty())
+ }
+
+ @Test
+ fun `concurrent updates on same missing node produce one merged row`() = runTest {
+ coroutineScope {
+ launch { repository.setFavorite(nodeNum = 108, isFavorite = true) }
+ launch { repository.setIgnored(nodeNum = 108, isIgnored = true) }
+ launch { repository.setMuted(nodeNum = 108, isMuted = true) }
+ launch { repository.setNotes(nodeNum = 108, notes = "Concurrent") }
+ launch { repository.setManuallyVerified(nodeNum = 108, verified = true) }
+ }
+ advanceUntilIdle()
+
+ val metadata = repository.metadataByNum.first()
+ assertEquals(1, metadata.size)
+ assertEquals(
+ NodeMetadata(
+ num = 108,
+ isFavorite = true,
+ isIgnored = true,
+ isMuted = true,
+ notes = "Concurrent",
+ manuallyVerified = true,
+ ),
+ metadata.getValue(108),
+ )
+ }
+
+ @Test
+ fun `updates for multiple nodes stay isolated`() = runTest {
+ repository.setFavorite(nodeNum = 201, isFavorite = true)
+ repository.setIgnored(nodeNum = 202, isIgnored = true)
+ repository.setNotes(nodeNum = 203, notes = "Third")
+ advanceUntilIdle()
+
+ val metadata = repository.metadataByNum.first()
+ assertEquals(3, metadata.size)
+ assertEquals(NodeMetadata(num = 201, isFavorite = true, notes = ""), metadata.getValue(201))
+ assertEquals(NodeMetadata(num = 202, isIgnored = true, notes = ""), metadata.getValue(202))
+ assertEquals(NodeMetadata(num = 203, notes = "Third"), metadata.getValue(203))
+ }
+}
diff --git a/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt
new file mode 100644
index 0000000000..519026dbd7
--- /dev/null
+++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImplTest.kt
@@ -0,0 +1,308 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.repository
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
+import dev.mokkery.answering.returns
+import dev.mokkery.answering.throws
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.mock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
+import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
+import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
+import org.meshtastic.core.database.entity.asEntity
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.BootloaderOtaQuirk
+import org.meshtastic.core.model.NetworkDeviceHardware
+import org.meshtastic.core.model.util.TimeConstants
+import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
+import org.meshtastic.core.network.service.ApiService
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import org.meshtastic.core.testing.setupTestContext
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+@OptIn(ExperimentalCoroutinesApi::class)
+class DeviceHardwareRepositoryImplTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
+
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var apiService: ApiService
+ private lateinit var jsonDataSource: DeviceHardwareJsonDataSource
+ private lateinit var quirksJsonDataSource: BootloaderOtaQuirksJsonDataSource
+ private lateinit var repository: DeviceHardwareRepositoryImpl
+
+ private var remoteCallCount = 0
+ private var jsonCallCount = 0
+
+ @BeforeTest
+ fun setUp() {
+ setupTestContext()
+ dbProvider = FakeDatabaseProvider()
+ apiService = mock(MockMode.autofill)
+ jsonDataSource = mock(MockMode.autofill)
+ quirksJsonDataSource = mock(MockMode.autofill)
+
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ emptyList()
+ }
+ every { jsonDataSource.loadDeviceHardwareFromJsonAsset() } calls {
+ jsonCallCount += 1
+ emptyList()
+ }
+ every { quirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns emptyList()
+
+ repository = DeviceHardwareRepositoryImpl(
+ remoteDataSource = DeviceHardwareRemoteDataSource(apiService, dispatchers),
+ localDataSource = DeviceHardwareLocalDataSource(dbProvider, dispatchers),
+ jsonDataSource = jsonDataSource,
+ bootloaderOtaQuirksJsonDataSource = quirksJsonDataSource,
+ dispatchers = dispatchers,
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ dbProvider.close()
+ }
+
+ @Test
+ fun `returns fresh cached hardware without hitting remote sources`() = runTest(testDispatcher) {
+ cacheHardware(hardware(hwModel = 1, target = "t-echo", displayName = "Cached"))
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 1)
+
+ assertEquals("Cached", result.getOrNull()?.displayName)
+ assertEquals(0, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `disambiguates cached variants by target ignoring case and preserves reported target`() = runTest(testDispatcher) {
+ cacheHardware(
+ hardware(hwModel = 7, target = "t-beam", displayName = "Beam"),
+ hardware(hwModel = 7, target = "t-deck", displayName = "Deck"),
+ )
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 7, target = "T-DECK")
+ val device = result.getOrNull()
+
+ assertNotNull(device)
+ assertEquals("Deck", device.displayName)
+ assertEquals("T-DECK", device.platformioTarget)
+ assertEquals(0, remoteCallCount)
+ }
+
+ @Test
+ fun `falls back to cached target lookup when model cache is empty`() = runTest(testDispatcher) {
+ cacheHardware(hardware(hwModel = 42, target = "target-only", displayName = "Target Match"))
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 999, target = "target-only")
+ val device = result.getOrNull()
+
+ assertNotNull(device)
+ assertEquals(42, device.hwModel)
+ assertEquals("Target Match", device.displayName)
+ }
+
+ @Test
+ fun `force refresh clears cache and replaces it with remote data`() = runTest(testDispatcher) {
+ cacheHardware(hardware(hwModel = 5, target = "old-target", displayName = "Old Cache"))
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ listOf(hardware(hwModel = 5, target = "new-target", displayName = "Remote Fresh"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 5, forceRefresh = true)
+ val cachedAfterRefresh = dbProvider.currentDb.value.deviceHardwareDao().getByHwModel(5)
+
+ assertEquals("Remote Fresh", result.getOrNull()?.displayName)
+ assertEquals(listOf("new-target"), cachedAfterRefresh.map { it.platformioTarget })
+ assertEquals(1, remoteCallCount)
+ }
+
+ @Test
+ fun `stale cache refreshes from remote and returns updated hardware`() = runTest(testDispatcher) {
+ cacheHardware(
+ hardware(hwModel = 9, target = "stale", displayName = "Stale Cache"),
+ lastUpdated = nowMillis - TimeConstants.ONE_DAY.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ listOf(hardware(hwModel = 9, target = "fresh", displayName = "Fresh Remote"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 9)
+
+ assertEquals("Fresh Remote", result.getOrNull()?.displayName)
+ assertEquals(1, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `returns stale cache when remote fails and stale data is still complete`() = runTest(testDispatcher) {
+ cacheHardware(
+ hardware(hwModel = 10, target = "complete", displayName = "Stale Complete"),
+ lastUpdated = nowMillis - TimeConstants.ONE_DAY.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 10)
+
+ assertEquals("Stale Complete", result.getOrNull()?.displayName)
+ assertTrue(result.isSuccess)
+ assertEquals(1, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `falls back to bundled json when stale cache is incomplete`() = runTest(testDispatcher) {
+ cacheHardware(
+ hardware(hwModel = 11, target = "broken", displayName = "", images = emptyList()),
+ lastUpdated = nowMillis - TimeConstants.ONE_DAY.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+ every { jsonDataSource.loadDeviceHardwareFromJsonAsset() } calls {
+ jsonCallCount += 1
+ listOf(hardware(hwModel = 11, target = "json-target", displayName = "Bundled Json"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 11)
+
+ assertEquals("Bundled Json", result.getOrNull()?.displayName)
+ assertEquals(1, remoteCallCount)
+ assertEquals(1, jsonCallCount)
+ }
+
+ @Test
+ fun `applies bootloader quirks to cached hardware`() = runTest(testDispatcher) {
+ cacheHardware(hardware(hwModel = 12, target = "quirky", displayName = "Quirky"))
+ every { quirksJsonDataSource.loadBootloaderOtaQuirksFromJsonAsset() } returns listOf(
+ BootloaderOtaQuirk(
+ hwModel = 12,
+ requiresBootloaderUpgradeForOta = true,
+ infoUrl = "https://example.invalid/bootloader",
+ ),
+ )
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 12)
+ val device = result.getOrNull()
+
+ assertNotNull(device)
+ assertTrue(device.requiresBootloaderUpgradeForOta == true)
+ assertEquals("https://example.invalid/bootloader", device.bootloaderInfoUrl)
+ }
+
+ @Test
+ fun `returns success null when remote data does not contain requested model`() = runTest(testDispatcher) {
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ listOf(hardware(hwModel = 99, target = "other", displayName = "Other"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 13)
+
+ assertTrue(result.isSuccess)
+ assertNull(result.getOrNull())
+ assertEquals(1, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `uses target lookup after remote fetch when requested model is absent`() = runTest(testDispatcher) {
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ listOf(hardware(hwModel = 77, target = "shared-target", displayName = "Remote Target Match"))
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 14, target = "shared-target")
+ val device = result.getOrNull()
+
+ assertNotNull(device)
+ assertEquals(77, device.hwModel)
+ assertEquals("shared-target", device.platformioTarget)
+ assertEquals(1, remoteCallCount)
+ }
+
+ @Test
+ fun `returns failure when both remote and bundled json sources fail`() = runTest(testDispatcher) {
+ everySuspend { apiService.getDeviceHardware() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+ every { jsonDataSource.loadDeviceHardwareFromJsonAsset() } calls {
+ jsonCallCount += 1
+ throw IllegalArgumentException("missing asset")
+ }
+
+ val result = repository.getDeviceHardwareByModel(hwModel = 15)
+
+ assertTrue(result.isFailure)
+ assertIs(result.exceptionOrNull())
+ assertEquals(1, remoteCallCount)
+ assertEquals(1, jsonCallCount)
+ }
+
+ private suspend fun cacheHardware(vararg hardware: NetworkDeviceHardware, lastUpdated: Long = nowMillis) {
+ dbProvider.currentDb.value.deviceHardwareDao().insertAll(hardware.map { it.asEntity().copy(lastUpdated = lastUpdated) })
+ }
+
+ private fun hardware(
+ hwModel: Int,
+ target: String,
+ displayName: String,
+ images: List? = listOf("$target.png"),
+ ) = NetworkDeviceHardware(
+ activelySupported = true,
+ architecture = "esp32s3",
+ displayName = displayName,
+ hwModel = hwModel,
+ hwModelSlug = "hw-$hwModel",
+ images = images,
+ platformioTarget = target,
+ requiresDfu = false,
+ supportLevel = 3,
+ tags = listOf("portable"),
+ )
+}
diff --git a/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
new file mode 100644
index 0000000000..74b21dc7d7
--- /dev/null
+++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImplTest.kt
@@ -0,0 +1,297 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.data.repository
+
+import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.mock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.runner.RunWith
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
+import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
+import org.meshtastic.core.database.entity.FirmwareRelease
+import org.meshtastic.core.database.entity.FirmwareReleaseType
+import org.meshtastic.core.database.entity.asEntity
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.NetworkFirmwareRelease
+import org.meshtastic.core.model.NetworkFirmwareReleases
+import org.meshtastic.core.model.Releases
+import org.meshtastic.core.model.util.TimeConstants
+import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource
+import org.meshtastic.core.network.service.ApiService
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import org.meshtastic.core.testing.setupTestContext
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+@OptIn(ExperimentalCoroutinesApi::class)
+class FirmwareReleaseRepositoryImplTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
+
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var apiService: ApiService
+ private lateinit var jsonDataSource: FirmwareReleaseJsonDataSource
+ private lateinit var repository: FirmwareReleaseRepositoryImpl
+
+ private var remoteCallCount = 0
+ private var jsonCallCount = 0
+
+ @BeforeTest
+ fun setUp() {
+ setupTestContext()
+ dbProvider = FakeDatabaseProvider()
+ apiService = mock(MockMode.autofill)
+ jsonDataSource = mock(MockMode.autofill)
+
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ NetworkFirmwareReleases()
+ }
+ every { jsonDataSource.loadFirmwareReleaseFromJsonAsset() } calls {
+ jsonCallCount += 1
+ NetworkFirmwareReleases()
+ }
+
+ repository = FirmwareReleaseRepositoryImpl(
+ remoteDataSource = FirmwareReleaseRemoteDataSource(apiService, dispatchers),
+ localDataSource = FirmwareReleaseLocalDataSource(dbProvider, dispatchers),
+ jsonDataSource = jsonDataSource,
+ )
+ }
+
+ @AfterTest
+ fun tearDown() {
+ dbProvider.close()
+ }
+
+ @Test
+ fun `empty cache emits null then latest stable from remote`() = runTest(testDispatcher) {
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(
+ stable = listOf(release("v2.9.0.abc"), release("v2.10.0.abc")),
+ alpha = listOf(release("v2.11.0.alpha.1")),
+ )
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, null, "v2.10.0.abc")
+ assertReleaseTypes(emissions, null, FirmwareReleaseType.STABLE)
+ assertEquals(1, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `fresh stable cache emits once and skips remote refresh`() = runTest(testDispatcher) {
+ cacheRelease(release("v2.8.0.abc"), FirmwareReleaseType.STABLE)
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.8.0.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE)
+ assertEquals(0, remoteCallCount)
+ assertEquals(0, jsonCallCount)
+ }
+
+ @Test
+ fun `stale stable cache emits stale value then refreshed remote value`() = runTest(testDispatcher) {
+ cacheRelease(
+ release("v2.7.0.abc"),
+ FirmwareReleaseType.STABLE,
+ lastUpdated = nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(stable = listOf(release("v2.10.1.abc")))
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.7.0.abc", "v2.10.1.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE, FirmwareReleaseType.STABLE)
+ }
+
+ @Test
+ fun `stale cache falls back to bundled json when remote fetch fails`() = runTest(testDispatcher) {
+ cacheRelease(
+ release("v2.6.0.abc"),
+ FirmwareReleaseType.STABLE,
+ lastUpdated = nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+ every { jsonDataSource.loadFirmwareReleaseFromJsonAsset() } calls {
+ jsonCallCount += 1
+ releases(stable = listOf(release("v2.11.0.abc")))
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.6.0.abc", "v2.11.0.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE, FirmwareReleaseType.STABLE)
+ assertEquals(1, remoteCallCount)
+ assertEquals(1, jsonCallCount)
+ }
+
+ @Test
+ fun `stale cache is re-emitted when both remote and json refreshes fail`() = runTest(testDispatcher) {
+ cacheRelease(
+ release("v2.5.0.abc"),
+ FirmwareReleaseType.STABLE,
+ lastUpdated = nowMillis - TimeConstants.ONE_HOUR.inWholeMilliseconds - 1,
+ )
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ throw IllegalStateException("network down")
+ }
+ every { jsonDataSource.loadFirmwareReleaseFromJsonAsset() } calls {
+ jsonCallCount += 1
+ throw IllegalArgumentException("missing asset")
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.5.0.abc", "v2.5.0.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE, FirmwareReleaseType.STABLE)
+ }
+
+ @Test
+ fun `alpha release emits the newest alpha version only`() = runTest(testDispatcher) {
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(
+ stable = listOf(release("v2.9.0.abc")),
+ alpha = listOf(release("v2.11.0.alpha.1"), release("v2.12.0.alpha.1")),
+ )
+ }
+
+ val emissions = repository.alphaRelease.toList()
+
+ assertReleaseIds(emissions, null, "v2.12.0.alpha.1")
+ assertReleaseTypes(emissions, null, FirmwareReleaseType.ALPHA)
+ }
+
+ @Test
+ fun `stable collection warms alpha cache for subsequent alpha collectors`() = runTest(testDispatcher) {
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(
+ stable = listOf(release("v2.9.9.abc")),
+ alpha = listOf(release("v2.12.0.alpha.2")),
+ )
+ }
+
+ val stableEmissions = repository.stableRelease.toList()
+ val alphaEmissions = repository.alphaRelease.toList()
+
+ assertReleaseIds(stableEmissions, null, "v2.9.9.abc")
+ assertReleaseTypes(stableEmissions, null, FirmwareReleaseType.STABLE)
+ assertReleaseIds(alphaEmissions, "v2.12.0.alpha.2")
+ assertReleaseTypes(alphaEmissions, FirmwareReleaseType.ALPHA)
+ assertEquals(1, remoteCallCount)
+ }
+
+ @Test
+ fun `invalidateCache clears database and next collection refetches`() = runTest(testDispatcher) {
+ cacheRelease(release("v2.8.0.abc"), FirmwareReleaseType.STABLE)
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ releases(stable = listOf(release("v2.10.2.abc")))
+ }
+
+ repository.invalidateCache()
+ val emissions = repository.stableRelease.toList()
+
+ assertTrue(dbProvider.currentDb.value.firmwareReleaseDao().getAllReleases().isNotEmpty())
+ assertReleaseIds(emissions, null, "v2.10.2.abc")
+ assertReleaseTypes(emissions, null, FirmwareReleaseType.STABLE)
+ assertEquals(1, remoteCallCount)
+ }
+
+ @Test
+ fun `selects highest semantic version from fresh cached releases`() = runTest(testDispatcher) {
+ cacheRelease(release("v2.9.9.abc"), FirmwareReleaseType.STABLE)
+ cacheRelease(release("v2.10.0.abc"), FirmwareReleaseType.STABLE)
+
+ val emissions = repository.stableRelease.toList()
+
+ assertReleaseIds(emissions, "v2.10.0.abc")
+ assertReleaseTypes(emissions, FirmwareReleaseType.STABLE)
+ assertEquals(0, remoteCallCount)
+ }
+
+ @Test
+ fun `empty remote release list emits null twice`() = runTest(testDispatcher) {
+ everySuspend { apiService.getFirmwareReleases() } calls {
+ remoteCallCount += 1
+ NetworkFirmwareReleases()
+ }
+
+ val emissions = repository.stableRelease.toList()
+
+ assertEquals(listOf(null, null), emissions)
+ assertEquals(1, remoteCallCount)
+ }
+
+ private suspend fun cacheRelease(
+ release: NetworkFirmwareRelease,
+ type: FirmwareReleaseType,
+ lastUpdated: Long = nowMillis,
+ ) {
+ dbProvider.currentDb.value.firmwareReleaseDao().insert(release.asEntity(type).copy(lastUpdated = lastUpdated))
+ }
+
+ private fun release(id: String) = NetworkFirmwareRelease(
+ id = id,
+ pageUrl = "https://example.invalid/$id",
+ releaseNotes = "notes for $id",
+ title = id,
+ zipUrl = "https://example.invalid/$id.zip",
+ )
+
+ private fun releases(
+ stable: List = emptyList(),
+ alpha: List = emptyList(),
+ ) = NetworkFirmwareReleases(releases = Releases(alpha = alpha, stable = stable))
+
+ private fun assertReleaseIds(emissions: List, vararg expected: String?) {
+ assertEquals(expected.toList(), emissions.map { it?.id })
+ }
+
+ private fun assertReleaseTypes(emissions: List, vararg expected: FirmwareReleaseType?) {
+ assertEquals(expected.toList(), emissions.map { it?.releaseType })
+ }
+}
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt
similarity index 65%
rename from core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt
rename to core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt
index d768ba0091..b4d0603eb6 100644
--- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt
+++ b/core/data/src/androidHostTest/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImplTest.kt
@@ -14,14 +14,19 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.domain.usecase.settings
+package org.meshtastic.core.data.repository
-import org.koin.core.annotation.Single
-import org.meshtastic.core.repository.UiPrefs
+import kotlin.test.BeforeTest
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
-@Single
-open class SetProvideLocationUseCase constructor(private val uiPrefs: UiPrefs) {
- operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
- uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [34])
+class PacketRepositoryImplTest : CommonPacketRepositoryTest() {
+
+ @BeforeTest
+ fun setUp() {
+ setupRepo()
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
index 58673c8798..e4048cdde3 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/BootloaderOtaQuirksJsonDataSource.kt
@@ -18,6 +18,10 @@ package org.meshtastic.core.data.datasource
import org.meshtastic.core.model.BootloaderOtaQuirk
+/**
+ * Loads bundled bootloader OTA quirk metadata for hardware-specific update handling.
+ */
interface BootloaderOtaQuirksJsonDataSource {
+ /** Returns bootloader OTA quirks parsed from the packaged JSON asset. */
fun loadBootloaderOtaQuirksFromJsonAsset(): List
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt
index 6c53b33670..864a7d59c2 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareJsonDataSource.kt
@@ -18,6 +18,10 @@ package org.meshtastic.core.data.datasource
import org.meshtastic.core.model.NetworkDeviceHardware
+/**
+ * Loads bundled device hardware metadata used when cached or remote data is unavailable.
+ */
interface DeviceHardwareJsonDataSource {
+ /** Returns device hardware entries parsed from the packaged JSON asset. */
fun loadDeviceHardwareFromJsonAsset(): List
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt
index 043d165e15..7612a41ca2 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseJsonDataSource.kt
@@ -18,6 +18,10 @@ package org.meshtastic.core.data.datasource
import org.meshtastic.core.model.NetworkFirmwareReleases
+/**
+ * Loads bundled firmware release metadata used as a local fallback source.
+ */
interface FirmwareReleaseJsonDataSource {
+ /** Returns firmware release metadata parsed from the packaged JSON asset. */
fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt
deleted file mode 100644
index c9438ebbf6..0000000000
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.core.data.datasource
-
-import kotlinx.coroutines.flow.Flow
-import org.meshtastic.core.database.entity.MyNodeEntity
-import org.meshtastic.core.database.entity.NodeEntity
-import org.meshtastic.core.database.entity.NodeWithRelations
-
-interface NodeInfoReadDataSource {
- fun myNodeInfoFlow(): Flow
-
- fun nodeDBbyNumFlow(): Flow