refactor: BLE transport and UI for Kotlin Multiplatform unification (#4911)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-24 21:15:51 -05:00
committed by GitHub
parent b0e91a390c
commit 6516287c62
42 changed files with 429 additions and 845 deletions

View File

@@ -39,7 +39,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
@@ -75,7 +75,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`.
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.

View File

@@ -60,6 +60,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -67,7 +67,6 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
@@ -78,8 +77,7 @@ import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.must_update
import org.meshtastic.core.ui.component.MeshtasticCommonAppSetup
import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
@@ -111,13 +109,6 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
MeshtasticCommonAppSetup(
uiViewModel = uIViewModel,
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
},
)
AndroidAppVersionCheck(uIViewModel)
val navSuiteType =
NavigationSuiteScaffoldDefaults.navigationSuiteType(
@@ -129,118 +120,127 @@ fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerVie
// State for determining the connection type icon to display
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
NavigationSuiteScaffold(
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
TopLevelDestination.entries.forEach { destination ->
val isSelected = destination == topLevelDestination
val isConnectionsRoute = destination == TopLevelDestination.Connections
item(
icon = {
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep ->
stringResource(Res.string.device_sleeping)
ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
},
)
}
},
state = rememberTooltipState(),
) {
if (isConnectionsRoute) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice),
meshActivityFlow = uIViewModel.meshActivity,
colorScheme = colorScheme,
)
} else {
BadgedBox(
badge = {
if (destination == TopLevelDestination.Conversations) {
// Keep track of the last non-zero count for display during exit animation
var lastNonZeroCount by remember { mutableIntStateOf(unreadMessageCount) }
if (unreadMessageCount > 0) {
lastNonZeroCount = unreadMessageCount
}
AnimatedVisibility(
visible = unreadMessageCount > 0,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
) {
Badge { Text(lastNonZeroCount.toString()) }
}
}
},
) {
Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
tint =
if (isSelectedState) colorScheme.primary else LocalContentColor.current,
MeshtasticAppShell(
backStack = backStack,
uiViewModel = uIViewModel,
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
) {
NavigationSuiteScaffold(
modifier = Modifier.fillMaxSize(),
navigationSuiteItems = {
TopLevelDestination.entries.forEach { destination ->
val isSelected = destination == topLevelDestination
val isConnectionsRoute = destination == TopLevelDestination.Connections
item(
icon = {
TooltipBox(
positionProvider =
TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
tooltip = {
PlainTooltip {
Text(
if (isConnectionsRoute) {
when (connectionState) {
ConnectionState.Connected -> stringResource(Res.string.connected)
ConnectionState.Connecting -> stringResource(Res.string.connecting)
ConnectionState.DeviceSleep ->
stringResource(Res.string.device_sleeping)
ConnectionState.Disconnected ->
stringResource(Res.string.disconnected)
}
} else {
stringResource(destination.label)
},
)
}
},
state = rememberTooltipState(),
) {
if (isConnectionsRoute) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice),
meshActivityFlow = uIViewModel.meshActivity,
colorScheme = colorScheme,
)
} else {
BadgedBox(
badge = {
if (destination == TopLevelDestination.Conversations) {
// Keep track of the last non-zero count for display during exit
// animation
var lastNonZeroCount by remember {
mutableIntStateOf(unreadMessageCount)
}
if (unreadMessageCount > 0) {
lastNonZeroCount = unreadMessageCount
}
AnimatedVisibility(
visible = unreadMessageCount > 0,
enter = scaleIn() + fadeIn(),
exit = scaleOut() + fadeOut(),
) {
Badge { Text(lastNonZeroCount.toString()) }
}
}
},
) {
Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
tint =
if (isSelectedState) {
colorScheme.primary
} else {
LocalContentColor.current
},
)
}
}
}
}
}
},
selected = isSelected,
label = {
Text(
text = stringResource(destination.label),
modifier =
if (navSuiteType == NavigationSuiteType.ShortNavigationBarCompact) {
Modifier.width(1.dp)
.height(1.dp) // hide on phone - min 1x1 or talkback won't see it.
},
selected = isSelected,
label = {
Text(
text = stringResource(destination.label),
modifier =
if (navSuiteType == NavigationSuiteType.ShortNavigationBarCompact) {
Modifier.width(1.dp)
.height(1.dp) // hide on phone - min 1x1 or talkback won't see it.
} else {
Modifier
},
)
},
onClick = {
val isRepress = destination == topLevelDestination
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
else -> Unit
}
} else {
Modifier
},
)
},
onClick = {
val isRepress = destination == topLevelDestination
if (isRepress) {
when (destination) {
TopLevelDestination.Nodes -> {
val onNodesList = currentKey is NodesRoutes.Nodes
if (!onNodesList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
}
TopLevelDestination.Conversations -> {
val onConversationsList = currentKey is ContactsRoutes.Contacts
if (!onConversationsList) {
backStack.navigateTopLevel(destination.route)
}
uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
}
else -> Unit
backStack.navigateTopLevel(destination.route)
}
} else {
backStack.navigateTopLevel(destination.route)
}
},
)
}
},
) {
MeshtasticSnackbarProvider(
snackbarManager = uIViewModel.snackbarManager,
hostModifier = Modifier.safeDrawingPadding().padding(bottom = 16.dp),
},
)
}
},
) {
val provider =
entryProvider<NavKey> {

View File

@@ -47,7 +47,7 @@ internal enum class PluginType(val id: String, val ref: String, val style: Strin
style = "fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000",
),
ComposeDesktopApplication(
id = "org.jetbrains.compose",
id = "?desktop",
ref = "compose-desktop-application",
style = "fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000",
),
@@ -81,6 +81,11 @@ internal enum class PluginType(val id: String, val ref: String, val style: Strin
ref = "kmp-feature",
style = "fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000",
),
KmpLibraryCompose(
id = "meshtastic.kmp.library.compose",
ref = "kmp-library-compose",
style = "fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000",
),
KmpLibrary(
id = "meshtastic.kmp.library",
ref = "kmp-library",
@@ -200,6 +205,7 @@ private abstract class GraphDumpTask : DefaultTask() {
appendLine(" L1[Application]:::android-application")
appendLine(" L2[Library]:::android-library")
appendLine(" L3[Feature]:::android-feature")
appendLine(" L4[KMP Library]:::kmp-library")
appendLine(" end")
PluginType.entries.forEach { appendLine("classDef ${it.ref} ${it.style};") }
}

View File

@@ -61,6 +61,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -42,7 +42,7 @@ scanner.startScan()
<!--region graph-->
```mermaid
graph TB
:core:barcode[barcode]:::compose-desktop-application
:core:barcode[barcode]:::android-library
:core:barcode -.-> :core:resources
:core:barcode -.-> :core:ui
@@ -55,6 +55,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -16,6 +16,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -33,6 +33,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -29,6 +29,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -36,6 +36,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -29,6 +29,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -30,6 +30,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -42,6 +42,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -26,7 +26,7 @@ navController.navigate(MessagingRoutes.Chat(nodeId = 12345))
<!--region graph-->
```mermaid
graph TB
:core:navigation[navigation]:::compose-desktop-application
:core:navigation[navigation]:::kmp-library-compose
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
@@ -37,6 +37,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -1,7 +1,7 @@
# `:core:network`
## Overview
The `:core:network` module handles all internet-based communication, including fetching firmware metadata, device hardware definitions, and map tiles (in the `fdroid` flavor).
The `:core:network` module handles all internet-based communication, including fetching firmware metadata, device hardware definitions, and map tiles (in the `fdroid` flavor). It also provides the shared radio transport layer (`TCPInterface`, `SerialTransport`, `BleRadioInterface`).
## Key Components
@@ -12,6 +12,12 @@ The module uses **Ktor** as its primary HTTP client for high-performance, asynch
- **`FirmwareReleaseRemoteDataSource`**: Fetches the latest firmware versions from GitHub or Meshtastic's metadata servers.
- **`DeviceHardwareRemoteDataSource`**: Fetches definitions for supported Meshtastic hardware devices.
### 3. Shared Transports
- **`BleRadioInterface`**: Multiplatform BLE transport powered by Kable.
- **`TCPInterface`**: Multiplatform TCP transport.
- **`SerialTransport`**: JVM-shared USB/Serial transport powered by jSerialComm.
- **`BaseRadioTransportFactory`**: Common factory for instantiating the KMP transports.
## Module dependency graph
<!--region graph-->
@@ -28,6 +34,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -37,6 +37,7 @@ kotlin {
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.ble)
implementation(libs.okio)
implementation(libs.kmqtt.client)
@@ -57,11 +58,7 @@ kotlin {
}
}
androidMain.dependencies {
implementation(projects.core.ble)
implementation(projects.core.prefs)
implementation(libs.usb.serial.android)
}
androidMain.dependencies { implementation(libs.usb.serial.android) }
commonTest.dependencies {
implementation(libs.kotlinx.coroutines.test)

View File

@@ -19,31 +19,42 @@ package org.meshtastic.core.network.radio
import android.content.Context
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.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportFactory
/** Android implementation of [RadioTransportFactory] delegating to the legacy [InterfaceFactory]. */
@Single
/**
* Android implementation of [RadioTransportFactory]. Handles pure-KMP transports (BLE) via [BaseRadioTransportFactory]
* while delegating legacy platform-specific connections (like USB/Serial, TCP, and Mocks) to the Android-specific
* [InterfaceFactory].
*/
@Single(binds = [RadioTransportFactory::class])
@Suppress("LongParameterList")
class AndroidRadioTransportFactory(
private val context: Context,
private val interfaceFactory: Lazy<InterfaceFactory>,
private val buildConfigProvider: BuildConfigProvider,
) : RadioTransportFactory {
scanner: BleScanner,
bluetoothRepository: BluetoothRepository,
connectionFactory: BleConnectionFactory,
dispatchers: CoroutineDispatchers,
) : BaseRadioTransportFactory(scanner, bluetoothRepository, connectionFactory, dispatchers) {
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
override fun isMockInterface(): Boolean =
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport =
interfaceFactory.value.createInterface(address, service)
override fun isPlatformAddressValid(address: String): Boolean = interfaceFactory.value.addressValid(address)
override fun isAddressValid(address: String?): Boolean = interfaceFactory.value.addressValid(address)
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String =
interfaceFactory.value.toInterfaceAddress(interfaceId, rest)
override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport {
// Fallback to legacy factory for Serial, Mocks, and NOPs
return interfaceFactory.value.createInterface(address, service)
}
}

View File

@@ -1,40 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.radio
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.repository.RadioInterfaceService
/** Factory for creating `BleRadioInterface` instances. */
@Single
class BleRadioInterfaceFactory(
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
) {
fun create(rest: String, service: RadioInterfaceService): BleRadioInterface = BleRadioInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = rest,
)
}

View File

@@ -1,34 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.network.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.RadioInterfaceService
/** Bluetooth backend implementation. */
@Single
class BleRadioInterfaceSpec(private val factory: BleRadioInterfaceFactory) : InterfaceSpec<BleRadioInterface> {
override fun createInterface(rest: String, service: RadioInterfaceService): BleRadioInterface =
factory.create(rest, service)
/** Return true if this address is still acceptable. For Kable we don't strictly require prior bonding. */
override fun addressValid(rest: String): Boolean {
// We no longer strictly require the device to be in the bonded list before attempting connection,
// as Kable and Android will handle bonding seamlessly during connection/characteristic access if needed.
return rest.isNotBlank()
}
}

View File

@@ -30,7 +30,6 @@ import org.meshtastic.core.repository.RadioTransport
@Single
class InterfaceFactory(
private val nopInterfaceFactory: NopInterfaceFactory,
private val bluetoothSpec: Lazy<BleRadioInterfaceSpec>,
private val mockSpec: Lazy<MockInterfaceSpec>,
private val serialSpec: Lazy<SerialInterfaceSpec>,
private val tcpSpec: Lazy<TCPInterfaceSpec>,
@@ -40,7 +39,6 @@ class InterfaceFactory(
private val specMap: Map<InterfaceId, InterfaceSpec<*>>
get() =
mapOf(
InterfaceId.BLUETOOTH to bluetoothSpec.value,
InterfaceId.MOCK to mockSpec.value,
InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory),
InterfaceId.SERIAL to serialSpec.value,

View File

@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.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 spec in
listOf(InterfaceId.TCP.id, InterfaceId.SERIAL.id, InterfaceId.BLUETOOTH.id, InterfaceId.MOCK.id) ||
spec == '!' ||
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 = when {
address.startsWith(InterfaceId.BLUETOOTH.id) -> {
BleRadioInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()),
)
}
address.startsWith("!") -> {
BleRadioInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address.removePrefix("!"),
)
}
else -> createPlatformTransport(address, service)
}
/** Delegate to platform for Mock, TCP, or Serial/USB interfaces. */
protected abstract fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport
}

View File

@@ -18,7 +18,6 @@
package org.meshtastic.core.network.radio
import android.annotation.SuppressLint
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -30,6 +29,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -47,6 +47,7 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
@@ -70,7 +71,6 @@ private val SCAN_TIMEOUT = 5.seconds
* @param service The [RadioInterfaceService] to use for handling radio events.
* @param address The BLE address of the device to connect to.
*/
@SuppressLint("MissingPermission")
class BleRadioInterface(
private val serviceScope: CoroutineScope,
private val scanner: BleScanner,
@@ -94,7 +94,9 @@ class BleRadioInterface(
}
private val connectionScope: CoroutineScope =
CoroutineScope(serviceScope.coroutineContext + SupervisorJob() + exceptionHandler)
CoroutineScope(
serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler,
)
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
private val writeMutex: Mutex = Mutex()
@@ -104,7 +106,9 @@ class BleRadioInterface(
private var bytesReceived: Long = 0
private var bytesSent: Long = 0
@Volatile private var isFullyConnected = false
@Suppress("VolatileModifier")
@Volatile
private var isFullyConnected = false
init {
connect()
@@ -344,10 +348,10 @@ class BleRadioInterface(
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
connectionScope.launch {
serviceScope.launch {
connectionScope.cancel()
bleConnection.disconnect()
service.onDisconnect(true)
connectionScope.cancel()
}
}

View File

@@ -16,7 +16,7 @@ The shared capability contract for NFC scanning, injected via `CompositionLocalP
<!--region graph-->
```mermaid
graph TB
:core:nfc[nfc]:::compose-desktop-application
:core:nfc[nfc]:::kmp-library-compose
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
@@ -27,6 +27,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -29,6 +29,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -32,6 +32,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -24,7 +24,7 @@ Text(text = stringResource(Res.string.your_string_key))
<!--region graph-->
```mermaid
graph TB
:core:resources[resources]:::compose-desktop-application
:core:resources[resources]:::kmp-library-compose
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
@@ -35,6 +35,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -33,6 +33,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -15,180 +15,51 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
# `:core:testing` — Shared Test Doubles and Utilities
# `:core:testing`
## Purpose
## Module dependency graph
The `:core:testing` module provides lightweight, reusable test doubles (fakes, builders, factories) and testing utilities for **all** KMP modules. This module **consolidates testing dependencies** into a single, well-controlled location to:
<!--region graph-->
```mermaid
graph TB
:core:testing[testing]:::kmp-library
- **Reduce duplication**: Shared fakes (e.g., `FakeNodeRepository`, `FakeRadioController`) used across multiple modules.
- **Keep dependency graph clean**: All test doubles and libraries are defined once; modules depend on `:core:testing` instead of scattered test deps.
- **Enable KMP-wide test patterns**: Every module (`commonTest`, `androidUnitTest`, JVM tests) can reuse the same fakes.
- **Maintain purity**: Core business logic modules (e.g., `core:domain`, `core:data`) depend on `:core:testing` via `commonTest`, avoiding test-code leakage into production.
## Dependency Strategy
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
```
┌─────────────────────────────────────┐
│ core:testing │
│ (only deps: core:model, │
│ core:repository, test libs) │
└──────────────┬──────────────────────┘
│ (commonTest dependency)
┌──────┴─────────────┬────────────────────┐
│ │ │
core:domain feature:messaging feature:node
core:data feature:settings feature:firmware
(etc.) (etc.)
```
<!--endregion-->
### Target Compatibility Warning (March 2026 Audit)
## Overview
The `:core:testing` module is a dedicated **Kotlin Multiplatform (KMP)** library that provides shared test fakes, doubles, rules, and utilities. It is designed to be consumed by the `commonTest` source sets of all other KMP modules to ensure consistent and unified testing behavior across the codebase.
- **MockK Removal:** MockK has been removed from `commonMain` because it does not natively support Kotlin/Native (iOS).
- **Future-Proofing:** The project is migrating to `dev.mokkery` for KMP-compatible mocking or favoring manual fakes.
- **Recommendation:** Favor manual fakes (like `FakeNodeRepository`) in `commonMain` to maintain pure KMP portability.
By centralizing fakes and mocking utilities here, we prevent duplication of test setups and enforce a standard approach to testing ViewModels, Repositories, and pure domain logic.
### Key Design Rules
## Key Components
1. **`:core:testing` has NO dependencies on heavy modules**: It only depends on:
- `core:model` — Domain types (Node, User, etc.)
- `core:repository` — Interfaces (NodeRepository, etc.)
- Test libraries (`kotlin("test")`, `kotlinx.coroutines.test`, `turbine`, `junit`)
- **Test Doubles / Fakes**: Provides in-memory implementations of core repositories (e.g., `FakeNodeRepository`, `FakeMeshLogRepository`) to isolate components under test.
- **Coroutines Testing**: Provides dispatchers and test rules that replace the main dispatcher with `TestDispatcher` to allow time-control and synchronous execution of coroutines in tests.
- **Mokkery Support**: Integrated with the Mokkery compiler plugin to provide robust and unified mocking capabilities in `commonTest`.
2. **No circular dependencies**: Modules that depend on `:core:testing` (in `commonTest`) cannot be dependencies of `:core:testing` itself.
3. **`:core:testing` is NOT part of the app bundle**: It's declared in `commonTest` sourceSet only, so it never appears in release APKs or final JARs.
## What's Included
### Test Doubles (Fakes)
#### `FakeRadioController`
A no-op implementation of `RadioController` for unit tests. Tracks method calls and state changes.
## Usage
Add this module to your `commonTest` source set dependencies in your KMP module's `build.gradle.kts`:
```kotlin
val radioController = FakeRadioController()
radioController.setConnectionState(ConnectionState.Connected)
assertEquals(1, radioController.sentPackets.size)
```
#### `FakeNodeRepository`
An in-memory implementation of `NodeRepository` for isolated testing.
```kotlin
val nodeRepo = FakeNodeRepository()
nodeRepo.setNodes(TestDataFactory.createTestNodes(5))
assertEquals(5, nodeRepo.nodeDBbyNum.value.size)
```
### Test Builders & Factories
#### `TestDataFactory`
Factory methods for creating domain objects with sensible defaults.
```kotlin
val node = TestDataFactory.createTestNode(num = 42, longName = "Alice")
val nodes = TestDataFactory.createTestNodes(10)
```
### Test Utilities
#### Flow collection helper
```kotlin
val emissions = flow { emit(1); emit(2) }.toList()
assertEquals(listOf(1, 2), emissions)
```
## Usage Examples
### Testing a ViewModel (in `feature:messaging/src/commonTest`)
```kotlin
class MessageViewModelTest {
private val nodeRepository = FakeNodeRepository()
@Test
fun testLoadsNodesCorrectly() = runTest {
nodeRepository.setNodes(TestDataFactory.createTestNodes(3))
val viewModel = createViewModel(nodeRepository)
assertEquals(3, viewModel.nodeCount.value)
kotlin {
sourceSets {
commonTest.dependencies {
implementation(projects.core.testing)
}
}
}
```
### Testing a UseCase (in `core:domain/src/commonTest`)
```kotlin
class SendMessageUseCaseTest {
private val radioController = FakeRadioController()
@Test
fun testSendsPacket() = runTest {
val useCase = SendMessageUseCase(radioController)
useCase.sendMessage(testPacket)
assertEquals(1, radioController.sentPackets.size)
}
}
```
## Adding New Test Doubles
When adding a new fake to `:core:testing`:
1. **Implement the interface** from `core:model` or `core:repository`.
2. **Track side effects** (e.g., `sentPackets`, `calledMethods`) for test assertions.
3. **Provide test helpers** (e.g., `setNodes()`, `clear()`) to manipulate state.
4. **Document with examples** in the class KDoc.
Example:
```kotlin
/**
* A test double for [SomeRepository].
*/
class FakeSomeRepository : SomeRepository {
val callHistory = mutableListOf<String>()
override suspend fun doSomething(value: String) {
callHistory.add(value)
}
// Test helpers
fun getCallCount() = callHistory.size
fun clear() = callHistory.clear()
}
```
## Dependency Maintenance
### When adding a new module:
- If it has `commonTest` tests, add `implementation(projects.core.testing)` to its `commonTest.dependencies`.
- Do NOT add heavy modules (e.g., `core:database`) to `:core:testing`'s dependencies.
### When a test needs a mock:
- Check `:core:testing` first for an existing fake.
- If none exists, consider adding it there (if it's reusable) vs. using `mockk()` inline.
### When updating interfaces:
- Update corresponding fakes in `:core:testing` to match new method signatures.
- Keep fakes no-op; don't replicate business logic.
## Files
```
core/testing/
├── build.gradle.kts # Lightweight, minimal dependencies
├── README.md # This file
└── src/commonMain/kotlin/org/meshtastic/core/testing/
├── FakeRadioController.kt # RadioController test double
├── FakeNodeRepository.kt # NodeRepository test double
└── TestDataFactory.kt # Builders and factories
```
## See Also
- `AGENTS.md` §3B: KMP platform purity guidelines (relevant for test code).
- `docs/kmp-status.md`: KMP module status and targets.
- `.github/copilot-instructions.md`: Build and test commands.

View File

@@ -49,7 +49,7 @@ MeshtasticResourceDialog(
<!--region graph-->
```mermaid
graph TB
:core:ui[ui]:::compose-desktop-application
:core:ui[ui]:::kmp-library-compose
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
@@ -60,6 +60,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.navigation.DeepLinkRouter
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
* Shared shell for setting up global UI logic across platforms (Android, Desktop).
*
* This component handles deep linking, shared dialogs (via [MeshtasticCommonAppSetup]), and provides the global
* [MeshtasticSnackbarProvider]. Platform entry points should wrap their navigation layout inside this shell.
*/
@Composable
fun MeshtasticAppShell(
backStack: NavBackStack<NavKey>,
uiViewModel: UIViewModel,
hostModifier: Modifier = Modifier.padding(bottom = 16.dp),
content: @Composable () -> Unit,
) {
LaunchedEffect(uiViewModel) {
uiViewModel.navigationDeepLink.collect { uri ->
val commonUri = CommonUri.parse(uri.uriString)
DeepLinkRouter.route(commonUri)?.let { navKeys ->
backStack.clear()
backStack.addAll(navKeys)
}
}
}
MeshtasticCommonAppSetup(
uiViewModel = uiViewModel,
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
},
)
MeshtasticSnackbarProvider(snackbarManager = uiViewModel.snackbarManager, hostModifier = hostModifier) { content() }
}

View File

@@ -97,9 +97,9 @@ The module depends on the JVM variants of KMP modules:
- [x] Create connections screen using shared `feature:connections` with dynamic transport detection
- [x] Replace 5 placeholder config screens with real desktop implementations (Device, Position, Network, Security, ExtNotification)
- [x] Add desktop language picker backed by shared `UiPreferencesDataSource.locale` with live translation updates
- [ ] Wire remaining `feature:*` composables (map) into the nav graph
- [ ] Move remaining node detail and message composables from `androidMain` to `commonMain`
- [x] Wire remaining `feature:*` composables (map) into the nav graph
- [x] Move remaining node detail and message composables from `androidMain` to `commonMain`
- [x] Add serial/USB transport for direct radio connection on Desktop
- [x] Add BLE transport (via Kable) for direct radio connection on Desktop
- [ ] Add MQTT transport for cloud-connected operation
- [x] Add MQTT transport for cloud-connected operation
- [x] Package as native distributions (DMG, MSI, DEB) via CI release pipeline

View File

@@ -1,367 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
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.MeshtasticBleConstants.SERVICE_UUID
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.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import kotlin.time.Duration.Companion.seconds
private const val SCAN_RETRY_COUNT = 3
private const val SCAN_RETRY_DELAY_MS = 1000L
private const val CONNECTION_TIMEOUT_MS = 15_000L
private val SCAN_TIMEOUT = 5.seconds
/**
* A [RadioTransport] implementation for BLE devices using Kable for desktop.
*
* 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 [RadioInterfaceService].
*
* @param serviceScope 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 service The [RadioInterfaceService] to use for handling radio events.
* @param address The BLE address of the device to connect to.
*/
@OptIn(kotlin.uuid.ExperimentalUuidApi::class)
@Suppress("TooManyFunctions", "TooGenericExceptionCaught", "SwallowedException")
class DesktopBleInterface(
private val serviceScope: CoroutineScope,
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
private val service: RadioInterfaceService,
val address: String,
) : RadioTransport {
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
serviceScope.launch {
try {
bleConnection.disconnect()
} catch (e: Exception) {
Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
}
}
val (isPermanent, msg) = throwable.toDisconnectReason()
service.onDisconnect(isPermanent, errorMessage = msg)
}
private val connectionScope: CoroutineScope =
CoroutineScope(
serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler,
)
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
private val writeMutex: Mutex = Mutex()
private var connectionStartTime: Long = 0
private var packetsReceived: Int = 0
private var packetsSent: Int = 0
private var bytesReceived: Long = 0
private var bytesSent: Long = 0
init {
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 == address }
?.let {
return it
}
Logger.i { "[$address] Device not found in bonded list, scanning..." }
repeat(SCAN_RETRY_COUNT) { attempt ->
try {
val d =
kotlinx.coroutines.withTimeoutOrNull(SCAN_TIMEOUT) {
scanner.scan(SCAN_TIMEOUT).first { it.address == address }
}
if (d != null) return d
} catch (e: Exception) {
// Ignore timeout exceptions
}
if (attempt < SCAN_RETRY_COUNT - 1) {
delay(SCAN_RETRY_DELAY_MS)
}
}
throw RadioNotConnectedException("Device not found at address $address")
}
private fun connect() {
connectionScope.launch {
bleConnection.connectionState
.onEach { state ->
if (state is BleConnectionState.Disconnected) {
onDisconnected(state)
}
}
.catch { e ->
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
handleFailure(e)
}
.launchIn(connectionScope)
while (isActive) {
try {
// Add a delay to allow any pending background disconnects (from a previous close() call)
// to complete before we attempt a new connection.
@Suppress("MagicNumber")
val connectDelayMs = 1000L
kotlinx.coroutines.delay(connectDelayMs)
connectionStartTime = nowMillis
Logger.i { "[$address] BLE connection attempt started" }
val device = findDevice()
val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
if (state !is BleConnectionState.Connected) {
throw RadioNotConnectedException("Failed to connect to device at address $address")
}
onConnected()
discoverServicesAndSetupCharacteristics()
// Suspend here until Kable drops the connection
bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
Logger.i { "[$address] BLE connection dropped, preparing to reconnect..." }
} catch (e: kotlinx.coroutines.CancellationException) {
Logger.d { "[$address] BLE connection coroutine cancelled" }
throw e
} catch (e: Exception) {
val failureTime = nowMillis - connectionStartTime
Logger.w(e) { "[$address] Failed to connect to device after ${failureTime}ms" }
handleFailure(e)
// Wait before retrying to prevent hot loops
@Suppress("MagicNumber")
kotlinx.coroutines.delay(5000L)
}
}
}
}
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: Exception) {
Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
}
}
private fun onDisconnected(@Suppress("UNUSED_PARAMETER") state: BleConnectionState.Disconnected) {
radioService = null
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.w {
"[$address] BLE disconnected, " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
// Note: Disconnected state in commonMain doesn't currently carry a reason.
// We might want to add that later if needed.
service.onDisconnect(false, errorMessage = "Disconnected")
}
private suspend fun discoverServicesAndSetupCharacteristics() {
try {
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
val radioService = service.toMeshtasticRadioProfile()
// Wire up notifications
radioService.fromRadio
.onEach { packet ->
Logger.d { "[$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.d { "[$address] Received packet logRadio (${packet.size} bytes)" }
dispatchPacket(packet)
}
.catch { e ->
Logger.w(e) { "[$address] Error in logRadio flow" }
handleFailure(e)
}
.launchIn(this)
// Store reference for handleSendToRadio
this@DesktopBleInterface.radioService = radioService
Logger.i { "[$address] Profile service active and characteristics subscribed" }
// 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" }
this@DesktopBleInterface.service.onConnect()
}
} catch (e: Exception) {
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
bleConnection.disconnect()
handleFailure(e)
}
}
private var radioService: org.meshtastic.core.ble.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.d {
"[$address] Successfully wrote packet #$packetsSent " +
"to toRadioCharacteristic - " +
"${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() {
Logger.d { "[$address] BLE keepAlive" }
}
/** Closes the connection to the device. */
override fun close() {
val uptime =
if (connectionStartTime > 0) {
nowMillis - connectionStartTime
} else {
0
}
Logger.i {
"[$address] BLE close() called - " +
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes), " +
"Packets TX: $packetsSent ($bytesSent bytes)"
}
serviceScope.launch {
connectionScope.cancel()
bleConnection.disconnect()
service.onDisconnect(true)
}
}
private fun dispatchPacket(packet: ByteArray) {
packetsReceived++
bytesReceived += packet.size
Logger.d {
"[$address] Dispatching packet to service.handleFromRadio() - " +
"Packet #$packetsReceived, ${packet.size} bytes (Total: $bytesReceived bytes)"
}
service.handleFromRadio(packet)
}
private fun handleFailure(throwable: Throwable) {
val (isPermanent, msg) = throwable.toDisconnectReason()
service.onDisconnect(isPermanent, errorMessage = msg)
}
private fun Throwable.toDisconnectReason(): Pair<Boolean, String> {
val isPermanent =
this::class.simpleName == "BluetoothUnavailableException" ||
this::class.simpleName == "ManagerClosedException"
val msg =
when {
this is RadioNotConnectedException -> this.message ?: "Device not found"
this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing"
this::class.simpleName == "GattException" -> "GATT Error: ${this.message}"
else -> this.message ?: this::class.simpleName ?: "Unknown"
}
return Pair(isPermanent, msg)
}
}

