mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 02:01:35 -04:00
feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876)
This commit is contained in:
24
.github/copilot-instructions.md
vendored
24
.github/copilot-instructions.md
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/reusable-check.yml
vendored
4
.github/workflows/reusable-check.yml
vendored
@@ -91,8 +91,8 @@ jobs:
|
||||
if: inputs.run_unit_tests == true
|
||||
run: ./gradlew test koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug core:api:koverXmlReportDebug core:barcode:koverXmlReportFdroidDebug core:barcode:koverXmlReportGoogleDebug mesh_service_example:koverXmlReportDebug desktop:koverXmlReport -Pci=true --continue --scan
|
||||
|
||||
- name: KMP JVM Smoke Compile
|
||||
run: ./gradlew :core:proto:compileKotlinJvm :core:common:compileKotlinJvm :core:model:compileKotlinJvm :core:repository:compileKotlinJvm :core:di:compileKotlinJvm :core:navigation:compileKotlinJvm :core:resources:compileKotlinJvm :core:datastore:compileKotlinJvm :core:database:compileKotlinJvm :core:domain:compileKotlinJvm :core:prefs:compileKotlinJvm :core:network:compileKotlinJvm :core:data:compileKotlinJvm :core:ble:compileKotlinJvm :core:nfc:compileKotlinJvm :core:service:compileKotlinJvm :core:testing:compileKotlinJvm :core:ui:compileKotlinJvm :feature:intro:compileKotlinJvm :feature:messaging:compileKotlinJvm :feature:connections:compileKotlinJvm :feature:map:compileKotlinJvm :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm :feature:firmware:compileKotlinJvm -Pci=true --continue --scan
|
||||
- name: KMP Smoke Compile
|
||||
run: ./gradlew :core:proto:compileKotlinJvm :core:common:compileKotlinJvm :core:model:compileKotlinJvm :core:repository:compileKotlinJvm :core:di:compileKotlinJvm :core:navigation:compileKotlinJvm :core:resources:compileKotlinJvm :core:datastore:compileKotlinJvm :core:database:compileKotlinJvm :core:domain:compileKotlinJvm :core:prefs:compileKotlinJvm :core:network:compileKotlinJvm :core:data:compileKotlinJvm :core:ble:compileKotlinJvm :core:nfc:compileKotlinJvm :core:service:compileKotlinJvm :core:testing:compileKotlinJvm :core:ui:compileKotlinJvm :feature:intro:compileKotlinJvm :feature:messaging:compileKotlinJvm :feature:connections:compileKotlinJvm :feature:map:compileKotlinJvm :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm :feature:firmware:compileKotlinJvm :core:proto:compileKotlinIosSimulatorArm64 :core:common:compileKotlinIosSimulatorArm64 :core:model:compileKotlinIosSimulatorArm64 :core:repository:compileKotlinIosSimulatorArm64 :core:di:compileKotlinIosSimulatorArm64 :core:navigation:compileKotlinIosSimulatorArm64 :core:resources:compileKotlinIosSimulatorArm64 :core:datastore:compileKotlinIosSimulatorArm64 :core:database:compileKotlinIosSimulatorArm64 :core:domain:compileKotlinIosSimulatorArm64 :core:prefs:compileKotlinIosSimulatorArm64 :core:network:compileKotlinIosSimulatorArm64 :core:data:compileKotlinIosSimulatorArm64 :core:ble:compileKotlinIosSimulatorArm64 :core:nfc:compileKotlinIosSimulatorArm64 :core:service:compileKotlinIosSimulatorArm64 :core:testing:compileKotlinIosSimulatorArm64 :core:ui:compileKotlinIosSimulatorArm64 :feature:intro:compileKotlinIosSimulatorArm64 :feature:messaging:compileKotlinIosSimulatorArm64 :feature:connections:compileKotlinIosSimulatorArm64 :feature:map:compileKotlinIosSimulatorArm64 :feature:node:compileKotlinIosSimulatorArm64 :feature:settings:compileKotlinIosSimulatorArm64 :feature:firmware:compileKotlinIosSimulatorArm64 -Pci=true --continue --scan
|
||||
|
||||
- name: Upload coverage results to Codecov
|
||||
if: ${{ !cancelled() && inputs.run_unit_tests }}
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -51,4 +51,4 @@ wireless-install.sh
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
/firebase-debug.log
|
||||
/firebase-debug.log.jdk/
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>LongMethod:TCPInterface.kt$TCPInterface$private suspend fun startConnect()</ID>
|
||||
<ID>LongParameterList:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, dispatchers: CoroutineDispatchers, meshLogRepository: MeshLogRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, tracerouteSnapshotRepository: TracerouteSnapshotRepository, nodeRequestActions: NodeRequestActions, alertManager: AlertManager, getNodeDetailsUseCase: GetNodeDetailsUseCase, )</ID>
|
||||
<ID>LongParameterList:AndroidNodeListViewModel.kt$AndroidNodeListViewModel$( savedStateHandle: SavedStateHandle, nodeRepository: NodeRepository, radioConfigRepository: RadioConfigRepository, serviceRepository: ServiceRepository, radioController: RadioController, nodeManagementActions: NodeManagementActions, getFilteredNodesUseCase: GetFilteredNodesUseCase, nodeFilterPreferences: NodeFilterPreferences, )</ID>
|
||||
<ID>LongParameterList:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$( savedStateHandle: SavedStateHandle, private val app: Application, radioConfigRepository: RadioConfigRepository, packetRepository: PacketRepository, serviceRepository: ServiceRepository, nodeRepository: NodeRepository, private val locationRepository: LocationRepository, mapConsentPrefs: MapConsentPrefs, analyticsPrefs: AnalyticsPrefs, homoglyphEncodingPrefs: HomoglyphPrefs, toggleAnalyticsUseCase: ToggleAnalyticsUseCase, toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase, importProfileUseCase: ImportProfileUseCase, exportProfileUseCase: ExportProfileUseCase, exportSecurityConfigUseCase: ExportSecurityConfigUseCase, installProfileUseCase: InstallProfileUseCase, radioConfigUseCase: RadioConfigUseCase, adminActionsUseCase: AdminActionsUseCase, processRadioResponseUseCase: ProcessRadioResponseUseCase, )</ID>
|
||||
<ID>LongParameterList:AndroidSettingsViewModel.kt$AndroidSettingsViewModel$( private val app: Application, radioConfigRepository: RadioConfigRepository, radioController: RadioController, nodeRepository: NodeRepository, uiPrefs: UiPrefs, buildConfigProvider: BuildConfigProvider, databaseManager: DatabaseManager, meshLogPrefs: MeshLogPrefs, setThemeUseCase: SetThemeUseCase, setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase, setProvideLocationUseCase: SetProvideLocationUseCase, setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase, setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase, meshLocationUseCase: MeshLocationUseCase, exportDataUseCase: ExportDataUseCase, isOtaCapableUseCase: IsOtaCapableUseCase, )</ID>
|
||||
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1000L</ID>
|
||||
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-5</ID>
|
||||
<ID>MagicNumber:AndroidMetricsViewModel.kt$AndroidMetricsViewModel$1e-7</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
|
||||
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
|
||||
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
|
||||
<ID>MagicNumber:StreamInterface.kt$StreamInterface$0xff</ID>
|
||||
<ID>MagicNumber:StreamInterface.kt$StreamInterface$3</ID>
|
||||
<ID>MagicNumber:StreamInterface.kt$StreamInterface$4</ID>
|
||||
<ID>MagicNumber:StreamInterface.kt$StreamInterface$8</ID>
|
||||
<ID>MagicNumber:TCPInterface.kt$TCPInterface$1000</ID>
|
||||
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
|
||||
<ID>SwallowedException:TCPInterface.kt$TCPInterface$ex: SocketTimeoutException</ID>
|
||||
<ID>TooGenericExceptionCaught:AndroidRadioConfigViewModel.kt$AndroidRadioConfigViewModel$ex: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:BleRadioInterface.kt$BleRadioInterface$e: Exception</ID>
|
||||
<ID>TooGenericExceptionCaught:TCPInterface.kt$TCPInterface$ex: Throwable</ID>
|
||||
<ID>TooManyFunctions:BleRadioInterface.kt$BleRadioInterface : RadioTransport</ID>
|
||||
>>>>>>> ba83c3564 (chore(conductor): Complete Phase 4 - Wire Kable and Remove Nordic)
|
||||
</CurrentIssues>
|
||||
<CurrentIssues/>
|
||||
</SmellBaseline>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -67,6 +67,14 @@ internal fun Project.configureKotlinAndroid(
|
||||
*/
|
||||
internal fun Project.configureKotlinMultiplatform() {
|
||||
extensions.configure<KotlinMultiplatformExtension> {
|
||||
// Standard KMP targets for Meshtastic
|
||||
jvm()
|
||||
|
||||
// Configure the iOS targets for compile-only validation
|
||||
// We only add these for modules that already have KMP structure
|
||||
iosArm64()
|
||||
iosSimulatorArm64()
|
||||
|
||||
// Configure the Android target if the plugin is applied
|
||||
pluginManager.withPlugin("com.android.kotlin.multiplatform.library") {
|
||||
extensions.findByType<KotlinMultiplatformAndroidLibraryTarget>()?.apply {
|
||||
@@ -166,6 +174,27 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
|
||||
// Using Java 17 for better compatibility with consumers (e.g. plugins, older environments)
|
||||
// while still supporting modern Kotlin features.
|
||||
jvmToolchain(17)
|
||||
|
||||
if (this is KotlinMultiplatformExtension) {
|
||||
targets.configureEach {
|
||||
compilations.configureEach {
|
||||
compileTaskProvider.configure {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
|
||||
"-opt-in=kotlin.time.ExperimentalTime",
|
||||
"-opt-in=kotlinx.cinterop.ExperimentalForeignApi",
|
||||
"-Xexpect-actual-classes",
|
||||
"-Xcontext-parameters",
|
||||
"-Xannotation-default-target=param-property",
|
||||
"-Xskip-prerelease-check"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
@@ -177,6 +206,7 @@ private inline fun <reified T : KotlinBaseExtension> Project.configureKotlin() {
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlin.uuid.ExperimentalUuidApi",
|
||||
"-opt-in=kotlin.time.ExperimentalTime",
|
||||
"-opt-in=kotlinx.cinterop.ExperimentalForeignApi",
|
||||
"-Xexpect-actual-classes",
|
||||
"-Xcontext-parameters",
|
||||
"-Xannotation-default-target=param-property",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ble
|
||||
|
||||
import com.juul.kable.Peripheral
|
||||
import com.juul.kable.PeripheralBuilder
|
||||
|
||||
/** No-op stubs for iOS target in core:ble. */
|
||||
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
|
||||
// No-op for stubs
|
||||
}
|
||||
|
||||
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
|
||||
throw UnsupportedOperationException("iOS Peripheral not yet implemented")
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
/** Access to the IO dispatcher in a multiplatform-safe way. */
|
||||
expect val ioDispatcher: CoroutineDispatcher
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
/** Multiplatform string formatting helper. */
|
||||
expect fun formatString(pattern: String, vararg args: Any?): String
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
/** Apple (iOS) implementation of string formatting. Stub implementation for compile-only validation. */
|
||||
actual fun formatString(pattern: String, vararg args: Any?): String = throw UnsupportedOperationException(
|
||||
"formatString is not supported on iOS at runtime; this target is intended for compile-only validation.",
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
/** No-op stubs for iOS target in core:common. */
|
||||
actual object BuildUtils {
|
||||
actual val isEmulator: Boolean = false
|
||||
actual val sdkInt: Int = 0
|
||||
}
|
||||
|
||||
actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List<String>) {
|
||||
actual fun getQueryParameter(key: String): String? = null
|
||||
|
||||
actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue
|
||||
|
||||
actual override fun toString(): String = ""
|
||||
|
||||
actual companion object {
|
||||
actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
actual fun CommonUri.toPlatformUri(): Any = Any()
|
||||
|
||||
actual object DateFormatter {
|
||||
actual fun formatRelativeTime(timestampMillis: Long): String = ""
|
||||
|
||||
actual fun formatDateTime(timestampMillis: Long): String = ""
|
||||
|
||||
actual fun formatShortDate(timestampMillis: Long): String = ""
|
||||
|
||||
actual fun formatTime(timestampMillis: Long): String = ""
|
||||
|
||||
actual fun formatTimeWithSeconds(timestampMillis: Long): String = ""
|
||||
|
||||
actual fun formatDate(timestampMillis: Long): String = ""
|
||||
|
||||
actual fun formatDateTimeShort(timestampMillis: Long): String = ""
|
||||
}
|
||||
|
||||
actual fun getSystemMeasurementSystem(): MeasurementSystem = MeasurementSystem.METRIC
|
||||
|
||||
actual fun String?.isValidAddress(): Boolean = false
|
||||
|
||||
actual interface CommonParcelable
|
||||
|
||||
@Target(AnnotationTarget.CLASS)
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
actual annotation class CommonParcelize actual constructor()
|
||||
|
||||
@Target(AnnotationTarget.PROPERTY)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
actual annotation class CommonIgnoredOnParcel actual constructor()
|
||||
|
||||
actual interface CommonParceler<T> {
|
||||
actual fun create(parcel: CommonParcel): T
|
||||
|
||||
actual fun T.write(parcel: CommonParcel, flags: Int)
|
||||
}
|
||||
|
||||
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
@Repeatable
|
||||
actual annotation class CommonTypeParceler<T, P : CommonParceler<in T>> actual constructor()
|
||||
|
||||
actual class CommonParcel {
|
||||
actual fun readString(): String? = null
|
||||
|
||||
actual fun readInt(): Int = 0
|
||||
|
||||
actual fun readLong(): Long = 0L
|
||||
|
||||
actual fun readFloat(): Float = 0.0f
|
||||
|
||||
actual fun createByteArray(): ByteArray? = null
|
||||
|
||||
actual fun writeByteArray(b: ByteArray?) {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.common.util
|
||||
|
||||
/** JVM/Android implementation of string formatting. */
|
||||
actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)
|
||||
@@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -27,6 +26,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
@@ -58,7 +58,7 @@ class CommandSenderImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
) : CommandSender {
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
|
||||
private val sessionPasskey = atomic(ByteString.EMPTY)
|
||||
override val tracerouteStartTimes = mutableMapOf<Int, Long>()
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshUser
|
||||
@@ -64,7 +64,7 @@ class MeshActionHandlerImpl(
|
||||
private val notificationManager: NotificationManager,
|
||||
private val messageProcessor: Lazy<MeshMessageProcessor>,
|
||||
) : MeshActionHandler {
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
|
||||
override fun start(scope: CoroutineScope) {
|
||||
this.scope = scope
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -18,7 +18,6 @@ package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -28,6 +27,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
@@ -55,7 +55,7 @@ class MeshMessageProcessorImpl(
|
||||
private val router: Lazy<MeshRouter>,
|
||||
private val fromRadioDispatcher: FromRadioPacketHandler,
|
||||
) : MeshMessageProcessor {
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
|
||||
private val mapsMutex = Mutex()
|
||||
private val logUuidByPacketId = mutableMapOf<Int, String>()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,13 +21,13 @@ import kotlinx.atomicfu.atomic
|
||||
import kotlinx.atomicfu.update
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okio.ByteString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.DeviceMetrics
|
||||
import org.meshtastic.core.model.EnvironmentMetrics
|
||||
@@ -43,7 +43,7 @@ import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.getString
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.new_node_seen
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
@@ -62,7 +62,7 @@ class NodeManagerImpl(
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val notificationManager: NotificationManager,
|
||||
) : NodeManager {
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
|
||||
|
||||
private val _nodeDBbyNodeNum = atomic(persistentMapOf<Int, Node>())
|
||||
private val _nodeDBbyID = atomic(persistentMapOf<String, Node>())
|
||||
@@ -196,13 +196,15 @@ class NodeManagerImpl(
|
||||
node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified)
|
||||
}
|
||||
if (newNode && !shouldPreserve) {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getString(Res.string.new_node_seen, next.user.short_name),
|
||||
message = next.user.long_name,
|
||||
category = Notification.Category.NodeEvent,
|
||||
),
|
||||
)
|
||||
scope.handledLaunch {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getStringSuspend(Res.string.new_node_seen, next.user.short_name),
|
||||
message = next.user.long_name,
|
||||
category = Notification.Category.NodeEvent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ package org.meshtastic.core.data.manager
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -30,6 +29,7 @@ import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
@@ -67,7 +67,7 @@ class PacketHandlerImpl(
|
||||
}
|
||||
|
||||
private var queueJob: Job? = null
|
||||
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
private var scope: CoroutineScope = CoroutineScope(ioDispatcher)
|
||||
|
||||
private val queueMutex = Mutex()
|
||||
private val queuedPackets = mutableListOf<MeshPacket>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +24,7 @@ import androidx.room3.RoomDatabase
|
||||
import androidx.room3.TypeConverters
|
||||
import androidx.room3.migration.AutoMigrationSpec
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.database.dao.DeviceHardwareDao
|
||||
import org.meshtastic.core.database.dao.FirmwareReleaseDao
|
||||
import org.meshtastic.core.database.dao.MeshLogDao
|
||||
@@ -122,14 +122,15 @@ abstract class MeshtasticDatabase : RoomDatabase() {
|
||||
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(): RoomDatabase.Builder<T> =
|
||||
this.fallbackToDestructiveMigration(dropAllTables = false)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.setQueryCoroutineContext(Dispatchers.IO)
|
||||
.setQueryCoroutineContext(ioDispatcher)
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteTable.Entries(DeleteTable(tableName = "NodeInfo"), DeleteTable(tableName = "MyNodeInfo"))
|
||||
@DeleteTable(tableName = "NodeInfo")
|
||||
@DeleteTable(tableName = "MyNodeInfo")
|
||||
class AutoMigration12to13 : AutoMigrationSpec
|
||||
|
||||
@DeleteColumn.Entries(DeleteColumn(tableName = "packet", columnName = "reply_id"))
|
||||
@DeleteColumn(tableName = "packet", columnName = "reply_id")
|
||||
class AutoMigration29to30 : AutoMigrationSpec
|
||||
|
||||
@DeleteColumn(tableName = "packet", columnName = "retry_count")
|
||||
|
||||
@@ -17,11 +17,16 @@
|
||||
package org.meshtastic.core.database
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.core.DataStoreFactory
|
||||
import androidx.datastore.core.okio.OkioSerializer
|
||||
import androidx.datastore.core.okio.OkioStorage
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
import okio.FileSystem
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
@@ -55,11 +60,32 @@ actual fun deleteDatabase(dbName: String) {
|
||||
/** Returns the system FileSystem for iOS. */
|
||||
actual fun getFileSystem(): FileSystem = FileSystem.SYSTEM
|
||||
|
||||
private object PreferencesSerializer : OkioSerializer<Preferences> {
|
||||
override val defaultValue: Preferences
|
||||
get() = emptyPreferences()
|
||||
|
||||
override suspend fun readFrom(source: BufferedSource): Preferences {
|
||||
// iOS stub: return an empty Preferences instance instead of crashing.
|
||||
return emptyPreferences()
|
||||
}
|
||||
|
||||
override suspend fun writeTo(t: Preferences, sink: BufferedSink) {
|
||||
// iOS stub: no-op to avoid crashing on write.
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates an iOS DataStore for database preferences. */
|
||||
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
|
||||
val dir = documentDirectory() + "/datastore"
|
||||
NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)
|
||||
return PreferenceDataStoreFactory.create(produceFile = { (dir + "/$name.preferences_pb").toPath().toNioPath() })
|
||||
return DataStoreFactory.create(
|
||||
storage =
|
||||
OkioStorage(
|
||||
fileSystem = FileSystem.SYSTEM,
|
||||
serializer = PreferencesSerializer,
|
||||
producePath = { (dir + "/$name.preferences_pb").toPath() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
|
||||
@@ -23,7 +23,6 @@ import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -33,6 +32,7 @@ import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.UiPreferences
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
|
||||
const val KEY_APP_INTRO_COMPLETED = "app_intro_completed"
|
||||
const val KEY_THEME = "theme"
|
||||
@@ -52,7 +52,7 @@ const val KEY_EXCLUDE_MQTT = "exclude-mqtt"
|
||||
open class UiPreferencesDataSource(@Named("CorePreferencesDataStore") private val dataStore: DataStore<Preferences>) :
|
||||
UiPreferences {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
|
||||
|
||||
// Start this flow eagerly, so app intro doesn't flash (when disabled) on cold app start.
|
||||
override val appIntroCompleted: StateFlow<Boolean> =
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.core.domain"
|
||||
|
||||
@@ -19,12 +19,8 @@ package org.meshtastic.core.model.util
|
||||
import org.meshtastic.core.common.util.nowInstant
|
||||
import org.meshtastic.core.common.util.toDate
|
||||
import org.meshtastic.core.common.util.toInstant
|
||||
import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY
|
||||
import java.text.DateFormat
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.DurationUnit
|
||||
|
||||
private val DAY_DURATION = 24.hours
|
||||
|
||||
@@ -53,9 +49,3 @@ fun getShortDate(time: Long): String? {
|
||||
* @param remainingMillis The remaining time in milliseconds
|
||||
* @return Pair of (days, hours), where days is Int and hours is Double
|
||||
*/
|
||||
fun formatMuteRemainingTime(remainingMillis: Long): Pair<Int, Double> {
|
||||
val duration = remainingMillis.milliseconds
|
||||
if (duration <= Duration.ZERO) return 0 to 0.0
|
||||
val totalHours = duration.toDouble(DurationUnit.HOURS)
|
||||
return (totalHours / HOURS_PER_DAY).toInt() to (totalHours % HOURS_PER_DAY)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import org.meshtastic.core.model.util.TimeConstants.HOURS_PER_DAY
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
@@ -46,3 +48,16 @@ fun formatUptime(seconds: Int): String {
|
||||
.joinToString(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the remaining mute time in days and hours.
|
||||
*
|
||||
* @param remainingMillis The remaining time in milliseconds
|
||||
* @return Pair of (days, hours), where days is Int and hours is Double
|
||||
*/
|
||||
fun formatMuteRemainingTime(remainingMillis: Long): Pair<Int, Double> {
|
||||
val duration = remainingMillis.milliseconds
|
||||
if (duration <= kotlin.time.Duration.ZERO) return 0 to 0.0
|
||||
val totalHours = duration.toDouble(kotlin.time.DurationUnit.HOURS)
|
||||
return (totalHours / HOURS_PER_DAY).toInt() to (totalHours % HOURS_PER_DAY)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
/** No-op stubs for core:model on iOS. */
|
||||
actual fun getShortDateTime(time: Long): String = ""
|
||||
|
||||
actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size)
|
||||
|
||||
actual object SfppHasher {
|
||||
actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32)
|
||||
}
|
||||
@@ -22,8 +22,6 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
android { namespace = "org.meshtastic.core.navigation" }
|
||||
|
||||
sourceSets {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.savedstate.serialization.SavedStateConfiguration
|
||||
import kotlinx.serialization.modules.SerializersModule
|
||||
import kotlinx.serialization.modules.polymorphic
|
||||
|
||||
/**
|
||||
* Shared polymorphic serialization configuration for Navigation 3 saved-state support. Registers all route types used
|
||||
* across Android and Desktop navigation graphs.
|
||||
*/
|
||||
val MeshtasticNavSavedStateConfig = SavedStateConfiguration {
|
||||
serializersModule = SerializersModule {
|
||||
polymorphic(NavKey::class) {
|
||||
// Nodes
|
||||
subclass(NodesRoutes.NodesGraph::class, NodesRoutes.NodesGraph.serializer())
|
||||
subclass(NodesRoutes.Nodes::class, NodesRoutes.Nodes.serializer())
|
||||
subclass(NodesRoutes.NodeDetailGraph::class, NodesRoutes.NodeDetailGraph.serializer())
|
||||
subclass(NodesRoutes.NodeDetail::class, NodesRoutes.NodeDetail.serializer())
|
||||
|
||||
// Node detail sub-screens
|
||||
subclass(NodeDetailRoutes.DeviceMetrics::class, NodeDetailRoutes.DeviceMetrics.serializer())
|
||||
subclass(NodeDetailRoutes.NodeMap::class, NodeDetailRoutes.NodeMap.serializer())
|
||||
subclass(NodeDetailRoutes.PositionLog::class, NodeDetailRoutes.PositionLog.serializer())
|
||||
subclass(NodeDetailRoutes.EnvironmentMetrics::class, NodeDetailRoutes.EnvironmentMetrics.serializer())
|
||||
subclass(NodeDetailRoutes.SignalMetrics::class, NodeDetailRoutes.SignalMetrics.serializer())
|
||||
subclass(NodeDetailRoutes.PowerMetrics::class, NodeDetailRoutes.PowerMetrics.serializer())
|
||||
subclass(NodeDetailRoutes.TracerouteLog::class, NodeDetailRoutes.TracerouteLog.serializer())
|
||||
subclass(NodeDetailRoutes.TracerouteMap::class, NodeDetailRoutes.TracerouteMap.serializer())
|
||||
subclass(NodeDetailRoutes.HostMetricsLog::class, NodeDetailRoutes.HostMetricsLog.serializer())
|
||||
subclass(NodeDetailRoutes.PaxMetrics::class, NodeDetailRoutes.PaxMetrics.serializer())
|
||||
subclass(NodeDetailRoutes.NeighborInfoLog::class, NodeDetailRoutes.NeighborInfoLog.serializer())
|
||||
|
||||
// Conversations
|
||||
subclass(ContactsRoutes.ContactsGraph::class, ContactsRoutes.ContactsGraph.serializer())
|
||||
subclass(ContactsRoutes.Contacts::class, ContactsRoutes.Contacts.serializer())
|
||||
subclass(ContactsRoutes.Messages::class, ContactsRoutes.Messages.serializer())
|
||||
subclass(ContactsRoutes.Share::class, ContactsRoutes.Share.serializer())
|
||||
subclass(ContactsRoutes.QuickChat::class, ContactsRoutes.QuickChat.serializer())
|
||||
|
||||
// Map
|
||||
subclass(MapRoutes.Map::class, MapRoutes.Map.serializer())
|
||||
|
||||
// Firmware
|
||||
subclass(FirmwareRoutes.FirmwareGraph::class, FirmwareRoutes.FirmwareGraph.serializer())
|
||||
subclass(FirmwareRoutes.FirmwareUpdate::class, FirmwareRoutes.FirmwareUpdate.serializer())
|
||||
|
||||
// Settings
|
||||
subclass(SettingsRoutes.SettingsGraph::class, SettingsRoutes.SettingsGraph.serializer())
|
||||
subclass(SettingsRoutes.Settings::class, SettingsRoutes.Settings.serializer())
|
||||
subclass(SettingsRoutes.DeviceConfiguration::class, SettingsRoutes.DeviceConfiguration.serializer())
|
||||
subclass(SettingsRoutes.ModuleConfiguration::class, SettingsRoutes.ModuleConfiguration.serializer())
|
||||
subclass(SettingsRoutes.Administration::class, SettingsRoutes.Administration.serializer())
|
||||
|
||||
// Settings - Config routes
|
||||
subclass(SettingsRoutes.User::class, SettingsRoutes.User.serializer())
|
||||
subclass(SettingsRoutes.ChannelConfig::class, SettingsRoutes.ChannelConfig.serializer())
|
||||
subclass(SettingsRoutes.Device::class, SettingsRoutes.Device.serializer())
|
||||
subclass(SettingsRoutes.Position::class, SettingsRoutes.Position.serializer())
|
||||
subclass(SettingsRoutes.Power::class, SettingsRoutes.Power.serializer())
|
||||
subclass(SettingsRoutes.Network::class, SettingsRoutes.Network.serializer())
|
||||
subclass(SettingsRoutes.Display::class, SettingsRoutes.Display.serializer())
|
||||
subclass(SettingsRoutes.LoRa::class, SettingsRoutes.LoRa.serializer())
|
||||
subclass(SettingsRoutes.Bluetooth::class, SettingsRoutes.Bluetooth.serializer())
|
||||
subclass(SettingsRoutes.Security::class, SettingsRoutes.Security.serializer())
|
||||
|
||||
// Settings - Module routes
|
||||
subclass(SettingsRoutes.MQTT::class, SettingsRoutes.MQTT.serializer())
|
||||
subclass(SettingsRoutes.Serial::class, SettingsRoutes.Serial.serializer())
|
||||
subclass(SettingsRoutes.ExtNotification::class, SettingsRoutes.ExtNotification.serializer())
|
||||
subclass(SettingsRoutes.StoreForward::class, SettingsRoutes.StoreForward.serializer())
|
||||
subclass(SettingsRoutes.RangeTest::class, SettingsRoutes.RangeTest.serializer())
|
||||
subclass(SettingsRoutes.Telemetry::class, SettingsRoutes.Telemetry.serializer())
|
||||
subclass(SettingsRoutes.CannedMessage::class, SettingsRoutes.CannedMessage.serializer())
|
||||
subclass(SettingsRoutes.Audio::class, SettingsRoutes.Audio.serializer())
|
||||
subclass(SettingsRoutes.RemoteHardware::class, SettingsRoutes.RemoteHardware.serializer())
|
||||
subclass(SettingsRoutes.NeighborInfo::class, SettingsRoutes.NeighborInfo.serializer())
|
||||
subclass(SettingsRoutes.AmbientLighting::class, SettingsRoutes.AmbientLighting.serializer())
|
||||
subclass(SettingsRoutes.DetectionSensor::class, SettingsRoutes.DetectionSensor.serializer())
|
||||
subclass(SettingsRoutes.Paxcounter::class, SettingsRoutes.Paxcounter.serializer())
|
||||
subclass(SettingsRoutes.StatusMessage::class, SettingsRoutes.StatusMessage.serializer())
|
||||
subclass(SettingsRoutes.TrafficManagement::class, SettingsRoutes.TrafficManagement.serializer())
|
||||
subclass(SettingsRoutes.TAK::class, SettingsRoutes.TAK.serializer())
|
||||
|
||||
// Settings - Advanced routes
|
||||
subclass(SettingsRoutes.CleanNodeDb::class, SettingsRoutes.CleanNodeDb.serializer())
|
||||
subclass(SettingsRoutes.DebugPanel::class, SettingsRoutes.DebugPanel.serializer())
|
||||
subclass(SettingsRoutes.About::class, SettingsRoutes.About.serializer())
|
||||
subclass(SettingsRoutes.FilterSettings::class, SettingsRoutes.FilterSettings.serializer())
|
||||
|
||||
// Channels
|
||||
subclass(ChannelsRoutes.ChannelsGraph::class, ChannelsRoutes.ChannelsGraph.serializer())
|
||||
subclass(ChannelsRoutes.Channels::class, ChannelsRoutes.Channels.serializer())
|
||||
|
||||
// Connections
|
||||
subclass(ConnectionsRoutes.ConnectionsGraph::class, ConnectionsRoutes.ConnectionsGraph.serializer())
|
||||
subclass(ConnectionsRoutes.Connections::class, ConnectionsRoutes.Connections.serializer())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,6 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.core.network"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -21,8 +21,6 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.core.nfc"
|
||||
|
||||
@@ -21,8 +21,6 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
android {
|
||||
namespace = "org.meshtastic.core.prefs"
|
||||
androidResources.enable = false
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android { androidResources.enable = false }
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
/** No-op stub for Location on iOS. */
|
||||
actual class Location
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
plugins { alias(libs.plugins.meshtastic.kmp.library) }
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.core.testing"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.painter.ColorPainter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.jvm.JvmName
|
||||
|
||||
@Composable
|
||||
fun <T : Enum<T>> DropDownPreference(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
|
||||
/**
|
||||
* Event emitted when a user re-presses a bottom navigation destination that should trigger a scroll-to-top behaviour on
|
||||
* the corresponding screen.
|
||||
@@ -25,3 +29,5 @@ sealed class ScrollToTopEvent {
|
||||
|
||||
data object ConversationsTabPressed : ScrollToTopEvent()
|
||||
}
|
||||
|
||||
@Composable fun rememberScrollToTopEvents(): MutableSharedFlow<ScrollToTopEvent> = remember { MutableSharedFlow() }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.component
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable actual fun rememberTimeTickWithLifecycle(): Long = 0L
|
||||
|
||||
internal actual fun <T : Enum<T>> enumEntriesOf(selectedItem: T): List<T> = emptyList()
|
||||
|
||||
internal actual fun Enum<*>.isDeprecatedEnumEntry(): Boolean = false
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable actual fun dynamicColorScheme(darkTheme: Boolean): ColorScheme? = null
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
actual fun createClipEntry(text: String, label: String): ClipEntry =
|
||||
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
|
||||
|
||||
actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): AnnotatedString = AnnotatedString(html)
|
||||
|
||||
@Composable actual fun rememberOpenNfcSettings(): () -> Unit = {}
|
||||
|
||||
@Composable actual fun rememberShowToast(): suspend (String) -> Unit = { _ -> }
|
||||
|
||||
@Composable actual fun rememberShowToastResource(): suspend (StringResource) -> Unit = { _ -> }
|
||||
|
||||
@Composable actual fun rememberOpenMap(): (latitude: Double, longitude: Double, label: String) -> Unit = { _, _, _ -> }
|
||||
|
||||
@Composable actual fun rememberOpenUrl(): (url: String) -> Unit = { _ -> }
|
||||
|
||||
@Composable actual fun SetScreenBrightness(brightness: Float) {}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.first
|
||||
import org.koin.core.context.startKoin
|
||||
import org.meshtastic.core.common.util.MeshtasticUri
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.service.MeshServiceOrchestrator
|
||||
@@ -57,7 +58,6 @@ import org.meshtastic.desktop.data.DesktopPreferencesDataSource
|
||||
import org.meshtastic.desktop.di.desktopModule
|
||||
import org.meshtastic.desktop.di.desktopPlatformModule
|
||||
import org.meshtastic.desktop.ui.DesktopMainScreen
|
||||
import org.meshtastic.desktop.ui.navSavedStateConfig
|
||||
import java.awt.Desktop
|
||||
import java.util.Locale
|
||||
|
||||
@@ -199,7 +199,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
|
||||
state = windowState,
|
||||
) {
|
||||
val backStack =
|
||||
rememberNavBackStack(navSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
|
||||
rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
|
||||
|
||||
MenuBar {
|
||||
Menu("File") {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.navigation
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.navigation.ChannelsRoutes
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.desktop.ui.messaging.DesktopAdaptiveContactsScreen
|
||||
import org.meshtastic.desktop.ui.messaging.DesktopMessageContent
|
||||
import org.meshtastic.feature.messaging.MessageViewModel
|
||||
import org.meshtastic.feature.messaging.QuickChatScreen
|
||||
import org.meshtastic.feature.messaging.QuickChatViewModel
|
||||
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
|
||||
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
|
||||
|
||||
/**
|
||||
* Registers real messaging/contacts feature composables into the desktop navigation graph.
|
||||
*
|
||||
* The contacts screen uses a desktop-specific adaptive composable with Material 3 Adaptive list-detail scaffolding,
|
||||
* backed by shared `ContactsViewModel` from commonMain. The list pane shows contacts and the detail pane shows
|
||||
* `DesktopMessageContent` using shared `MessageViewModel` with a non-paged message list.
|
||||
*/
|
||||
fun EntryProviderScope<NavKey>.desktopMessagingGraph(backStack: NavBackStack<NavKey>) {
|
||||
entry<ContactsRoutes.ContactsGraph> {
|
||||
val viewModel: ContactsViewModel = koinViewModel()
|
||||
DesktopAdaptiveContactsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
|
||||
)
|
||||
}
|
||||
|
||||
entry<ContactsRoutes.Contacts> {
|
||||
val viewModel: ContactsViewModel = koinViewModel()
|
||||
DesktopAdaptiveContactsScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
|
||||
)
|
||||
}
|
||||
|
||||
entry<ContactsRoutes.Messages> { route ->
|
||||
val viewModel: MessageViewModel = koinViewModel(key = "messages-${route.contactKey}")
|
||||
DesktopMessageContent(
|
||||
contactKey = route.contactKey,
|
||||
viewModel = viewModel,
|
||||
initialMessage = route.message,
|
||||
onNavigateUp = { backStack.removeLastOrNull() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<ContactsRoutes.Share> { route ->
|
||||
val viewModel: ContactsViewModel = koinViewModel()
|
||||
ShareScreen(
|
||||
viewModel = viewModel,
|
||||
onConfirm = { contactKey ->
|
||||
backStack.removeLastOrNull()
|
||||
backStack.add(ContactsRoutes.Messages(contactKey, route.message))
|
||||
},
|
||||
onNavigateUp = { backStack.removeLastOrNull() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<ContactsRoutes.QuickChat> {
|
||||
val viewModel: QuickChatViewModel = koinViewModel()
|
||||
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
}
|
||||
@@ -26,57 +26,47 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.meshtastic.core.navigation.ConnectionsRoutes
|
||||
import org.meshtastic.core.navigation.FirmwareRoutes
|
||||
import org.meshtastic.core.navigation.MapRoutes
|
||||
import org.meshtastic.desktop.ui.firmware.DesktopFirmwareScreen
|
||||
import org.meshtastic.desktop.ui.map.KmpMapPlaceholder
|
||||
import org.meshtastic.feature.connections.ui.ConnectionsScreen
|
||||
import org.meshtastic.feature.connections.navigation.connectionsGraph
|
||||
import org.meshtastic.feature.firmware.navigation.firmwareGraph
|
||||
import org.meshtastic.feature.map.navigation.mapGraph
|
||||
import org.meshtastic.feature.messaging.navigation.contactsGraph
|
||||
import org.meshtastic.feature.node.navigation.nodesGraph
|
||||
import org.meshtastic.feature.settings.navigation.settingsGraph
|
||||
import org.meshtastic.feature.settings.radio.channel.channelsGraph
|
||||
|
||||
/**
|
||||
* Registers entry providers for all top-level desktop destinations.
|
||||
*
|
||||
* Nodes uses real composables from `feature:node` via [desktopNodeGraph]. Conversations uses real composables from
|
||||
* Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from
|
||||
* `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via
|
||||
* [desktopSettingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until
|
||||
* their shared composables are wired.
|
||||
* [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their
|
||||
* shared composables are wired.
|
||||
*/
|
||||
fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>) {
|
||||
// Nodes — real composables from feature:node
|
||||
desktopNodeGraph(backStack)
|
||||
nodesGraph(
|
||||
backStack = backStack,
|
||||
nodeMapScreen = { destNum, _ -> KmpMapPlaceholder(title = "Node Map ($destNum)") },
|
||||
)
|
||||
|
||||
// Conversations — real composables from feature:messaging
|
||||
desktopMessagingGraph(backStack)
|
||||
contactsGraph(backStack)
|
||||
|
||||
// Map — placeholder for now, will be replaced with feature:map real implementation
|
||||
entry<MapRoutes.Map> { KmpMapPlaceholder() }
|
||||
mapGraph(backStack)
|
||||
|
||||
// Firmware — in-flow destination (for example from Settings), not a top-level rail tab
|
||||
entry<FirmwareRoutes.FirmwareGraph> { DesktopFirmwareScreen() }
|
||||
entry<FirmwareRoutes.FirmwareUpdate> { DesktopFirmwareScreen() }
|
||||
firmwareGraph(backStack)
|
||||
|
||||
// Settings — real composables from feature:settings
|
||||
desktopSettingsGraph(backStack)
|
||||
settingsGraph(backStack)
|
||||
|
||||
// Channels
|
||||
channelsGraph(backStack)
|
||||
|
||||
// Connections — shared screen
|
||||
entry<ConnectionsRoutes.ConnectionsGraph> {
|
||||
ConnectionsScreen(
|
||||
onClickNodeChip = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
|
||||
onNavigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
|
||||
onConfigNavigate = { route -> backStack.add(route) },
|
||||
)
|
||||
}
|
||||
entry<ConnectionsRoutes.Connections> {
|
||||
ConnectionsScreen(
|
||||
onClickNodeChip = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
|
||||
onNavigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
|
||||
onConfigNavigate = { route -> backStack.add(route) },
|
||||
)
|
||||
}
|
||||
connectionsGraph(backStack)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.desktop.ui.map.KmpMapPlaceholder
|
||||
import org.meshtastic.desktop.ui.nodes.DesktopAdaptiveNodeListScreen
|
||||
import org.meshtastic.feature.node.list.NodeListViewModel
|
||||
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
|
||||
import org.meshtastic.feature.node.metrics.MetricsViewModel
|
||||
import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen
|
||||
import org.meshtastic.feature.node.metrics.PaxMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
|
||||
|
||||
/**
|
||||
* Registers real node feature composables into the desktop navigation graph.
|
||||
*
|
||||
* The node list screen uses a desktop-specific adaptive composable with Material 3 Adaptive list-detail scaffolding,
|
||||
* backed by shared `NodeListViewModel` and commonMain components. The detail pane shows real shared node detail content
|
||||
* from commonMain.
|
||||
*
|
||||
* Metrics screens (logs + chart-based detail metrics) use shared composables from commonMain with `MetricsViewModel`
|
||||
* scoped to the destination node number.
|
||||
*/
|
||||
fun EntryProviderScope<NavKey>.desktopNodeGraph(backStack: NavBackStack<NavKey>) {
|
||||
entry<NodesRoutes.NodesGraph> {
|
||||
val viewModel: NodeListViewModel = koinViewModel()
|
||||
DesktopAdaptiveNodeListScreen(viewModel = viewModel, onNavigate = { backStack.add(it) })
|
||||
}
|
||||
|
||||
entry<NodesRoutes.Nodes> {
|
||||
val viewModel: NodeListViewModel = koinViewModel()
|
||||
DesktopAdaptiveNodeListScreen(viewModel = viewModel, onNavigate = { backStack.add(it) })
|
||||
}
|
||||
|
||||
// Node detail graph routes open the real shared list-detail screen focused on the requested node.
|
||||
entry<NodesRoutes.NodeDetailGraph> { route ->
|
||||
val viewModel: NodeListViewModel = koinViewModel()
|
||||
DesktopAdaptiveNodeListScreen(
|
||||
viewModel = viewModel,
|
||||
initialNodeId = route.destNum,
|
||||
onNavigate = { backStack.add(it) },
|
||||
)
|
||||
}
|
||||
|
||||
entry<NodesRoutes.NodeDetail> { route ->
|
||||
val viewModel: NodeListViewModel = koinViewModel()
|
||||
DesktopAdaptiveNodeListScreen(
|
||||
viewModel = viewModel,
|
||||
initialNodeId = route.destNum,
|
||||
onNavigate = { backStack.add(it) },
|
||||
)
|
||||
}
|
||||
|
||||
// Traceroute log — real shared screen from commonMain
|
||||
desktopMetricsEntry<NodeDetailRoutes.TracerouteLog>(getDestNum = { it.destNum }) { viewModel ->
|
||||
TracerouteLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
|
||||
// Neighbor info log — real shared screen from commonMain
|
||||
desktopMetricsEntry<NodeDetailRoutes.NeighborInfoLog>(getDestNum = { it.destNum }) { viewModel ->
|
||||
NeighborInfoLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
|
||||
// Host metrics log — real shared screen from commonMain
|
||||
desktopMetricsEntry<NodeDetailRoutes.HostMetricsLog>(getDestNum = { it.destNum }) { viewModel ->
|
||||
HostMetricsLogScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
|
||||
// Chart-based metrics — real shared screens from commonMain
|
||||
desktopMetricsEntry<NodeDetailRoutes.DeviceMetrics>(getDestNum = { it.destNum }) { viewModel ->
|
||||
DeviceMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
desktopMetricsEntry<NodeDetailRoutes.EnvironmentMetrics>(getDestNum = { it.destNum }) { viewModel ->
|
||||
EnvironmentMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
desktopMetricsEntry<NodeDetailRoutes.SignalMetrics>(getDestNum = { it.destNum }) { viewModel ->
|
||||
SignalMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
desktopMetricsEntry<NodeDetailRoutes.PowerMetrics>(getDestNum = { it.destNum }) { viewModel ->
|
||||
PowerMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
desktopMetricsEntry<NodeDetailRoutes.PaxMetrics>(getDestNum = { it.destNum }) { viewModel ->
|
||||
PaxMetricsScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
|
||||
}
|
||||
|
||||
// Map-based screens — placeholders (map integration needed)
|
||||
entry<NodeDetailRoutes.NodeMap> { route -> KmpMapPlaceholder(title = "Node Map (${route.destNum})") }
|
||||
entry<NodeDetailRoutes.TracerouteMap> { KmpMapPlaceholder(title = "Traceroute Map") }
|
||||
entry<NodeDetailRoutes.PositionLog> { route -> KmpMapPlaceholder(title = "Position Log (${route.destNum})") }
|
||||
}
|
||||
|
||||
private inline fun <reified R : NavKey> EntryProviderScope<NavKey>.desktopMetricsEntry(
|
||||
crossinline getDestNum: (R) -> Int,
|
||||
crossinline content: @Composable (MetricsViewModel) -> Unit,
|
||||
) {
|
||||
entry<R> { route ->
|
||||
val destNum = getDestNum(route)
|
||||
val viewModel: MetricsViewModel = koinViewModel(key = "metrics-$destNum") { parametersOf(destNum) }
|
||||
LaunchedEffect(destNum) { viewModel.setNodeId(destNum) }
|
||||
content(viewModel)
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.desktop.ui.settings.DesktopDeviceConfigScreen
|
||||
import org.meshtastic.desktop.ui.settings.DesktopExternalNotificationConfigScreen
|
||||
import org.meshtastic.desktop.ui.settings.DesktopNetworkConfigScreen
|
||||
import org.meshtastic.desktop.ui.settings.DesktopPositionConfigScreen
|
||||
import org.meshtastic.desktop.ui.settings.DesktopSecurityConfigScreen
|
||||
import org.meshtastic.desktop.ui.settings.DesktopSettingsScreen
|
||||
import org.meshtastic.feature.settings.AboutScreen
|
||||
import org.meshtastic.feature.settings.AdministrationScreen
|
||||
import org.meshtastic.feature.settings.DeviceConfigurationScreen
|
||||
import org.meshtastic.feature.settings.ModuleConfigurationScreen
|
||||
import org.meshtastic.feature.settings.SettingsViewModel
|
||||
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
|
||||
import org.meshtastic.feature.settings.filter.FilterSettingsViewModel
|
||||
import org.meshtastic.feature.settings.navigation.ConfigRoute
|
||||
import org.meshtastic.feature.settings.navigation.ModuleRoute
|
||||
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseScreen
|
||||
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseViewModel
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.AmbientLightingConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.AudioConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.BluetoothConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.CannedMessageConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.DetectionSensorConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.PowerConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.RangeTestConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.RemoteHardwareConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.TAKConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigScreen
|
||||
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Composable
|
||||
private fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewModel {
|
||||
val viewModel = koinViewModel<RadioConfigViewModel>()
|
||||
LaunchedEffect(backStack) {
|
||||
val destNum =
|
||||
backStack.lastOrNull { it is SettingsRoutes.Settings }?.let { (it as SettingsRoutes.Settings).destNum }
|
||||
?: backStack
|
||||
.lastOrNull { it is SettingsRoutes.SettingsGraph }
|
||||
?.let { (it as SettingsRoutes.SettingsGraph).destNum }
|
||||
viewModel.initDestNum(destNum)
|
||||
}
|
||||
return viewModel
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers real settings feature composables into the desktop navigation graph.
|
||||
*
|
||||
* Top-level settings screen is a desktop-specific composable since Android's [SettingsScreen] uses Android-only APIs.
|
||||
* All sub-screens (device config, module config, radio config, channels, etc.) use the shared commonMain composables
|
||||
* from `feature:settings`.
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavKey>) {
|
||||
// Top-level settings — desktop-specific screen (Android version uses Activity, permissions, etc.)
|
||||
entry<SettingsRoutes.SettingsGraph> {
|
||||
DesktopSettingsScreen(
|
||||
radioConfigViewModel = getRadioConfigViewModel(backStack),
|
||||
settingsViewModel = koinViewModel<SettingsViewModel>(),
|
||||
onNavigate = { route -> backStack.add(route) },
|
||||
)
|
||||
}
|
||||
|
||||
entry<SettingsRoutes.Settings> {
|
||||
DesktopSettingsScreen(
|
||||
radioConfigViewModel = getRadioConfigViewModel(backStack),
|
||||
settingsViewModel = koinViewModel<SettingsViewModel>(),
|
||||
onNavigate = { route -> backStack.add(route) },
|
||||
)
|
||||
}
|
||||
|
||||
// Device configuration — shared commonMain composable
|
||||
entry<SettingsRoutes.DeviceConfiguration> {
|
||||
DeviceConfigurationScreen(
|
||||
viewModel = getRadioConfigViewModel(backStack),
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
onNavigate = { route -> backStack.add(route) },
|
||||
)
|
||||
}
|
||||
|
||||
// Module configuration — shared commonMain composable
|
||||
entry<SettingsRoutes.ModuleConfiguration> {
|
||||
val settingsViewModel: SettingsViewModel = koinViewModel()
|
||||
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
|
||||
ModuleConfigurationScreen(
|
||||
viewModel = getRadioConfigViewModel(backStack),
|
||||
excludedModulesUnlocked = excludedModulesUnlocked,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
onNavigate = { route -> backStack.add(route) },
|
||||
)
|
||||
}
|
||||
|
||||
// Administration — shared commonMain composable
|
||||
entry<SettingsRoutes.Administration> {
|
||||
AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() })
|
||||
}
|
||||
|
||||
// Clean node database — shared commonMain composable
|
||||
entry<SettingsRoutes.CleanNodeDb> {
|
||||
val viewModel: CleanNodeDatabaseViewModel = koinViewModel()
|
||||
CleanNodeDatabaseScreen(viewModel = viewModel)
|
||||
}
|
||||
|
||||
// Debug Panel — shared commonMain composable
|
||||
entry<SettingsRoutes.DebugPanel> {
|
||||
val viewModel: org.meshtastic.feature.settings.debugging.DebugViewModel = koinViewModel()
|
||||
org.meshtastic.feature.settings.debugging.DebugScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateUp = { backStack.removeLastOrNull() },
|
||||
)
|
||||
}
|
||||
|
||||
// Config routes — all from commonMain composables
|
||||
ConfigRoute.entries.forEach { routeInfo ->
|
||||
desktopConfigComposable(routeInfo.route::class, backStack) { viewModel ->
|
||||
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
|
||||
when (routeInfo) {
|
||||
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ConfigRoute.DEVICE -> DesktopDeviceConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ConfigRoute.POSITION ->
|
||||
DesktopPositionConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ConfigRoute.NETWORK -> DesktopNetworkConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ConfigRoute.SECURITY ->
|
||||
DesktopSecurityConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Module routes — all from commonMain composables
|
||||
ModuleRoute.entries.forEach { routeInfo ->
|
||||
desktopConfigComposable(routeInfo.route::class, backStack) { viewModel ->
|
||||
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
|
||||
when (routeInfo) {
|
||||
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.EXT_NOTIFICATION ->
|
||||
DesktopExternalNotificationConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.STORE_FORWARD ->
|
||||
StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.CANNED_MESSAGE ->
|
||||
CannedMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.REMOTE_HARDWARE ->
|
||||
RemoteHardwareConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.NEIGHBOR_INFO ->
|
||||
NeighborInfoConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.AMBIENT_LIGHTING ->
|
||||
AmbientLightingConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.DETECTION_SENSOR ->
|
||||
DetectionSensorConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.STATUS_MESSAGE ->
|
||||
StatusMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.TRAFFIC_MANAGEMENT ->
|
||||
TrafficManagementConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// About — shared commonMain screen, per-platform library definitions loaded from JVM classpath
|
||||
entry<SettingsRoutes.About> {
|
||||
AboutScreen(
|
||||
onNavigateUp = { backStack.removeLastOrNull() },
|
||||
jsonProvider = { SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" },
|
||||
)
|
||||
}
|
||||
|
||||
// Filter settings — shared commonMain composable
|
||||
entry<SettingsRoutes.FilterSettings> {
|
||||
val viewModel: FilterSettingsViewModel = koinViewModel()
|
||||
FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() })
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper to register a config/module route entry with a [RadioConfigViewModel] scoped to that entry. */
|
||||
fun <R : Route> EntryProviderScope<NavKey>.desktopConfigComposable(
|
||||
route: KClass<R>,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
content: @Composable (RadioConfigViewModel) -> Unit,
|
||||
) {
|
||||
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
|
||||
}
|
||||
@@ -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].
|
||||
*
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.ui.messaging
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.conversations
|
||||
import org.meshtastic.core.resources.mark_as_read
|
||||
import org.meshtastic.core.resources.unread_count
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticImportFAB
|
||||
import org.meshtastic.core.ui.icon.MarkChatRead
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.feature.messaging.MessageViewModel
|
||||
import org.meshtastic.feature.messaging.component.EmptyConversationsPlaceholder
|
||||
import org.meshtastic.feature.messaging.ui.contact.ContactItem
|
||||
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
|
||||
|
||||
/**
|
||||
* Desktop adaptive contacts screen using [ListDetailPaneScaffold] from JetBrains Material 3 Adaptive.
|
||||
*
|
||||
* On wide screens, the contacts list is shown on the left and the selected conversation detail on the right. On narrow
|
||||
* screens, the scaffold automatically switches to a single-pane layout.
|
||||
*
|
||||
* Uses the shared [ContactsViewModel] and [ContactItem] from commonMain. The detail pane shows [DesktopMessageContent]
|
||||
* with a non-paged message list and send input, backed by the shared [MessageViewModel].
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun DesktopAdaptiveContactsScreen(
|
||||
viewModel: ContactsViewModel,
|
||||
onNavigateToShareChannels: () -> Unit = {},
|
||||
uiViewModel: UIViewModel = koinViewModel(),
|
||||
) {
|
||||
val contacts by viewModel.contactList.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val unreadTotal by viewModel.unreadCountTotal.collectAsStateWithLifecycle()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.conversations),
|
||||
subtitle =
|
||||
if (unreadTotal > 0) {
|
||||
stringResource(Res.string.unread_count, unreadTotal)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
ourNode = ourNode,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = false,
|
||||
onNavigateUp = {},
|
||||
actions = {
|
||||
if (unreadTotal > 0) {
|
||||
IconButton(onClick = { viewModel.markAllAsRead() }) {
|
||||
Icon(
|
||||
MeshtasticIcons.MarkChatRead,
|
||||
contentDescription = stringResource(Res.string.mark_as_read),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
MeshtasticImportFAB(
|
||||
onImport = { uriString ->
|
||||
uiViewModel.handleScannedUri(
|
||||
org.meshtastic.core.common.util.MeshtasticUri(uriString),
|
||||
) {
|
||||
// OnInvalid
|
||||
}
|
||||
},
|
||||
onShareChannels = onNavigateToShareChannels,
|
||||
sharedContact = sharedContactRequested,
|
||||
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
|
||||
isContactContext = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { contentPadding ->
|
||||
if (contacts.isEmpty()) {
|
||||
EmptyConversationsPlaceholder(modifier = Modifier.padding(contentPadding))
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
|
||||
items(contacts, key = { it.contactKey }) { contact ->
|
||||
val isActive = navigator.currentDestination?.contentKey == contact.contactKey
|
||||
ContactItem(
|
||||
contact = contact,
|
||||
selected = false,
|
||||
isActive = isActive,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contact.contactKey)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
navigator.currentDestination?.contentKey?.let { contactKey ->
|
||||
val messageViewModel: MessageViewModel = koinViewModel(key = "messages-$contactKey")
|
||||
DesktopMessageContent(contactKey = contactKey, viewModel = messageViewModel)
|
||||
} ?: EmptyConversationsPlaceholder(modifier = Modifier)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.ui.messaging
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.isShiftPressed
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.no_messages_yet
|
||||
import org.meshtastic.core.resources.unknown_channel
|
||||
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
|
||||
import org.meshtastic.core.ui.icon.Conversations
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.util.createClipEntry
|
||||
import org.meshtastic.feature.messaging.MessageViewModel
|
||||
import org.meshtastic.feature.messaging.component.ActionModeTopBar
|
||||
import org.meshtastic.feature.messaging.component.DeleteMessageDialog
|
||||
import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES
|
||||
import org.meshtastic.feature.messaging.component.MessageInput
|
||||
import org.meshtastic.feature.messaging.component.MessageItem
|
||||
import org.meshtastic.feature.messaging.component.MessageMenuAction
|
||||
import org.meshtastic.feature.messaging.component.MessageStatusDialog
|
||||
import org.meshtastic.feature.messaging.component.MessageTopBar
|
||||
import org.meshtastic.feature.messaging.component.QuickChatRow
|
||||
import org.meshtastic.feature.messaging.component.ReplySnippet
|
||||
import org.meshtastic.feature.messaging.component.ScrollToBottomFab
|
||||
import org.meshtastic.feature.messaging.component.UnreadMessagesDivider
|
||||
import org.meshtastic.feature.messaging.component.handleQuickChatAction
|
||||
|
||||
/**
|
||||
* Desktop message content view for the contacts detail pane.
|
||||
*
|
||||
* Uses a non-paged [LazyColumn] to display messages for a selected conversation. Now shares the full message screen
|
||||
* component set with Android, including: proper reply-to-message with replyId, message selection mode, quick chat row,
|
||||
* message filtering, delivery info dialog, overflow menu, byte counter input, and unread dividers.
|
||||
*
|
||||
* The only difference from Android is the non-paged data source (Flow<List<Message>> vs LazyPagingItems) and the
|
||||
* absence of PredictiveBackHandler.
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun DesktopMessageContent(
|
||||
contactKey: String,
|
||||
viewModel: MessageViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
initialMessage: String = "",
|
||||
onNavigateUp: (() -> Unit)? = null,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboard.current
|
||||
|
||||
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||
val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val contactSettings by viewModel.contactSettings.collectAsStateWithLifecycle(initialValue = emptyMap())
|
||||
val homoglyphEncodingEnabled by viewModel.homoglyphEncodingEnabled.collectAsStateWithLifecycle(initialValue = false)
|
||||
|
||||
val messages by viewModel.getMessagesFlow(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
|
||||
// UI State
|
||||
var replyingToPacketId by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
|
||||
var messageText by rememberSaveable(contactKey) { mutableStateOf(initialMessage) }
|
||||
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
|
||||
val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle()
|
||||
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
|
||||
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
|
||||
|
||||
var showStatusDialog by remember { mutableStateOf<org.meshtastic.core.model.Message?>(null) }
|
||||
val inSelectionMode by remember { derivedStateOf { selectedMessageIds.value.isNotEmpty() } }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val unreadCount by viewModel.unreadCount.collectAsStateWithLifecycle()
|
||||
|
||||
// Derive title
|
||||
val channelInfo =
|
||||
remember(contactKey, channels) {
|
||||
val index = contactKey.firstOrNull()?.digitToIntOrNull()
|
||||
val id = contactKey.substring(1)
|
||||
val name = index?.let { channels.getChannel(it)?.name }
|
||||
Triple(index, id, name)
|
||||
}
|
||||
val (channelIndex, nodeId, rawChannelName) = channelInfo
|
||||
val unknownChannelText = stringResource(Res.string.unknown_channel)
|
||||
val channelName = rawChannelName ?: unknownChannelText
|
||||
|
||||
val title =
|
||||
remember(nodeId, channelName, viewModel) {
|
||||
when (nodeId) {
|
||||
DataPacket.ID_BROADCAST -> channelName
|
||||
else -> viewModel.getUser(nodeId).long_name
|
||||
}
|
||||
}
|
||||
|
||||
val isMismatchKey =
|
||||
remember(channelIndex, nodeId, viewModel) {
|
||||
channelIndex == DataPacket.PKC_CHANNEL_INDEX && viewModel.getNode(nodeId).mismatchKey
|
||||
}
|
||||
|
||||
// Find the original message for reply snippet
|
||||
val originalMessage by
|
||||
remember(replyingToPacketId, messages.size) {
|
||||
derivedStateOf { replyingToPacketId?.let { id -> messages.firstOrNull { it.packetId == id } } }
|
||||
}
|
||||
|
||||
// Scroll to bottom when new messages arrive and we're already at the bottom
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty() && !listState.canScrollBackward) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed route-provided draft text
|
||||
LaunchedEffect(contactKey, initialMessage) {
|
||||
if (initialMessage.isNotBlank() && messageText.isBlank()) {
|
||||
messageText = initialMessage
|
||||
}
|
||||
}
|
||||
|
||||
// Mark messages as read when they become visible
|
||||
@OptIn(kotlinx.coroutines.FlowPreview::class)
|
||||
LaunchedEffect(messages.size) {
|
||||
snapshotFlow { if (listState.isScrollInProgress) null else listState.layoutInfo }
|
||||
.debounce(SCROLL_SETTLE_MILLIS)
|
||||
.collectLatest { layoutInfo ->
|
||||
if (layoutInfo == null || messages.isEmpty()) return@collectLatest
|
||||
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
if (visibleItems.isEmpty()) return@collectLatest
|
||||
|
||||
val topVisibleIndex = visibleItems.first().index
|
||||
val bottomVisibleIndex = visibleItems.last().index
|
||||
|
||||
val firstVisibleUnread =
|
||||
(bottomVisibleIndex..topVisibleIndex)
|
||||
.mapNotNull { if (it in messages.indices) messages[it] else null }
|
||||
.firstOrNull { !it.fromLocal && !it.read }
|
||||
|
||||
firstVisibleUnread?.let { message ->
|
||||
viewModel.clearUnreadCount(contactKey, message.uuid, message.receivedTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
if (showDeleteDialog) {
|
||||
DeleteMessageDialog(
|
||||
count = selectedMessageIds.value.size,
|
||||
onConfirm = {
|
||||
viewModel.deleteMessages(selectedMessageIds.value.toList())
|
||||
selectedMessageIds.value = emptySet()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
showStatusDialog?.let { message ->
|
||||
MessageStatusDialog(
|
||||
message = message,
|
||||
nodes = nodes,
|
||||
ourNode = ourNode,
|
||||
resendOption = message.status?.equals(MessageStatus.ERROR) ?: false,
|
||||
onResend = {
|
||||
viewModel.deleteMessages(listOf(message.uuid))
|
||||
viewModel.sendMessage(message.text, contactKey)
|
||||
showStatusDialog = null
|
||||
},
|
||||
onDismiss = { showStatusDialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
if (inSelectionMode) {
|
||||
ActionModeTopBar(
|
||||
selectedCount = selectedMessageIds.value.size,
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
MessageMenuAction.ClipboardCopy -> {
|
||||
val copiedText =
|
||||
messages
|
||||
.filter { it.uuid in selectedMessageIds.value }
|
||||
.joinToString("\n") { it.text }
|
||||
coroutineScope.launch {
|
||||
clipboardManager.setClipEntry(createClipEntry(copiedText, "messages"))
|
||||
}
|
||||
selectedMessageIds.value = emptySet()
|
||||
}
|
||||
|
||||
MessageMenuAction.Delete -> showDeleteDialog = true
|
||||
MessageMenuAction.Dismiss -> selectedMessageIds.value = emptySet()
|
||||
MessageMenuAction.SelectAll -> {
|
||||
selectedMessageIds.value =
|
||||
if (selectedMessageIds.value.size == messages.size) {
|
||||
emptySet()
|
||||
} else {
|
||||
messages.map { it.uuid }.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
MessageTopBar(
|
||||
title = title,
|
||||
channelIndex = channelIndex,
|
||||
mismatchKey = isMismatchKey,
|
||||
onNavigateBack = { onNavigateUp?.invoke() },
|
||||
channels = channels,
|
||||
channelIndexParam = channelIndex,
|
||||
showQuickChat = showQuickChat,
|
||||
onToggleQuickChat = viewModel::toggleShowQuickChat,
|
||||
filteringDisabled = filteringDisabled,
|
||||
onToggleFilteringDisabled = {
|
||||
viewModel.setContactFilteringDisabled(contactKey, !filteringDisabled)
|
||||
},
|
||||
filteredCount = filteredCount,
|
||||
showFiltered = showFiltered,
|
||||
onToggleShowFiltered = viewModel::toggleShowFiltered,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
Column {
|
||||
AnimatedVisibility(visible = showQuickChat) {
|
||||
QuickChatRow(
|
||||
enabled = connectionState.isConnected(),
|
||||
actions = quickChatActions,
|
||||
onClick = { action ->
|
||||
handleQuickChatAction(
|
||||
action = action,
|
||||
currentText = messageText,
|
||||
onUpdateText = { messageText = it },
|
||||
onSendMessage = { text -> viewModel.sendMessage(text, contactKey) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
ReplySnippet(
|
||||
originalMessage = originalMessage,
|
||||
onClearReply = { replyingToPacketId = null },
|
||||
ourNode = ourNode,
|
||||
)
|
||||
MessageInput(
|
||||
messageText = messageText,
|
||||
onMessageChange = { messageText = it },
|
||||
onSendMessage = {
|
||||
val trimmed = messageText.trim()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
viewModel.sendMessage(trimmed, contactKey, replyingToPacketId)
|
||||
if (replyingToPacketId != null) replyingToPacketId = null
|
||||
messageText = ""
|
||||
}
|
||||
},
|
||||
isEnabled = connectionState.isConnected(),
|
||||
isHomoglyphEncodingEnabled = homoglyphEncodingEnabled,
|
||||
modifier =
|
||||
Modifier.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isShiftPressed) {
|
||||
val currentByteLength = messageText.encodeToByteArray().size
|
||||
val isOverLimit = currentByteLength > MESSAGE_CHARACTER_LIMIT_BYTES
|
||||
val trimmed = messageText.trim()
|
||||
if (trimmed.isNotEmpty() && connectionState.isConnected() && !isOverLimit) {
|
||||
viewModel.sendMessage(trimmed, contactKey, replyingToPacketId)
|
||||
if (replyingToPacketId != null) replyingToPacketId = null
|
||||
messageText = ""
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
// If over limit or empty, we still consume Enter to prevent newlines if the user
|
||||
// intended to send, but only if they are not holding shift.
|
||||
if (!event.isShiftPressed) return@onPreviewKeyEvent true
|
||||
}
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { contentPadding ->
|
||||
Box(Modifier.fillMaxSize().padding(contentPadding).focusable()) {
|
||||
if (messages.isEmpty()) {
|
||||
EmptyDetailPlaceholder(
|
||||
icon = MeshtasticIcons.Conversations,
|
||||
title = stringResource(Res.string.no_messages_yet),
|
||||
)
|
||||
} else {
|
||||
// Pre-calculate node map for O(1) lookup
|
||||
val nodeMap = remember(nodes) { nodes.associateBy { it.num } }
|
||||
|
||||
// Find first unread index
|
||||
val firstUnreadIndex by
|
||||
remember(messages.size) {
|
||||
derivedStateOf { messages.indexOfFirst { !it.fromLocal && !it.read }.takeIf { it != -1 } }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(bottom = 24.dp, top = 24.dp),
|
||||
) {
|
||||
items(messages.size, key = { messages[it].uuid }) { index ->
|
||||
val message = messages[index]
|
||||
val isSender = message.fromLocal
|
||||
|
||||
// Because reverseLayout = true, visually previous (above) is index + 1
|
||||
val visuallyPrevMessage = if (index < messages.size - 1) messages[index + 1] else null
|
||||
val visuallyNextMessage = if (index > 0) messages[index - 1] else null
|
||||
|
||||
val hasSamePrev =
|
||||
if (visuallyPrevMessage != null) {
|
||||
visuallyPrevMessage.fromLocal == message.fromLocal &&
|
||||
(message.fromLocal || visuallyPrevMessage.node.num == message.node.num)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val hasSameNext =
|
||||
if (visuallyNextMessage != null) {
|
||||
visuallyNextMessage.fromLocal == message.fromLocal &&
|
||||
(message.fromLocal || visuallyNextMessage.node.num == message.node.num)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
val isFirstUnread = firstUnreadIndex == index
|
||||
val selected by
|
||||
remember(message.uuid, selectedMessageIds.value) {
|
||||
derivedStateOf { selectedMessageIds.value.contains(message.uuid) }
|
||||
}
|
||||
val node = nodeMap[message.node.num] ?: message.node
|
||||
|
||||
if (isFirstUnread) {
|
||||
Column {
|
||||
UnreadMessagesDivider()
|
||||
DesktopMessageItemRow(
|
||||
message = message,
|
||||
node = node,
|
||||
ourNode = ourNode ?: Node(num = 0),
|
||||
selected = selected,
|
||||
inSelectionMode = inSelectionMode,
|
||||
selectedMessageIds = selectedMessageIds,
|
||||
contactKey = contactKey,
|
||||
viewModel = viewModel,
|
||||
listState = listState,
|
||||
messages = messages,
|
||||
onShowStatusDialog = { showStatusDialog = it },
|
||||
onReply = { replyingToPacketId = it?.packetId },
|
||||
hasSamePrev = hasSamePrev,
|
||||
hasSameNext = hasSameNext,
|
||||
showUserName = !isSender && !hasSamePrev,
|
||||
quickEmojis = viewModel.frequentEmojis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
DesktopMessageItemRow(
|
||||
message = message,
|
||||
node = node,
|
||||
ourNode = ourNode ?: Node(num = 0),
|
||||
selected = selected,
|
||||
inSelectionMode = inSelectionMode,
|
||||
selectedMessageIds = selectedMessageIds,
|
||||
contactKey = contactKey,
|
||||
viewModel = viewModel,
|
||||
listState = listState,
|
||||
messages = messages,
|
||||
onShowStatusDialog = { showStatusDialog = it },
|
||||
onReply = { replyingToPacketId = it?.packetId },
|
||||
hasSamePrev = hasSamePrev,
|
||||
hasSameNext = hasSameNext,
|
||||
showUserName = !isSender && !hasSamePrev,
|
||||
quickEmojis = viewModel.frequentEmojis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show FAB if we can scroll towards the newest messages (index 0).
|
||||
if (listState.canScrollBackward) {
|
||||
ScrollToBottomFab(coroutineScope = coroutineScope, listState = listState, unreadCount = unreadCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
private fun DesktopMessageItemRow(
|
||||
message: org.meshtastic.core.model.Message,
|
||||
node: Node,
|
||||
ourNode: Node,
|
||||
selected: Boolean,
|
||||
inSelectionMode: Boolean,
|
||||
selectedMessageIds: androidx.compose.runtime.MutableState<Set<Long>>,
|
||||
contactKey: String,
|
||||
viewModel: MessageViewModel,
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
messages: List<org.meshtastic.core.model.Message>,
|
||||
onShowStatusDialog: (org.meshtastic.core.model.Message) -> Unit,
|
||||
onReply: (org.meshtastic.core.model.Message?) -> Unit,
|
||||
hasSamePrev: Boolean,
|
||||
hasSameNext: Boolean,
|
||||
showUserName: Boolean,
|
||||
quickEmojis: List<String>,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
MessageItem(
|
||||
message = message,
|
||||
node = node,
|
||||
ourNode = ourNode,
|
||||
selected = selected,
|
||||
inSelectionMode = inSelectionMode,
|
||||
onClick = { if (inSelectionMode) selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid) },
|
||||
onLongClick = {
|
||||
if (inSelectionMode) {
|
||||
selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid)
|
||||
}
|
||||
},
|
||||
onSelect = { selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid) },
|
||||
onDelete = { viewModel.deleteMessages(listOf(message.uuid)) },
|
||||
onReply = { onReply(message) },
|
||||
sendReaction = { emoji ->
|
||||
val hasReacted =
|
||||
message.emojis.any { reaction ->
|
||||
(reaction.user.id == ourNode.user.id || reaction.user.id == DataPacket.ID_LOCAL) &&
|
||||
reaction.emoji == emoji
|
||||
}
|
||||
if (!hasReacted) {
|
||||
viewModel.sendReaction(emoji, message.packetId, contactKey)
|
||||
}
|
||||
},
|
||||
onStatusClick = { onShowStatusDialog(message) },
|
||||
onNavigateToOriginalMessage = { replyId ->
|
||||
coroutineScope.launch {
|
||||
val targetIndex = messages.indexOfFirst { it.packetId == replyId }.takeIf { it != -1 }
|
||||
if (targetIndex != null) {
|
||||
listState.animateScrollToItem(targetIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
emojis = message.emojis,
|
||||
showUserName = showUserName,
|
||||
hasSamePrev = hasSamePrev,
|
||||
hasSameNext = hasSameNext,
|
||||
quickEmojis = quickEmojis,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Set<Long>.toggle(uuid: Long): Set<Long> = if (contains(uuid)) this - uuid else this + uuid
|
||||
|
||||
/** Debounce delay before marking messages as read after scroll settles. */
|
||||
private const val SCROLL_SETTLE_MILLIS = 300L
|
||||
@@ -1,290 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.ui.nodes
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.node_count_template
|
||||
import org.meshtastic.core.resources.nodes
|
||||
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MeshtasticImportFAB
|
||||
import org.meshtastic.core.ui.component.SharedContactDialog
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Nodes
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.feature.node.component.NodeContextMenu
|
||||
import org.meshtastic.feature.node.component.NodeFilterTextField
|
||||
import org.meshtastic.feature.node.component.NodeItem
|
||||
import org.meshtastic.feature.node.detail.NodeDetailContent
|
||||
import org.meshtastic.feature.node.detail.NodeDetailViewModel
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.list.NodeListViewModel
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
|
||||
/**
|
||||
* Desktop adaptive node list screen using [ListDetailPaneScaffold] from JetBrains Material 3 Adaptive.
|
||||
*
|
||||
* On wide screens, the node list is shown on the left and the selected node detail on the right. On narrow screens, the
|
||||
* scaffold automatically switches to a single-pane layout.
|
||||
*
|
||||
* Uses the shared [NodeListViewModel] and commonMain composables ([NodeItem], [NodeFilterTextField], [MainAppBar]). The
|
||||
* detail pane renders the shared [NodeDetailContent] from commonMain with the full node detail sections (identity,
|
||||
* device actions, position, hardware details, notes, administration). Android-only overlays (compass permissions,
|
||||
* bottom sheets) are no-ops on desktop.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun DesktopAdaptiveNodeListScreen(
|
||||
viewModel: NodeListViewModel,
|
||||
initialNodeId: Int? = null,
|
||||
onNavigate: (Route) -> Unit = {},
|
||||
uiViewModel: UIViewModel = koinViewModel(),
|
||||
) {
|
||||
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
|
||||
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
|
||||
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
|
||||
val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle()
|
||||
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
|
||||
var shareNode by remember { mutableStateOf<org.meshtastic.core.model.Node?>(null) }
|
||||
|
||||
if (shareNode != null) {
|
||||
SharedContactDialog(contact = shareNode, onDismiss = { shareNode = null })
|
||||
}
|
||||
|
||||
LaunchedEffect(initialNodeId) {
|
||||
initialNodeId?.let { nodeId -> navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
|
||||
}
|
||||
|
||||
ListDetailPaneScaffold(
|
||||
directive = navigator.scaffoldDirective,
|
||||
value = navigator.scaffoldValue,
|
||||
listPane = {
|
||||
AnimatedPane {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(Res.string.nodes),
|
||||
subtitle =
|
||||
stringResource(
|
||||
Res.string.node_count_template,
|
||||
onlineNodeCount,
|
||||
nodes.size,
|
||||
totalNodeCount,
|
||||
),
|
||||
ourNode = ourNode,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = false,
|
||||
onNavigateUp = {},
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (connectionState == ConnectionState.Connected) {
|
||||
MeshtasticImportFAB(
|
||||
onImport = { uriString ->
|
||||
uiViewModel.handleScannedUri(
|
||||
org.meshtastic.core.common.util.MeshtasticUri(uriString),
|
||||
) {
|
||||
// OnInvalid
|
||||
}
|
||||
},
|
||||
sharedContact = sharedContactRequested,
|
||||
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
|
||||
isContactContext = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { contentPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
item {
|
||||
NodeFilterTextField(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceDim)
|
||||
.padding(8.dp),
|
||||
filterText = state.filter.filterText,
|
||||
onTextChange = { viewModel.nodeFilterText = it },
|
||||
currentSortOption = state.sort,
|
||||
onSortSelect = viewModel::setSortOption,
|
||||
includeUnknown = state.filter.includeUnknown,
|
||||
onToggleIncludeUnknown = { viewModel.nodeFilterPreferences.toggleIncludeUnknown() },
|
||||
excludeInfrastructure = state.filter.excludeInfrastructure,
|
||||
onToggleExcludeInfrastructure = {
|
||||
viewModel.nodeFilterPreferences.toggleExcludeInfrastructure()
|
||||
},
|
||||
onlyOnline = state.filter.onlyOnline,
|
||||
onToggleOnlyOnline = { viewModel.nodeFilterPreferences.toggleOnlyOnline() },
|
||||
onlyDirect = state.filter.onlyDirect,
|
||||
onToggleOnlyDirect = { viewModel.nodeFilterPreferences.toggleOnlyDirect() },
|
||||
showIgnored = state.filter.showIgnored,
|
||||
onToggleShowIgnored = { viewModel.nodeFilterPreferences.toggleShowIgnored() },
|
||||
ignoredNodeCount = ignoredNodeCount,
|
||||
excludeMqtt = state.filter.excludeMqtt,
|
||||
onToggleExcludeMqtt = { viewModel.nodeFilterPreferences.toggleExcludeMqtt() },
|
||||
)
|
||||
}
|
||||
|
||||
items(nodes, key = { it.num }) { node ->
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val isActive = navigator.currentDestination?.contentKey == node.num
|
||||
|
||||
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
val longClick =
|
||||
if (node.num != ourNode?.num) {
|
||||
{ expanded = true }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
NodeItem(
|
||||
thisNode = ourNode,
|
||||
thatNode = node,
|
||||
distanceUnits = state.distanceUnits,
|
||||
tempInFahrenheit = state.tempInFahrenheit,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, node.num)
|
||||
}
|
||||
},
|
||||
onLongClick = longClick,
|
||||
connectionState = connectionState,
|
||||
isActive = isActive,
|
||||
)
|
||||
|
||||
val isThisNode = remember(node) { ourNode?.num == node.num }
|
||||
if (!isThisNode) {
|
||||
NodeContextMenu(
|
||||
expanded = expanded,
|
||||
node = node,
|
||||
onFavorite = { viewModel.favoriteNode(node) },
|
||||
onIgnore = { viewModel.ignoreNode(node) },
|
||||
onMute = { viewModel.muteNode(node) },
|
||||
onRemove = { viewModel.removeNode(node) },
|
||||
onDismiss = { expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
AnimatedPane {
|
||||
navigator.currentDestination?.contentKey?.let { nodeNum ->
|
||||
val detailViewModel: NodeDetailViewModel = koinViewModel(key = "node-detail-$nodeNum")
|
||||
LaunchedEffect(nodeNum) { detailViewModel.start(nodeNum) }
|
||||
val detailUiState by detailViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
detailViewModel.effects.collect { effect ->
|
||||
if (effect is NodeRequestEffect.ShowFeedback) {
|
||||
snackbarHostState.showSnackbar(effect.text.resolve())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues ->
|
||||
NodeDetailContent(
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
uiState = detailUiState,
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
is NodeDetailAction.Navigate -> onNavigate(action.route)
|
||||
is NodeDetailAction.TriggerServiceAction ->
|
||||
detailViewModel.onServiceAction(action.action)
|
||||
is NodeDetailAction.ShareContact -> shareNode = detailUiState.node
|
||||
is NodeDetailAction.HandleNodeMenuAction -> {
|
||||
val menuAction = action.action
|
||||
if (
|
||||
menuAction
|
||||
is org.meshtastic.feature.node.component.NodeMenuAction.DirectMessage
|
||||
) {
|
||||
val routeStr =
|
||||
detailViewModel.getDirectMessageRoute(
|
||||
menuAction.node,
|
||||
detailUiState.ourNode,
|
||||
)
|
||||
onNavigate(
|
||||
org.meshtastic.core.navigation.ContactsRoutes.Messages(
|
||||
contactKey = routeStr,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
detailViewModel.handleNodeMenuAction(menuAction)
|
||||
}
|
||||
}
|
||||
else -> {} // Actions requiring Android APIs are no-ops on desktop
|
||||
}
|
||||
},
|
||||
onFirmwareSelect = { /* Firmware update not available on desktop */ },
|
||||
onSaveNotes = { num, notes -> detailViewModel.setNodeNotes(num, notes) },
|
||||
)
|
||||
}
|
||||
} ?: EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.ui.settings
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.advanced
|
||||
import org.meshtastic.core.resources.config_network_eth_enabled_summary
|
||||
import org.meshtastic.core.resources.config_network_udp_enabled_summary
|
||||
import org.meshtastic.core.resources.config_network_wifi_enabled_summary
|
||||
import org.meshtastic.core.resources.connection_status
|
||||
import org.meshtastic.core.resources.ethernet_config
|
||||
import org.meshtastic.core.resources.ethernet_enabled
|
||||
import org.meshtastic.core.resources.ethernet_ip
|
||||
import org.meshtastic.core.resources.gateway
|
||||
import org.meshtastic.core.resources.ip
|
||||
import org.meshtastic.core.resources.ipv4_mode
|
||||
import org.meshtastic.core.resources.network
|
||||
import org.meshtastic.core.resources.ntp_server
|
||||
import org.meshtastic.core.resources.password
|
||||
import org.meshtastic.core.resources.rsyslog_server
|
||||
import org.meshtastic.core.resources.ssid
|
||||
import org.meshtastic.core.resources.subnet
|
||||
import org.meshtastic.core.resources.udp_enabled
|
||||
import org.meshtastic.core.resources.wifi_config
|
||||
import org.meshtastic.core.resources.wifi_enabled
|
||||
import org.meshtastic.core.resources.wifi_ip
|
||||
import org.meshtastic.core.ui.component.DropDownPreference
|
||||
import org.meshtastic.core.ui.component.EditIPv4Preference
|
||||
import org.meshtastic.core.ui.component.EditPasswordPreference
|
||||
import org.meshtastic.core.ui.component.EditTextPreference
|
||||
import org.meshtastic.core.ui.component.ListItem
|
||||
import org.meshtastic.core.ui.component.SwitchPreference
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
|
||||
import org.meshtastic.feature.settings.radio.component.rememberConfigState
|
||||
import org.meshtastic.proto.Config
|
||||
|
||||
@Composable
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
fun DesktopNetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
val networkConfig = state.radioConfig.network ?: Config.NetworkConfig()
|
||||
val formState = rememberConfigState(initialValue = networkConfig)
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
RadioConfigScreenList(
|
||||
title = stringResource(Res.string.network),
|
||||
onBack = onBack,
|
||||
configState = formState,
|
||||
enabled = state.connected,
|
||||
responseState = state.responseState,
|
||||
onDismissPacketResponse = viewModel::clearPacketResponse,
|
||||
onSave = {
|
||||
val config = Config(network = it)
|
||||
viewModel.setConfig(config)
|
||||
},
|
||||
) {
|
||||
// Display device connection status
|
||||
state.deviceConnectionStatus?.let { connectionStatus ->
|
||||
val ws = connectionStatus.wifi?.status
|
||||
val es = connectionStatus.ethernet?.status
|
||||
if (ws?.is_connected == true || es?.is_connected == true) {
|
||||
item {
|
||||
TitledCard(title = stringResource(Res.string.connection_status)) {
|
||||
ws?.let { wifiStatus ->
|
||||
if (wifiStatus.is_connected) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.wifi_ip),
|
||||
supportingText = formatIpAddress(wifiStatus.ip_address ?: 0),
|
||||
trailingIcon = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
es?.let { ethernetStatus ->
|
||||
if (ethernetStatus.is_connected) {
|
||||
ListItem(
|
||||
text = stringResource(Res.string.ethernet_ip),
|
||||
supportingText = formatIpAddress(ethernetStatus.ip_address ?: 0),
|
||||
trailingIcon = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.metadata?.hasWifi == true) {
|
||||
item {
|
||||
TitledCard(title = stringResource(Res.string.wifi_config)) {
|
||||
SwitchPreference(
|
||||
title = stringResource(Res.string.wifi_enabled),
|
||||
summary = stringResource(Res.string.config_network_wifi_enabled_summary),
|
||||
checked = formState.value.wifi_enabled ?: false,
|
||||
enabled = state.connected,
|
||||
onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) },
|
||||
containerColor = CardDefaults.cardColors().containerColor,
|
||||
)
|
||||
HorizontalDivider()
|
||||
EditTextPreference(
|
||||
title = stringResource(Res.string.ssid),
|
||||
value = formState.value.wifi_ssid ?: "",
|
||||
maxSize = 32, // wifi_ssid max_size:33
|
||||
enabled = state.connected,
|
||||
isError = false,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { formState.value = formState.value.copy(wifi_ssid = it) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
EditPasswordPreference(
|
||||
title = stringResource(Res.string.password),
|
||||
value = formState.value.wifi_psk ?: "",
|
||||
maxSize = 64, // wifi_psk max_size:65
|
||||
enabled = state.connected,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { formState.value = formState.value.copy(wifi_psk = it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.metadata?.hasEthernet == true) {
|
||||
item {
|
||||
TitledCard(title = stringResource(Res.string.ethernet_config)) {
|
||||
SwitchPreference(
|
||||
title = stringResource(Res.string.ethernet_enabled),
|
||||
summary = stringResource(Res.string.config_network_eth_enabled_summary),
|
||||
checked = formState.value.eth_enabled ?: false,
|
||||
enabled = state.connected,
|
||||
onCheckedChange = { formState.value = formState.value.copy(eth_enabled = it) },
|
||||
containerColor = CardDefaults.cardColors().containerColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.metadata?.hasEthernet == true || state.metadata?.hasWifi == true) {
|
||||
item {
|
||||
TitledCard(title = stringResource(Res.string.network)) {
|
||||
SwitchPreference(
|
||||
title = stringResource(Res.string.udp_enabled),
|
||||
summary = stringResource(Res.string.config_network_udp_enabled_summary),
|
||||
checked = (formState.value.enabled_protocols ?: 0) == 1,
|
||||
enabled = state.connected,
|
||||
onCheckedChange = {
|
||||
formState.value = formState.value.copy(enabled_protocols = if (it) 1 else 0)
|
||||
},
|
||||
containerColor = CardDefaults.cardColors().containerColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
TitledCard(title = stringResource(Res.string.advanced)) {
|
||||
EditTextPreference(
|
||||
title = stringResource(Res.string.ntp_server),
|
||||
value = formState.value.ntp_server ?: "",
|
||||
maxSize = 32, // ntp_server max_size:33
|
||||
enabled = state.connected,
|
||||
isError = formState.value.ntp_server?.isEmpty() ?: true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { formState.value = formState.value.copy(ntp_server = it) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
EditTextPreference(
|
||||
title = stringResource(Res.string.rsyslog_server),
|
||||
value = formState.value.rsyslog_server ?: "",
|
||||
maxSize = 32, // rsyslog_server max_size:33
|
||||
enabled = state.connected,
|
||||
isError = false,
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { formState.value = formState.value.copy(rsyslog_server = it) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropDownPreference(
|
||||
title = stringResource(Res.string.ipv4_mode),
|
||||
enabled = state.connected,
|
||||
items = Config.NetworkConfig.AddressMode.entries.map { it to it.name },
|
||||
selectedItem = formState.value.address_mode ?: Config.NetworkConfig.AddressMode.DHCP,
|
||||
onItemSelected = { formState.value = formState.value.copy(address_mode = it) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config()
|
||||
EditIPv4Preference(
|
||||
title = stringResource(Res.string.ip),
|
||||
value = ipv4.ip,
|
||||
enabled =
|
||||
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
EditIPv4Preference(
|
||||
title = stringResource(Res.string.gateway),
|
||||
value = ipv4.gateway,
|
||||
enabled =
|
||||
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it)) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
EditIPv4Preference(
|
||||
title = stringResource(Res.string.subnet),
|
||||
value = ipv4.subnet,
|
||||
enabled =
|
||||
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it)) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
EditIPv4Preference(
|
||||
title = "DNS",
|
||||
value = ipv4.dns,
|
||||
enabled =
|
||||
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(dns = it)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber")
|
||||
private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." +
|
||||
"${(ipAddress shr 8) and 0xFF}." +
|
||||
"${(ipAddress shr 16) and 0xFF}." +
|
||||
"${(ipAddress shr 24) and 0xFF}"
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -33,17 +33,18 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver
|
||||
- Do keep route definitions in `core:navigation` and use typed route objects.
|
||||
- Don't mutate back navigation with custom stacks disconnected from app backstack.
|
||||
- Do mutate `NavBackStack<NavKey>` with `add(...)` and `removeLastOrNull()`.
|
||||
- Don't use Android's `androidx.activity.compose.BackHandler` or custom `PredictiveBackHandler` in multiplatform UI.
|
||||
- Do use the official KMP `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures.
|
||||
|
||||
### Current code anchors (Navigation 3)
|
||||
|
||||
- Typed routes: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
|
||||
- Shared saved-state config: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt`
|
||||
- App root backstack + `NavDisplay`: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt`
|
||||
- Graph entry provider pattern: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
|
||||
- Feature-level Navigation 3 usage: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt`
|
||||
- Shared graph entry provider pattern: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
|
||||
- Desktop Navigation 3 shell: `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`
|
||||
- Desktop nav graph entries: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt`
|
||||
- Desktop real feature wiring: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt`
|
||||
- Desktop `SavedStateConfiguration` for polymorphic NavKey serialization: `DesktopMainScreen.kt`
|
||||
- Desktop nav graph assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt`
|
||||
|
||||
|
||||
## Quick pre-PR checks for DI/navigation edits
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
plugins { alias(libs.plugins.meshtastic.kmp.feature) }
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.connections"
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<SmellBaseline>
|
||||
<ManuallySuppressedIssues/>
|
||||
<CurrentIssues>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
|
||||
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
|
||||
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
|
||||
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
|
||||
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
|
||||
</CurrentIssues>
|
||||
<CurrentIssues/>
|
||||
</SmellBaseline>
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -22,7 +22,7 @@ import androidx.navigation3.runtime.NavKey
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.navigation.ConnectionsRoutes
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.feature.connections.AndroidScannerViewModel
|
||||
import org.meshtastic.feature.connections.ScannerViewModel
|
||||
import org.meshtastic.feature.connections.ui.ConnectionsScreen
|
||||
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
|
||||
@@ -30,12 +30,9 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
|
||||
fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
|
||||
entry<ConnectionsRoutes.ConnectionsGraph> {
|
||||
ConnectionsScreen(
|
||||
scanModel = koinViewModel<AndroidScannerViewModel>(),
|
||||
scanModel = koinViewModel<ScannerViewModel>(),
|
||||
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
|
||||
onClickNodeChip = {
|
||||
// Navigation 3 ignores back stack behavior options; we handle this by popping if necessary.
|
||||
backStack.add(NodesRoutes.NodeDetailGraph(it))
|
||||
},
|
||||
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
|
||||
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
|
||||
onConfigNavigate = { route -> backStack.add(route) },
|
||||
)
|
||||
@@ -43,7 +40,7 @@ fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>)
|
||||
|
||||
entry<ConnectionsRoutes.Connections> {
|
||||
ConnectionsScreen(
|
||||
scanModel = koinViewModel<AndroidScannerViewModel>(),
|
||||
scanModel = koinViewModel<ScannerViewModel>(),
|
||||
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
|
||||
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
|
||||
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateViewModel
|
||||
|
||||
@Composable
|
||||
actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
|
||||
val viewModel = koinViewModel<FirmwareUpdateViewModel>()
|
||||
FirmwareUpdateScreen(onNavigateUp = onNavigateUp, viewModel = viewModel)
|
||||
}
|
||||
@@ -16,17 +16,15 @@
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.meshtastic.core.navigation.FirmwareRoutes
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
|
||||
import org.meshtastic.feature.firmware.FirmwareUpdateViewModel
|
||||
|
||||
fun EntryProviderScope<NavKey>.firmwareGraph(backStack: NavBackStack<NavKey>) {
|
||||
entry<FirmwareRoutes.FirmwareUpdate> {
|
||||
val viewModel = koinViewModel<FirmwareUpdateViewModel>()
|
||||
FirmwareUpdateScreen(onNavigateUp = { backStack.removeLastOrNull() }, viewModel = viewModel)
|
||||
}
|
||||
entry<FirmwareRoutes.FirmwareGraph> { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
|
||||
entry<FirmwareRoutes.FirmwareUpdate> { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
|
||||
}
|
||||
|
||||
@Composable expect fun FirmwareScreen(onNavigateUp: () -> Unit)
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
|
||||
// TODO: Implement iOS firmware screen
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.ui.firmware
|
||||
package org.meshtastic.feature.firmware
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.firmware.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.meshtastic.feature.firmware.DesktopFirmwareScreen
|
||||
|
||||
@Composable
|
||||
actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
|
||||
DesktopFirmwareScreen()
|
||||
}
|
||||
@@ -21,8 +21,6 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.intro"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user