From 6516287c626017d22879bc6d9c9eb3c9058b39bc Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:15:51 -0500 Subject: [PATCH] refactor: BLE transport and UI for Kotlin Multiplatform unification (#4911) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- GEMINI.md | 4 +- app/README.md | 1 + .../main/kotlin/org/meshtastic/app/ui/Main.kt | 232 +++++------ .../kotlin/org/meshtastic/buildlogic/Graph.kt | 8 +- core/api/README.md | 1 + core/barcode/README.md | 3 +- core/ble/README.md | 1 + core/common/README.md | 1 + core/data/README.md | 1 + core/database/README.md | 1 + core/datastore/README.md | 1 + core/di/README.md | 1 + core/model/README.md | 1 + core/navigation/README.md | 3 +- core/network/README.md | 9 +- core/network/build.gradle.kts | 7 +- .../radio/AndroidRadioTransportFactory.kt | 31 +- .../network/radio/BleRadioInterfaceFactory.kt | 40 -- .../network/radio/BleRadioInterfaceSpec.kt | 34 -- .../core/network/radio/InterfaceFactory.kt | 2 - .../radio/BaseRadioTransportFactory.kt | 77 ++++ .../core/network/radio/BleRadioInterface.kt | 16 +- core/nfc/README.md | 3 +- core/prefs/README.md | 1 + core/proto/README.md | 1 + core/resources/README.md | 3 +- core/service/README.md | 1 + core/testing/README.md | 195 ++-------- core/ui/README.md | 3 +- .../core/ui/component/MeshtasticAppShell.kt | 62 +++ desktop/README.md | 6 +- .../desktop/radio/DesktopBleInterface.kt | 367 ------------------ .../radio/DesktopRadioTransportFactory.kt | 57 +-- .../desktop/ui/DesktopMainScreen.kt | 81 ++-- docs/kmp-status.md | 9 +- docs/roadmap.md | 4 +- feature/firmware/README.md | 1 + feature/intro/README.md | 1 + feature/map/README.md | 1 + feature/messaging/README.md | 1 + feature/node/README.md | 1 + feature/settings/README.md | 1 + 42 files changed, 429 insertions(+), 845 deletions(-) delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceFactory.kt delete mode 100644 core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceSpec.kt create mode 100644 core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt rename core/network/src/{androidMain => commonMain}/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt (97%) create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopBleInterface.kt diff --git a/GEMINI.md b/GEMINI.md index e07a2f79e..e4092c351 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -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`. diff --git a/app/README.md b/app/README.md index cbd045e77..d462c3d1b 100644 --- a/app/README.md +++ b/app/README.md @@ -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; 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 12a7a6ee3..1109840fe 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -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 { diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt index 6e93053f3..082693c3f 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt @@ -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};") } } diff --git a/core/api/README.md b/core/api/README.md index 1a8f10f02..fe5153764 100644 --- a/core/api/README.md +++ b/core/api/README.md @@ -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; diff --git a/core/barcode/README.md b/core/barcode/README.md index ebbaf06f9..c64fcca6c 100644 --- a/core/barcode/README.md +++ b/core/barcode/README.md @@ -42,7 +42,7 @@ scanner.startScan() ```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; diff --git a/core/ble/README.md b/core/ble/README.md index 90cb7f3f2..a0f1adc75 100644 --- a/core/ble/README.md +++ b/core/ble/README.md @@ -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; diff --git a/core/common/README.md b/core/common/README.md index e68323fa6..da7700ac5 100644 --- a/core/common/README.md +++ b/core/common/README.md @@ -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; diff --git a/core/data/README.md b/core/data/README.md index b30b59f3b..62fd73bdf 100644 --- a/core/data/README.md +++ b/core/data/README.md @@ -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; diff --git a/core/database/README.md b/core/database/README.md index 873fdd394..6ad4d603f 100644 --- a/core/database/README.md +++ b/core/database/README.md @@ -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; diff --git a/core/datastore/README.md b/core/datastore/README.md index 931d680d5..b87db8138 100644 --- a/core/datastore/README.md +++ b/core/datastore/README.md @@ -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; diff --git a/core/di/README.md b/core/di/README.md index 40481d3cb..c1cfc7517 100644 --- a/core/di/README.md +++ b/core/di/README.md @@ -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; diff --git a/core/model/README.md b/core/model/README.md index 9521c445f..54dfabafc 100644 --- a/core/model/README.md +++ b/core/model/README.md @@ -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; diff --git a/core/navigation/README.md b/core/navigation/README.md index 00951f30e..d9fd84d1c 100644 --- a/core/navigation/README.md +++ b/core/navigation/README.md @@ -26,7 +26,7 @@ navController.navigate(MessagingRoutes.Chat(nodeId = 12345)) ```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; diff --git a/core/network/README.md b/core/network/README.md index 0d7649343..a81e78ba4 100644 --- a/core/network/README.md +++ b/core/network/README.md @@ -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 @@ -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; diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 2fb26bfa8..b0f31ebdd 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -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) diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt index 7307ef0a9..28eb2175d 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt @@ -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, private val buildConfigProvider: BuildConfigProvider, -) : RadioTransportFactory { + scanner: BleScanner, + bluetoothRepository: BluetoothRepository, + connectionFactory: BleConnectionFactory, + dispatchers: CoroutineDispatchers, +) : BaseRadioTransportFactory(scanner, bluetoothRepository, connectionFactory, dispatchers) { override val supportedDeviceTypes: List = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB) override fun 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) + } } diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceFactory.kt deleted file mode 100644 index 26956824c..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceFactory.kt +++ /dev/null @@ -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 . - */ -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, - ) -} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceSpec.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceSpec.kt deleted file mode 100644 index 461ac4b65..000000000 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterfaceSpec.kt +++ /dev/null @@ -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 . - */ -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 { - 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() - } -} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt index e7a1900ef..f33cedfae 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt +++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt @@ -30,7 +30,6 @@ import org.meshtastic.core.repository.RadioTransport @Single class InterfaceFactory( private val nopInterfaceFactory: NopInterfaceFactory, - private val bluetoothSpec: Lazy, private val mockSpec: Lazy, private val serialSpec: Lazy, private val tcpSpec: Lazy, @@ -40,7 +39,6 @@ class InterfaceFactory( private val specMap: Map> get() = mapOf( - InterfaceId.BLUETOOTH to bluetoothSpec.value, InterfaceId.MOCK to mockSpec.value, InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory), InterfaceId.SERIAL to serialSpec.value, diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt new file mode 100644 index 000000000..2c5a02784 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt @@ -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 . + */ +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 +} diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt similarity index 97% rename from core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt index dfe7a07bc..65950848a 100644 --- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt @@ -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() } } diff --git a/core/nfc/README.md b/core/nfc/README.md index 8a5df3c59..5e722e381 100644 --- a/core/nfc/README.md +++ b/core/nfc/README.md @@ -16,7 +16,7 @@ The shared capability contract for NFC scanning, injected via `CompositionLocalP ```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; diff --git a/core/prefs/README.md b/core/prefs/README.md index d9fbe8f5e..ecaf0feb6 100644 --- a/core/prefs/README.md +++ b/core/prefs/README.md @@ -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; diff --git a/core/proto/README.md b/core/proto/README.md index aedb7ac34..002cb5a5d 100644 --- a/core/proto/README.md +++ b/core/proto/README.md @@ -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; diff --git a/core/resources/README.md b/core/resources/README.md index 0528e762c..d443ebe49 100644 --- a/core/resources/README.md +++ b/core/resources/README.md @@ -24,7 +24,7 @@ Text(text = stringResource(Res.string.your_string_key)) ```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; diff --git a/core/service/README.md b/core/service/README.md index c889b3d90..b9dae4a9e 100644 --- a/core/service/README.md +++ b/core/service/README.md @@ -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; diff --git a/core/testing/README.md b/core/testing/README.md index f46bab78b..ed3483a0c 100644 --- a/core/testing/README.md +++ b/core/testing/README.md @@ -15,180 +15,51 @@ * along with this program. If not, see . */ -# `: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: + +```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.) -``` + -### 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() - - 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. - diff --git a/core/ui/README.md b/core/ui/README.md index f660cb942..641d70bda 100644 --- a/core/ui/README.md +++ b/core/ui/README.md @@ -49,7 +49,7 @@ MeshtasticResourceDialog( ```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; diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt new file mode 100644 index 000000000..3f8319780 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt @@ -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 . + */ +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, + 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() } +} diff --git a/desktop/README.md b/desktop/README.md index a981d2d2e..129f49e94 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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 diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopBleInterface.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopBleInterface.kt deleted file mode 100644 index 0d7e4a1f2..000000000 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopBleInterface.kt +++ /dev/null @@ -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 . - */ -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 { - 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) - } -} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt index a66850730..c1f562818 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt @@ -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 = 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") + } } diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt index 9de71059e..30078ffb4 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt @@ -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 { desktopNavGraph(backStack, uiViewModel) } NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = provider, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.weight(1f).fillMaxSize(), ) } } diff --git a/docs/kmp-status.md b/docs/kmp-status.md index 05c0b49ed..8f3db2fc5 100644 --- a/docs/kmp-status.md +++ b/docs/kmp-status.md @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 41e1eb593..efbe736d0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/feature/firmware/README.md b/feature/firmware/README.md index 19e5e6a71..59e009dd6 100644 --- a/feature/firmware/README.md +++ b/feature/firmware/README.md @@ -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; diff --git a/feature/intro/README.md b/feature/intro/README.md index a9215fd76..259ae6daf 100644 --- a/feature/intro/README.md +++ b/feature/intro/README.md @@ -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; diff --git a/feature/map/README.md b/feature/map/README.md index e2791d299..3e38406a9 100644 --- a/feature/map/README.md +++ b/feature/map/README.md @@ -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; diff --git a/feature/messaging/README.md b/feature/messaging/README.md index 3999d07bd..fc486d09b 100644 --- a/feature/messaging/README.md +++ b/feature/messaging/README.md @@ -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; diff --git a/feature/node/README.md b/feature/node/README.md index 8d53b284f..23e56b536 100644 --- a/feature/node/README.md +++ b/feature/node/README.md @@ -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; diff --git a/feature/settings/README.md b/feature/settings/README.md index 10b7ae14d..b7ed0c0ad 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -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;