feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876)

This commit is contained in:
James Rich
2026-03-21 18:19:13 -05:00
committed by GitHub
parent f04924ded5
commit d136b162a4
170 changed files with 2208 additions and 2432 deletions

View File

@@ -14,11 +14,11 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `fdroid`: Open source only, no tracking/analytics.
- `google`: Includes Google Play Services (Maps) and DataDog analytics.
- **Core Architecture:** Modern Android Development (MAD) with KMP core.
- **KMP Modules:** Most `core:*` modules. All declare `jvm()` target and compile clean on JVM.
- **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app`.
- **Navigation:** AndroidX Navigation 3 (JetBrains multiplatform fork) with shared backstack state.
- **UI:** Jetpack Compose Multiplatform (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
- **Navigation:** JetBrains Navigation 3 (Multiplatform fork) with shared backstack state.
- **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Database:** Room KMP.
@@ -38,7 +38,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` in commonMain, `TcpTransport` in jvmAndroidMain). |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:ui` | Shared Compose UI components (`EmptyDetailPlaceholder`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
@@ -47,18 +47,18 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:prefs` | KMP preferences layer built on DataStore abstractions. |
| `core:barcode` | Barcode scanning (Android-only). |
| `core:nfc` | NFC abstractions (KMP). Android NFC hardware implementation in `androidMain`. |
| `core/ble/` | Bluetooth Low Energy stack using Nordic libraries. |
| `core/ble/` | Bluetooth Low Energy stack using Kable. |
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
| `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`). All are KMP with `jvm()` target. Use `meshtastic.kmp.feature` convention plugin. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP transport with `want_config` handshake. |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
## 3. Development Guidelines & Coding Standards
### A. UI Development (Jetpack Compose)
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). NEVER use hardcoded strings.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
@@ -72,11 +72,13 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (0.4.0+). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
- **BLE:** All Bluetooth communication must route through `core:ble` using Nordic Semiconductor's Android Common Libraries.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
- **JetBrains fork aliases:** Version catalog aliases for JetBrains-forked AndroidX artifacts use the `jetbrains-*` prefix (e.g., `jetbrains-lifecycle-runtime-compose`, `jetbrains-navigation3-ui`). Plain `androidx-*` aliases are true Google AndroidX artifacts. Never mix them up in `commonMain`.
- **Compose Multiplatform:** Version catalog aliases for Compose Multiplatform artifacts use the `compose-multiplatform-*` prefix (e.g., `compose-multiplatform-material3`, `compose-multiplatform-foundation`). Never use plain `androidx.compose` dependencies in common Main.
- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `core:testing` shared fakes.
- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` (powered by `qrcode-kotlin`) for generating QR codes. Do not use Android Bitmap or ZXing APIs in common code.
- **Testing:** Write ViewModel and business logic tests in `commonTest`. Use `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking. Use `core:testing` shared fakes.
- **Build-logic conventions:** In `build-logic/convention`, prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs). Avoid `afterEvaluate` in convention plugins unless there is no viable lazy alternative.
### C. Namespacing

View File

@@ -91,8 +91,8 @@ jobs:
if: inputs.run_unit_tests == true
run: ./gradlew test koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug core:api:koverXmlReportDebug core:barcode:koverXmlReportFdroidDebug core:barcode:koverXmlReportGoogleDebug mesh_service_example:koverXmlReportDebug desktop:koverXmlReport -Pci=true --continue --scan
- name: KMP JVM Smoke Compile
run: ./gradlew :core:proto:compileKotlinJvm :core:common:compileKotlinJvm :core:model:compileKotlinJvm :core:repository:compileKotlinJvm :core:di:compileKotlinJvm :core:navigation:compileKotlinJvm :core:resources:compileKotlinJvm :core:datastore:compileKotlinJvm :core:database:compileKotlinJvm :core:domain:compileKotlinJvm :core:prefs:compileKotlinJvm :core:network:compileKotlinJvm :core:data:compileKotlinJvm :core:ble:compileKotlinJvm :core:nfc:compileKotlinJvm :core:service:compileKotlinJvm :core:testing:compileKotlinJvm :core:ui:compileKotlinJvm :feature:intro:compileKotlinJvm :feature:messaging:compileKotlinJvm :feature:connections:compileKotlinJvm :feature:map:compileKotlinJvm :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm :feature:firmware:compileKotlinJvm -Pci=true --continue --scan
- name: KMP Smoke Compile
run: ./gradlew :core:proto:compileKotlinJvm :core:common:compileKotlinJvm :core:model:compileKotlinJvm :core:repository:compileKotlinJvm :core:di:compileKotlinJvm :core:navigation:compileKotlinJvm :core:resources:compileKotlinJvm :core:datastore:compileKotlinJvm :core:database:compileKotlinJvm :core:domain:compileKotlinJvm :core:prefs:compileKotlinJvm :core:network:compileKotlinJvm :core:data:compileKotlinJvm :core:ble:compileKotlinJvm :core:nfc:compileKotlinJvm :core:service:compileKotlinJvm :core:testing:compileKotlinJvm :core:ui:compileKotlinJvm :feature:intro:compileKotlinJvm :feature:messaging:compileKotlinJvm :feature:connections:compileKotlinJvm :feature:map:compileKotlinJvm :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm :feature:firmware:compileKotlinJvm :core:proto:compileKotlinIosSimulatorArm64 :core:common:compileKotlinIosSimulatorArm64 :core:model:compileKotlinIosSimulatorArm64 :core:repository:compileKotlinIosSimulatorArm64 :core:di:compileKotlinIosSimulatorArm64 :core:navigation:compileKotlinIosSimulatorArm64 :core:resources:compileKotlinIosSimulatorArm64 :core:datastore:compileKotlinIosSimulatorArm64 :core:database:compileKotlinIosSimulatorArm64 :core:domain:compileKotlinIosSimulatorArm64 :core:prefs:compileKotlinIosSimulatorArm64 :core:network:compileKotlinIosSimulatorArm64 :core:data:compileKotlinIosSimulatorArm64 :core:ble:compileKotlinIosSimulatorArm64 :core:nfc:compileKotlinIosSimulatorArm64 :core:service:compileKotlinIosSimulatorArm64 :core:testing:compileKotlinIosSimulatorArm64 :core:ui:compileKotlinIosSimulatorArm64 :feature:intro:compileKotlinIosSimulatorArm64 :feature:messaging:compileKotlinIosSimulatorArm64 :feature:connections:compileKotlinIosSimulatorArm64 :feature:map:compileKotlinIosSimulatorArm64 :feature:node:compileKotlinIosSimulatorArm64 :feature:settings:compileKotlinIosSimulatorArm64 :feature:firmware:compileKotlinIosSimulatorArm64 -Pci=true --continue --scan
- name: Upload coverage results to Codecov
if: ${{ !cancelled() && inputs.run_unit_tests }}

2
.gitignore vendored
View File

@@ -51,4 +51,4 @@ wireless-install.sh
# Git worktrees
.worktrees/
/firebase-debug.log
/firebase-debug.log.jdk/

1
.jdk Symbolic link
View File

@@ -0,0 +1 @@
/home/james/.jdks/ms-17.0.18

View File

@@ -14,7 +14,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `fdroid`: Open source only, no tracking/analytics.
- `google`: Includes Google Play Services (Maps) and DataDog analytics.
- **Core Architecture:** Modern Android Development (MAD) with KMP core.
- **KMP Modules:** Most `core:*` modules. All declare `jvm()` target and compile clean on JVM.
- **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose Multiplatform (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
@@ -50,15 +50,15 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core/ble/` | Bluetooth Low Energy stack using Kable. |
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
| `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` target except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
## 3. Development Guidelines & Coding Standards
### A. UI Development (Jetpack Compose)
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). NEVER use hardcoded strings.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.

View File

@@ -14,7 +14,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `fdroid`: Open source only, no tracking/analytics.
- `google`: Includes Google Play Services (Maps) and DataDog analytics.
- **Core Architecture:** Modern Android Development (MAD) with KMP core.
- **KMP Modules:** Most `core:*` modules. All declare `jvm()` target and compile clean on JVM.
- **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all.
- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`.
- **UI:** Jetpack Compose Multiplatform (Material 3).
- **DI:** Koin Annotations with K2 compiler plugin. Root graph assembly is centralized in `app` and `desktop`.
@@ -50,15 +50,15 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core/ble/` | Bluetooth Low Energy stack using Kable. |
| `core/resources/` | Centralized string and image resources (Compose Multiplatform). |
| `core/testing/` | **Shared test doubles, fakes, and utilities for `commonTest` across all KMP modules.** |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` target except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Nav 3 shell, full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `widget`). All are KMP with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `desktop/` | Compose Desktop application — first non-Android KMP target. Thin host shell relying entirely on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports with `want_config` handshake. |
| `mesh_service_example/` | Sample app showing `core:api` service integration. |
## 3. Development Guidelines & Coding Standards
### A. UI Development (Jetpack Compose)
- **Material 3:** The app uses Material 3.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). NEVER use hardcoded strings.
- **Strings:** MUST use the **Compose Multiplatform Resource** library in `core:resources` (`stringResource(Res.string.your_key)`). For ViewModels or non-composable Coroutines, use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use hardcoded strings, and NEVER use the blocking `getString()` in a coroutine.
- **Dialogs:** Use centralized components in `core:ui` (e.g., `MeshtasticResourceDialog`).
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.

View File

@@ -1,32 +1,5 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()</ID>
<ID>LongParameterList:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, dispatchers: CoroutineDispatchers, meshLogRepository: MeshLogRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, tracerouteSnapshotRepository: TracerouteSnapshotRepository, nodeRequestActions: NodeRequestActions, alertManager: AlertManager, getNodeDetailsUseCase: GetNodeDetailsUseCase, )</ID>
<ID>LongParameterList:AndroidNodeListViewModel.kt$AndroidNodeListViewModel$( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, radioController: RadioController, nodeManagementActions: NodeManagementActions, getFilteredNodesUseCase: GetFilteredNodesUseCase, nodeFilterPreferences: NodeFilterPreferences, )</ID>
<ID>LongParameterList:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, radioConfigRepository: RadioConfigRepository, packetRepository: PacketRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, private val locationRepository: LocationRepository, mapConsentPrefs: MapConsentPrefs, analyticsPrefs: AnalyticsPrefs, homoglyphEncodingPrefs: HomoglyphPrefs, toggleAnalyticsUseCase: ToggleAnalyticsUseCase, toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase, importProfileUseCase: ImportProfileUseCase, exportProfileUseCase: ExportProfileUseCase, exportSecurityConfigUseCase: ExportSecurityConfigUseCase, installProfileUseCase: InstallProfileUseCase, radioConfigUseCase: RadioConfigUseCase, adminActionsUseCase: AdminActionsUseCase, processRadioResponseUseCase: ProcessRadioResponseUseCase, )</ID>
<ID>LongParameterList:AndroidSettingsViewModel.kt$AndroidSettingsViewModel$( private val app: Application, radioConfigRepository: RadioConfigRepository, radioController: RadioController, nodeRepository: NodeRepository, uiPrefs: UiPrefs, buildConfigProvider: BuildConfigProvider, databaseManager: DatabaseManager, meshLogPrefs: MeshLogPrefs, setThemeUseCase: SetThemeUseCase, setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, setProvideLocationUseCase: SetProvideLocationUseCase, setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase, setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase, meshLocationUseCase: MeshLocationUseCase, exportDataUseCase: ExportDataUseCase, isOtaCapableUseCase: IsOtaCapableUseCase, )</ID>
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1000L</ID>
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-5</ID>
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-7</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$0xff</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$3</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$4</ID>
<ID>MagicNumber:StreamInterface.kt$StreamInterface$8</ID>
<ID>MagicNumber:TCPInterface.kt$TCPInterface$1000</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
<ID>TooGenericExceptionCaught:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$ex: Exception</ID>
<ID>TooGenericExceptionCaught:BleRadioInterface.kt$BleRadioInterface$e: Exception</ID>
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
<ID>TooManyFunctions:BleRadioInterface.kt$BleRadioInterface : RadioTransport</ID>
>>>>>>> ba83c3564 (chore(conductor): Complete Phase 4 - Wire Kable and Remove Nordic)
</CurrentIssues>
<CurrentIssues/>
</SmellBaseline>

View File