View File

@@ -16,6 +16,7 @@
*/
package org.meshtastic.desktop.radio
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BluetoothRepository
@@ -23,55 +24,35 @@ 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.TCPInterface
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.
*/
@Single(binds = [RadioTransportFactory::class])
class DesktopRadioTransportFactory(
private val scanner: BleScanner,
private val bluetoothRepository: BluetoothRepository,
private val connectionFactory: BleConnectionFactory,
private val dispatchers: CoroutineDispatchers,
) : RadioTransportFactory {
scanner: BleScanner,
bluetoothRepository: BluetoothRepository,
connectionFactory: BleConnectionFactory,
dispatchers: CoroutineDispatchers,
) : BaseRadioTransportFactory(scanner, bluetoothRepository, connectionFactory, dispatchers) {
override val supportedDeviceTypes: List<DeviceType> = listOf(DeviceType.TCP, DeviceType.BLE, DeviceType.USB)
override fun isMockInterface(): Boolean = false
override fun isAddressValid(address: String?): Boolean {
val spec = address?.getOrNull(0) ?: return false
return spec == InterfaceId.TCP.id ||
spec == InterfaceId.SERIAL.id ||
spec == InterfaceId.BLUETOOTH.id ||
address.startsWith("!")
}
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport =
if (address.startsWith(InterfaceId.TCP.id)) {
override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport = when {
address.startsWith(InterfaceId.TCP.id) -> {
TCPInterface(service, dispatchers, address.removePrefix(InterfaceId.TCP.id.toString()))
} else if (address.startsWith(InterfaceId.SERIAL.id)) {
SerialTransport(portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), service = service)
} else if (address.startsWith(InterfaceId.BLUETOOTH.id)) {
DesktopBleInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()),
)
} else {
val stripped = if (address.startsWith("!")) address.removePrefix("!") else address
DesktopBleInterface(
serviceScope = service.serviceScope,
scanner = scanner,
bluetoothRepository = bluetoothRepository,
connectionFactory = connectionFactory,
service = service,
address = stripped,
)
}
address.startsWith(InterfaceId.SERIAL.id) -> {
SerialTransport(portName = address.removePrefix(InterfaceId.SERIAL.id.toString()), service = service)
}
else -> error("Unsupported transport for address: $address")
}
}

