From d136b162a428ae852930bcef2df42d237308bea3 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sat, 21 Mar 2026 18:19:13 -0500
Subject: [PATCH] feat: Implement iOS support and unify Compose Multiplatform
infrastructure (#4876)
---
.github/copilot-instructions.md | 24 +-
.github/workflows/reusable-check.yml | 4 +-
.gitignore | 2 +-
.jdk | 1 +
AGENTS.md | 8 +-
GEMINI.md | 8 +-
app/detekt-baseline.xml | 29 +-
.../main/kotlin/org/meshtastic/app/ui/Main.kt | 3 +-
.../meshtastic/buildlogic/KotlinAndroid.kt | 30 ++
.../org/meshtastic/core/ble/NoopStubs.kt | 28 +
.../core/common/util/Dispatchers.kt | 22 +
.../meshtastic/core/common/util/Formatter.kt | 20 +
.../HomoglyphCharacterStringTransformer.kt | 2 +-
.../core/common/util/Dispatchers.kt | 22 +
.../meshtastic/core/common/util/Formatter.kt | 22 +
.../meshtastic/core/common/util/NoopStubs.kt | 92 ++++
.../core/common/util/Dispatchers.kt | 22 +
.../meshtastic/core/common/util/Formatter.kt | 20 +
.../core/data/manager/CommandSenderImpl.kt | 4 +-
.../data/manager/MeshActionHandlerImpl.kt | 4 +-
.../data/manager/MeshConfigFlowManagerImpl.kt | 4 +-
.../data/manager/MeshConfigHandlerImpl.kt | 4 +-
.../data/manager/MeshConnectionManagerImpl.kt | 4 +-
.../core/data/manager/MeshDataHandlerImpl.kt | 44 +-
.../data/manager/MeshMessageProcessorImpl.kt | 4 +-
.../core/data/manager/MqttManagerImpl.kt | 4 +-
.../data/manager/NeighborInfoHandlerImpl.kt | 4 +-
.../core/data/manager/NodeManagerImpl.kt | 22 +-
.../core/data/manager/PacketHandlerImpl.kt | 4 +-
.../data/manager/TracerouteHandlerImpl.kt | 4 +-
.../core/database/MeshtasticDatabase.kt | 9 +-
.../core/database/DatabaseBuilder.kt | 30 +-
.../core/datastore/UiPreferencesDataSource.kt | 4 +-
.../core/datastore/di/CoreDatastoreModule.kt | 4 +-
core/di/build.gradle.kts | 7 +-
.../org/meshtastic/core/di/di/CoreDiModule.kt | 3 +-
core/domain/build.gradle.kts | 2 -
.../core/model/util/AndroidDateTimeUtils.kt | 10 -
.../org/meshtastic/core/model/Channel.kt | 2 +-
.../org/meshtastic/core/model/DataPacket.kt | 3 +-
.../kotlin/org/meshtastic/core/model/Node.kt | 17 +-
.../core/model/util/DateTimeUtils.kt | 15 +
.../core/model/util/DistanceExtensions.kt | 22 +-
.../core/model/util/SharedContact.kt | 4 +-
.../meshtastic/core/model/util/NoopStubs.kt | 26 +
core/navigation/build.gradle.kts | 2 -
.../core/navigation/NavigationConfig.kt | 116 ++++
core/network/build.gradle.kts | 2 -
.../core/network/radio/MockInterface.kt | 4 +-
core/nfc/build.gradle.kts | 2 -
core/prefs/build.gradle.kts | 2 -
core/proto/build.gradle.kts | 3 -
core/repository/build.gradle.kts | 2 -
.../meshtastic/core/repository/Location.kt | 20 +
core/resources/build.gradle.kts | 5 +-
.../meshtastic/core/resources/GetString.kt | 23 +-
core/service/build.gradle.kts | 3 +-
.../service/SharedRadioInterfaceService.kt | 66 +--
core/testing/build.gradle.kts | 2 -
core/ui/build.gradle.kts | 6 +-
.../core/ui/component/DropDownPreference.kt | 1 +
.../core/ui/component/EditTextPreference.kt | 4 +-
.../core/ui/component/LoraSignalIndicator.kt | 5 +-
.../core/ui/component/MaterialBatteryInfo.kt | 5 +-
.../core/ui/component/ScrollToTopEvent.kt | 6 +
.../core/ui/component/SignalInfo.kt | 3 +-
.../core/ui/qr/ScannedQrCodeDialog.kt | 4 +-
.../meshtastic/core/ui/component/NoopStubs.kt | 25 +
.../org/meshtastic/core/ui/theme/NoopStubs.kt | 22 +
.../org/meshtastic/core/ui/util/NoopStubs.kt | 40 ++
desktop/README.md | 25 +-
desktop/build.gradle.kts | 1 +
.../kotlin/org/meshtastic/desktop/Main.kt | 4 +-
.../navigation/DesktopMessagingNavigation.kt | 83 ---
.../desktop/navigation/DesktopNavigation.kt | 46 +-
.../navigation/DesktopNodeNavigation.kt | 129 -----
.../navigation/DesktopSettingsNavigation.kt | 228 --------
.../desktop/ui/DesktopMainScreen.kt | 95 ----
.../DesktopAdaptiveContactsScreen.kt | 165 ------
.../ui/messaging/DesktopMessageContent.kt | 507 ------------------
.../ui/nodes/DesktopAdaptiveNodeListScreen.kt | 290 ----------
.../ui/settings/DesktopNetworkConfigScreen.kt | 260 ---------
docs/agent-playbooks/README.md | 1 +
docs/agent-playbooks/common-practices.md | 3 +-
.../di-navigation3-anti-patterns-playbook.md | 11 +-
docs/agent-playbooks/task-playbooks.md | 19 +-
docs/decisions/architecture-review-2026-03.md | 6 +-
docs/decisions/navigation3-parity-2026-03.md | 23 +-
docs/kmp-status.md | 17 +-
docs/roadmap.md | 9 +-
feature/connections/build.gradle.kts | 2 -
feature/connections/detekt-baseline.xml | 10 +-
.../feature/connections/ScannerViewModel.kt | 3 +-
.../navigation/ConnectionsNavigation.kt | 11 +-
.../firmware/navigation/FirmwareScreen.kt | 28 +
.../firmware/navigation/FirmwareNavigation.kt | 12 +-
.../firmware/navigation/FirmwareNavigation.kt | 24 +
.../firmware/DesktopFirmwareScreen.kt | 2 +-
.../firmware/navigation/FirmwareScreen.kt | 25 +
feature/intro/build.gradle.kts | 2 -
feature/map/build.gradle.kts | 2 -
.../feature/map/navigation/MapMainScreen.kt | 33 ++
.../feature/map/BaseMapViewModel.kt | 6 +-
.../feature/map/navigation/MapNavigation.kt | 11 +-
.../feature/map/navigation/MapNavigation.kt | 24 +
.../feature/map/navigation/MapMainScreen.kt | 40 ++
feature/messaging/build.gradle.kts | 7 +-
.../navigation/ContactsEntryContent.kt | 59 ++
.../messaging/component/MessageItemTest.kt | 2 +-
.../meshtastic/feature/messaging/Message.kt | 11 +-
.../feature/messaging/MessageListPaged.kt | 0
.../feature/messaging/MessageViewModel.kt | 8 +-
.../meshtastic/feature/messaging/QuickChat.kt | 2 +-
.../feature/messaging/QuickChatViewModel.kt | 8 +-
.../navigation/ContactsNavigation.kt | 33 +-
.../ui/contact/AdaptiveContactsScreen.kt | 52 +-
.../feature/messaging/ui/contact/Contacts.kt | 23 +-
.../messaging/ui/contact/ContactsViewModel.kt | 10 +-
.../navigation/ContactsNavigation.kt | 33 ++
.../navigation/ContactsEntryContent.kt | 64 +++
.../feature/node/detail/NodeDetailScreen.kt | 12 +-
.../feature/node/metrics/PositionLog.kt | 2 +-
.../node/navigation/TracerouteMapScreens.kt | 36 ++
.../feature/node/compass/CompassViewModel.kt | 3 +-
.../node/component/CompassBottomSheet.kt | 16 +-
.../node/component/NodeDetailsSection.kt | 5 +-
.../feature/node/component/NodeItem.kt | 23 +-
.../node/detail/CommonNodeRequestActions.kt | 12 +-
.../feature/node/detail/NodeDetailScreens.kt | 33 ++
.../node/detail/NodeManagementActions.kt | 12 +-
.../feature/node/list/NodeListScreen.kt | 8 +-
.../feature/node/metrics/DeviceMetrics.kt | 24 +-
.../feature/node/metrics/EnvironmentCharts.kt | 7 +-
.../node/metrics/EnvironmentMetrics.kt | 25 +-
.../feature/node/metrics/MetricsViewModel.kt | 5 +-
.../feature/node/metrics/PaxMetrics.kt | 9 +-
.../node/metrics/PositionLogComponents.kt | 7 +-
.../node/metrics/PositionLogScreens.kt | 21 +
.../feature/node/metrics/PowerMetrics.kt | 15 +-
.../feature/node/metrics/SignalMetrics.kt | 13 +-
.../feature/node/metrics/TracerouteLog.kt | 4 +-
.../node/navigation/AdaptiveNodeListScreen.kt | 16 +-
.../node/navigation/NodesNavigation.kt | 20 +-
.../feature/node/detail/NodeDetailScreens.kt | 35 ++
.../node/metrics/PositionLogScreens.kt | 24 +
.../node/navigation/TracerouteMapScreens.kt | 24 +
.../feature/node/detail/NodeDetailScreens.kt | 70 +++
.../node/metrics/PositionLogScreens.kt | 36 ++
.../node/navigation/TracerouteMapScreens.kt | 42 ++
feature/settings/detekt-baseline.xml | 2 -
.../navigation/AboutLibrariesLoader.kt | 22 +
.../settings/navigation/SettingsMainScreen.kt | 62 +++
.../settings/debugging/DebugViewModel.kt | 5 +-
.../navigation/AboutLibrariesLoader.kt | 19 +
.../settings/navigation/SettingsNavigation.kt | 62 ++-
.../radio/component/LoadingOverlay.kt | 3 +-
.../radio/component/NetworkConfigItemList.kt | 181 +++----
.../component/PacketResponseStateDialog.kt | 3 +-
.../feature/settings/debugging/NoopStubs.kt | 24 +
.../navigation/AboutLibrariesLoader.kt | 21 +
.../settings/navigation/SettingsNavigation.kt | 52 ++
.../settings/DesktopDeviceConfigScreen.kt | 2 +-
...DesktopExternalNotificationConfigScreen.kt | 2 +-
.../settings/DesktopPositionConfigScreen.kt | 2 +-
.../settings/DesktopSecurityConfigScreen.kt | 2 +-
.../settings/DesktopSettingsScreen.kt | 3 +-
.../navigation/AboutLibrariesLoader.kt | 22 +
.../settings/navigation/SettingsMainScreen.kt | 61 +++
fix_dispatchers.py | 63 +++
gradle/libs.versions.toml | 3 +
170 files changed, 2208 insertions(+), 2432 deletions(-)
create mode 120000 .jdk
create mode 100644 core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt
create mode 100644 core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
create mode 100644 core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt
create mode 100644 core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
create mode 100644 core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
create mode 100644 core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt
create mode 100644 core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
create mode 100644 core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
create mode 100644 core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt
create mode 100644 core/repository/src/iosMain/kotlin/org/meshtastic/core/repository/Location.kt
create mode 100644 core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/component/NoopStubs.kt
create mode 100644 core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/theme/NoopStubs.kt
create mode 100644 core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt
delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNodeNavigation.kt
delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt
delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt
delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopMessageContent.kt
delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt
delete mode 100644 desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopNetworkConfigScreen.kt
rename feature/connections/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt (83%)
create mode 100644 feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt
rename feature/firmware/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt (72%)
create mode 100644 feature/firmware/src/iosMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt
rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature}/firmware/DesktopFirmwareScreen.kt (99%)
create mode 100644 feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt
create mode 100644 feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt
rename feature/map/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt (83%)
create mode 100644 feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
create mode 100644 feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt
create mode 100644 feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/Message.kt (98%)
rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt (100%)
rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt (67%)
rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt (78%)
rename feature/messaging/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt (97%)
create mode 100644 feature/messaging/src/iosMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt
create mode 100644 feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
create mode 100644 feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt
create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
rename feature/node/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt (96%)
create mode 100644 feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
rename feature/node/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/node/navigation/AdaptiveNodeListScreen.kt (91%)
rename feature/node/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt (94%)
create mode 100644 feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
create mode 100644 feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
create mode 100644 feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt
create mode 100644 feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
create mode 100644 feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/metrics/PositionLogScreens.kt
create mode 100644 feature/node/src/jvmMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt
create mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt
create mode 100644 feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt
create mode 100644 feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt
rename feature/settings/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt (88%)
rename feature/settings/src/{androidMain => commonMain}/kotlin/org/meshtastic/feature/settings/radio/component/NetworkConfigItemList.kt (71%)
create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/debugging/NoopStubs.kt
create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt
create mode 100644 feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopDeviceConfigScreen.kt (99%)
rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopExternalNotificationConfigScreen.kt (99%)
rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopPositionConfigScreen.kt (99%)
rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopSecurityConfigScreen.kt (99%)
rename {desktop/src/main/kotlin/org/meshtastic/desktop/ui => feature/settings/src/jvmMain/kotlin/org/meshtastic/feature}/settings/DesktopSettingsScreen.kt (99%)
create mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/AboutLibrariesLoader.kt
create mode 100644 feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsMainScreen.kt
create mode 100644 fix_dispatchers.py
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index e828b3671..fcb614b12 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -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
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 3fa8dff85..6fdbecfb8 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -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 }}
diff --git a/.gitignore b/.gitignore
index 0c80d6537..4a057e39f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,4 +51,4 @@ wireless-install.sh
# Git worktrees
.worktrees/
-/firebase-debug.log
\ No newline at end of file
+/firebase-debug.log.jdk/
diff --git a/.jdk b/.jdk
new file mode 120000
index 000000000..096e1a9e3
--- /dev/null
+++ b/.jdk
@@ -0,0 +1 @@
+/home/james/.jdks/ms-17.0.18
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 9e5f6d412..fcb614b12 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`.
diff --git a/GEMINI.md b/GEMINI.md
index 9e5f6d412..fcb614b12 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -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`.
diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml
index 876b1b215..c373eea43 100644
--- a/app/detekt-baseline.xml
+++ b/app/detekt-baseline.xml
@@ -1,32 +1,5 @@
-
- LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()
- 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, )
- LongParameterList:AndroidNodeListViewModel.kt$AndroidNodeListViewModel$( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, radioController: RadioController, nodeManagementActions: NodeManagementActions, getFilteredNodesUseCase: GetFilteredNodesUseCase, nodeFilterPreferences: NodeFilterPreferences, )
- 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, )
- 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, )
- MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1000L
- MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-5
- MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-7
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114
- MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200
- MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200
- MagicNumber:StreamInterface.kt$StreamInterface$0xff
- MagicNumber:StreamInterface.kt$StreamInterface$3
- MagicNumber:StreamInterface.kt$StreamInterface$4
- MagicNumber:StreamInterface.kt$StreamInterface$8
- MagicNumber:TCPInterface.kt$TCPInterface$1000
- SwallowedException:NsdManager.kt$ex: IllegalArgumentException
- SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException
- TooGenericExceptionCaught:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$ex: Exception
- TooGenericExceptionCaught:BleRadioInterface.kt$BleRadioInterface$e: Exception
- TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable
- TooManyFunctions:BleRadioInterface.kt$BleRadioInterface : RadioTransport
->>>>>>> ba83c3564 (chore(conductor): Complete Phase 4 - Wire Kable and Remove Nordic)
-
+
diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
index 346be20af..66bd779d0 100644
--- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
@@ -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()
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt
index 984736838..39c965255 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt
@@ -67,6 +67,14 @@ internal fun Project.configureKotlinAndroid(
*/
internal fun Project.configureKotlinMultiplatform() {
extensions.configure {
+ // 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()?.apply {
@@ -166,6 +174,27 @@ private inline fun 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().configureEach {
@@ -177,6 +206,7 @@ private inline fun 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",
diff --git a/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt
new file mode 100644
index 000000000..1ab2d0814
--- /dev/null
+++ b/core/ble/src/iosMain/kotlin/org/meshtastic/core/ble/NoopStubs.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.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")
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt
new file mode 100644
index 000000000..73d686700
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt
@@ -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 .
+ */
+package org.meshtastic.core.common.util
+
+import kotlinx.coroutines.CoroutineDispatcher
+
+/** Access to the IO dispatcher in a multiplatform-safe way. */
+expect val ioDispatcher: CoroutineDispatcher
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
new file mode 100644
index 000000000..d54455df8
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
@@ -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 .
+ */
+package org.meshtastic.core.common.util
+
+/** Multiplatform string formatting helper. */
+expect fun formatString(pattern: String, vararg args: Any?): String
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
index d91c02b7e..e3612dfda 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
@@ -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()
}
}
diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt
new file mode 100644
index 000000000..86c423b73
--- /dev/null
+++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt
@@ -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 .
+ */
+package org.meshtastic.core.common.util
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+
+actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default
diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
new file mode 100644
index 000000000..6d1acf46f
--- /dev/null
+++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
@@ -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 .
+ */
+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.",
+)
diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
new file mode 100644
index 000000000..35e2906ff
--- /dev/null
+++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
@@ -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 .
+ */
+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) {
+ 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 {
+ 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> 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?) {}
+}
diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt
new file mode 100644
index 000000000..fa9e65661
--- /dev/null
+++ b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Dispatchers.kt
@@ -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 .
+ */
+package org.meshtastic.core.common.util
+
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+
+actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
new file mode 100644
index 000000000..a450b9856
--- /dev/null
+++ b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
@@ -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 .
+ */
+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)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
index 1e5f5eaeb..8084a9507 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
@@ -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()
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
index b1a33330d..e89b11c3d 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
@@ -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,
) : MeshActionHandler {
- private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
index ff20feddb..d7d0cebfe 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
@@ -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
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
index 652e3bb79..b8263c253 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
@@ -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()
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index 5e706c288..02192894b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -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
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 6e029545d..64811d0e9 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -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),
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index fb68ee906..0c9ceaa2b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -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,
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()
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
index c1b064efb..9b2a0c5e4 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
@@ -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) {
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
index 5eb40d4b0..631f2e4ca 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
@@ -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
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
index dd554e6ea..d674c4b5a 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -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())
private val _nodeDBbyID = atomic(persistentMapOf())
@@ -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
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
index 56a664f8e..0ac6ef9ce 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
@@ -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()
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
index 0d389b1d1..ae03186f0 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
@@ -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
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 29d9b17ec..ebb77a297 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -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 RoomDatabase.Builder.configureCommon(): RoomDatabase.Builder =
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")
diff --git a/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt b/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
index 04da47d3f..718da5aea 100644
--- a/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
+++ b/core/database/src/iosMain/kotlin/org/meshtastic/core/database/DatabaseBuilder.kt
@@ -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 {
+ 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 {
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)
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt
index acac4f39c..5277feb8f 100644
--- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/UiPreferencesDataSource.kt
@@ -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) :
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 =
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
index 9ef808bc3..aa81f1ac6 100644
--- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
@@ -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())
}
diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts
index 57f4d2fd5..06e868655 100644
--- a/core/di/build.gradle.kts
+++ b/core/di/build.gradle.kts
@@ -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)
+ }
+ }
}
diff --git a/core/di/src/commonMain/kotlin/org/meshtastic/core/di/di/CoreDiModule.kt b/core/di/src/commonMain/kotlin/org/meshtastic/core/di/di/CoreDiModule.kt
index 9ad24502a..0ad68db8a 100644
--- a/core/di/src/commonMain/kotlin/org/meshtastic/core/di/di/CoreDiModule.kt
+++ b/core/di/src/commonMain/kotlin/org/meshtastic/core/di/di/CoreDiModule.kt
@@ -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)
}
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
index 88166c417..e08765edb 100644
--- a/core/domain/build.gradle.kts
+++ b/core/domain/build.gradle.kts
@@ -21,8 +21,6 @@ plugins {
}
kotlin {
- jvm()
-
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.domain"
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
index ec8ddfa7b..473e482e2 100644
--- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
@@ -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 {
- 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)
-}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt
index e3bf15d7c..7e19e0295 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Channel.kt
@@ -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()
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
index e7f0b44e4..1f69d4a0d 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
@@ -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()
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
index 55c4fefee..fe6cad31b 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
@@ -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(
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt
index 7241cb80e..79e2636a2 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DateTimeUtils.kt
@@ -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 {
+ 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)
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt
index ea7e37340..3421c4517 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/DistanceExtensions.kt
@@ -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)
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
index 4ab635a6d..b2e175382 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
@@ -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}",
)
}
}
diff --git a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
new file mode 100644
index 000000000..7545a00a7
--- /dev/null
+++ b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model.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)
+}
diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts
index a397ce986..472fdf023 100644
--- a/core/navigation/build.gradle.kts
+++ b/core/navigation/build.gradle.kts
@@ -22,8 +22,6 @@ plugins {
}
kotlin {
- jvm()
-
android { namespace = "org.meshtastic.core.navigation" }
sourceSets {
diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt
new file mode 100644
index 000000000..fe5c6225a
--- /dev/null
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt
@@ -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 .
+ */
+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())
+ }
+ }
+}
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index 1e086af6a..9e7e4f8df 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -23,8 +23,6 @@ plugins {
}
kotlin {
- jvm()
-
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.network"
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt
index 8de3000af..5a5a1314a 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterface.kt
@@ -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 =
diff --git a/core/nfc/build.gradle.kts b/core/nfc/build.gradle.kts
index 559a96868..801bbf8f2 100644
--- a/core/nfc/build.gradle.kts
+++ b/core/nfc/build.gradle.kts
@@ -21,8 +21,6 @@ plugins {
}
kotlin {
- jvm()
-
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.nfc"
diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts
index 431d3bb13..97f728e81 100644
--- a/core/prefs/build.gradle.kts
+++ b/core/prefs/build.gradle.kts
@@ -21,8 +21,6 @@ plugins {
}
kotlin {
- jvm()
-
android {
namespace = "org.meshtastic.core.prefs"
androidResources.enable = false
diff --git a/core/proto/build.gradle.kts b/core/proto/build.gradle.kts
index 615074efc..e60195e19 100644
--- a/core/proto/build.gradle.kts
+++ b/core/proto/build.gradle.kts
@@ -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 }
diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts
index a3cc369c7..0c9b7d8f8 100644
--- a/core/repository/build.gradle.kts
+++ b/core/repository/build.gradle.kts
@@ -21,8 +21,6 @@ plugins {
}
kotlin {
- jvm()
-
@Suppress("UnstableApiUsage")
android { androidResources.enable = false }
diff --git a/core/repository/src/iosMain/kotlin/org/meshtastic/core/repository/Location.kt b/core/repository/src/iosMain/kotlin/org/meshtastic/core/repository/Location.kt
new file mode 100644
index 000000000..e7abe31bb
--- /dev/null
+++ b/core/repository/src/iosMain/kotlin/org/meshtastic/core/repository/Location.kt
@@ -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 .
+ */
+package org.meshtastic.core.repository
+
+/** No-op stub for Location on iOS. */
+actual class Location
diff --git a/core/resources/build.gradle.kts b/core/resources/build.gradle.kts
index 7edce86b6..47d8c12e0 100644
--- a/core/resources/build.gradle.kts
+++ b/core/resources/build.gradle.kts
@@ -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 {
diff --git a/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/GetString.kt b/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/GetString.kt
index 54aa4760a..9557ce752 100644
--- a/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/GetString.kt
+++ b/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/GetString.kt
@@ -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)
}
}
diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts
index dbffac9af..622f08eb1 100644
--- a/core/service/build.gradle.kts
+++ b/core/service/build.gradle.kts
@@ -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)
}
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
index 6132a8c39..b5a4617a9 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
@@ -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)
+ }
}
}
diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts
index 8f8559af0..8e3c6f043 100644
--- a/core/testing/build.gradle.kts
+++ b/core/testing/build.gradle.kts
@@ -18,8 +18,6 @@
plugins { alias(libs.plugins.meshtastic.kmp.library) }
kotlin {
- jvm()
-
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.core.testing"
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index 8dc0af751..7ba2bdae3 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -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 {
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
index 16a5d5b34..22c6bfaf5 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/DropDownPreference.kt
@@ -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 > DropDownPreference(
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt
index 5752287ae..9f6a59d5f 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/EditTextPreference.kt
@@ -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),
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
index a818208a7..18992c0e7 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
@@ -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,
)
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
index 4a4cc5ee8..4b64052e5 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MaterialBatteryInfo.kt
@@ -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),
)
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt
index 5c28ce6e7..abd339888 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ScrollToTopEvent.kt
@@ -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 = remember { MutableSharedFlow() }
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
index e8c964743..0a9c9b7e1 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt
@@ -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,
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
index 7f64f18b5..632c8abb4 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt
@@ -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(
diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/component/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/component/NoopStubs.kt
new file mode 100644
index 000000000..e18ffd84c
--- /dev/null
+++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/component/NoopStubs.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ui.component
+
+import androidx.compose.runtime.Composable
+
+@Composable actual fun rememberTimeTickWithLifecycle(): Long = 0L
+
+internal actual fun > enumEntriesOf(selectedItem: T): List = emptyList()
+
+internal actual fun Enum<*>.isDeprecatedEnumEntry(): Boolean = false
diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/theme/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/theme/NoopStubs.kt
new file mode 100644
index 000000000..90010567f
--- /dev/null
+++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/theme/NoopStubs.kt
@@ -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 .
+ */
+package org.meshtastic.core.ui.theme
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.runtime.Composable
+
+@Composable actual fun dynamicColorScheme(darkTheme: Boolean): ColorScheme? = null
diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
new file mode 100644
index 000000000..4fd37a5ee
--- /dev/null
+++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.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) {}
diff --git a/desktop/README.md b/desktop/README.md
index 8c47ca545..5e177a548 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -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 |
diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts
index 668b1145b..8bbc886af 100644
--- a/desktop/build.gradle.kts
+++ b/desktop/build.gradle.kts
@@ -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)
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
index 24b73cbc5..3b2585fe3 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt
@@ -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) = application(exitProcessOnExit = false) {
state = windowState,
) {
val backStack =
- rememberNavBackStack(navSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
+ rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
MenuBar {
Menu("File") {
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt
deleted file mode 100644
index cc0f19c09..000000000
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopMessagingNavigation.kt
+++ /dev/null
@@ -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 .
- */
-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.desktopMessagingGraph(backStack: NavBackStack) {
- entry {
- val viewModel: ContactsViewModel = koinViewModel()
- DesktopAdaptiveContactsScreen(
- viewModel = viewModel,
- onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
- )
- }
-
- entry {
- val viewModel: ContactsViewModel = koinViewModel()
- DesktopAdaptiveContactsScreen(
- viewModel = viewModel,
- onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
- )
- }
-
- entry { route ->
- val viewModel: MessageViewModel = koinViewModel(key = "messages-${route.contactKey}")
- DesktopMessageContent(
- contactKey = route.contactKey,
- viewModel = viewModel,
- initialMessage = route.message,
- onNavigateUp = { backStack.removeLastOrNull() },
- )
- }
-
- entry { route ->
- val viewModel: ContactsViewModel = koinViewModel()
- ShareScreen(
- viewModel = viewModel,
- onConfirm = { contactKey ->
- backStack.removeLastOrNull()
- backStack.add(ContactsRoutes.Messages(contactKey, route.message))
- },
- onNavigateUp = { backStack.removeLastOrNull() },
- )
- }
-
- entry {
- val viewModel: QuickChatViewModel = koinViewModel()
- QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
- }
-}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
index 5ec4d35f7..c32eae750 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt
@@ -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.desktopNavGraph(backStack: NavBackStack) {
// 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 { KmpMapPlaceholder() }
+ mapGraph(backStack)
// Firmware — in-flow destination (for example from Settings), not a top-level rail tab
- entry { DesktopFirmwareScreen() }
- entry { DesktopFirmwareScreen() }
+ firmwareGraph(backStack)
// Settings — real composables from feature:settings
- desktopSettingsGraph(backStack)
+ settingsGraph(backStack)
// Channels
channelsGraph(backStack)
// Connections — shared screen
- entry {
- 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 {
- 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
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNodeNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNodeNavigation.kt
deleted file mode 100644
index 42b6ded59..000000000
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNodeNavigation.kt
+++ /dev/null
@@ -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 .
- */
-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.desktopNodeGraph(backStack: NavBackStack) {
- entry {
- val viewModel: NodeListViewModel = koinViewModel()
- DesktopAdaptiveNodeListScreen(viewModel = viewModel, onNavigate = { backStack.add(it) })
- }
-
- entry {
- 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 { route ->
- val viewModel: NodeListViewModel = koinViewModel()
- DesktopAdaptiveNodeListScreen(
- viewModel = viewModel,
- initialNodeId = route.destNum,
- onNavigate = { backStack.add(it) },
- )
- }
-
- entry { route ->
- val viewModel: NodeListViewModel = koinViewModel()
- DesktopAdaptiveNodeListScreen(
- viewModel = viewModel,
- initialNodeId = route.destNum,
- onNavigate = { backStack.add(it) },
- )
- }
-
- // Traceroute log — real shared screen from commonMain
- desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel ->
- TracerouteLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
- }
-
- // Neighbor info log — real shared screen from commonMain
- desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel ->
- NeighborInfoLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
- }
-
- // Host metrics log — real shared screen from commonMain
- desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel ->
- HostMetricsLogScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
- }
-
- // Chart-based metrics — real shared screens from commonMain
- desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel ->
- DeviceMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
- }
- desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel ->
- EnvironmentMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
- }
- desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel ->
- SignalMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
- }
- desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel ->
- PowerMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
- }
- desktopMetricsEntry(getDestNum = { it.destNum }) { viewModel ->
- PaxMetricsScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
- }
-
- // Map-based screens — placeholders (map integration needed)
- entry { route -> KmpMapPlaceholder(title = "Node Map (${route.destNum})") }
- entry { KmpMapPlaceholder(title = "Traceroute Map") }
- entry { route -> KmpMapPlaceholder(title = "Position Log (${route.destNum})") }
-}
-
-private inline fun EntryProviderScope.desktopMetricsEntry(
- crossinline getDestNum: (R) -> Int,
- crossinline content: @Composable (MetricsViewModel) -> Unit,
-) {
- entry { route ->
- val destNum = getDestNum(route)
- val viewModel: MetricsViewModel = koinViewModel(key = "metrics-$destNum") { parametersOf(destNum) }
- LaunchedEffect(destNum) { viewModel.setNodeId(destNum) }
- content(viewModel)
- }
-}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt
deleted file mode 100644
index aab9ea8e5..000000000
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt
+++ /dev/null
@@ -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 .
- */
-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): RadioConfigViewModel {
- val viewModel = koinViewModel()
- 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.desktopSettingsGraph(backStack: NavBackStack) {
- // Top-level settings — desktop-specific screen (Android version uses Activity, permissions, etc.)
- entry {
- DesktopSettingsScreen(
- radioConfigViewModel = getRadioConfigViewModel(backStack),
- settingsViewModel = koinViewModel(),
- onNavigate = { route -> backStack.add(route) },
- )
- }
-
- entry {
- DesktopSettingsScreen(
- radioConfigViewModel = getRadioConfigViewModel(backStack),
- settingsViewModel = koinViewModel(),
- onNavigate = { route -> backStack.add(route) },
- )
- }
-
- // Device configuration — shared commonMain composable
- entry {
- DeviceConfigurationScreen(
- viewModel = getRadioConfigViewModel(backStack),
- onBack = { backStack.removeLastOrNull() },
- onNavigate = { route -> backStack.add(route) },
- )
- }
-
- // Module configuration — shared commonMain composable
- entry {
- 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 {
- AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() })
- }
-
- // Clean node database — shared commonMain composable
- entry {
- val viewModel: CleanNodeDatabaseViewModel = koinViewModel()
- CleanNodeDatabaseScreen(viewModel = viewModel)
- }
-
- // Debug Panel — shared commonMain composable
- entry {
- 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 {
- AboutScreen(
- onNavigateUp = { backStack.removeLastOrNull() },
- jsonProvider = { SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" },
- )
- }
-
- // Filter settings — shared commonMain composable
- entry {
- 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 EntryProviderScope.desktopConfigComposable(
- route: KClass,
- backStack: NavBackStack,
- content: @Composable (RadioConfigViewModel) -> Unit,
-) {
- addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
-}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
index 26ff14e5a..f5224f63d 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
+++ b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt
@@ -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].
*
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt
deleted file mode 100644
index f6a3433f0..000000000
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopAdaptiveContactsScreen.kt
+++ /dev/null
@@ -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 .
- */
-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()
- 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)
- }
- },
- )
-}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopMessageContent.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopMessageContent.kt
deleted file mode 100644
index 8a2b50a3a..000000000
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/messaging/DesktopMessageContent.kt
+++ /dev/null
@@ -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 .
- */
-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> 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(null) }
- var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
- val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet()) }
- 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(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>,
- contactKey: String,
- viewModel: MessageViewModel,
- listState: androidx.compose.foundation.lazy.LazyListState,
- messages: List,
- onShowStatusDialog: (org.meshtastic.core.model.Message) -> Unit,
- onReply: (org.meshtastic.core.model.Message?) -> Unit,
- hasSamePrev: Boolean,
- hasSameNext: Boolean,
- showUserName: Boolean,
- quickEmojis: List,
-) {
- 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.toggle(uuid: Long): Set = 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
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt
deleted file mode 100644
index 249b2fcdf..000000000
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/nodes/DesktopAdaptiveNodeListScreen.kt
+++ /dev/null
@@ -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 .
- */
-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()
- val scope = rememberCoroutineScope()
- val listState = rememberLazyListState()
-
- val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
- var shareNode by remember { mutableStateOf(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))
- }
- },
- )
-}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopNetworkConfigScreen.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopNetworkConfigScreen.kt
deleted file mode 100644
index 53c21d950..000000000
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/settings/DesktopNetworkConfigScreen.kt
+++ /dev/null
@@ -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 .
- */
-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}"
diff --git a/docs/agent-playbooks/README.md b/docs/agent-playbooks/README.md
index 375920e15..15550deea 100644
--- a/docs/agent-playbooks/README.md
+++ b/docs/agent-playbooks/README.md
@@ -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 |
diff --git a/docs/agent-playbooks/common-practices.md b/docs/agent-playbooks/common-practices.md
index 212af9517..05190aead 100644
--- a/docs/agent-playbooks/common-practices.md
+++ b/docs/agent-playbooks/common-practices.md
@@ -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
diff --git a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md
index 3d42ffbe2..e00166729 100644
--- a/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md
+++ b/docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md
@@ -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` 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
diff --git a/docs/agent-playbooks/task-playbooks.md b/docs/agent-playbooks/task-playbooks.md
index be25a9c7c..1929f157c 100644
--- a/docs/agent-playbooks/task-playbooks.md
+++ b/docs/agent-playbooks/task-playbooks.md
@@ -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`
diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md
index a9c064667..3bdef3723 100644
--- a/docs/decisions/architecture-review-2026-03.md
+++ b/docs/decisions/architecture-review-2026-03.md
@@ -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 |
diff --git a/docs/decisions/navigation3-parity-2026-03.md b/docs/decisions/navigation3-parity-2026-03.md
index f8ae3a0d8..a314af54d 100644
--- a/docs/decisions/navigation3-parity-2026-03.md
+++ b/docs/decisions/navigation3-parity-2026-03.md
@@ -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`
diff --git a/docs/kmp-status.md b/docs/kmp-status.md
index ffa4f0c66..aaec88b0f 100644
--- a/docs/kmp-status.md
+++ b/docs/kmp-status.md
@@ -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
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 058b0671d..b97be24c1 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -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.
diff --git a/feature/connections/build.gradle.kts b/feature/connections/build.gradle.kts
index 3bc65aec8..66c51bab7 100644
--- a/feature/connections/build.gradle.kts
+++ b/feature/connections/build.gradle.kts
@@ -18,8 +18,6 @@
plugins { alias(libs.plugins.meshtastic.kmp.feature) }
kotlin {
- jvm()
-
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.connections"
diff --git a/feature/connections/detekt-baseline.xml b/feature/connections/detekt-baseline.xml
index 9ba3ffcf6..c373eea43 100644
--- a/feature/connections/detekt-baseline.xml
+++ b/feature/connections/detekt-baseline.xml
@@ -1,13 +1,5 @@
-
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790
- MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114
- MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200
- MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200
- SwallowedException:NsdManager.kt$ex: IllegalArgumentException
-
+
diff --git a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
index 3f2c9014f..426e8476d 100644
--- a/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/ScannerViewModel.kt
@@ -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) }
diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt
similarity index 83%
rename from feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt
rename to feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt
index c94c1ab3a..d239dcf00 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt
+++ b/feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/navigation/ConnectionsNavigation.kt
@@ -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.connectionsGraph(backStack: NavBackStack) {
entry {
ConnectionsScreen(
- scanModel = koinViewModel(),
+ scanModel = koinViewModel(),
radioConfigViewModel = koinViewModel(),
- 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.connectionsGraph(backStack: NavBackStack)
entry {
ConnectionsScreen(
- scanModel = koinViewModel(),
+ scanModel = koinViewModel(),
radioConfigViewModel = koinViewModel(),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt
new file mode 100644
index 000000000..f9f26deb3
--- /dev/null
+++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt
@@ -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 .
+ */
+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()
+ FirmwareUpdateScreen(onNavigateUp = onNavigateUp, viewModel = viewModel)
+}
diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt
similarity index 72%
rename from feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt
rename to feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt
index 87ee9f82c..c71d597bd 100644
--- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt
@@ -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.firmwareGraph(backStack: NavBackStack) {
- entry {
- val viewModel = koinViewModel()
- FirmwareUpdateScreen(onNavigateUp = { backStack.removeLastOrNull() }, viewModel = viewModel)
- }
+ entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
+ entry { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
}
+
+@Composable expect fun FirmwareScreen(onNavigateUp: () -> Unit)
diff --git a/feature/firmware/src/iosMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt b/feature/firmware/src/iosMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt
new file mode 100644
index 000000000..750a409c3
--- /dev/null
+++ b/feature/firmware/src/iosMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareNavigation.kt
@@ -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 .
+ */
+package org.meshtastic.feature.firmware.navigation
+
+import androidx.compose.runtime.Composable
+
+@Composable
+actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
+ // TODO: Implement iOS firmware screen
+}
diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/firmware/DesktopFirmwareScreen.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareScreen.kt
similarity index 99%
rename from desktop/src/main/kotlin/org/meshtastic/desktop/ui/firmware/DesktopFirmwareScreen.kt
rename to feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareScreen.kt
index f31dd1e05..d9e2de815 100644
--- a/desktop/src/main/kotlin/org/meshtastic/desktop/ui/firmware/DesktopFirmwareScreen.kt
+++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/DesktopFirmwareScreen.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.desktop.ui.firmware
+package org.meshtastic.feature.firmware
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt
new file mode 100644
index 000000000..5e6d85da7
--- /dev/null
+++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/navigation/FirmwareScreen.kt
@@ -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 .
+ */
+package org.meshtastic.feature.firmware.navigation
+
+import androidx.compose.runtime.Composable
+import org.meshtastic.feature.firmware.DesktopFirmwareScreen
+
+@Composable
+actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
+ DesktopFirmwareScreen()
+}
diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts
index 81997c438..6a3f9f5a0 100644
--- a/feature/intro/build.gradle.kts
+++ b/feature/intro/build.gradle.kts
@@ -21,8 +21,6 @@ plugins {
}
kotlin {
- jvm()
-
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.intro"
diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts
index ff1010c15..11cd70588 100644
--- a/feature/map/build.gradle.kts
+++ b/feature/map/build.gradle.kts
@@ -20,8 +20,6 @@ plugins {
}
kotlin {
- jvm()
-
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.map"
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt
new file mode 100644
index 000000000..d2e6e9895
--- /dev/null
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.navigation
+
+import androidx.compose.runtime.Composable
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.feature.map.MapScreen
+import org.meshtastic.feature.map.SharedMapViewModel
+
+@Composable
+actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
+ val viewModel = koinViewModel()
+ MapScreen(
+ viewModel = viewModel,
+ onClickNodeChip = onClickNodeChip,
+ navigateToNodeDetails = navigateToNodeDetails,
+ waypointId = waypointId,
+ )
+}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
index 73dcbe499..bd8f8615b 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt
@@ -18,7 +18,6 @@ package org.meshtastic.feature.map
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -26,6 +25,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
+import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
@@ -139,7 +139,7 @@ open class BaseMapViewModel(
fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum)
- fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteWaypoint(id) }
+ fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) }
fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") {
// contactKey: unique contact key filter (channel)+(nodeId)
@@ -151,7 +151,7 @@ open class BaseMapViewModel(
}
private fun sendDataPacket(p: DataPacket) {
- viewModelScope.launch(Dispatchers.IO) { radioController.sendMessage(p) }
+ viewModelScope.launch(ioDispatcher) { radioController.sendMessage(p) }
}
fun generatePacketId(): Int = radioController.getPacketId()
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
similarity index 83%
rename from feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
index c590c1a7a..ba9420bf0 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
+++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
@@ -16,23 +16,22 @@
*/
package org.meshtastic.feature.map.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.MapRoutes
import org.meshtastic.core.navigation.NodesRoutes
-import org.meshtastic.feature.map.MapScreen
-import org.meshtastic.feature.map.SharedMapViewModel
fun EntryProviderScope.mapGraph(backStack: NavBackStack) {
entry { args ->
- val viewModel = koinViewModel()
- MapScreen(
- viewModel = viewModel,
+ MapMainScreen(
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
waypointId = args.waypointId,
)
}
}
+
+@Composable
+expect fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?)
diff --git a/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
new file mode 100644
index 000000000..b08a13126
--- /dev/null
+++ b/feature/map/src/iosMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt
@@ -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 .
+ */
+package org.meshtastic.feature.map.navigation
+
+import androidx.compose.runtime.Composable
+
+@Composable
+actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
+ // TODO: Implement iOS map main screen
+}
diff --git a/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt
new file mode 100644
index 000000000..3e0810824
--- /dev/null
+++ b/feature/map/src/jvmMain/kotlin/org/meshtastic/feature/map/navigation/MapMainScreen.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.map.navigation
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+
+@Composable
+actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
+ // Desktop placeholder for now
+ org.meshtastic.feature.map.navigation.PlaceholderScreen(name = "Map")
+}
+
+@Composable
+internal fun PlaceholderScreen(name: String) {
+ androidx.compose.foundation.layout.Box(
+ modifier = androidx.compose.ui.Modifier.fillMaxSize(),
+ contentAlignment = androidx.compose.ui.Alignment.Center,
+ ) {
+ androidx.compose.material3.Text(
+ text = name,
+ style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
+ color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts
index 66dbd0e41..7b245ac08 100644
--- a/feature/messaging/build.gradle.kts
+++ b/feature/messaging/build.gradle.kts
@@ -43,7 +43,9 @@ kotlin {
implementation(projects.core.ui)
implementation(libs.jetbrains.navigation3.runtime)
+ implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.androidx.paging.common)
+ implementation(libs.androidx.paging.compose)
// JetBrains Material 3 Adaptive (multiplatform ListDetailPaneScaffold)
implementation(libs.jetbrains.compose.material3.adaptive)
@@ -51,10 +53,7 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
}
- androidMain.dependencies {
- implementation(libs.androidx.paging.compose)
- implementation(libs.androidx.work.runtime.ktx)
- }
+ androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) }
androidUnitTest.dependencies {
implementation(libs.androidx.work.testing)
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
new file mode 100644
index 000000000..79cfd92b4
--- /dev/null
+++ b/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.messaging.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import kotlinx.coroutines.flow.Flow
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.core.ui.component.ScrollToTopEvent
+import org.meshtastic.core.ui.viewmodel.UIViewModel
+import org.meshtastic.feature.messaging.MessageViewModel
+import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
+import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
+
+@Composable
+actual fun ContactsEntryContent(
+ backStack: NavBackStack,
+ scrollToTopEvents: Flow,
+ initialContactKey: String?,
+ initialMessage: String,
+) {
+ val uiViewModel: UIViewModel = koinViewModel()
+ val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
+ val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
+ val contactsViewModel = koinViewModel()
+ val messageViewModel = koinViewModel()
+ initialContactKey?.let { messageViewModel.setContactKey(it) }
+
+ AdaptiveContactsScreen(
+ backStack = backStack,
+ contactsViewModel = contactsViewModel,
+ messageViewModel = messageViewModel,
+ scrollToTopEvents = scrollToTopEvents,
+ sharedContactRequested = sharedContactRequested,
+ requestChannelSet = requestChannelSet,
+ onHandleScannedUri = uiViewModel::handleScannedUri,
+ onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
+ onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
+ initialContactKey = initialContactKey,
+ initialMessage = initialMessage,
+ )
+}
diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
index 6abacade7..30f65afff 100644
--- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
+++ b/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
@@ -19,6 +19,7 @@ package org.meshtastic.feature.messaging.component
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.tooling.preview.NodePreviewParameterProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
@@ -26,7 +27,6 @@ import org.junit.runner.RunWith
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
-import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
@RunWith(AndroidJUnit4::class)
class MessageItemTest {
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
similarity index 98%
rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/Message.kt
rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
index b5116d3fb..7be0b4027 100644
--- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -18,7 +18,6 @@
package org.meshtastic.feature.messaging
-import android.content.ClipData
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
@@ -78,6 +77,7 @@ import org.meshtastic.core.resources.unknown_channel
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.smartScrollToIndex
import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.feature.messaging.component.ActionModeTopBar
import org.meshtastic.feature.messaging.component.DeleteMessageDialog
import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES
@@ -86,7 +86,6 @@ 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 java.nio.charset.StandardCharsets
private const val ROUNDED_CORNER_PERCENT = 100
private const val MAX_LINES = 3
@@ -243,11 +242,7 @@ fun MessageScreen(
is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum)
MessageScreenEvent.NavigateBack -> onNavigateBack()
is MessageScreenEvent.CopyToClipboard -> {
- coroutineScope.launch {
- clipboardManager.setClipEntry(
- androidx.compose.ui.platform.ClipEntry(ClipData.newPlainText(event.text, event.text)),
- )
- }
+ coroutineScope.launch { clipboardManager.setClipEntry(createClipEntry(event.text, event.text)) }
selectedMessageIds.value = emptySet()
}
}
@@ -450,7 +445,7 @@ private fun MessageInput(
val currentByteLength =
remember(currentText) {
// Recalculate only when text changes
- currentText.toByteArray(StandardCharsets.UTF_8).size
+ currentText.encodeToByteArray().size
}
val isOverLimit = currentByteLength > maxByteSize
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
similarity index 100%
rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageListPaged.kt
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index d93006619..1c84c26f1 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -21,7 +21,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -34,6 +33,7 @@ import kotlinx.coroutines.flow.flow
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.model.ContactSettings
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
@@ -190,7 +190,7 @@ class MessageViewModel(
}
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
- viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
+ viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
}
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
@@ -218,10 +218,10 @@ class MessageViewModel(
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) }
fun deleteMessages(uuidList: List) =
- viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) }
+ viewModelScope.launch(ioDispatcher) { packetRepository.deleteMessages(uuidList) }
fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) =
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(ioDispatcher) {
val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE
if (lastReadTimestamp <= existingTimestamp) {
return@launch
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt
index 685732197..02278d15b 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChat.kt
@@ -202,7 +202,7 @@ internal fun EditQuickChatDialog(
label = stringResource(Res.string.message),
value = actionInput.message,
maxSize = 200,
- getSize = { it.toByteArray().size + 1 },
+ getSize = { it.encodeToByteArray().size + 1 },
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
) {
actionInput = actionInput.copy(message = it)
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt
index e114d3964..53d023d08 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/QuickChatViewModel.kt
@@ -18,9 +18,9 @@ package org.meshtastic.feature.messaging
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.repository.QuickChatActionRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@@ -31,7 +31,7 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR
get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList())
fun updateActionPositions(actions: List) {
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(ioDispatcher) {
for (position in actions.indices) {
quickChatActionRepository.setItemPosition(actions[position].uuid, position)
}
@@ -39,8 +39,8 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR
}
fun addQuickChatAction(action: QuickChatAction) =
- viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) }
+ viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) }
fun deleteQuickChatAction(action: QuickChatAction) =
- viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.delete(action) }
+ viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) }
}
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt
similarity index 67%
rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt
rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt
index 79017141b..15e2de883 100644
--- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt
@@ -17,27 +17,23 @@
package org.meshtastic.feature.messaging.navigation
import androidx.compose.runtime.Composable
-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 kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.ui.component.ScrollToTopEvent
-import org.meshtastic.core.ui.viewmodel.UIViewModel
-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.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
@Suppress("LongMethod")
fun EntryProviderScope.contactsGraph(
backStack: NavBackStack,
- scrollToTopEvents: Flow,
+ scrollToTopEvents: Flow = MutableSharedFlow(),
) {
entry {
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
@@ -77,30 +73,9 @@ fun EntryProviderScope.contactsGraph(
}
@Composable
-private fun ContactsEntryContent(
+expect fun ContactsEntryContent(
backStack: NavBackStack,
scrollToTopEvents: Flow,
initialContactKey: String? = null,
initialMessage: String = "",
-) {
- val uiViewModel: UIViewModel = koinViewModel()
- val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
- val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
- val contactsViewModel = koinViewModel()
- val messageViewModel = koinViewModel()
- initialContactKey?.let { messageViewModel.setContactKey(it) }
-
- AdaptiveContactsScreen(
- backStack = backStack,
- contactsViewModel = contactsViewModel,
- messageViewModel = messageViewModel,
- scrollToTopEvents = scrollToTopEvents,
- sharedContactRequested = sharedContactRequested,
- requestChannelSet = requestChannelSet,
- onHandleScannedUri = uiViewModel::handleScannedUri,
- onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
- onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
- initialContactKey = initialContactKey,
- initialMessage = initialMessage,
- )
-}
+)
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
similarity index 78%
rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
index 318a6431f..00814a3a8 100644
--- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/AdaptiveContactsScreen.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.messaging.ui.contact
-import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
@@ -29,7 +28,10 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
-import kotlinx.coroutines.CancellationException
+import androidx.navigationevent.NavigationEventInfo
+import androidx.navigationevent.NavigationEventTransitionState
+import androidx.navigationevent.compose.NavigationBackHandler
+import androidx.navigationevent.compose.rememberNavigationEventState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@@ -44,6 +46,7 @@ import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.messaging.MessageScreen
+import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
@@ -52,8 +55,8 @@ import org.meshtastic.proto.SharedContact
@Composable
fun AdaptiveContactsScreen(
backStack: NavBackStack,
- contactsViewModel: org.meshtastic.feature.messaging.ui.contact.ContactsViewModel,
- messageViewModel: org.meshtastic.feature.messaging.MessageViewModel,
+ contactsViewModel: ContactsViewModel,
+ messageViewModel: MessageViewModel,
scrollToTopEvents: Flow,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
@@ -62,6 +65,7 @@ fun AdaptiveContactsScreen(
onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,
initialMessage: String = "",
+ detailPaneCustom: @Composable ((contactKey: String) -> Unit)? = null,
) {
val navigator = rememberListDetailPaneScaffoldNavigator()
val scope = rememberCoroutineScope()
@@ -95,14 +99,18 @@ fun AdaptiveContactsScreen(
}
}
- PredictiveBackHandler(
- enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
- ) { progress ->
- try {
- progress.collect { /* Predictive back progress could be used here to drive UI if scaffold supported it */ }
- handleBack()
- } catch (_: CancellationException) {
- // Gesture cancelled
+ val navState = rememberNavigationEventState(NavigationEventInfo.None)
+ NavigationBackHandler(
+ state = navState,
+ isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
+ onBackCancelled = { /* Gesture cancelled */ },
+ onBackCompleted = { handleBack() },
+ )
+ LaunchedEffect(navState.transitionState) {
+ val transitionState = navState.transitionState
+ if (transitionState is NavigationEventTransitionState.InProgress) {
+ val progress = transitionState.latestEvent.progress
+ // Animate the back gesture progress could be used here to drive UI if scaffold supported it
}
}
@@ -154,14 +162,18 @@ fun AdaptiveContactsScreen(
AnimatedPane {
navigator.currentDestination?.contentKey?.let { contactKey ->
key(contactKey) {
- MessageScreen(
- contactKey = contactKey,
- message = if (contactKey == initialContactKey) initialMessage else "",
- viewModel = messageViewModel,
- navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
- navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) },
- onNavigateBack = handleBack,
- )
+ if (detailPaneCustom != null) {
+ detailPaneCustom(contactKey)
+ } else {
+ MessageScreen(
+ contactKey = contactKey,
+ message = if (contactKey == initialContactKey) initialMessage else "",
+ viewModel = messageViewModel,
+ navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
+ navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) },
+ onNavigateBack = handleBack,
+ )
+ }
}
}
?: EmptyDetailPlaceholder(
diff --git a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
similarity index 97%
rename from feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
rename to feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
index 028b5be82..8be27f165 100644
--- a/feature/messaging/src/androidMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt
@@ -49,16 +49,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
-import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -66,7 +63,6 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.util.TimeConstants
@@ -108,12 +104,11 @@ import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
-import org.meshtastic.core.ui.util.showToast
+import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
import kotlin.time.Duration.Companion.days
-@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList")
@Composable
fun ContactsScreen(
@@ -124,13 +119,13 @@ fun ContactsScreen(
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel,
- onClickNodeChip: (Int) -> Unit = {},
- onNavigateToMessages: (String) -> Unit = {},
- onNavigateToNodeDetails: (Int) -> Unit = {},
- scrollToTopEvents: Flow? = null,
- activeContactKey: String? = null,
+ onClickNodeChip: (Int) -> Unit,
+ onNavigateToMessages: (String) -> Unit,
+ onNavigateToNodeDetails: (Int) -> Unit,
+ scrollToTopEvents: Flow?,
+ activeContactKey: String?,
) {
- val context = LocalContext.current
+ val showToast = rememberShowToastResource()
val scope = rememberCoroutineScope()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
@@ -258,8 +253,8 @@ fun ContactsScreen(
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uriString ->
- onHandleScannedUri(uriString.toUri().toMeshtasticUri()) {
- scope.launch { context.showToast(Res.string.channel_invalid) }
+ onHandleScannedUri(MeshtasticUri(uriString)) {
+ scope.launch { showToast(Res.string.channel_invalid) }
}
},
onShareChannels = onNavigateToShare,
diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
index def86b6dd..58f16af1f 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/ContactsViewModel.kt
@@ -21,13 +21,13 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.DataPacket
@@ -189,17 +189,17 @@ class ContactsViewModel(
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun deleteContacts(contacts: List) =
- viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
+ viewModelScope.launch(ioDispatcher) { packetRepository.deleteContacts(contacts) }
- fun markAllAsRead() = viewModelScope.launch(Dispatchers.IO) { packetRepository.clearAllUnreadCounts() }
+ fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() }
fun setMuteUntil(contacts: List, until: Long) =
- viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }
+ viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) }
fun getContactSettings() = packetRepository.getContactSettings()
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
- viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
+ viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
}
/**
diff --git a/feature/messaging/src/iosMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt b/feature/messaging/src/iosMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt
new file mode 100644
index 000000000..d9746d891
--- /dev/null
+++ b/feature/messaging/src/iosMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsNavigation.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.messaging.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import kotlinx.coroutines.flow.Flow
+import org.meshtastic.core.ui.component.ScrollToTopEvent
+
+@Composable
+actual fun ContactsEntryContent(
+ backStack: NavBackStack,
+ scrollToTopEvents: Flow,
+ initialContactKey: String?,
+ initialMessage: String,
+) {
+ // TODO: Implement iOS contacts screen
+}
diff --git a/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt b/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
new file mode 100644
index 000000000..182b79276
--- /dev/null
+++ b/feature/messaging/src/jvmMain/kotlin/org/meshtastic/feature/messaging/navigation/ContactsEntryContent.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.messaging.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import kotlinx.coroutines.flow.Flow
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.core.ui.component.ScrollToTopEvent
+import org.meshtastic.feature.messaging.MessageScreen
+import org.meshtastic.feature.messaging.MessageViewModel
+import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
+import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
+
+@Composable
+actual fun ContactsEntryContent(
+ backStack: NavBackStack,
+ scrollToTopEvents: Flow,
+ initialContactKey: String?,
+ initialMessage: String,
+) {
+ val viewModel: ContactsViewModel = koinViewModel()
+ AdaptiveContactsScreen(
+ backStack = backStack,
+ contactsViewModel = viewModel,
+ messageViewModel = koinViewModel(), // Used for desktop detail pane
+ scrollToTopEvents = scrollToTopEvents,
+ sharedContactRequested = null,
+ requestChannelSet = null,
+ onHandleScannedUri = { _, _ -> },
+ onClearSharedContactRequested = {},
+ onClearRequestChannelUrl = {},
+ initialContactKey = initialContactKey,
+ initialMessage = initialMessage,
+ detailPaneCustom = { contactKey ->
+ val messageViewModel: MessageViewModel = koinViewModel(key = "messages-$contactKey")
+ MessageScreen(
+ contactKey = contactKey,
+ message = if (contactKey == initialContactKey) initialMessage else "",
+ viewModel = messageViewModel,
+ navigateToNodeDetails = {
+ backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it))
+ },
+ navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoutes.QuickChat) },
+ onNavigateBack = { backStack.removeLastOrNull() },
+ )
+ },
+ )
+}
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
index a52d4d13e..e5eb00bd5 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
+++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
@@ -70,14 +70,14 @@ private sealed interface NodeDetailOverlay {
}
@Composable
-fun NodeDetailScreen(
+actual fun NodeDetailScreen(
nodeId: Int,
- modifier: Modifier = Modifier,
+ modifier: Modifier,
viewModel: NodeDetailViewModel,
- navigateToMessages: (String) -> Unit = {},
- onNavigate: (Route) -> Unit = {},
- onNavigateUp: () -> Unit = {},
- compassViewModel: CompassViewModel? = null,
+ navigateToMessages: (String) -> Unit,
+ onNavigate: (Route) -> Unit,
+ onNavigateUp: () -> Unit,
+ compassViewModel: CompassViewModel?,
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt
index 78cc07fa8..2ed2fa7cd 100644
--- a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt
+++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/metrics/PositionLog.kt
@@ -102,7 +102,7 @@ private fun ActionButtons(
@Suppress("LongMethod")
@Composable
-fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
+actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
diff --git a/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt
new file mode 100644
index 000000000..109115492
--- /dev/null
+++ b/feature/node/src/androidMain/kotlin/org/meshtastic/feature/node/navigation/TracerouteMapScreens.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.node.navigation
+
+import androidx.compose.runtime.Composable
+import org.koin.compose.viewmodel.koinViewModel
+import org.koin.core.parameter.parametersOf
+import org.meshtastic.feature.node.metrics.MetricsViewModel
+import org.meshtastic.feature.node.metrics.TracerouteMapScreen as AndroidTracerouteMapScreen
+
+@Composable
+actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
+ val metricsViewModel = koinViewModel(key = "metrics-$destNum") { parametersOf(destNum) }
+ metricsViewModel.setNodeId(destNum)
+
+ AndroidTracerouteMapScreen(
+ metricsViewModel = metricsViewModel,
+ requestId = requestId,
+ logUuid = logUuid,
+ onNavigateUp = onNavigateUp,
+ )
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
index 2a3584321..c261b7aab 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
@@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
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.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@@ -119,7 +120,7 @@ class CompassViewModel(
val bearingDegrees = calculateBearing(locationState, target)
val trueHeading = applyTrueNorthCorrection(headingState.heading, locationState)
val distanceText = distanceMeters?.toDistanceString(current.displayUnits)
- val bearingText = bearingDegrees?.let { BEARING_FORMAT.format(it) }
+ val bearingText = bearingDegrees?.let { formatString(BEARING_FORMAT, it) }
val isAligned = isAligned(trueHeading, bearingDegrees)
val lastUpdateText = targetPositionTimeSec?.let { formatElapsed(it) }
val angularErrorDeg = calculateAngularError(positionalAccuracyMeters, distanceMeters)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
index fc8a5ad5c..7b42dd374 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/CompassBottomSheet.kt
@@ -55,6 +55,7 @@ import androidx.compose.ui.text.rememberTextMeasurer
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.compass_bearing
import org.meshtastic.core.resources.compass_bearing_na
@@ -71,6 +72,7 @@ import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.last_position_update
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassWarning
+import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@@ -126,7 +128,7 @@ fun CompassSheetContent(
Text(
text =
uiState.errorRadiusText?.let { radius ->
- val angle = uiState.angularErrorDeg?.let { "%.0f°".format(it) } ?: "?"
+ val angle = uiState.angularErrorDeg?.let { formatString("%.0f°", it) } ?: "?"
stringResource(Res.string.compass_uncertainty, radius, angle)
} ?: stringResource(Res.string.compass_uncertainty_unknown),
style = MaterialTheme.typography.bodyMedium,
@@ -279,7 +281,7 @@ private fun CompassDial(
else -> 1.dp.toPx()
}
- val angle = Math.toRadians(deg.toDouble())
+ val angle = (deg * PI / 180.0)
val outer = Offset(center.x + radius * sin(angle).toFloat(), center.y - radius * cos(angle).toFloat())
val inner =
Offset(
@@ -310,7 +312,7 @@ private fun CompassDial(
)
for ((label, deg, color) in cardinals) {
- val angle = Math.toRadians(deg.toDouble())
+ val angle = (deg * PI / 180.0)
val x = center.x + cardinalRadius * sin(angle).toFloat()
val y = center.y - cardinalRadius * cos(angle).toFloat()
@@ -327,7 +329,7 @@ private fun CompassDial(
// Degree labels
val degRadius = radius * 0.72f
for (d in 0 until 360 step 30) {
- val angle = Math.toRadians(d.toDouble())
+ val angle = (d * PI / 180.0)
val x = center.x + degRadius * sin(angle).toFloat()
val y = center.y - degRadius * cos(angle).toFloat()
@@ -363,8 +365,8 @@ private fun CompassDial(
// Cone edge lines for clarity
val edgeRadius = arcRadius
- val startRad = Math.toRadians(startAngleNorth.toDouble())
- val endRad = Math.toRadians((startAngleNorth + sweep).toDouble())
+ val startRad = (startAngleNorth * PI / 180.0)
+ val endRad = ((startAngleNorth + sweep) * PI / 180.0)
val startEnd =
Offset(
center.x + edgeRadius * sin(startRad).toFloat(),
@@ -376,7 +378,7 @@ private fun CompassDial(
drawLine(color = faint, start = center, end = endEnd, strokeWidth = 6f, cap = StrokeCap.Round)
}
if (bearingForDraw != null) {
- val angle = Math.toRadians(bearingForDraw.toDouble())
+ val angle = (bearingForDraw * PI / 180.0)
val dot =
Offset(
center.x + (radius * 0.95f) * sin(angle).toFloat(),
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
index a72fc7c0e..95291e07c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.Base64Factory
+import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
@@ -261,7 +262,7 @@ private fun SignalRow(node: Node) {
if (node.snr != Float.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.snr),
- value = "%.1f dB".format(node.snr),
+ value = formatString("%.1f dB", node.snr),
icon = MeshtasticIcons.ChannelUtilization,
modifier = Modifier.weight(1f),
)
@@ -271,7 +272,7 @@ private fun SignalRow(node: Node) {
if (node.rssi != Int.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.rssi),
- value = "%d dBm".format(node.rssi),
+ value = formatString("%d dBm", node.rssi),
icon = MeshtasticIcons.ChannelUtilization,
modifier = Modifier.weight(1f),
)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index ba857744c..15d317cae 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.isUnmessageableRole
@@ -256,14 +257,14 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
icon = MeshtasticIcons.ChannelUtilization,
contentDescription = stringResource(Res.string.channel_utilization),
label = stringResource(Res.string.channel_utilization),
- text = "%.1f%%".format(thatNode.deviceMetrics.channel_utilization),
+ text = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization),
contentColor = contentColor,
)
IconInfo(
icon = MeshtasticIcons.AirUtilization,
contentDescription = stringResource(Res.string.air_utilization),
label = stringResource(Res.string.air_utilization),
- text = "%.1f%%".format(thatNode.deviceMetrics.air_util_tx),
+ text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx),
contentColor = contentColor,
)
}
@@ -318,26 +319,28 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
- "%.1f°F".format(celsiusToFahrenheit(env.temperature ?: 0f))
+ formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f))
} else {
- "%.1f°C".format(env.temperature ?: 0f)
+ formatString("%.1f°C", env.temperature ?: 0f)
}
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
}
if ((env.relative_humidity ?: 0f) != 0f) {
- items.add { HumidityInfo(humidity = "%.0f%%".format(env.relative_humidity ?: 0f), contentColor = contentColor) }
+ items.add {
+ HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor)
+ }
}
if ((env.barometric_pressure ?: 0f) != 0f) {
items.add {
- PressureInfo(pressure = "%.1fhPa".format(env.barometric_pressure ?: 0f), contentColor = contentColor)
+ PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor)
}
}
if ((env.soil_temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
- "%.1f°F".format(celsiusToFahrenheit(env.soil_temperature ?: 0f))
+ formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f))
} else {
- "%.1f°C".format(env.soil_temperature ?: 0f)
+ formatString("%.1f°C", env.soil_temperature ?: 0f)
}
items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) }
}
@@ -347,7 +350,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.voltage ?: 0f) != 0f) {
items.add {
PowerInfo(
- value = "%.2fV".format(env.voltage ?: 0f),
+ value = formatString("%.2fV", env.voltage ?: 0f),
label = stringResource(Res.string.voltage),
contentColor = contentColor,
)
@@ -356,7 +359,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.current ?: 0f) != 0f) {
items.add {
PowerInfo(
- value = "%.1fmA".format(env.current ?: 0f),
+ value = formatString("%.1fmA", env.current ?: 0f),
label = stringResource(Res.string.current),
contentColor = contentColor,
)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
index 4b38e476d..2ec8c9d50 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
@@ -18,7 +18,6 @@ package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -28,6 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
@@ -60,7 +60,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
override val lastRequestNeighborTimes: StateFlow