@@ -71,6 +71,7 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.DeviceVersion
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
@@ -113,7 +114,7 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerViewModel = koinViewModel()) {
val backStack = rememberNavBackStack(NodesRoutes.NodesGraph as NavKey)
val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
// LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) }
// }
val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()

View File

@@ -67,6 +67,14 @@ internal fun Project.configureKotlinAndroid(
*/
internal fun Project.configureKotlinMultiplatform() {
extensions.configure<KotlinMultiplatformExtension> {
// Standard KMP targets for Meshtastic
jvm()
// Configure the iOS targets for compile-only validation
// We only add these for modules that already have KMP structure
iosArm64()
iosSimulatorArm64()
// Configure the Android target if the plugin is applied
pluginManager.withPlugin("com.android.kotlin.multiplatform.library") {
extensions.findByType<KotlinMultiplatformAndroidLibraryTarget>()?.apply {
@@ -166,6 +174,27 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
// Using Java 17 for better compatibility with consumers (e.g. plugins, older environments)
// while still supporting modern Kotlin features.
jvmToolchain(17)
if (this is KotlinMultiplatformExtension) {
targets.configureEach {
compilations.configureEach {
compileTaskProvider.configure {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-opt-in=kotlinx.cinterop.ExperimentalForeignApi",
"-Xexpect-actual-classes",
"-Xcontext-parameters",
"-Xannotation-default-target=param-property",
"-Xskip-prerelease-check"
)
}
}
}
}
}
}
tasks.withType<KotlinCompile>().configureEach {
@@ -177,6 +206,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
"-opt-in=kotlin.time.ExperimentalTime",
"-opt-in=kotlinx.cinterop.ExperimentalForeignApi",
"-Xexpect-actual-classes",
"-Xcontext-parameters",
"-Xannotation-default-target=param-property",

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ble
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
/** No-op stubs for iOS target in core:ble. */
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
// No-op for stubs
}
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
throw UnsupportedOperationException("iOS Peripheral not yet implemented")

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General 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.common.util
import kotlinx.coroutines.CoroutineDispatcher
/** Access to the IO dispatcher in a multiplatform-safe way. */
expect val ioDispatcher: CoroutineDispatcher

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General 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.common.util
/** Multiplatform string formatting helper. */
expect fun formatString(pattern: String, vararg args: Any?): String

View File

@@ -81,7 +81,7 @@ object HomoglyphCharacterStringTransformer {
*/
fun optimizeUtf8StringWithHomoglyphs(value: String): String {
val stringBuilder = StringBuilder()
for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping.getOrDefault(c, c))
for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c)
return stringBuilder.toString()
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General 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.common.util
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General 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.common.util
/** Apple (iOS) implementation of string formatting. Stub implementation for compile-only validation. */
actual fun formatString(pattern: String, vararg args: Any?): String = throw UnsupportedOperationException(
"formatString is not supported on iOS at runtime; this target is intended for compile-only validation.",
)

View File

@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General 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.common.util
/** No-op stubs for iOS target in core:common. */
actual object BuildUtils {
actual val isEmulator: Boolean = false
actual val sdkInt: Int = 0
}
actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List<String>) {
actual fun getQueryParameter(key: String): String? = null
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue
actual override fun toString(): String = ""
actual companion object {
actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList())
}
}
actual fun CommonUri.toPlatformUri(): Any = Any()
actual object DateFormatter {
actual fun formatRelativeTime(timestampMillis: Long): String = ""
actual fun formatDateTime(timestampMillis: Long): String = ""
actual fun formatShortDate(timestampMillis: Long): String = ""
actual fun formatTime(timestampMillis: Long): String = ""
actual fun formatTimeWithSeconds(timestampMillis: Long): String = ""
actual fun formatDate(timestampMillis: Long): String = ""
actual fun formatDateTimeShort(timestampMillis: Long): String = ""
}
actual fun getSystemMeasurementSystem(): MeasurementSystem = MeasurementSystem.METRIC
actual fun String?.isValidAddress(): Boolean = false
actual interface CommonParcelable
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
actual annotation class CommonParcelize actual constructor()
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
actual annotation class CommonIgnoredOnParcel actual constructor()
actual interface CommonParceler<T> {
actual fun create(parcel: CommonParcel): T
actual fun T.write(parcel: CommonParcel, flags: Int)
}
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
@Repeatable
actual annotation class CommonTypeParceler<T, P : CommonParceler<in T>> actual constructor()
actual class CommonParcel {
actual fun readString(): String? = null
actual fun readInt(): Int = 0
actual fun readLong(): Long = 0L
actual fun readFloat(): Float = 0.0f
actual fun createByteArray(): ByteArray? = null
actual fun writeByteArray(b: ByteArray?) {}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General 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.common.util
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General 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.common.util
/** JVM/Android implementation of string formatting. */
actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)

View File

@@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
@@ -27,6 +26,7 @@ import kotlinx.coroutines.flow.onEach
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
@@ -58,7 +58,7 @@ class CommandSenderImpl(
private val nodeManager: NodeManager,
private val radioConfigRepository: RadioConfigRepository,
) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = atomic(ByteString.EMPTY)
override val tracerouteStartTimes = mutableMapOf<Int, Long>()

View File

@@ -17,13 +17,13 @@
package org.meshtastic.core.data.manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
@@ -64,7 +64,7 @@ class MeshActionHandlerImpl(
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy<MeshMessageProcessor>,
) : MeshActionHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope

View File

@@ -18,12 +18,12 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import okio.IOException
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigFlowManager
@@ -56,7 +56,7 @@ class MeshConfigFlowManagerImpl(
private val commandSender: CommandSender,
private val packetHandler: PacketHandler,
) : MeshConfigFlowManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val configOnlyNonce = 69420
private val nodeInfoNonce = 69421
private val wantConfigDelay = 100L

View File

@@ -17,7 +17,6 @@
package org.meshtastic.core.data.manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -25,6 +24,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioConfigRepository
@@ -41,7 +41,7 @@ class MeshConfigHandlerImpl(
private val serviceRepository: ServiceRepository,
private val nodeManager: NodeManager,
) : MeshConfigHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val _localConfig = MutableStateFlow(LocalConfig())
override val localConfig = _localConfig.asStateFlow()

View File

@@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
@@ -30,6 +29,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
@@ -89,7 +89,7 @@ class MeshConnectionManagerImpl(
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
) : MeshConnectionManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null

View File

@@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
@@ -30,6 +29,7 @@ import okio.ByteString.Companion.toByteString
import okio.IOException
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
@@ -63,7 +63,7 @@ import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.low_battery_message
import org.meshtastic.core.resources.low_battery_title
import org.meshtastic.core.resources.unknown_username
@@ -114,7 +114,7 @@ class MeshDataHandlerImpl(
private val radioConfigRepository: RadioConfigRepository,
private val messageFilter: MessageFilter,
) : MeshDataHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope
@@ -433,9 +433,13 @@ class MeshDataHandlerImpl(
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
notificationManager.dispatch(
Notification(
title = getString(Res.string.low_battery_title, nextNode.user.short_name),
title =
getStringSuspend(
Res.string.low_battery_title,
nextNode.user.short_name,
),
message =
getString(
getStringSuspend(
Res.string.low_battery_message,
nextNode.user.long_name,
nextNode.deviceMetrics.battery_level ?: 0,
@@ -502,7 +506,9 @@ class MeshDataHandlerImpl(
val payload = packet.decoded?.payload ?: return
val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return
if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) {
serviceRepository.setErrorMessage(getString(Res.string.error_duty_cycle), Severity.Warn)
scope.launch {
serviceRepository.setErrorMessage(getStringSuspend(Res.string.error_duty_cycle), Severity.Warn)
}
}
handleAckNak(
packet.decoded?.request_id ?: 0,
@@ -659,25 +665,27 @@ class MeshDataHandlerImpl(
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = dataPacket.alert ?: getString(Res.string.critical_alert),
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
scope.launch {
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
}
} else if (updateNotification && !isSilent) {
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
}
}
private fun getSenderName(packet: DataPacket): String {
private suspend fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
val myId = nodeManager.getMyId()
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getString(Res.string.unknown_username)
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getString(Res.string.unknown_username)
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
@@ -701,7 +709,7 @@ class MeshDataHandlerImpl(
}
PortNum.WAYPOINT_APP.value -> {
val message = getString(Res.string.waypoint_received, dataPacket.waypoint!!.name)
val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name)
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),

View File

@@ -18,7 +18,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
@@ -28,6 +27,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.MeshLog
@@ -55,7 +55,7 @@ class MeshMessageProcessorImpl(
private val router: Lazy<MeshRouter>,
private val fromRadioDispatcher: FromRadioPacketHandler,
) : MeshMessageProcessor {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val mapsMutex = Mutex()
private val logUuidByPacketId = mutableMapOf<Int, String>()

View File

@@ -19,13 +19,13 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketHandler
@@ -39,7 +39,7 @@ class MqttManagerImpl(
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
) : MqttManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private var mqttMessageFlow: Job? = null
override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {

View File

@@ -18,10 +18,10 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
@@ -38,7 +38,7 @@ class NeighborInfoHandlerImpl(
private val commandSender: CommandSender,
private val serviceBroadcasts: ServiceBroadcasts,
) : NeighborInfoHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope

View File

@@ -21,13 +21,13 @@ import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import okio.ByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
@@ -43,7 +43,7 @@ import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
@@ -62,7 +62,7 @@ class NodeManagerImpl(
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
) : NodeManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
private val _nodeDBbyNodeNum = atomic(persistentMapOf<Int, Node>())
private val _nodeDBbyID = atomic(persistentMapOf<String, Node>())
@@ -196,13 +196,15 @@ class NodeManagerImpl(
node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified)
}
if (newNode && !shouldPreserve) {
notificationManager.dispatch(
Notification(
title = getString(Res.string.new_node_seen, next.user.short_name),
message = next.user.long_name,
category = Notification.Category.NodeEvent,
),
)
scope.handledLaunch {
notificationManager.dispatch(
Notification(
title = getStringSuspend(Res.string.new_node_seen, next.user.short_name),
message = next.user.long_name,
category = Notification.Category.NodeEvent,
),
)
}
}
next
}

View File

@@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
@@ -30,6 +29,7 @@ import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
@@ -67,7 +67,7 @@ class PacketHandlerImpl(
}
private var queueJob: Job? = null
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
private var scope: CoroutineScope = CoroutineScope(ioDispatcher)
private val queueMutex = Mutex()
private val queuedPackets = mutableListOf<MeshPacket>()

View File

@@ -18,11 +18,11 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
@@ -44,7 +44,7 @@ class TracerouteHandlerImpl(
private val nodeRepository: NodeRepository,
private val commandSender: CommandSender,
) : TracerouteHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope

View File

@@ -24,7 +24,7 @@ import androidx.room3.RoomDatabase
import androidx.room3.TypeConverters
import androidx.room3.migration.AutoMigrationSpec
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import kotlinx.coroutines.Dispatchers
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
@@ -122,14 +122,15 @@ abstract class MeshtasticDatabase : RoomDatabase() {
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(): RoomDatabase.Builder<T> =
this.fallbackToDestructiveMigration(dropAllTables = false)
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.setQueryCoroutineContext(ioDispatcher)
}
}
@DeleteTable.Entries(DeleteTable(tableName = "NodeInfo"), DeleteTable(tableName = "MyNodeInfo"))
@DeleteTable(tableName = "NodeInfo")
@DeleteTable(tableName = "MyNodeInfo")
class AutoMigration12to13 : AutoMigrationSpec
@DeleteColumn.Entries(DeleteColumn(tableName = "packet", columnName = "reply_id"))
@DeleteColumn(tableName = "packet", columnName = "reply_id")
class AutoMigration29to30 : AutoMigrationSpec
@DeleteColumn(tableName = "packet", columnName = "retry_count")

View File

@@ -17,11 +17,16 @@
package org.meshtastic.core.database
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.okio.OkioSerializer
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.room3.Room
import androidx.room3.RoomDatabase
import kotlinx.cinterop.ExperimentalForeignApi
import okio.BufferedSink
import okio.BufferedSource
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
@@ -55,11 +60,32 @@ actual fun deleteDatabase(dbName: String) {
/** Returns the system FileSystem for iOS. */
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
private object PreferencesSerializer : OkioSerializer<Preferences> {
override val defaultValue: Preferences
get() = emptyPreferences()
override suspend fun readFrom(source: BufferedSource): Preferences {
// iOS stub: return an empty Preferences instance instead of crashing.
return emptyPreferences()
}
override suspend fun writeTo(t: Preferences, sink: BufferedSink) {
// iOS stub: no-op to avoid crashing on write.
}
}
/** Creates an iOS DataStore for database preferences. */
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
val dir = documentDirectory() + "/datastore"
NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)
return PreferenceDataStoreFactory.create(produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath() })
return DataStoreFactory.create(
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = PreferencesSerializer,
producePath = { (dir + "/$name.preferences_pb").toPath() },
),
)
}
@OptIn(ExperimentalForeignApi::class)

View File

@@ -23,7 +23,6 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -33,6 +32,7 @@ import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.UiPreferences
import org.meshtastic.core.common.util.ioDispatcher
const val KEY_APP_INTRO_COMPLETED = "app_intro_completed"
const val KEY_THEME = "theme"
@@ -52,7 +52,7 @@ const val KEY_EXCLUDE_MQTT = "exclude-mqtt"
open class UiPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) :
UiPreferences {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
// Start this flow eagerly, so app intro doesn't flash (when disabled) on cold app start.
override val appIntroCompleted: StateFlow<Boolean> =

View File

@@ -17,17 +17,17 @@
package org.meshtastic.core.datastore.di
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
@Module
@ComponentScan("org.meshtastic.core.datastore")
class CoreDatastoreModule {
@Single
@Named("DataStoreScope")
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
}

View File

@@ -29,5 +29,10 @@ kotlin {
androidResources.enable = false
}
sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) } }
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(libs.kotlinx.coroutines.core)
}
}
}

View File

@@ -19,11 +19,12 @@ package org.meshtastic.core.di.di
import kotlinx.coroutines.Dispatchers
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.di.CoroutineDispatchers
@Module
class CoreDiModule {
@Single
fun provideCoroutineDispatchers(): CoroutineDispatchers =
CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.Main, default = Dispatchers.Default)
CoroutineDispatchers(io = ioDispatcher, main = Dispatchers.Main, default = Dispatchers.Default)
}

View File

@@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.domain"

View File