View File

@@ -40,12 +40,10 @@ import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.navigation.navigateTopLevel
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.ui.component.MeshtasticCommonAppSetup
import org.meshtastic.core.ui.component.MeshtasticSnackbarProvider
import org.meshtastic.core.ui.component.MeshtasticAppShell
import org.meshtastic.core.ui.navigation.icon
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
@@ -80,57 +78,50 @@ fun DesktopMainScreen(
val selectedDevice by radioService.currentDeviceAddressFlow.collectAsStateWithLifecycle()
val colorScheme = MaterialTheme.colorScheme
MeshtasticCommonAppSetup(
uiViewModel = uiViewModel,
onNavigateToTracerouteMap = { destNum, requestId, logUuid ->
backStack.add(NodeDetailRoutes.TracerouteMap(destNum = destNum, requestId = requestId, logUuid = logUuid))
},
)
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail {
TopLevelDestination.entries.forEach { destination ->
NavigationRailItem(
selected = destination == selected,
onClick = {
if (destination != selected) {
backStack.navigateTopLevel(destination.route)
}
},
icon = {
if (destination == TopLevelDestination.Connections) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
meshActivityFlow = radioService.meshActivity,
colorScheme = colorScheme,
)
} else {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
)
}
},
label = { Text(stringResource(destination.label)) },
)
MeshtasticAppShell(
backStack = backStack,
uiViewModel = uiViewModel,
hostModifier = Modifier.padding(bottom = 24.dp),
) {
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) {
NavigationRail {
TopLevelDestination.entries.forEach { destination ->
NavigationRailItem(
selected = destination == selected,
onClick = {
if (destination != selected) {
backStack.navigateTopLevel(destination.route)
}
},
icon = {
if (destination == TopLevelDestination.Connections) {
org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
connectionState = connectionState,
deviceType = DeviceType.fromAddress(selectedDevice ?: "NoDevice"),
meshActivityFlow = radioService.meshActivity,
colorScheme = colorScheme,
)
} else {
Icon(
imageVector = destination.icon,
contentDescription = stringResource(destination.label),
)
}
},
label = { Text(stringResource(destination.label)) },
)
}
}
}
MeshtasticSnackbarProvider(
snackbarManager = uiViewModel.snackbarManager,
modifier = Modifier.weight(1f).fillMaxSize(),
hostModifier = Modifier.padding(bottom = 24.dp),
) {
val provider = entryProvider<NavKey> { desktopNavGraph(backStack, uiViewModel) }
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryProvider = provider,
modifier = Modifier.fillMaxSize(),
modifier = Modifier.weight(1f).fillMaxSize(),
)
}
}