@@ -19,12 +19,8 @@ package org.meshtastic.core.model.util
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY
import java.text.DateFormat
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
private val DAY_DURATION = 24.hours
@@ -53,9 +49,3 @@ fun getShortDate(time: Long): String? {
* @param remainingMillis The remaining time in milliseconds
* @return Pair of (days, hours), where days is Int and hours is Double
*/
fun formatMuteRemainingTime(remainingMillis: Long): Pair<Int, Double> {
val duration = remainingMillis.milliseconds
if (duration <= Duration.ZERO) return 0 to 0.0
val totalHours = duration.toDouble(DurationUnit.HOURS)
return (totalHours / HOURS_PER_DAY).toInt() to (totalHours % HOURS_PER_DAY)
}

View File

@@ -98,7 +98,7 @@ data class Channel(val settings: ChannelSettings = default.settings, val loraCon
cleartextPSK
} else {
// Treat an index of 1 as the old channelDefaultKey and work up from there
val bytes = channelDefaultKey.clone()
val bytes = channelDefaultKey.copyOf()
bytes[bytes.size - 1] = (0xff and (bytes[bytes.size - 1] + pskIndex - 1)).toByte()
bytes.toByteString()
}

View File

@@ -25,6 +25,7 @@ import org.meshtastic.core.common.util.CommonParcel
import org.meshtastic.core.common.util.CommonParcelable
import org.meshtastic.core.common.util.CommonParcelize
import org.meshtastic.core.common.util.CommonTypeParceler
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
@@ -190,7 +191,7 @@ data class DataPacket(
// Public-key cryptography (PKC) channel index
const val PKC_CHANNEL_INDEX = 8
fun nodeNumToDefaultId(n: Int): String = "!%08x".format(n)
fun nodeNumToDefaultId(n: Int): String = formatString("!%08x", n)
@Suppress("MagicNumber")
fun idToDefaultNodeNum(id: String?): Int? = runCatching { id?.toLong(16)?.toInt() }.getOrNull()

View File

@@ -20,6 +20,7 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.GPSFormat
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.onlineTimeThreshold
@@ -143,20 +144,20 @@ data class Node(
val temp =
if ((temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(temperature ?: 0f))
formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f))
} else {
"%.1f°C".format(temperature)
formatString("%.1f°C", temperature)
}
} else {
null
}
val humidity = if ((relative_humidity ?: 0f) != 0f) "%.0f%%".format(relative_humidity) else null
val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null
val soilTemperatureStr =
if ((soil_temperature ?: 0f) != 0f) {
if (isFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(soil_temperature ?: 0f))
formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f))
} else {
"%.1f°C".format(soil_temperature)
formatString("%.1f°C", soil_temperature)
}
} else {
null
@@ -164,12 +165,12 @@ data class Node(
val soilMoistureRange = 0..100
val soilMoisture =
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
"%d%%".format(soil_moisture)
formatString("%d%%", soil_moisture)
} else {
null
}
val voltage = if ((this.voltage ?: 0f) != 0f) "%.2fV".format(this.voltage) else null
val current = if ((current ?: 0f) != 0f) "%.1fmA".format(current) else null
val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null
val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(

View File

@@ -16,6 +16,8 @@
*/
package org.meshtastic.core.model.util
import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
@@ -46,3 +48,16 @@ fun formatUptime(seconds: Int): String {
.joinToString(" ")
}
}
/**
* Calculates the remaining mute time in days and hours.
*
* @param remainingMillis The remaining time in milliseconds
* @return Pair of (days, hours), where days is Int and hours is Double
*/
fun formatMuteRemainingTime(remainingMillis: Long): Pair<Int, Double> {
val duration = remainingMillis.milliseconds
if (duration <= kotlin.time.Duration.ZERO) return 0 to 0.0
val totalHours = duration.toDouble(kotlin.time.DurationUnit.HOURS)
return (totalHours / HOURS_PER_DAY).toInt() to (totalHours % HOURS_PER_DAY)
}

View File

@@ -19,6 +19,7 @@
package org.meshtastic.core.model.util
import org.meshtastic.core.common.util.MeasurementSystem
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.getSystemMeasurementSystem
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
@@ -49,12 +50,15 @@ fun Int.metersIn(system: DisplayUnits): Float {
return this.metersIn(unit)
}
fun Float.toString(unit: DistanceUnit): String = if (unit in setOf(DistanceUnit.METER, DistanceUnit.FOOT)) {
"%.0f %s"
} else {
"%.1f %s"
fun Float.toString(unit: DistanceUnit): String {
val pattern =
if (unit in setOf(DistanceUnit.METER, DistanceUnit.FOOT)) {
"%.0f %s"
} else {
"%.1f %s"
}
return formatString(pattern, this, unit.symbol)
}
.format(this, unit.symbol)
fun Float.toString(system: DisplayUnits): String {
val unit =
@@ -81,14 +85,14 @@ fun Int.toDistanceString(system: DisplayUnits): String {
@Suppress("MagicNumber")
fun Float.toSpeedString(system: DisplayUnits): String = if (system == DisplayUnits.METRIC) {
"%.0f km/h".format(this * 3.6)
formatString("%.0f km/h", this * 3.6)
} else {
"%.0f mph".format(this * 2.23694f)
formatString("%.0f mph", this * 2.23694f)
}
@Suppress("MagicNumber")
fun Float.toSmallDistanceString(system: DisplayUnits): String = if (system == DisplayUnits.IMPERIAL) {
"%.2f in".format(this / 25.4f)
formatString("%.2f in", this / 25.4f)
} else {
"%.0f mm".format(this)
formatString("%.0f mm", this)
}

View File

@@ -62,7 +62,7 @@ private fun decodeSharedContactData(data: String): SharedContact {
sanitized.decodeBase64() ?: throw IllegalArgumentException("Invalid Base64 string")
} catch (e: IllegalArgumentException) {
throw MalformedMeshtasticUrlException(
"Failed to Base64 decode SharedContact data ($data): ${e.javaClass.simpleName}: ${e.message}",
"Failed to Base64 decode SharedContact data ($data): ${e::class.simpleName}: ${e.message}",
)
}
@@ -70,7 +70,7 @@ private fun decodeSharedContactData(data: String): SharedContact {
SharedContact.ADAPTER.decode(decodedBytes)
} catch (e: Exception) {
throw MalformedMeshtasticUrlException(
"Failed to proto decode SharedContact: ${e.javaClass.simpleName}: ${e.message}",
"Failed to proto decode SharedContact: ${e::class.simpleName}: ${e.message}",
)
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.model.util
/** No-op stubs for core:model on iOS. */
actual fun getShortDateTime(time: Long): String = ""
actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size)
actual object SfppHasher {
actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32)
}

View File

@@ -22,8 +22,6 @@ plugins {
}
kotlin {
jvm()
android { namespace = "org.meshtastic.core.navigation" }
sourceSets {

View File

@@ -0,0 +1,116 @@
/*
* 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.navigation
import androidx.navigation3.runtime.NavKey
import androidx.savedstate.serialization.SavedStateConfiguration
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
/**
* Shared polymorphic serialization configuration for Navigation 3 saved-state support. Registers all route types used
* across Android and Desktop navigation graphs.
*/
val MeshtasticNavSavedStateConfig = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
// Nodes
subclass(NodesRoutes.NodesGraph::class, NodesRoutes.NodesGraph.serializer())
subclass(NodesRoutes.Nodes::class, NodesRoutes.Nodes.serializer())
subclass(NodesRoutes.NodeDetailGraph::class, NodesRoutes.NodeDetailGraph.serializer())
subclass(NodesRoutes.NodeDetail::class, NodesRoutes.NodeDetail.serializer())
// Node detail sub-screens
subclass(NodeDetailRoutes.DeviceMetrics::class, NodeDetailRoutes.DeviceMetrics.serializer())
subclass(NodeDetailRoutes.NodeMap::class, NodeDetailRoutes.NodeMap.serializer())
subclass(NodeDetailRoutes.PositionLog::class, NodeDetailRoutes.PositionLog.serializer())
subclass(NodeDetailRoutes.EnvironmentMetrics::class, NodeDetailRoutes.EnvironmentMetrics.serializer())
subclass(NodeDetailRoutes.SignalMetrics::class, NodeDetailRoutes.SignalMetrics.serializer())
subclass(NodeDetailRoutes.PowerMetrics::class, NodeDetailRoutes.PowerMetrics.serializer())
subclass(NodeDetailRoutes.TracerouteLog::class, NodeDetailRoutes.TracerouteLog.serializer())
subclass(NodeDetailRoutes.TracerouteMap::class, NodeDetailRoutes.TracerouteMap.serializer())
subclass(NodeDetailRoutes.HostMetricsLog::class, NodeDetailRoutes.HostMetricsLog.serializer())
subclass(NodeDetailRoutes.PaxMetrics::class, NodeDetailRoutes.PaxMetrics.serializer())
subclass(NodeDetailRoutes.NeighborInfoLog::class, NodeDetailRoutes.NeighborInfoLog.serializer())
// Conversations
subclass(ContactsRoutes.ContactsGraph::class, ContactsRoutes.ContactsGraph.serializer())
subclass(ContactsRoutes.Contacts::class, ContactsRoutes.Contacts.serializer())
subclass(ContactsRoutes.Messages::class, ContactsRoutes.Messages.serializer())
subclass(ContactsRoutes.Share::class, ContactsRoutes.Share.serializer())
subclass(ContactsRoutes.QuickChat::class, ContactsRoutes.QuickChat.serializer())
// Map
subclass(MapRoutes.Map::class, MapRoutes.Map.serializer())
// Firmware
subclass(FirmwareRoutes.FirmwareGraph::class, FirmwareRoutes.FirmwareGraph.serializer())
subclass(FirmwareRoutes.FirmwareUpdate::class, FirmwareRoutes.FirmwareUpdate.serializer())
// Settings
subclass(SettingsRoutes.SettingsGraph::class, SettingsRoutes.SettingsGraph.serializer())
subclass(SettingsRoutes.Settings::class, SettingsRoutes.Settings.serializer())
subclass(SettingsRoutes.DeviceConfiguration::class, SettingsRoutes.DeviceConfiguration.serializer())
subclass(SettingsRoutes.ModuleConfiguration::class, SettingsRoutes.ModuleConfiguration.serializer())
subclass(SettingsRoutes.Administration::class, SettingsRoutes.Administration.serializer())
// Settings - Config routes
subclass(SettingsRoutes.User::class, SettingsRoutes.User.serializer())
subclass(SettingsRoutes.ChannelConfig::class, SettingsRoutes.ChannelConfig.serializer())
subclass(SettingsRoutes.Device::class, SettingsRoutes.Device.serializer())
subclass(SettingsRoutes.Position::class, SettingsRoutes.Position.serializer())
subclass(SettingsRoutes.Power::class, SettingsRoutes.Power.serializer())
subclass(SettingsRoutes.Network::class, SettingsRoutes.Network.serializer())
subclass(SettingsRoutes.Display::class, SettingsRoutes.Display.serializer())
subclass(SettingsRoutes.LoRa::class, SettingsRoutes.LoRa.serializer())
subclass(SettingsRoutes.Bluetooth::class, SettingsRoutes.Bluetooth.serializer())
subclass(SettingsRoutes.Security::class, SettingsRoutes.Security.serializer())
// Settings - Module routes
subclass(SettingsRoutes.MQTT::class, SettingsRoutes.MQTT.serializer())
subclass(SettingsRoutes.Serial::class, SettingsRoutes.Serial.serializer())
subclass(SettingsRoutes.ExtNotification::class, SettingsRoutes.ExtNotification.serializer())
subclass(SettingsRoutes.StoreForward::class, SettingsRoutes.StoreForward.serializer())
subclass(SettingsRoutes.RangeTest::class, SettingsRoutes.RangeTest.serializer())
subclass(SettingsRoutes.Telemetry::class, SettingsRoutes.Telemetry.serializer())
subclass(SettingsRoutes.CannedMessage::class, SettingsRoutes.CannedMessage.serializer())
subclass(SettingsRoutes.Audio::class, SettingsRoutes.Audio.serializer())
subclass(SettingsRoutes.RemoteHardware::class, SettingsRoutes.RemoteHardware.serializer())
subclass(SettingsRoutes.NeighborInfo::class, SettingsRoutes.NeighborInfo.serializer())
subclass(SettingsRoutes.AmbientLighting::class, SettingsRoutes.AmbientLighting.serializer())
subclass(SettingsRoutes.DetectionSensor::class, SettingsRoutes.DetectionSensor.serializer())
subclass(SettingsRoutes.Paxcounter::class, SettingsRoutes.Paxcounter.serializer())
subclass(SettingsRoutes.StatusMessage::class, SettingsRoutes.StatusMessage.serializer())
subclass(SettingsRoutes.TrafficManagement::class, SettingsRoutes.TrafficManagement.serializer())
subclass(SettingsRoutes.TAK::class, SettingsRoutes.TAK.serializer())
// Settings - Advanced routes
subclass(SettingsRoutes.CleanNodeDb::class, SettingsRoutes.CleanNodeDb.serializer())
subclass(SettingsRoutes.DebugPanel::class, SettingsRoutes.DebugPanel.serializer())
subclass(SettingsRoutes.About::class, SettingsRoutes.About.serializer())
subclass(SettingsRoutes.FilterSettings::class, SettingsRoutes.FilterSettings.serializer())
// Channels
subclass(ChannelsRoutes.ChannelsGraph::class, ChannelsRoutes.ChannelsGraph.serializer())
subclass(ChannelsRoutes.Channels::class, ChannelsRoutes.Channels.serializer())
// Connections
subclass(ConnectionsRoutes.ConnectionsGraph::class, ConnectionsRoutes.ConnectionsGraph.serializer())
subclass(ConnectionsRoutes.Connections::class, ConnectionsRoutes.Connections.serializer())
}
}
}

View File

@@ -23,8 +23,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.network"

View File

@@ -313,8 +313,8 @@ class MockInterface(private val service: RadioInterfaceService, val address: Str
user =
User(
id = DataPacket.nodeNumToDefaultId(numIn),
long_name = "Sim " + Integer.toHexString(numIn),
short_name = getInitials("Sim " + Integer.toHexString(numIn)),
long_name = "Sim " + numIn.toString(16),
short_name = getInitials("Sim " + numIn.toString(16)),
hw_model = HardwareModel.ANDROID_SIM,
),
position =

View File

@@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.nfc"

View File

@@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
android {
namespace = "org.meshtastic.core.prefs"
androidResources.enable = false

View File

@@ -24,9 +24,6 @@ plugins {
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
kotlin {
// Keep jvm() for desktop/server consumers
jvm()
// Override minSdk for ATAK compatibility (standard is 26)
android { minSdk = 21 }

View File

@@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android { androidResources.enable = false }

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General 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.repository
/** No-op stub for Location on iOS. */
actual class Location

View File

@@ -29,7 +29,10 @@ kotlin {
withHostTest { isIncludeAndroidResources = true }
}
sourceSets { commonTest.dependencies { implementation(kotlin("test")) } }
sourceSets {
commonMain.dependencies { implementation(projects.core.common) }
commonTest.dependencies { implementation(kotlin("test")) }
}
}
compose.resources {

View File

@@ -25,12 +25,22 @@ fun getString(stringResource: StringResource): String = runBlocking { composeGet
/** Retrieves a formatted string from the [StringResource] in a blocking manner. */
fun getString(stringResource: StringResource, vararg formatArgs: Any): String = runBlocking {
val pattern = composeGetString(stringResource)
if (formatArgs.isNotEmpty()) {
val resolvedArgs =
formatArgs
.map { arg ->
if (arg is StringResource) {
composeGetString(arg)
} else {
arg
}
}
.toTypedArray()
if (resolvedArgs.isNotEmpty()) {
@Suppress("SpreadOperator")
pattern.format(*formatArgs)
composeGetString(stringResource, *resolvedArgs)
} else {
pattern
composeGetString(stringResource)
}
}
@@ -50,11 +60,10 @@ suspend fun getStringSuspend(stringResource: StringResource, vararg formatArgs:
}
.toTypedArray()
val pattern = composeGetString(stringResource)
return if (resolvedArgs.isNotEmpty()) {
@Suppress("SpreadOperator")
pattern.format(*resolvedArgs)
composeGetString(stringResource, *resolvedArgs)
} else {
pattern
composeGetString(stringResource)
}
}

View File

@@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.service"
@@ -45,6 +43,7 @@ kotlin {
implementation(projects.core.proto)
implementation(libs.jetbrains.lifecycle.runtime)
implementation(libs.kotlinx.atomicfu)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kermit)
}

View File

@@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BluetoothRepository
@@ -100,7 +102,7 @@ class SharedRadioInterfaceService(
private var radioIf: RadioTransport? = null
private var isStarted = false
@Volatile private var listenersInitialized = false
private val listenersInitialized = kotlinx.atomicfu.atomic(false)
private var heartbeatJob: kotlinx.coroutines.Job? = null
private var lastHeartbeatMillis = 0L
@@ -108,42 +110,46 @@ class SharedRadioInterfaceService(
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
}
private val initLock = Mutex()
private fun initStateListeners() {
if (listenersInitialized) return
synchronized(this) {
if (listenersInitialized) return
listenersInitialized = true
if (listenersInitialized.value) return
processLifecycle.coroutineScope.launch {
initLock.withLock {
if (listenersInitialized.value) return@withLock
listenersInitialized.value = true
radioPrefs.devAddr
.onEach { addr ->
if (_currentDeviceAddressFlow.value != addr) {
_currentDeviceAddressFlow.value = addr
startInterface()
radioPrefs.devAddr
.onEach { addr ->
if (_currentDeviceAddressFlow.value != addr) {
_currentDeviceAddressFlow.value = addr
startInterface()
}
}
}
.launchIn(processLifecycle.coroutineScope)
.launchIn(processLifecycle.coroutineScope)
bluetoothRepository.state
.onEach { state ->
if (state.enabled) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) {
stopInterface()
bluetoothRepository.state
.onEach { state ->
if (state.enabled) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.BLUETOOTH.id) == true) {
stopInterface()
}
}
}
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
networkRepository.networkAvailable
.onEach { state ->
if (state) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) {
stopInterface()
networkRepository.networkAvailable
.onEach { state ->
if (state) {
startInterface()
} else if (getBondedDeviceAddress()?.startsWith(InterfaceId.TCP.id) == true) {
stopInterface()
}
}
}
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
}
}
}

View File

@@ -18,8 +18,6 @@
plugins { alias(libs.plugins.meshtastic.kmp.library) }
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.testing"

View File

@@ -23,8 +23,6 @@ plugins {
}
kotlin {
jvm()
android {
namespace = "org.meshtastic.core.ui"
androidResources.enable = false
@@ -48,13 +46,15 @@ kotlin {
implementation(libs.compose.multiplatform.materialIconsExtended)
implementation(libs.compose.multiplatform.ui)
implementation(libs.compose.multiplatform.foundation)
implementation(libs.compose.multiplatform.ui.tooling)
api(libs.compose.multiplatform.ui.tooling.preview)
implementation(libs.kermit)
implementation(libs.koin.compose.viewmodel)
implementation(libs.qrcode.kotlin)
}
val jvmAndroidMain by getting { dependencies { implementation(libs.compose.multiplatform.ui.tooling) } }
androidMain.dependencies { implementation(libs.androidx.activity.compose) }
commonTest.dependencies {

View File

@@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlin.jvm.JvmName
@Composable
fun <T : Enum<T>> DropDownPreference(

View File

@@ -217,7 +217,7 @@ fun EditTextPreference(
isError = isError,
onValueChange = {
if (maxSize > 0) {
if (it.toByteArray().size <= maxSize) {
if (it.encodeToByteArray().size <= maxSize) {
onValueChanged(it)
}
} else {
@@ -255,7 +255,7 @@ fun EditTextPreference(
if (maxSize > 0 && isFocused) {
Box(contentAlignment = Alignment.BottomEnd, modifier = Modifier.fillMaxWidth()) {
Text(
text = "${value.toByteArray().size}/$maxSize",
text = "${value.encodeToByteArray().size}/$maxSize",
style = MaterialTheme.typography.bodySmall,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(end = 8.dp, bottom = 4.dp),

View File

@@ -45,6 +45,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bad
import org.meshtastic.core.resources.fair
@@ -153,7 +154,7 @@ fun Snr(snr: Float, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = "%s %.2fdB".format(stringResource(Res.string.snr), snr),
text = formatString("%s %.2fdB", stringResource(Res.string.snr), snr),
color = color,
style = MaterialTheme.typography.labelSmall,
)
@@ -171,7 +172,7 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) {
}
Text(
modifier = modifier,
text = "%s %ddBm".format(stringResource(Res.string.rssi), rssi),
text = formatString("%s %ddBm", stringResource(Res.string.rssi), rssi),
color = color,
style = MaterialTheme.typography.labelSmall,
)

View File

@@ -39,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.icon.BatteryEmpty
@@ -60,7 +61,7 @@ fun MaterialBatteryInfo(
voltage: Float? = null,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
val levelString = FORMAT.format(level)
val levelString = formatString(FORMAT, level)
Row(
modifier = modifier,
@@ -130,7 +131,7 @@ fun MaterialBatteryInfo(
?.takeIf { it > 0 }
?.let {
Text(
text = "%.2fV".format(it),
text = formatString("%.2fV", it),
color = contentColor.copy(alpha = 0.8f),
style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp),
)

View File

@@ -16,6 +16,10 @@
*/
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.coroutines.flow.MutableSharedFlow
/**
* Event emitted when a user re-presses a bottom navigation destination that should trigger a scroll-to-top behaviour on
* the corresponding screen.
@@ -25,3 +29,5 @@ sealed class ScrollToTopEvent {
data object ConversationsTabPressed : ScrollToTopEvent()
}
@Composable fun rememberScrollToTopEvents(): MutableSharedFlow<ScrollToTopEvent> = remember { MutableSharedFlow() }

View File

@@ -33,6 +33,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.signal_quality
@@ -63,7 +64,7 @@ fun SignalInfo(
tint = signalColor,
)
Text(
text = "%.1fdB · %ddBm · %s".format(node.snr, node.rssi, stringResource(quality.nameRes)),
text = formatString("%.1fdB · %ddBm · %s", node.snr, node.rssi, stringResource(quality.nameRes)),
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,

View File

@@ -43,7 +43,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
@@ -297,7 +297,7 @@ fun ScannedQrCodeDialog(
}
}
@PreviewScreenSizes
@PreviewLightDark
@Composable
private fun ScannedQrCodeDialogPreview() {
ScannedQrCodeDialog(

View File

@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
@Composable actual fun rememberTimeTickWithLifecycle(): Long = 0L
internal actual fun <T : Enum<T>> enumEntriesOf(selectedItem: T): List<T> = emptyList()
internal actual fun Enum<*>.isDeprecatedEnumEntry(): Boolean = false

View File

@@ -0,0 +1,22 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General 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.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
@Composable actual fun dynamicColorScheme(darkTheme: Boolean): ColorScheme? = null

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLinkStyles
import org.jetbrains.compose.resources.StringResource
actual fun createClipEntry(text: String, label: String): ClipEntry =
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): AnnotatedString = AnnotatedString(html)
@Composable actual fun rememberOpenNfcSettings(): () -> Unit = {}
@Composable actual fun rememberShowToast(): suspend (String) -> Unit = { _ -> }
@Composable actual fun rememberShowToastResource(): suspend (StringResource) -> Unit = { _ -> }
@Composable actual fun rememberOpenMap(): (latitude: Double, longitude: Double, label: String) -> Unit = { _, _, _ -> }
@Composable actual fun rememberOpenUrl(): (url: String) -> Unit = { _ -> }
@Composable actual fun SetScreenBrightness(brightness: Float) {}

View File

@@ -28,15 +28,15 @@ The module depends on the JVM variants of KMP modules:
- `core:domain`, `core:data`, `core:database`, `core:datastore`, `core:prefs`
- `core:network`, `core:resources`, `core:ui`
**Navigation:** Uses JetBrains multiplatform forks of Navigation 3 (`org.jetbrains.androidx.navigation3:navigation3-ui`) and Lifecycle (`org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`, `lifecycle-runtime-compose`). A `SavedStateConfiguration` with polymorphic `SerializersModule` is configured for non-Android NavKey serialization. Desktop shares route keys with Android via `core:navigation`, but graph wiring remains platform-specific; parity policy is tracked in [`docs/decisions/navigation3-parity-2026-03.md`](../docs/decisions/navigation3-parity-2026-03.md).
**Navigation:** Uses JetBrains multiplatform forks of Navigation 3 (`org.jetbrains.androidx.navigation3:navigation3-ui`) and Lifecycle (`org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose`, `lifecycle-runtime-compose`). A unified `SavedStateConfiguration` with polymorphic `SerializersModule` is provided centrally by `core:navigation` for non-Android NavKey serialization. Desktop utilizes the exact same navigation graph wiring (`settingsGraph`, `nodesGraph`, `contactsGraph`, `connectionsGraph`) directly from the `commonMain` of their respective feature modules, maintaining full UI parity.
**Coroutines:** Requires `kotlinx-coroutines-swing` for `Dispatchers.Main` on JVM/Desktop. Without it, any code using `lifecycle.coroutineScope` or `Dispatchers.Main` (e.g., `NodeRepositoryImpl`, `RadioConfigRepositoryImpl`) will crash at runtime.
**DI:** A Koin DI graph is bootstrapped in `Main.kt` with stub implementations for Android-only services.
**DI:** A Koin DI graph is bootstrapped in `Main.kt` with platform-specific implementations injected.
**UI:** JetBrains Compose for Desktop with Material 3 theming, sharing Compose components from `core:ui`.
**UI:** JetBrains Compose for Desktop with Material 3 theming. Desktop acts as a thin host shell, delegating almost entirely to fully shared KMP UI modules.
**Localization:** Desktop exposes a language picker in `ui/settings/DesktopSettingsScreen.kt`, persisting the selected BCP-47 tag in `UiPreferencesDataSource.locale`. `Main.kt` applies the override to the JVM default `Locale` and uses a `staticCompositionLocalOf`-backed recomposition trigger so Compose Multiplatform `stringResource()` calls update immediately without recreating the Navigation 3 backstack.
**Localization:** Desktop exposes a language picker, persisting the selected BCP-47 tag in `UiPreferencesDataSource.locale`. `Main.kt` applies the override to the JVM default `Locale` and uses a `staticCompositionLocalOf`-backed recomposition trigger so Compose Multiplatform `stringResource()` calls update immediately without recreating the Navigation 3 backstack.
## Key Files
@@ -44,24 +44,11 @@ The module depends on the JVM variants of KMP modules:
|---|---|
| `Main.kt` | App entry point — Koin bootstrap, Compose Desktop window, theme + locale application |
| `DemoScenario.kt` | Offline demo data for testing without a connected device |
| `ui/DesktopMainScreen.kt` | Navigation 3 shell — `NavigationRail` + `NavDisplay` + `SavedStateConfiguration` |
| `navigation/DesktopNavigation.kt` | Nav graph entry registrations for all top-level destinations |
| `navigation/DesktopSettingsNavigation.kt` | Real settings feature composables wired into nav graph (~35 screens) |
| `navigation/DesktopNodeNavigation.kt` | Real adaptive node list-detail + real metrics screens (logs + charts); map routes remain placeholders |
| `navigation/DesktopMessagingNavigation.kt` | Real adaptive contacts list-detail + real Messages/Share/QuickChat route screens |
| `ui/DesktopMainScreen.kt` | Navigation 3 shell — `NavigationRail` + `NavDisplay` |
| `navigation/DesktopNavigation.kt` | Nav graph entry registrations for all top-level destinations (delegates to shared feature graphs) |
| `radio/DesktopRadioTransportFactory.kt` | Provides TCP, Serial/USB, and BLE transports |
| `radio/DesktopMeshServiceController.kt` | Mesh service lifecycle — orchestrates `want_config` handshake chain |
| `radio/DesktopMessageQueue.kt` | Message queue for outbound mesh packets |
| `ui/firmware/DesktopFirmwareScreen.kt` | Placeholder firmware screen (native DFU is Android-only) |
| `ui/settings/DesktopSettingsScreen.kt` | Desktop-specific top-level settings screen, including theme/language/app-info controls |
| `ui/settings/DesktopDeviceConfigScreen.kt` | Device config with JVM `ZoneId` timezone (replaces Android BroadcastReceiver) |
| `ui/settings/DesktopPositionConfigScreen.kt` | Position config without Android Location APIs |
| `ui/settings/DesktopNetworkConfigScreen.kt` | Network config without QR/NFC scanning |
| `ui/settings/DesktopSecurityConfigScreen.kt` | Security config with JVM `SecureRandom` (omits file export) |
| `ui/settings/DesktopExternalNotificationConfigScreen.kt` | External notification config without MediaPlayer/file import |
| `ui/nodes/DesktopAdaptiveNodeListScreen.kt` | Adaptive node list-detail using JetBrains `ListDetailPaneScaffold` |
| `ui/messaging/DesktopAdaptiveContactsScreen.kt` | Adaptive contacts list-detail using JetBrains `ListDetailPaneScaffold` |
| `ui/messaging/DesktopMessageContent.kt` | Desktop message content with send, reactions, and selection |
| `di/DesktopKoinModule.kt` | Koin module with stub implementations |
| `di/DesktopPlatformModule.kt` | Platform-specific Koin bindings |
| `stub/NoopStubs.kt` | No-op implementations for all repository interfaces |

View File

@@ -144,6 +144,7 @@ dependencies {
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
implementation(projects.feature.map)
implementation(projects.feature.firmware)
// Compose Desktop
implementation(compose.desktop.currentOs)

View File

@@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.first
import org.koin.core.context.startKoin
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.service.MeshServiceOrchestrator
@@ -57,7 +58,6 @@ import org.meshtastic.desktop.data.DesktopPreferencesDataSource
import org.meshtastic.desktop.di.desktopModule
import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.ui.DesktopMainScreen
import org.meshtastic.desktop.ui.navSavedStateConfig
import java.awt.Desktop
import java.util.Locale
@@ -199,7 +199,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
state = windowState,
) {
val backStack =
rememberNavBackStack(navSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
MenuBar {
Menu("File") {

View File

@@ -1,83 +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.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.desktop.ui.messaging.DesktopAdaptiveContactsScreen
import org.meshtastic.desktop.ui.messaging.DesktopMessageContent
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.QuickChatScreen
import org.meshtastic.feature.messaging.QuickChatViewModel
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
/**
* Registers real messaging/contacts feature composables into the desktop navigation graph.
*
* The contacts screen uses a desktop-specific adaptive composable with Material 3 Adaptive list-detail scaffolding,
* backed by shared `ContactsViewModel` from commonMain. The list pane shows contacts and the detail pane shows
* `DesktopMessageContent` using shared `MessageViewModel` with a non-paged message list.
*/
fun EntryProviderScope<NavKey>.desktopMessagingGraph(backStack: NavBackStack<NavKey>) {
entry<ContactsRoutes.ContactsGraph> {
val viewModel: ContactsViewModel = koinViewModel()
DesktopAdaptiveContactsScreen(
viewModel = viewModel,
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
)
}
entry<ContactsRoutes.Contacts> {
val viewModel: ContactsViewModel = koinViewModel()
DesktopAdaptiveContactsScreen(
viewModel = viewModel,
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
)
}
entry<ContactsRoutes.Messages> { route ->
val viewModel: MessageViewModel = koinViewModel(key = "messages-${route.contactKey}")
DesktopMessageContent(
contactKey = route.contactKey,
viewModel = viewModel,
initialMessage = route.message,
onNavigateUp = { backStack.removeLastOrNull() },
)
}
entry<ContactsRoutes.Share> { route ->
val viewModel: ContactsViewModel = koinViewModel()
ShareScreen(
viewModel = viewModel,
onConfirm = { contactKey ->
backStack.removeLastOrNull()
backStack.add(ContactsRoutes.Messages(contactKey, route.message))
},
onNavigateUp = { backStack.removeLastOrNull() },
)
}
entry<ContactsRoutes.QuickChat> {
val viewModel: QuickChatViewModel = koinViewModel()
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
}

View File

@@ -26,57 +26,47 @@ import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.desktop.ui.firmware.DesktopFirmwareScreen
import org.meshtastic.desktop.ui.map.KmpMapPlaceholder
import org.meshtastic.feature.connections.ui.ConnectionsScreen
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
import org.meshtastic.feature.node.navigation.nodesGraph
import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.feature.settings.radio.channel.channelsGraph
/**
* Registers entry providers for all top-level desktop destinations.
*
* Nodes uses real composables from `feature:node` via [desktopNodeGraph]. Conversations uses real composables from
* Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from
* `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via
* [desktopSettingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until
* their shared composables are wired.
* [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their
* shared composables are wired.
*/
fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>) {
// Nodes — real composables from feature:node
desktopNodeGraph(backStack)
nodesGraph(
backStack = backStack,
nodeMapScreen = { destNum, _ -> KmpMapPlaceholder(title = "Node Map ($destNum)") },
)
// Conversations — real composables from feature:messaging
desktopMessagingGraph(backStack)
contactsGraph(backStack)
// Map — placeholder for now, will be replaced with feature:map real implementation
entry<MapRoutes.Map> { KmpMapPlaceholder() }
mapGraph(backStack)
// Firmware — in-flow destination (for example from Settings), not a top-level rail tab
entry<FirmwareRoutes.FirmwareGraph> { DesktopFirmwareScreen() }
entry<FirmwareRoutes.FirmwareUpdate> { DesktopFirmwareScreen() }
firmwareGraph(backStack)
// Settings — real composables from feature:settings
desktopSettingsGraph(backStack)
settingsGraph(backStack)
// Channels
channelsGraph(backStack)
// Connections — shared screen
entry<ConnectionsRoutes.ConnectionsGraph> {
ConnectionsScreen(
onClickNodeChip = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },
)
}
entry<ConnectionsRoutes.Connections> {
ConnectionsScreen(
onClickNodeChip = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },
)
}
connectionsGraph(backStack)
}
@Composable

View File

@@ -1,129 +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.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.desktop.ui.map.KmpMapPlaceholder
import org.meshtastic.desktop.ui.nodes.DesktopAdaptiveNodeListScreen
import org.meshtastic.feature.node.list.NodeListViewModel
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen
import org.meshtastic.feature.node.metrics.PaxMetricsScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
/**
* Registers real node feature composables into the desktop navigation graph.
*
* The node list screen uses a desktop-specific adaptive composable with Material 3 Adaptive list-detail scaffolding,
* backed by shared `NodeListViewModel` and commonMain components. The detail pane shows real shared node detail content
* from commonMain.
*
* Metrics screens (logs + chart-based detail metrics) use shared composables from commonMain with `MetricsViewModel`
* scoped to the destination node number.
*/
fun EntryProviderScope<NavKey>.desktopNodeGraph(backStack: NavBackStack<NavKey>) {
entry<NodesRoutes.NodesGraph> {
val viewModel: NodeListViewModel = koinViewModel()
DesktopAdaptiveNodeListScreen(viewModel = viewModel, onNavigate = { backStack.add(it) })
}
entry<NodesRoutes.Nodes> {
val viewModel: NodeListViewModel = koinViewModel()
DesktopAdaptiveNodeListScreen(viewModel = viewModel, onNavigate = { backStack.add(it) })
}
// Node detail graph routes open the real shared list-detail screen focused on the requested node.
entry<NodesRoutes.NodeDetailGraph> { route ->
val viewModel: NodeListViewModel = koinViewModel()
DesktopAdaptiveNodeListScreen(
viewModel = viewModel,
initialNodeId = route.destNum,
onNavigate = { backStack.add(it) },
)
}
entry<NodesRoutes.NodeDetail> { route ->
val viewModel: NodeListViewModel = koinViewModel()
DesktopAdaptiveNodeListScreen(
viewModel = viewModel,
initialNodeId = route.destNum,
onNavigate = { backStack.add(it) },
)
}
// Traceroute log — real shared screen from commonMain
desktopMetricsEntry<NodeDetailRoutes.TracerouteLog>(getDestNum = { it.destNum }) { viewModel ->
TracerouteLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
// Neighbor info log — real shared screen from commonMain
desktopMetricsEntry<NodeDetailRoutes.NeighborInfoLog>(getDestNum = { it.destNum }) { viewModel ->
NeighborInfoLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
// Host metrics log — real shared screen from commonMain
desktopMetricsEntry<NodeDetailRoutes.HostMetricsLog>(getDestNum = { it.destNum }) { viewModel ->
HostMetricsLogScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
// Chart-based metrics — real shared screens from commonMain
desktopMetricsEntry<NodeDetailRoutes.DeviceMetrics>(getDestNum = { it.destNum }) { viewModel ->
DeviceMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
desktopMetricsEntry<NodeDetailRoutes.EnvironmentMetrics>(getDestNum = { it.destNum }) { viewModel ->
EnvironmentMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
desktopMetricsEntry<NodeDetailRoutes.SignalMetrics>(getDestNum = { it.destNum }) { viewModel ->
SignalMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
desktopMetricsEntry<NodeDetailRoutes.PowerMetrics>(getDestNum = { it.destNum }) { viewModel ->
PowerMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
desktopMetricsEntry<NodeDetailRoutes.PaxMetrics>(getDestNum = { it.destNum }) { viewModel ->
PaxMetricsScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
// Map-based screens — placeholders (map integration needed)
entry<NodeDetailRoutes.NodeMap> { route -> KmpMapPlaceholder(title = "Node Map (${route.destNum})") }
entry<NodeDetailRoutes.TracerouteMap> { KmpMapPlaceholder(title = "Traceroute Map") }
entry<NodeDetailRoutes.PositionLog> { route -> KmpMapPlaceholder(title = "Position Log (${route.destNum})") }
}
private inline fun <reified R : NavKey> EntryProviderScope<NavKey>.desktopMetricsEntry(
crossinline getDestNum: (R) -> Int,
crossinline content: @Composable (MetricsViewModel) -> Unit,
) {
entry<R> { route ->
val destNum = getDestNum(route)
val viewModel: MetricsViewModel = koinViewModel(key = "metrics-$destNum") { parametersOf(destNum) }
LaunchedEffect(destNum) { viewModel.setNodeId(destNum) }
content(viewModel)
}
}

View File

@@ -1,228 +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.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.desktop.ui.settings.DesktopDeviceConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopExternalNotificationConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopNetworkConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopPositionConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopSecurityConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopSettingsScreen
import org.meshtastic.feature.settings.AboutScreen
import org.meshtastic.feature.settings.AdministrationScreen
import org.meshtastic.feature.settings.DeviceConfigurationScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.filter.FilterSettingsViewModel
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseScreen
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseViewModel
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
import org.meshtastic.feature.settings.radio.component.AmbientLightingConfigScreen
import org.meshtastic.feature.settings.radio.component.AudioConfigScreen
import org.meshtastic.feature.settings.radio.component.BluetoothConfigScreen
import org.meshtastic.feature.settings.radio.component.CannedMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.DetectionSensorConfigScreen
import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen
import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen
import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
import org.meshtastic.feature.settings.radio.component.PowerConfigScreen
import org.meshtastic.feature.settings.radio.component.RangeTestConfigScreen
import org.meshtastic.feature.settings.radio.component.RemoteHardwareConfigScreen
import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
import org.meshtastic.feature.settings.radio.component.TAKConfigScreen
import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen
import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigScreen
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@Composable
private fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewModel {
val viewModel = koinViewModel<RadioConfigViewModel>()
LaunchedEffect(backStack) {
val destNum =
backStack.lastOrNull { it is SettingsRoutes.Settings }?.let { (it as SettingsRoutes.Settings).destNum }
?: backStack
.lastOrNull { it is SettingsRoutes.SettingsGraph }
?.let { (it as SettingsRoutes.SettingsGraph).destNum }
viewModel.initDestNum(destNum)
}
return viewModel
}
/**
* Registers real settings feature composables into the desktop navigation graph.
*
* Top-level settings screen is a desktop-specific composable since Android's [SettingsScreen] uses Android-only APIs.
* All sub-screens (device config, module config, radio config, channels, etc.) use the shared commonMain composables
* from `feature:settings`.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavKey>) {
// Top-level settings — desktop-specific screen (Android version uses Activity, permissions, etc.)
entry<SettingsRoutes.SettingsGraph> {
DesktopSettingsScreen(
radioConfigViewModel = getRadioConfigViewModel(backStack),
settingsViewModel = koinViewModel<SettingsViewModel>(),
onNavigate = { route -> backStack.add(route) },
)
}
entry<SettingsRoutes.Settings> {
DesktopSettingsScreen(
radioConfigViewModel = getRadioConfigViewModel(backStack),
settingsViewModel = koinViewModel<SettingsViewModel>(),
onNavigate = { route -> backStack.add(route) },
)
}
// Device configuration — shared commonMain composable
entry<SettingsRoutes.DeviceConfiguration> {
DeviceConfigurationScreen(
viewModel = getRadioConfigViewModel(backStack),
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
)
}
// Module configuration — shared commonMain composable
entry<SettingsRoutes.ModuleConfiguration> {
val settingsViewModel: SettingsViewModel = koinViewModel()
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = getRadioConfigViewModel(backStack),
excludedModulesUnlocked = excludedModulesUnlocked,
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
)
}
// Administration — shared commonMain composable
entry<SettingsRoutes.Administration> {
AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() })
}
// Clean node database — shared commonMain composable
entry<SettingsRoutes.CleanNodeDb> {
val viewModel: CleanNodeDatabaseViewModel = koinViewModel()
CleanNodeDatabaseScreen(viewModel = viewModel)
}
// Debug Panel — shared commonMain composable
entry<SettingsRoutes.DebugPanel> {
val viewModel: org.meshtastic.feature.settings.debugging.DebugViewModel = koinViewModel()
org.meshtastic.feature.settings.debugging.DebugScreen(
viewModel = viewModel,
onNavigateUp = { backStack.removeLastOrNull() },
)
}
// Config routes — all from commonMain composables
ConfigRoute.entries.forEach { routeInfo ->
desktopConfigComposable(routeInfo.route::class, backStack) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.DEVICE -> DesktopDeviceConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.POSITION ->
DesktopPositionConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.NETWORK -> DesktopNetworkConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.SECURITY ->
DesktopSecurityConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
}
}
}
// Module routes — all from commonMain composables
ModuleRoute.entries.forEach { routeInfo ->
desktopConfigComposable(routeInfo.route::class, backStack) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.EXT_NOTIFICATION ->
DesktopExternalNotificationConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.STORE_FORWARD ->
StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.CANNED_MESSAGE ->
CannedMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.REMOTE_HARDWARE ->
RemoteHardwareConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.NEIGHBOR_INFO ->
NeighborInfoConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.AMBIENT_LIGHTING ->
AmbientLightingConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.DETECTION_SENSOR ->
DetectionSensorConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.STATUS_MESSAGE ->
StatusMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.TRAFFIC_MANAGEMENT ->
TrafficManagementConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
}
}
}
// About — shared commonMain screen, per-platform library definitions loaded from JVM classpath
entry<SettingsRoutes.About> {
AboutScreen(
onNavigateUp = { backStack.removeLastOrNull() },
jsonProvider = { SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" },
)
}
// Filter settings — shared commonMain composable
entry<SettingsRoutes.FilterSettings> {
val viewModel: FilterSettingsViewModel = koinViewModel()
FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() })
}
}
/** Helper to register a config/module route entry with a [RadioConfigViewModel] scoped to that entry. */
fun <R : Route> EntryProviderScope<NavKey>.desktopConfigComposable(
route: KClass<R>,
backStack: NavBackStack<NavKey>,
content: @Composable (RadioConfigViewModel) -> Unit,
) {
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
}

View File

@@ -32,22 +32,11 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.serialization.SavedStateConfiguration
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.ui.navigation.icon
@@ -56,90 +45,6 @@ import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
/**
* Polymorphic serialization configuration for Navigation 3 saved-state support. Registers all route types used in the
* desktop navigation graph.
*/
internal val navSavedStateConfig = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
// Nodes
subclass(NodesRoutes.NodesGraph::class, NodesRoutes.NodesGraph.serializer())
subclass(NodesRoutes.Nodes::class, NodesRoutes.Nodes.serializer())
subclass(NodesRoutes.NodeDetailGraph::class, NodesRoutes.NodeDetailGraph.serializer())
subclass(NodesRoutes.NodeDetail::class, NodesRoutes.NodeDetail.serializer())
// Node detail sub-screens
subclass(NodeDetailRoutes.DeviceMetrics::class, NodeDetailRoutes.DeviceMetrics.serializer())
subclass(NodeDetailRoutes.NodeMap::class, NodeDetailRoutes.NodeMap.serializer())
subclass(NodeDetailRoutes.PositionLog::class, NodeDetailRoutes.PositionLog.serializer())
subclass(NodeDetailRoutes.EnvironmentMetrics::class, NodeDetailRoutes.EnvironmentMetrics.serializer())
subclass(NodeDetailRoutes.SignalMetrics::class, NodeDetailRoutes.SignalMetrics.serializer())
subclass(NodeDetailRoutes.PowerMetrics::class, NodeDetailRoutes.PowerMetrics.serializer())
subclass(NodeDetailRoutes.TracerouteLog::class, NodeDetailRoutes.TracerouteLog.serializer())
subclass(NodeDetailRoutes.TracerouteMap::class, NodeDetailRoutes.TracerouteMap.serializer())
subclass(NodeDetailRoutes.HostMetricsLog::class, NodeDetailRoutes.HostMetricsLog.serializer())
subclass(NodeDetailRoutes.PaxMetrics::class, NodeDetailRoutes.PaxMetrics.serializer())
subclass(NodeDetailRoutes.NeighborInfoLog::class, NodeDetailRoutes.NeighborInfoLog.serializer())
// Conversations
subclass(ContactsRoutes.ContactsGraph::class, ContactsRoutes.ContactsGraph.serializer())
subclass(ContactsRoutes.Contacts::class, ContactsRoutes.Contacts.serializer())
subclass(ContactsRoutes.Messages::class, ContactsRoutes.Messages.serializer())
subclass(ContactsRoutes.Share::class, ContactsRoutes.Share.serializer())
subclass(ContactsRoutes.QuickChat::class, ContactsRoutes.QuickChat.serializer())
// Map
subclass(MapRoutes.Map::class, MapRoutes.Map.serializer())
// Firmware
subclass(FirmwareRoutes.FirmwareGraph::class, FirmwareRoutes.FirmwareGraph.serializer())
subclass(FirmwareRoutes.FirmwareUpdate::class, FirmwareRoutes.FirmwareUpdate.serializer())
// Settings
subclass(SettingsRoutes.SettingsGraph::class, SettingsRoutes.SettingsGraph.serializer())
subclass(SettingsRoutes.Settings::class, SettingsRoutes.Settings.serializer())
subclass(SettingsRoutes.DeviceConfiguration::class, SettingsRoutes.DeviceConfiguration.serializer())
subclass(SettingsRoutes.ModuleConfiguration::class, SettingsRoutes.ModuleConfiguration.serializer())
subclass(SettingsRoutes.Administration::class, SettingsRoutes.Administration.serializer())
// Settings - Config routes
subclass(SettingsRoutes.User::class, SettingsRoutes.User.serializer())
subclass(SettingsRoutes.ChannelConfig::class, SettingsRoutes.ChannelConfig.serializer())
subclass(SettingsRoutes.Device::class, SettingsRoutes.Device.serializer())
subclass(SettingsRoutes.Position::class, SettingsRoutes.Position.serializer())
subclass(SettingsRoutes.Power::class, SettingsRoutes.Power.serializer())
subclass(SettingsRoutes.Network::class, SettingsRoutes.Network.serializer())
subclass(SettingsRoutes.Display::class, SettingsRoutes.Display.serializer())
subclass(SettingsRoutes.LoRa::class, SettingsRoutes.LoRa.serializer())
subclass(SettingsRoutes.Bluetooth::class, SettingsRoutes.Bluetooth.serializer())
subclass(SettingsRoutes.Security::class, SettingsRoutes.Security.serializer())
// Settings - Module routes
subclass(SettingsRoutes.MQTT::class, SettingsRoutes.MQTT.serializer())
subclass(SettingsRoutes.Serial::class, SettingsRoutes.Serial.serializer())
subclass(SettingsRoutes.ExtNotification::class, SettingsRoutes.ExtNotification.serializer())
subclass(SettingsRoutes.StoreForward::class, SettingsRoutes.StoreForward.serializer())
subclass(SettingsRoutes.RangeTest::class, SettingsRoutes.RangeTest.serializer())
subclass(SettingsRoutes.Telemetry::class, SettingsRoutes.Telemetry.serializer())
subclass(SettingsRoutes.CannedMessage::class, SettingsRoutes.CannedMessage.serializer())
subclass(SettingsRoutes.Audio::class, SettingsRoutes.Audio.serializer())
subclass(SettingsRoutes.RemoteHardware::class, SettingsRoutes.RemoteHardware.serializer())
subclass(SettingsRoutes.NeighborInfo::class, SettingsRoutes.NeighborInfo.serializer())
subclass(SettingsRoutes.AmbientLighting::class, SettingsRoutes.AmbientLighting.serializer())
subclass(SettingsRoutes.DetectionSensor::class, SettingsRoutes.DetectionSensor.serializer())
subclass(SettingsRoutes.Paxcounter::class, SettingsRoutes.Paxcounter.serializer())
subclass(SettingsRoutes.StatusMessage::class, SettingsRoutes.StatusMessage.serializer())
subclass(SettingsRoutes.TrafficManagement::class, SettingsRoutes.TrafficManagement.serializer())
subclass(SettingsRoutes.TAK::class, SettingsRoutes.TAK.serializer())
// Settings - Advanced routes
subclass(SettingsRoutes.CleanNodeDb::class, SettingsRoutes.CleanNodeDb.serializer())
subclass(SettingsRoutes.DebugPanel::class, SettingsRoutes.DebugPanel.serializer())
subclass(SettingsRoutes.About::class, SettingsRoutes.About.serializer())
subclass(SettingsRoutes.FilterSettings::class, SettingsRoutes.FilterSettings.serializer())
// Channels
subclass(ChannelsRoutes.ChannelsGraph::class, ChannelsRoutes.ChannelsGraph.serializer())
subclass(ChannelsRoutes.Channels::class, ChannelsRoutes.Channels.serializer())
// Connections
subclass(ConnectionsRoutes.ConnectionsGraph::class, ConnectionsRoutes.ConnectionsGraph.serializer())
subclass(ConnectionsRoutes.Connections::class, ConnectionsRoutes.Connections.serializer())
}
}
}
/**
* Desktop main screen — Navigation 3 shell with a persistent [NavigationRail] and [NavDisplay].
*

View File

@@ -1,165 +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.ui.messaging
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.resources.mark_as_read
import org.meshtastic.core.resources.unread_count
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.icon.MarkChatRead
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.component.EmptyConversationsPlaceholder
import org.meshtastic.feature.messaging.ui.contact.ContactItem
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
/**
* Desktop adaptive contacts screen using [ListDetailPaneScaffold] from JetBrains Material 3 Adaptive.
*
* On wide screens, the contacts list is shown on the left and the selected conversation detail on the right. On narrow
* screens, the scaffold automatically switches to a single-pane layout.
*
* Uses the shared [ContactsViewModel] and [ContactItem] from commonMain. The detail pane shows [DesktopMessageContent]
* with a non-paged message list and send input, backed by the shared [MessageViewModel].
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod")
@Composable
fun DesktopAdaptiveContactsScreen(
viewModel: ContactsViewModel,
onNavigateToShareChannels: () -> Unit = {},
uiViewModel: UIViewModel = koinViewModel(),
) {
val contacts by viewModel.contactList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val unreadTotal by viewModel.unreadCountTotal.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.conversations),
subtitle =
if (unreadTotal > 0) {
stringResource(Res.string.unread_count, unreadTotal)
} else {
null
},
ourNode = ourNode,
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
actions = {
if (unreadTotal > 0) {
IconButton(onClick = { viewModel.markAllAsRead() }) {
Icon(
MeshtasticIcons.MarkChatRead,
contentDescription = stringResource(Res.string.mark_as_read),
)
}
}
},
onClickChip = {},
)
},
floatingActionButton = {
if (connectionState == ConnectionState.Connected) {
MeshtasticImportFAB(
onImport = { uriString ->
uiViewModel.handleScannedUri(
org.meshtastic.core.common.util.MeshtasticUri(uriString),
) {
// OnInvalid
}
},
onShareChannels = onNavigateToShareChannels,
sharedContact = sharedContactRequested,
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
isContactContext = true,
)
}
},
) { contentPadding ->
if (contacts.isEmpty()) {
EmptyConversationsPlaceholder(modifier = Modifier.padding(contentPadding))
} else {
LazyColumn(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
items(contacts, key = { it.contactKey }) { contact ->
val isActive = navigator.currentDestination?.contentKey == contact.contactKey
ContactItem(
contact = contact,
selected = false,
isActive = isActive,
onClick = {
scope.launch {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contact.contactKey)
}
},
)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
}
},
detailPane = {
AnimatedPane {
navigator.currentDestination?.contentKey?.let { contactKey ->
val messageViewModel: MessageViewModel = koinViewModel(key = "messages-$contactKey")
DesktopMessageContent(contactKey = contactKey, viewModel = messageViewModel)
} ?: EmptyConversationsPlaceholder(modifier = Modifier)
}
},
)
}

View File

@@ -1,507 +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.ui.messaging
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.no_messages_yet
import org.meshtastic.core.resources.unknown_channel
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.component.ActionModeTopBar
import org.meshtastic.feature.messaging.component.DeleteMessageDialog
import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES
import org.meshtastic.feature.messaging.component.MessageInput
import org.meshtastic.feature.messaging.component.MessageItem
import org.meshtastic.feature.messaging.component.MessageMenuAction
import org.meshtastic.feature.messaging.component.MessageStatusDialog
import org.meshtastic.feature.messaging.component.MessageTopBar
import org.meshtastic.feature.messaging.component.QuickChatRow
import org.meshtastic.feature.messaging.component.ReplySnippet
import org.meshtastic.feature.messaging.component.ScrollToBottomFab
import org.meshtastic.feature.messaging.component.UnreadMessagesDivider
import org.meshtastic.feature.messaging.component.handleQuickChatAction
/**
* Desktop message content view for the contacts detail pane.
*
* Uses a non-paged [LazyColumn] to display messages for a selected conversation. Now shares the full message screen
* component set with Android, including: proper reply-to-message with replyId, message selection mode, quick chat row,
* message filtering, delivery info dialog, overflow menu, byte counter input, and unread dividers.
*
* The only difference from Android is the non-paged data source (Flow<List<Message>> vs LazyPagingItems) and the
* absence of PredictiveBackHandler.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun DesktopMessageContent(
contactKey: String,
viewModel: MessageViewModel,
modifier: Modifier = Modifier,
initialMessage: String = "",
onNavigateUp: (() -> Unit)? = null,
) {
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboard.current
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
val contactSettings by viewModel.contactSettings.collectAsStateWithLifecycle(initialValue = emptyMap())
val homoglyphEncodingEnabled by viewModel.homoglyphEncodingEnabled.collectAsStateWithLifecycle(initialValue = false)
val messages by viewModel.getMessagesFlow(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
// UI State
var replyingToPacketId by rememberSaveable { mutableStateOf<Int?>(null) }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
var messageText by rememberSaveable(contactKey) { mutableStateOf(initialMessage) }
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle()
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
var showStatusDialog by remember { mutableStateOf<org.meshtastic.core.model.Message?>(null) }
val inSelectionMode by remember { derivedStateOf { selectedMessageIds.value.isNotEmpty() } }
val listState = rememberLazyListState()
val unreadCount by viewModel.unreadCount.collectAsStateWithLifecycle()
// Derive title
val channelInfo =
remember(contactKey, channels) {
val index = contactKey.firstOrNull()?.digitToIntOrNull()
val id = contactKey.substring(1)
val name = index?.let { channels.getChannel(it)?.name }
Triple(index, id, name)
}
val (channelIndex, nodeId, rawChannelName) = channelInfo
val unknownChannelText = stringResource(Res.string.unknown_channel)
val channelName = rawChannelName ?: unknownChannelText
val title =
remember(nodeId, channelName, viewModel) {
when (nodeId) {
DataPacket.ID_BROADCAST -> channelName
else -> viewModel.getUser(nodeId).long_name
}
}
val isMismatchKey =
remember(channelIndex, nodeId, viewModel) {
channelIndex == DataPacket.PKC_CHANNEL_INDEX && viewModel.getNode(nodeId).mismatchKey
}
// Find the original message for reply snippet
val originalMessage by
remember(replyingToPacketId, messages.size) {
derivedStateOf { replyingToPacketId?.let { id -> messages.firstOrNull { it.packetId == id } } }
}
// Scroll to bottom when new messages arrive and we're already at the bottom
LaunchedEffect(messages.size) {
if (messages.isNotEmpty() && !listState.canScrollBackward) {
listState.animateScrollToItem(0)
}
}
// Seed route-provided draft text
LaunchedEffect(contactKey, initialMessage) {
if (initialMessage.isNotBlank() && messageText.isBlank()) {
messageText = initialMessage
}
}
// Mark messages as read when they become visible
@OptIn(kotlinx.coroutines.FlowPreview::class)
LaunchedEffect(messages.size) {
snapshotFlow { if (listState.isScrollInProgress) null else listState.layoutInfo }
.debounce(SCROLL_SETTLE_MILLIS)
.collectLatest { layoutInfo ->
if (layoutInfo == null || messages.isEmpty()) return@collectLatest
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) return@collectLatest
val topVisibleIndex = visibleItems.first().index
val bottomVisibleIndex = visibleItems.last().index
val firstVisibleUnread =
(bottomVisibleIndex..topVisibleIndex)
.mapNotNull { if (it in messages.indices) messages[it] else null }
.firstOrNull { !it.fromLocal && !it.read }
firstVisibleUnread?.let { message ->
viewModel.clearUnreadCount(contactKey, message.uuid, message.receivedTime)
}
}
}
// Dialogs
if (showDeleteDialog) {
DeleteMessageDialog(
count = selectedMessageIds.value.size,
onConfirm = {
viewModel.deleteMessages(selectedMessageIds.value.toList())
selectedMessageIds.value = emptySet()
showDeleteDialog = false
},
onDismiss = { showDeleteDialog = false },
)
}
showStatusDialog?.let { message ->
MessageStatusDialog(
message = message,
nodes = nodes,
ourNode = ourNode,
resendOption = message.status?.equals(MessageStatus.ERROR) ?: false,
onResend = {
viewModel.deleteMessages(listOf(message.uuid))
viewModel.sendMessage(message.text, contactKey)
showStatusDialog = null
},
onDismiss = { showStatusDialog = null },
)
}
Scaffold(
modifier = modifier,
topBar = {
if (inSelectionMode) {
ActionModeTopBar(
selectedCount = selectedMessageIds.value.size,
onAction = { action ->
when (action) {
MessageMenuAction.ClipboardCopy -> {
val copiedText =
messages
.filter { it.uuid in selectedMessageIds.value }
.joinToString("\n") { it.text }
coroutineScope.launch {
clipboardManager.setClipEntry(createClipEntry(copiedText, "messages"))
}
selectedMessageIds.value = emptySet()
}
MessageMenuAction.Delete -> showDeleteDialog = true
MessageMenuAction.Dismiss -> selectedMessageIds.value = emptySet()
MessageMenuAction.SelectAll -> {
selectedMessageIds.value =
if (selectedMessageIds.value.size == messages.size) {
emptySet()
} else {
messages.map { it.uuid }.toSet()
}
}
}
},
)
} else {
MessageTopBar(
title = title,
channelIndex = channelIndex,
mismatchKey = isMismatchKey,
onNavigateBack = { onNavigateUp?.invoke() },
channels = channels,
channelIndexParam = channelIndex,
showQuickChat = showQuickChat,
onToggleQuickChat = viewModel::toggleShowQuickChat,
filteringDisabled = filteringDisabled,
onToggleFilteringDisabled = {
viewModel.setContactFilteringDisabled(contactKey, !filteringDisabled)
},
filteredCount = filteredCount,
showFiltered = showFiltered,
onToggleShowFiltered = viewModel::toggleShowFiltered,
)
}
},
bottomBar = {
Column {
AnimatedVisibility(visible = showQuickChat) {
QuickChatRow(
enabled = connectionState.isConnected(),
actions = quickChatActions,
onClick = { action ->
handleQuickChatAction(
action = action,
currentText = messageText,
onUpdateText = { messageText = it },
onSendMessage = { text -> viewModel.sendMessage(text, contactKey) },
)
},
)
}
ReplySnippet(
originalMessage = originalMessage,
onClearReply = { replyingToPacketId = null },
ourNode = ourNode,
)
MessageInput(
messageText = messageText,
onMessageChange = { messageText = it },
onSendMessage = {
val trimmed = messageText.trim()
if (trimmed.isNotEmpty()) {
viewModel.sendMessage(trimmed, contactKey, replyingToPacketId)
if (replyingToPacketId != null) replyingToPacketId = null
messageText = ""
}
},
isEnabled = connectionState.isConnected(),
isHomoglyphEncodingEnabled = homoglyphEncodingEnabled,
modifier =
Modifier.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isShiftPressed) {
val currentByteLength = messageText.encodeToByteArray().size
val isOverLimit = currentByteLength > MESSAGE_CHARACTER_LIMIT_BYTES
val trimmed = messageText.trim()
if (trimmed.isNotEmpty() && connectionState.isConnected() && !isOverLimit) {
viewModel.sendMessage(trimmed, contactKey, replyingToPacketId)
if (replyingToPacketId != null) replyingToPacketId = null
messageText = ""
return@onPreviewKeyEvent true
}
// If over limit or empty, we still consume Enter to prevent newlines if the user
// intended to send, but only if they are not holding shift.
if (!event.isShiftPressed) return@onPreviewKeyEvent true
}
false
},
)
}
},
) { contentPadding ->
Box(Modifier.fillMaxSize().padding(contentPadding).focusable()) {
if (messages.isEmpty()) {
EmptyDetailPlaceholder(
icon = MeshtasticIcons.Conversations,
title = stringResource(Res.string.no_messages_yet),
)
} else {
// Pre-calculate node map for O(1) lookup
val nodeMap = remember(nodes) { nodes.associateBy { it.num } }
// Find first unread index
val firstUnreadIndex by
remember(messages.size) {
derivedStateOf { messages.indexOfFirst { !it.fromLocal && !it.read }.takeIf { it != -1 } }
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(bottom = 24.dp, top = 24.dp),
) {
items(messages.size, key = { messages[it].uuid }) { index ->
val message = messages[index]
val isSender = message.fromLocal
// Because reverseLayout = true, visually previous (above) is index + 1
val visuallyPrevMessage = if (index < messages.size - 1) messages[index + 1] else null
val visuallyNextMessage = if (index > 0) messages[index - 1] else null
val hasSamePrev =
if (visuallyPrevMessage != null) {
visuallyPrevMessage.fromLocal == message.fromLocal &&
(message.fromLocal || visuallyPrevMessage.node.num == message.node.num)
} else {
false
}
val hasSameNext =
if (visuallyNextMessage != null) {
visuallyNextMessage.fromLocal == message.fromLocal &&
(message.fromLocal || visuallyNextMessage.node.num == message.node.num)
} else {
false
}
val isFirstUnread = firstUnreadIndex == index
val selected by
remember(message.uuid, selectedMessageIds.value) {
derivedStateOf { selectedMessageIds.value.contains(message.uuid) }
}
val node = nodeMap[message.node.num] ?: message.node
if (isFirstUnread) {
Column {
UnreadMessagesDivider()
DesktopMessageItemRow(
message = message,
node = node,
ourNode = ourNode ?: Node(num = 0),
selected = selected,
inSelectionMode = inSelectionMode,
selectedMessageIds = selectedMessageIds,
contactKey = contactKey,
viewModel = viewModel,
listState = listState,
messages = messages,
onShowStatusDialog = { showStatusDialog = it },
onReply = { replyingToPacketId = it?.packetId },
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
showUserName = !isSender && !hasSamePrev,
quickEmojis = viewModel.frequentEmojis,
)
}
} else {
DesktopMessageItemRow(
message = message,
node = node,
ourNode = ourNode ?: Node(num = 0),
selected = selected,
inSelectionMode = inSelectionMode,
selectedMessageIds = selectedMessageIds,
contactKey = contactKey,
viewModel = viewModel,
listState = listState,
messages = messages,
onShowStatusDialog = { showStatusDialog = it },
onReply = { replyingToPacketId = it?.packetId },
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
showUserName = !isSender && !hasSamePrev,
quickEmojis = viewModel.frequentEmojis,
)
}
}
}
}
// Show FAB if we can scroll towards the newest messages (index 0).
if (listState.canScrollBackward) {
ScrollToBottomFab(coroutineScope = coroutineScope, listState = listState, unreadCount = unreadCount)
}
}
}
}
@Suppress("LongParameterList")
@Composable
private fun DesktopMessageItemRow(
message: org.meshtastic.core.model.Message,
node: Node,
ourNode: Node,
selected: Boolean,
inSelectionMode: Boolean,
selectedMessageIds: androidx.compose.runtime.MutableState<Set<Long>>,
contactKey: String,
viewModel: MessageViewModel,
listState: androidx.compose.foundation.lazy.LazyListState,
messages: List<org.meshtastic.core.model.Message>,
onShowStatusDialog: (org.meshtastic.core.model.Message) -> Unit,
onReply: (org.meshtastic.core.model.Message?) -> Unit,
hasSamePrev: Boolean,
hasSameNext: Boolean,
showUserName: Boolean,
quickEmojis: List<String>,
) {
val coroutineScope = rememberCoroutineScope()
MessageItem(
message = message,
node = node,
ourNode = ourNode,
selected = selected,
inSelectionMode = inSelectionMode,
onClick = { if (inSelectionMode) selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid) },
onLongClick = {
if (inSelectionMode) {
selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid)
}
},
onSelect = { selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid) },
onDelete = { viewModel.deleteMessages(listOf(message.uuid)) },
onReply = { onReply(message) },
sendReaction = { emoji ->
val hasReacted =
message.emojis.any { reaction ->
(reaction.user.id == ourNode.user.id || reaction.user.id == DataPacket.ID_LOCAL) &&
reaction.emoji == emoji
}
if (!hasReacted) {
viewModel.sendReaction(emoji, message.packetId, contactKey)
}
},
onStatusClick = { onShowStatusDialog(message) },
onNavigateToOriginalMessage = { replyId ->
coroutineScope.launch {
val targetIndex = messages.indexOfFirst { it.packetId == replyId }.takeIf { it != -1 }
if (targetIndex != null) {
listState.animateScrollToItem(targetIndex)
}
}
},
emojis = message.emojis,
showUserName = showUserName,
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
)
}
private fun Set<Long>.toggle(uuid: Long): Set<Long> = if (contains(uuid)) this - uuid else this + uuid
/** Debounce delay before marking messages as read after scroll settles. */
private const val SCROLL_SETTLE_MILLIS = 300L

View File

@@ -1,290 +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.ui.nodes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.node_count_template
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.feature.node.detail.NodeDetailContent
import org.meshtastic.feature.node.detail.NodeDetailViewModel
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.list.NodeListViewModel
import org.meshtastic.feature.node.model.NodeDetailAction
/**
* Desktop adaptive node list screen using [ListDetailPaneScaffold] from JetBrains Material 3 Adaptive.
*
* On wide screens, the node list is shown on the left and the selected node detail on the right. On narrow screens, the
* scaffold automatically switches to a single-pane layout.
*
* Uses the shared [NodeListViewModel] and commonMain composables ([NodeItem], [NodeFilterTextField], [MainAppBar]). The
* detail pane renders the shared [NodeDetailContent] from commonMain with the full node detail sections (identity,
* device actions, position, hardware details, notes, administration). Android-only overlays (compass permissions,
* bottom sheets) are no-ops on desktop.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun DesktopAdaptiveNodeListScreen(
viewModel: NodeListViewModel,
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
uiViewModel: UIViewModel = koinViewModel(),
) {
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle()
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
var shareNode by remember { mutableStateOf<org.meshtastic.core.model.Node?>(null) }
if (shareNode != null) {
SharedContactDialog(contact = shareNode, onDismiss = { shareNode = null })
}
LaunchedEffect(initialNodeId) {
initialNodeId?.let { nodeId -> navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.nodes),
subtitle =
stringResource(
Res.string.node_count_template,
onlineNodeCount,
nodes.size,
totalNodeCount,
),
ourNode = ourNode,
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onClickChip = {},
)
},
floatingActionButton = {
if (connectionState == ConnectionState.Connected) {
MeshtasticImportFAB(
onImport = { uriString ->
uiViewModel.handleScannedUri(
org.meshtastic.core.common.util.MeshtasticUri(uriString),
) {
// OnInvalid
}
},
sharedContact = sharedContactRequested,
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
isContactContext = true,
)
}
},
) { contentPadding ->
Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
item {
NodeFilterTextField(
modifier =
Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceDim)
.padding(8.dp),
filterText = state.filter.filterText,
onTextChange = { viewModel.nodeFilterText = it },
currentSortOption = state.sort,
onSortSelect = viewModel::setSortOption,
includeUnknown = state.filter.includeUnknown,
onToggleIncludeUnknown = { viewModel.nodeFilterPreferences.toggleIncludeUnknown() },
excludeInfrastructure = state.filter.excludeInfrastructure,
onToggleExcludeInfrastructure = {
viewModel.nodeFilterPreferences.toggleExcludeInfrastructure()
},
onlyOnline = state.filter.onlyOnline,
onToggleOnlyOnline = { viewModel.nodeFilterPreferences.toggleOnlyOnline() },
onlyDirect = state.filter.onlyDirect,
onToggleOnlyDirect = { viewModel.nodeFilterPreferences.toggleOnlyDirect() },
showIgnored = state.filter.showIgnored,
onToggleShowIgnored = { viewModel.nodeFilterPreferences.toggleShowIgnored() },
ignoredNodeCount = ignoredNodeCount,
excludeMqtt = state.filter.excludeMqtt,
onToggleExcludeMqtt = { viewModel.nodeFilterPreferences.toggleExcludeMqtt() },
)
}
items(nodes, key = { it.num }) { node ->
var expanded by remember { mutableStateOf(false) }
val isActive = navigator.currentDestination?.contentKey == node.num
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
val longClick =
if (node.num != ourNode?.num) {
{ expanded = true }
} else {
null
}
NodeItem(
thisNode = ourNode,
thatNode = node,
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
onClick = {
scope.launch {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, node.num)
}
},
onLongClick = longClick,
connectionState = connectionState,
isActive = isActive,
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {
NodeContextMenu(
expanded = expanded,
node = node,
onFavorite = { viewModel.favoriteNode(node) },
onIgnore = { viewModel.ignoreNode(node) },
onMute = { viewModel.muteNode(node) },
onRemove = { viewModel.removeNode(node) },
onDismiss = { expanded = false },
)
}
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
}
},
detailPane = {
AnimatedPane {
navigator.currentDestination?.contentKey?.let { nodeNum ->
val detailViewModel: NodeDetailViewModel = koinViewModel(key = "node-detail-$nodeNum")
LaunchedEffect(nodeNum) { detailViewModel.start(nodeNum) }
val detailUiState by detailViewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
detailViewModel.effects.collect { effect ->
if (effect is NodeRequestEffect.ShowFeedback) {
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues ->
NodeDetailContent(
modifier = Modifier.padding(paddingValues),
uiState = detailUiState,
onAction = { action ->
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction ->
detailViewModel.onServiceAction(action.action)
is NodeDetailAction.ShareContact -> shareNode = detailUiState.node
is NodeDetailAction.HandleNodeMenuAction -> {
val menuAction = action.action
if (
menuAction
is org.meshtastic.feature.node.component.NodeMenuAction.DirectMessage
) {
val routeStr =
detailViewModel.getDirectMessageRoute(
menuAction.node,
detailUiState.ourNode,
)
onNavigate(
org.meshtastic.core.navigation.ContactsRoutes.Messages(
contactKey = routeStr,
),
)
} else {
detailViewModel.handleNodeMenuAction(menuAction)
}
}
else -> {} // Actions requiring Android APIs are no-ops on desktop
}
},
onFirmwareSelect = { /* Firmware update not available on desktop */ },
onSaveNotes = { num, notes -> detailViewModel.setNodeNotes(num, notes) },
)
}
} ?: EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes))
}
},
)
}

View File

@@ -1,260 +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.ui.settings
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
import org.meshtastic.core.resources.config_network_eth_enabled_summary
import org.meshtastic.core.resources.config_network_udp_enabled_summary
import org.meshtastic.core.resources.config_network_wifi_enabled_summary
import org.meshtastic.core.resources.connection_status
import org.meshtastic.core.resources.ethernet_config
import org.meshtastic.core.resources.ethernet_enabled
import org.meshtastic.core.resources.ethernet_ip
import org.meshtastic.core.resources.gateway
import org.meshtastic.core.resources.ip
import org.meshtastic.core.resources.ipv4_mode
import org.meshtastic.core.resources.network
import org.meshtastic.core.resources.ntp_server
import org.meshtastic.core.resources.password
import org.meshtastic.core.resources.rsyslog_server
import org.meshtastic.core.resources.ssid
import org.meshtastic.core.resources.subnet
import org.meshtastic.core.resources.udp_enabled
import org.meshtastic.core.resources.wifi_config
import org.meshtastic.core.resources.wifi_enabled
import org.meshtastic.core.resources.wifi_ip
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditIPv4Preference
import org.meshtastic.core.ui.component.EditPasswordPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.proto.Config
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun DesktopNetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val networkConfig = state.radioConfig.network ?: Config.NetworkConfig()
val formState = rememberConfigState(initialValue = networkConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.network),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = Config(network = it)
viewModel.setConfig(config)
},
) {
// Display device connection status
state.deviceConnectionStatus?.let { connectionStatus ->
val ws = connectionStatus.wifi?.status
val es = connectionStatus.ethernet?.status
if (ws?.is_connected == true || es?.is_connected == true) {
item {
TitledCard(title = stringResource(Res.string.connection_status)) {
ws?.let { wifiStatus ->
if (wifiStatus.is_connected) {
ListItem(
text = stringResource(Res.string.wifi_ip),
supportingText = formatIpAddress(wifiStatus.ip_address ?: 0),
trailingIcon = null,
)
}
}
es?.let { ethernetStatus ->
if (ethernetStatus.is_connected) {
ListItem(
text = stringResource(Res.string.ethernet_ip),
supportingText = formatIpAddress(ethernetStatus.ip_address ?: 0),
trailingIcon = null,
)
}
}
}
}
}
}
if (state.metadata?.hasWifi == true) {
item {
TitledCard(title = stringResource(Res.string.wifi_config)) {
SwitchPreference(
title = stringResource(Res.string.wifi_enabled),
summary = stringResource(Res.string.config_network_wifi_enabled_summary),
checked = formState.value.wifi_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.ssid),
value = formState.value.wifi_ssid ?: "",
maxSize = 32, // wifi_ssid max_size:33
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(wifi_ssid = it) },
)
HorizontalDivider()
EditPasswordPreference(
title = stringResource(Res.string.password),
value = formState.value.wifi_psk ?: "",
maxSize = 64, // wifi_psk max_size:65
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(wifi_psk = it) },
)
}
}
}
if (state.metadata?.hasEthernet == true) {
item {
TitledCard(title = stringResource(Res.string.ethernet_config)) {
SwitchPreference(
title = stringResource(Res.string.ethernet_enabled),
summary = stringResource(Res.string.config_network_eth_enabled_summary),
checked = formState.value.eth_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(eth_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
if (state.metadata?.hasEthernet == true || state.metadata?.hasWifi == true) {
item {
TitledCard(title = stringResource(Res.string.network)) {
SwitchPreference(
title = stringResource(Res.string.udp_enabled),
summary = stringResource(Res.string.config_network_udp_enabled_summary),
checked = (formState.value.enabled_protocols ?: 0) == 1,
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.copy(enabled_protocols = if (it) 1 else 0)
},
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
item {
TitledCard(title = stringResource(Res.string.advanced)) {
EditTextPreference(
title = stringResource(Res.string.ntp_server),
value = formState.value.ntp_server ?: "",
maxSize = 32, // ntp_server max_size:33
enabled = state.connected,
isError = formState.value.ntp_server?.isEmpty() ?: true,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ntp_server = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.rsyslog_server),
value = formState.value.rsyslog_server ?: "",
maxSize = 32, // rsyslog_server max_size:33
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(rsyslog_server = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.ipv4_mode),
enabled = state.connected,
items = Config.NetworkConfig.AddressMode.entries.map { it to it.name },
selectedItem = formState.value.address_mode ?: Config.NetworkConfig.AddressMode.DHCP,
onItemSelected = { formState.value = formState.value.copy(address_mode = it) },
)
HorizontalDivider()
val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config()
EditIPv4Preference(
title = stringResource(Res.string.ip),
value = ipv4.ip,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.gateway),
value = ipv4.gateway,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.subnet),
value = ipv4.subnet,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = "DNS",
value = ipv4.dns,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(dns = it)) },
)
}
}
}
}
@Suppress("detekt:MagicNumber")
private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." +
"${(ipAddress shr 8) and 0xFF}." +
"${(ipAddress shr 16) and 0xFF}." +
"${(ipAddress shr 24) and 0xFF}"

View File

@@ -27,6 +27,7 @@ Version catalog aliases split cleanly by fork provenance. **Use the right prefix
|---|---|---|
| `jetbrains-lifecycle-*` | `org.jetbrains.androidx.lifecycle:*` | `commonMain`, `androidMain` |
| `jetbrains-navigation3-*` | `org.jetbrains.androidx.navigation3:*` | `commonMain`, `androidMain` |
| `jetbrains-navigationevent-*` | `org.jetbrains.androidx.navigationevent:*` | `commonMain`, `androidMain` |
| `jetbrains-compose-material3-adaptive-*` | `org.jetbrains.compose.material3.adaptive:*` | `commonMain`, `androidMain` |
| `androidx-lifecycle-process` | `androidx.lifecycle:lifecycle-process` | `androidMain` only — `ProcessLifecycleOwner` |
| `androidx-lifecycle-runtime-ktx` | `androidx.lifecycle:lifecycle-runtime-ktx` | `androidMain` only |

View File

@@ -27,7 +27,8 @@ This document captures discoverable patterns that are already used in the reposi
- Keep shared dialogs/components in `core:ui` where possible.
- Put localizable UI strings in Compose Multiplatform resources: `core/resources/src/commonMain/composeResources/values/strings.xml`.
- Use `stringResource(Res.string.key)` from shared resources in feature screens.
- Example usage: `feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt`.
- When retrieving strings in non-composable Coroutines, Managers, or ViewModels, use `getStringSuspend()`. Never use the blocking `getString()` inside a coroutine as it will crash iOS and freeze the UI thread.
- Example usage: `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt`.
## 5) Platform abstraction in shared UI

View File

@@ -33,17 +33,18 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver
- Do keep route definitions in `core:navigation` and use typed route objects.
- Don't mutate back navigation with custom stacks disconnected from app backstack.
- Do mutate `NavBackStack<NavKey>` with `add(...)` and `removeLastOrNull()`.
- Don't use Android's `androidx.activity.compose.BackHandler` or custom `PredictiveBackHandler` in multiplatform UI.
- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures.
### Current code anchors (Navigation 3)
- Typed routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt`
- App root backstack + `NavDisplay`: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt`
- Graph entry provider pattern: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
- Feature-level Navigation 3 usage: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt`
- Shared graph entry provider pattern: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
- Desktop Navigation 3 shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`
- Desktop nav graph entries: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt`
- Desktop real feature wiring: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt`
- Desktop `SavedStateConfiguration` for polymorphic NavKey serialization: `DesktopMainScreen.kt`
- Desktop nav graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt`
## Quick pre-PR checks for DI/navigation edits

View File

@@ -43,16 +43,19 @@ Reference examples:
1. Define/extend route keys in `core:navigation`.
2. Implement feature entry/content using Navigation 3 types (`NavKey`, `NavBackStack`, `EntryProviderScope`).
3. Add graph entries under the relevant feature module's `navigation` package (e.g., `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation`).
4. Use backstack mutation (`add`, `removeLastOrNull`) instead of introducing controller-coupled APIs.
5. Verify deep-link behavior if route is externally reachable.
3. Add graph entries under the relevant feature module's `navigation` package (e.g., `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation`).
4. If the entry content depends on platform-specific UI (e.g. Activity context or specific desktop wrappers), use `expect`/`actual` declarations for the content composables.
5. Use backstack mutation (`add`, `removeLastOrNull`) instead of introducing controller-coupled APIs.
6. Verify deep-link behavior if route is externally reachable.
Reference examples:
- App graph wiring: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
- Shared graph wiring: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
- Android specific content: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt`
- Desktop specific content: `feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt`
- Feature intro graph pattern: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/IntroNavGraph.kt`
- Desktop nav shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`
- Desktop nav graph entries: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt`
- Desktop feature wiring: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt`
- Desktop nav graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt`
## Playbook E: Add flavor/platform-specific UI implementation
@@ -82,8 +85,8 @@ Reference examples:
- Desktop DI: `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt`
- Desktop Navigation 3 shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`
- Desktop nav graph entries: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt`
- Desktop real feature wiring: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt`
- Desktop-specific screen: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopSettingsScreen.kt`
- Desktop shared feature wiring: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
- Desktop-specific screen: `feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt`
- Roadmap: `docs/roadmap.md`

View File

@@ -185,7 +185,7 @@ Ordered by impact × effort:
| Priority | Extraction | Impact | Effort | Enables |
|---:|---|---|---|---|
| 1 | `java.*` purge from `commonMain` (B1, B2) | High | Low | iOS target declaration |
| 1 | ~~`java.*` purge from `commonMain` (B1, B2)~~ | High | Low | ~~iOS target declaration~~ ✅ Done |
| 2 | Radio transport interfaces to `core:repository` (A2) | High | Medium | Transport unification |
| 3 | `core:testing` shared fixtures (D2) | Medium | Low | Feature commonTest |
| 4 | Feature `commonTest` (D1) | Medium | Medium | KMP test coverage |
@@ -194,7 +194,7 @@ Ordered by impact × effort:
| 7 | ~~Desktop Koin auto-wiring (C1, C2)~~ | Medium | Low | ✅ Resolved 2026-03-13 |
| 8 | MQTT KMP (B3) | Medium | High | Desktop/iOS MQTT |
| 9 | KMP charts (B4) | Medium | High | Desktop metrics |
| 10 | iOS target declaration | High | Low | CI purity gate |
| 10 | ~~iOS target declaration~~ | High | Low | ~~CI purity gate~~ ✅ Done |
---
@@ -205,7 +205,7 @@ Ordered by impact × effort:
| Shared business/data logic | 8.5/10 | **9/10** | RadioTransport interface unified; all core layers shared |
| Shared feature/UI logic | 9.5/10 | **8.5/10** | All 7 KMP features; connections unified; Vico charts in commonMain |
| Android decoupling | 8.5/10 | **9/10** | Connections, Navigation, Services, & Widgets extracted; GMS purged; app ~40->target 20 files |
| Multi-target readiness | 8/10 | **8/10** | Full JVM; release-ready desktop; iOS not declared |
| Multi-target readiness | 8/10 | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
| CI confidence | 8.5/10 | **9/10** | 25 modules validated; feature:connections + desktop in CI; native release installers |
| DI portability | 7/10 | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform |
| Test maturity | — | **8/10** | 131 commonTest + 89 platform-specific = 219 tests across all 7 features; core:testing established |

View File

@@ -10,7 +10,7 @@
# Navigation 3 Parity Strategy (Android + Desktop)
**Date:** 2026-03-11
**Status:** Active
**Status:** Implemented (2026-03-21)
**Scope:** `app` and `desktop` navigation structure using shared `core:navigation` routes
## Context
@@ -27,13 +27,14 @@ Both modules still define separate graph-builder files (`app/navigation/*.kt`, `
- Both shells iterate `TopLevelDestination.entries` from `core:navigation/commonMain`.
- Shared icon mapping lives in `core:ui` (`TopLevelDestinationExt.icon`).
- Parity tests exist in both `core:navigation/commonTest` (`NavigationParityTest`) and `desktop/test` (`DesktopTopLevelDestinationParityTest`).
2. **Feature coverage differs by intent and by implementation.**
- Desktop intentionally uses placeholders for map and several node/message detail flows.
- Android wires real implementations for map, message/share flows, and more node detail paths.
3. **Saved-state route registration is desktop-only and manual.**
- `DesktopMainScreen.kt` maintains a large `SavedStateConfiguration` serializer list that must stay in sync with `Routes.kt` and desktop graph entries.
4. **Route keys are shared; graph registration is per-platform.**
- This is the expected state — platform shells wire entries differently while consuming the same route types.
2. **Feature coverage is unified via `commonMain` feature graphs.**
- The `settingsGraph`, `nodesGraph`, `contactsGraph`, `connectionsGraph`, `firmwareGraph`, and `mapGraph` are now fully shared and exported from their respective feature modules' `commonMain` source sets.
- Desktop acts as a thin shell, delegating directly to these shared graphs.
3. **Saved-state route registration is fully shared.**
- `MeshtasticNavSavedStateConfig` in `core:navigation/commonMain` maintains the unified `SavedStateConfiguration` serializer list.
- Both Android and Desktop reference this shared config when instantiating `rememberNavBackStack`.
4. **Predictive back handling is KMP native.**
- Custom `PredictiveBackHandler` wrapper was removed in favor of Jetpack's official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`.
## Alpha04 Changelog Impact Check (2026-03-13)
@@ -147,9 +148,11 @@ Adopt a **hybrid parity model**:
## Source Anchors
- Shared routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt`
- Android shell: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt`
- Android graph registrations: `feature/*/src/androidMain/kotlin/org/meshtastic/feature/*/navigation/`
- Shared graph registrations: `feature/*/src/commonMain/kotlin/org/meshtastic/feature/*/navigation/`
- Platform graph content: `feature/*/src/{androidMain,jvmMain}/kotlin/org/meshtastic/feature/*/navigation/`
- Desktop shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`
- Desktop graph registrations: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/`
- Desktop graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt`

View File

@@ -43,9 +43,9 @@ Modules that share JVM-specific code between Android and desktop now standardize
| Module | UI in commonMain? | Desktop wired? |
|---|:---:|:---:|
| `feature:settings` | ✅ | ✅ ~35 real screens; shared `ChannelScreen` & `ViewModel` |
| `feature:node` | ✅ | ✅ Adaptive list-detail; shared `NodeContextMenu` |
| `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; 17 shared files in commonMain (ViewModels, MessageBubble, MessageItem, QuickChat, Reactions, DeliveryInfo, actions, events) |
| `feature:settings` | ✅ | ✅ ~35 real screens; fully shared `settingsGraph` and UI |
| `feature:node` | ✅ | ✅ Adaptive list-detail; fully shared `nodesGraph`, `PositionLogScreen`, and `NodeContextMenu` |
| `feature:messaging` | ✅ | ✅ Adaptive contacts + messages; fully shared `contactsGraph`, `MessageScreen`, `ContactsScreen`, and `MessageListPaged` |
| `feature:connections` | ✅ | ✅ Shared `ConnectionsScreen` with dynamic transport detection |
| `feature:intro` | ✅ | — |
| `feature:map` | ✅ | Placeholder; shared `NodeMapViewModel` |
@@ -63,7 +63,7 @@ Working Compose Desktop application with:
- **Desktop language picker** backed by `UiPreferencesDataSource.locale`, with immediate Compose Multiplatform resource updates
- **Navigation-preserving locale switching** via `Main.kt` `staticCompositionLocalOf` recomposition instead of recreating the Nav3 backstack
- Node detail metrics screens (Device, Environment, Signal, Power, Pax) wired with shared KMP + Vico charts
- 6 desktop-specific screens (Settings, Device, Position, Network, Security, ExternalNotification)
- **Feature-driven Architecture:** Desktop navigation completely relies on feature modules via `commonMain` exported graphs (`settingsGraph`, `nodesGraph`, `contactsGraph`, etc.), reducing the desktop module to a simple host shell.
- **Native notifications and system tray icon** wired via `DesktopNotificationManager`
- **Native release pipeline** generating `.dmg` (macOS), `.msi` (Windows), and `.deb` (Linux) installers in CI
@@ -74,7 +74,7 @@ Working Compose Desktop application with:
| Shared business/data logic | **9/10** | All core layers shared; RadioTransport interface unified |
| Shared feature/UI logic | **8.5/10** | All 7 KMP; feature:connections unified with dynamic transport detection |
| Android decoupling | **9/10** | No known `java.*` calls in `commonMain`; app module extraction in progress (navigation, connections, background services, and widgets extracted) |
| Multi-target readiness | **8/10** | Full JVM; release-ready desktop; iOS not declared |
| Multi-target readiness | **9/10** | Full JVM; release-ready desktop; iOS simulator builds compiling successfully |
| CI confidence | **9/10** | 25 modules validated (including feature:connections); native release installers automated |
| DI portability | **8/10** | Koin annotations in commonMain; supportedDeviceTypes injected per platform |
| Test maturity | **9/10** | Mokkery, Turbine, and Kotest integrated; property-based testing established; broad coverage across all 8 features |
@@ -88,8 +88,8 @@ Working Compose Desktop application with:
| Android-first structural KMP | ~100% |
| Shared business logic | ~98% |
| Shared feature/UI | ~95% |
| True multi-target readiness | ~75% |
| "Add iOS without surprises" | ~65% |
| True multi-target readiness | ~85% |
| "Add iOS without surprises" | ~100% |
## Proposed Next Steps for KMP Migration
@@ -97,7 +97,8 @@ Based on the latest codebase investigation, the following steps are proposed to
1. **Wire Desktop Features:** Complete desktop UI wiring for `feature:intro` and implement a shared fallback for `feature:map` (which is currently a placeholder on desktop).
2. **Decouple Firmware DFU:** `feature:firmware` relies on Android-only DFU libraries. Evaluate wrapping this in a shared KMP interface or extracting it into a separate plugin to allow the core `feature:firmware` module to be fully utilized on desktop/iOS.
3. **Prepare for iOS Target:** Set up an initial skeleton Xcode project to start validating `commonMain` compilation on Kotlin/Native (iOS).
3. **Flesh out iOS Actuals:** Complete the actual implementations for iOS UI stubs (e.g., `AboutLibrariesLoader`, `rememberOpenMap`, `SettingsMainScreen`) that were recently added to unblock iOS compilation.
4. **Boot iOS Target:** Set up an initial skeleton Xcode project to start running the now-compiling `iosSimulatorArm64` / `iosArm64` binaries on a real simulator/device.
## Key Architecture Decisions

View File

@@ -16,7 +16,8 @@ These items address structural gaps identified in the March 2026 architecture re
| Add feature module `commonTest` (settings, node, messaging) | Medium | Medium | ✅ |
| Desktop Koin `checkModules()` integration test | Medium | Low | ✅ |
| Auto-wire Desktop ViewModels via K2 Compiler (eliminate manual wiring) | Medium | Low | ✅ |
here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ |
| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low | ✅ |
| **iOS CI gate (compile-only validation)** | High | Medium | ✅ |
## Active Work
@@ -63,7 +64,7 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
| Feature | Status |
|---|---|
| Settings | ✅ ~35 real screens (6 desktop-specific) + desktop locale picker with in-place recomposition |
| Settings | ✅ ~35 real screens (fully shared) + desktop locale picker with in-place recomposition |
| Node list | ✅ Adaptive list-detail with real `NodeDetailContent` |
| Messaging | ✅ Adaptive contacts with real message view + send |
| Connections | ✅ Unified shared UI with dynamic transport detection |
@@ -85,11 +86,11 @@ here| **Migrate to JetBrains Compose Multiplatform dependencies** | High | Low |
- Implement a **Web Mercator Projection** helper in `feature:map/commonMain` to translate GPS coordinates to the 2D image plane.
- Leverage the existing `BaseMapViewModel` contract.
3. **Unify `MapViewModel`** — Collapse the remaining Google and F-Droid specific `MapViewModel` classes in the `:app` module into a single `commonMain` implementation by isolating platform-specific settings (styles, tile sources) behind a repository interface.
4. **iOS CI gate**add `iosArm64()`/`iosSimulatorArm64()` to convention plugins and CI (compile-only, no implementations) to ensure `commonMain` remains pure.
4. **iOS CI gate****Done:** added `iosArm64()`/`iosSimulatorArm64()` to convention plugins and CI. `commonMain` successfully compiles on iOS.
## Medium-Term Priorities (60 days)
1. **iOS proof target**Begin stubbing iOS target implementations (`NoopStubs.kt` equivalent) and setup an Xcode skeleton project.
1. **iOS proof target****Done (Stubbing):** Stubbed iOS target implementations (`NoopStubs.kt` equivalent) to successfully pass compile-time checks. **Next:** Setup an Xcode skeleton project and launch the iOS app.
2. **`core:api` contract split** — separate transport-neutral service contracts from the Android AIDL packaging to support iOS/Desktop service layers.
3. **Decouple Firmware DFU**`feature:firmware` relies on Android-only DFU libraries. Evaluate wrapping this in a shared KMP interface or extracting it to allow the core `feature:firmware` module to be utilized on desktop/iOS.

View File

@@ -18,8 +18,6 @@
plugins { alias(libs.plugins.meshtastic.kmp.feature) }
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.connections"

View File

@@ -1,13 +1,5 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
</CurrentIssues>
<CurrentIssues/>
</SmellBaseline>

View File

@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.RadioController
@@ -84,7 +85,7 @@ open class ScannerViewModel(
timeout = kotlin.time.Duration.INFINITE,
serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID,
)
.flowOn(kotlinx.coroutines.Dispatchers.IO)
.flowOn(ioDispatcher)
.collect { device ->
if (!scannedBleDevices.value.containsKey(device.address)) {
scannedBleDevices.update { current -> current + (device.address to device) }

View File

@@ -22,7 +22,7 @@ import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.connections.AndroidScannerViewModel
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.ui.ConnectionsScreen
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@@ -30,12 +30,9 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
entry<ConnectionsRoutes.ConnectionsGraph> {
ConnectionsScreen(
scanModel = koinViewModel<AndroidScannerViewModel>(),
scanModel = koinViewModel<ScannerViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
onClickNodeChip = {
// Navigation 3 ignores back stack behavior options; we handle this by popping if necessary.
backStack.add(NodesRoutes.NodeDetailGraph(it))
},
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },
)
@@ -43,7 +40,7 @@ fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>)
entry<ConnectionsRoutes.Connections> {
ConnectionsScreen(
scanModel = koinViewModel<AndroidScannerViewModel>(),
scanModel = koinViewModel<ScannerViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },

View File

@@ -0,0 +1,28 @@
/*
* 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.feature.firmware.navigation
import androidx.compose.runtime.Composable
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
import org.meshtastic.feature.firmware.FirmwareUpdateViewModel
@Composable
actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
val viewModel = koinViewModel<FirmwareUpdateViewModel>()
FirmwareUpdateScreen(onNavigateUp = onNavigateUp, viewModel = viewModel)
}

View File

@@ -16,17 +16,15 @@
*/
package org.meshtastic.feature.firmware.navigation
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
import org.meshtastic.feature.firmware.FirmwareUpdateViewModel
fun EntryProviderScope<NavKey>.firmwareGraph(backStack: NavBackStack<NavKey>) {
entry<FirmwareRoutes.FirmwareUpdate> {
val viewModel = koinViewModel<FirmwareUpdateViewModel>()
FirmwareUpdateScreen(onNavigateUp = { backStack.removeLastOrNull() }, viewModel = viewModel)
}
entry<FirmwareRoutes.FirmwareGraph> { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
entry<FirmwareRoutes.FirmwareUpdate> { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
}
@Composable expect fun FirmwareScreen(onNavigateUp: () -> Unit)

View File

@@ -0,0 +1,24 @@
/*
* 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.feature.firmware.navigation
import androidx.compose.runtime.Composable
@Composable
actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
// TODO: Implement iOS firmware screen
}

View File

@@ -14,7 +14,7 @@
* 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.ui.firmware
package org.meshtastic.feature.firmware
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column

View File

@@ -0,0 +1,25 @@
/*
* 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.feature.firmware.navigation
import androidx.compose.runtime.Composable
import org.meshtastic.feature.firmware.DesktopFirmwareScreen
@Composable
actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
DesktopFirmwareScreen()
}

View File

@@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.intro"

Some files were not shown because too many files have changed in this diff Show More