View File

@@ -27,7 +27,7 @@ Modules that share JVM-specific code between Android and desktop now standardize
| `core:database` | ✅ | ✅ | Room KMP |
| `core:domain` | ✅ | ✅ | UseCases |
| `core:prefs` | ✅ | ✅ | Preferences layer |
| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport` |
| `core:network` | ✅ | ✅ | Ktor, `StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface` |
| `core:data` | ✅ | ✅ | Data orchestration |
| `core:ble` | ✅ | ✅ | Kable multiplatform BLE abstractions in commonMain |
| `core:nfc` | ✅ | ✅ | NFC contract in commonMain; hardware in androidMain |
@@ -114,7 +114,7 @@ Based on the latest codebase investigation, the following steps are proposed to
| **Transport Lifecycle Unification** | ✅ Done | `SharedRadioInterfaceService` orchestrates auto-reconnect, connection state, and heartbeat uniformly across Android and Desktop. |
| **Database Parity** | ✅ Done | `DatabaseManager` is pure KMP, giving iOS and Desktop support for multiple connected nodes with LRU caching. |
| Emoji picker unification | ✅ Done | Single commonMain implementation replacing 3 platform variants |
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `AdaptiveListDetailScaffold`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop |
| Cross-platform deduplication pass | ✅ Done | Extracted shared `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `ThemePickerDialog`, `AdaptiveListDetailScaffold`, `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BleRadioInterface`, and `BaseRadioTransportFactory` to `commonMain`; eliminated ~1,200 lines of duplicated Compose UI code across Android/desktop |
## Navigation Parity Note
@@ -145,12 +145,11 @@ Extracted to shared `commonMain` (no longer app-only):
Extracted to core KMP modules:
- Android Services, WorkManager Workers, and BroadcastReceivers → `core:service/androidMain`
- BLE and USB/Serial radio connections → `core:network/androidMain`
- TCP radio connections and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations)
- USB/Serial radio connections → `core:network/androidMain`
- TCP radio connections, BLE radio connections (`BleRadioInterface`), and mDNS/NSD Service Discovery → `core:network/commonMain` (with Android `NsdManager` and Desktop `JmDNS` implementations)
Remaining to be extracted from `:app` or unified in `commonMain`:
- `MapViewModel` (Unify Google/F-Droid flavors into a single `commonMain` class consuming a `MapConfigProvider` interface)
- Top-level UI composition (`ui/Main.kt`)
## Prerelease Dependencies

View File

@@ -57,9 +57,7 @@ These items address structural gaps identified in the March 2026 architecture re
| TCP | Desktop (JVM) | ✅ Done — shared `StreamFrameCodec` + `TcpTransport` in `core:network` |
| Serial/USB | Desktop (JVM) | ✅ Done — jSerialComm |
| MQTT | All (KMP) | ✅ Completed — KMQTT in commonMain |
| BLE | Android | ✅ Done — Kable |
| BLE | Desktop | ✅ Done — Kable (JVM) |
| BLE | iOS | ❌ Future — Kable/CoreBluetooth |
| BLE | All (KMP) | ✅ Done — Kable in `commonMain` (`BleRadioInterface`) |
### Desktop Feature Gaps

View File

@@ -16,6 +16,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -30,6 +30,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -37,6 +37,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -36,6 +36,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -33,6 +33,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -35,6 +35,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;