chore: standardize resources and update documentation for Navigation 3 (#4961)

This commit is contained in:
James Rich 2026-03-31 16:25:37 -05:00 committed by GitHub
parent 1faa802fe6
commit 464a12b9f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 216 additions and 149 deletions

View file

@ -18,7 +18,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **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`.
- **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state.
- **Navigation:** JetBrains Navigation 3 (Stable Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`.
- **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
- **Database:** Room KMP.
@ -28,7 +28,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| Directory | Description |
| :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.koin`). |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
| `core/model` | Domain models and common data structures. |
@ -39,9 +39,9 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:repository` | High-level domain interfaces (e.g., `NodeRepository`, `LocationRepository`). |
| `core:domain` | Pure KMP business logic and UseCases. |
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`). |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for backstack persistence. |
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
@ -61,10 +61,10 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Material 3:** The app uses Material 3.
- **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`).
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). Do NOT duplicate inline alert-rendering boilerplate. For shared QR/contact dialogs, use the `SharedDialogs` composable.
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes, `extraPane()`, and draggable dividers (`VerticalDragHandle` + `paneExpansionState`) for widths ≥ 1200dp.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer
@ -75,8 +75,12 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `java.util.concurrent.locks.*``kotlinx.coroutines.sync.Mutex`.
- `java.io.*` → Okio (`BufferedSource`/`BufferedSink`).
- `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`.
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **`jvmAndroidMain` source set:** Modules that share JVM-specific code between Android and Desktop apply the `meshtastic.kmp.jvm.android` convention plugin. This creates a `jvmAndroidMain` source set via Kotlin's hierarchy template API. Used in `core:common`, `core:model`, `core:data`, `core:network`, and `core:ui`.
- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model.
- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient.
- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` use `MeshtasticNavDisplay` from `core:ui/commonMain`, which configures `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator`. ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
@ -140,8 +144,8 @@ Always run commands in the following order to ensure reliability. Do not attempt
- PR `check-changes` path filtering lives in `.github/workflows/pull-request.yml` and must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.) so CI is not skipped for infra-only changes.
- **Runner strategy (three tiers):**
- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). These run only shell scripts or GitHub API calls and benefit from ARM runners' shorter queue times.
- **`ubuntu-24.04`** — Gradle-heavy jobs (CI host-check, android-check, release builds, Dokka, CodeQL, publish, dependency-submission). Pinned for reproducibility; avoid `ubuntu-latest` to prevent breakage when GitHub rolls the alias forward.
- **Desktop release matrix** — `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]` for cross-platform native packaging (DMG, MSI, deb/rpm/AppImage for x64 and ARM).
- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `host-check`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin where possible for reproducibility.
- **Desktop runners:** Reusable CI uses `ubuntu-latest` for the `build-desktop` job in `.github/workflows/reusable-check.yml`; release packaging matrix remains `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]`.
- **CI JVM tuning:** `gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI workflows override via `GRADLE_OPTS` env var to fit the 7GB RAM budget of standard runners: `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon, VFS watching disabled, workers capped at 4.
- **KMP Smoke Compile:** Use `./gradlew kmpSmokeCompile` instead of listing individual module compile tasks. The `kmpSmokeCompile` lifecycle task (registered in `RootConventionPlugin`) auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
- **`mavenLocal()` gated:** The `mavenLocal()` repository is disabled by default to prevent CI cache poisoning. For local JitPack testing, pass `-PuseMavenLocal` to Gradle.

View file

@ -78,10 +78,13 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **`jvmAndroidMain` source set:** Modules that share JVM-specific code between Android and Desktop apply the `meshtastic.kmp.jvm.android` convention plugin. This creates a `jvmAndroidMain` source set via Kotlin's hierarchy template API. Used in `core:common`, `core:model`, `core:data`, `core:network`, and `core:ui`.
- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model.
- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient.
- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` pass `ViewModelStoreNavEntryDecorator` to `NavDisplay`, so ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` use `MeshtasticNavDisplay` from `core:ui/commonMain`, which configures `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator`. ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **Navigation host:** Use `MeshtasticNavDisplay` from `core:ui/commonMain` instead of calling `NavDisplay` directly. It provides entry-scoped ViewModel decoration, `DialogSceneStrategy` for dialog entries, and a shared 350 ms crossfade transition. Host modules (`app`, `desktop`) should NOT configure `entryDecorators`, `sceneStrategies`, or `transitionSpec` themselves.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
@ -141,8 +144,8 @@ Always run commands in the following order to ensure reliability. Do not attempt
- PR `check-changes` path filtering lives in `.github/workflows/pull-request.yml` and must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.) so CI is not skipped for infra-only changes.
- **Runner strategy (three tiers):**
- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). These run only shell scripts or GitHub API calls and benefit from ARM runners' shorter queue times.
- **`ubuntu-24.04`** — Gradle-heavy jobs (CI host-check, android-check, release builds, Dokka, CodeQL, publish, dependency-submission). Pinned for reproducibility; avoid `ubuntu-latest` to prevent breakage when GitHub rolls the alias forward.
- **Desktop release matrix** — `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]` for cross-platform native packaging (DMG, MSI, deb/rpm/AppImage for x64 and ARM).
- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `host-check`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin where possible for reproducibility.
- **Desktop runners:** Reusable CI uses `ubuntu-latest` for the `build-desktop` job in `.github/workflows/reusable-check.yml`; release packaging matrix remains `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]`.
- **CI JVM tuning:** `gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI workflows override via `GRADLE_OPTS` env var to fit the 7GB RAM budget of standard runners: `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon, VFS watching disabled, workers capped at 4.
- **KMP Smoke Compile:** Use `./gradlew kmpSmokeCompile` instead of listing individual module compile tasks. The `kmpSmokeCompile` lifecycle task (registered in `RootConventionPlugin`) auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
- **`mavenLocal()` gated:** The `mavenLocal()` repository is disabled by default to prevent CI cache poisoning. For local JitPack testing, pass `-PuseMavenLocal` to Gradle.

View file

@ -28,7 +28,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| Directory | Description |
| :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.koin`). |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
| `core/model` | Domain models and common data structures. |
@ -41,7 +41,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
| `core:data` | Core manager implementations and data orchestration. |
| `core:network` | KMP networking layer using Ktor, MQTT abstractions, and shared transport (`StreamFrameCodec`, `TcpTransport`, `SerialTransport`, `BleRadioInterface`). |
| `core:di` | Common DI qualifiers and dispatchers. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
| `core:navigation` | Shared navigation keys/routes for Navigation 3, `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` for backstack persistence. |
| `core:ui` | Shared Compose UI components (`MeshtasticAppShell`, `MeshtasticNavDisplay`, `MeshtasticNavigationSuite`, `AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
| `core:api` | Public AIDL/API integration module for external clients. |
@ -64,7 +64,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- **Alerts:** Use `AlertHost(alertManager)` from `core:ui/commonMain` in each platform host shell (`Main.kt`, `DesktopMainScreen.kt`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes and `ThreePaneScaffold` for widths ≥ 1200dp.
- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes, `extraPane()`, and draggable dividers (`VerticalDragHandle` + `paneExpansionState`) for widths ≥ 1200dp.
- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
### B. Logic & Data Layer
@ -77,10 +77,17 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec
- `kotlinx.coroutines.Dispatchers.IO``org.meshtastic.core.common.util.ioDispatcher` (expect/actual).
- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
- **`jvmAndroidMain` source set:** Modules that share JVM-specific code between Android and Desktop apply the `meshtastic.kmp.jvm.android` convention plugin. This creates a `jvmAndroidMain` source set via Kotlin's hierarchy template API. Used in `core:common`, `core:model`, `core:data`, `core:network`, and `core:ui`.
- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model.
- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient.
- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider<NavKey>` block. Do NOT define navigation graphs in platform-specific source sets.
- **Concurrency:** Use Kotlin Coroutines and Flow.
- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` pass `ViewModelStoreNavEntryDecorator` to `NavDisplay`, so ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` use `MeshtasticNavDisplay` from `core:ui/commonMain`, which configures `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator`. ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are scoped to the entry's backstack lifetime and cleared on pop.
- **Navigation host:** Use `MeshtasticNavDisplay` from `core:ui/commonMain` instead of calling `NavDisplay` directly. It provides entry-scoped ViewModel decoration, `DialogSceneStrategy` for dialog entries, and a shared 350 ms crossfade transition. Host modules (`app`, `desktop`) should NOT configure `entryDecorators`, `sceneStrategies`, or `transitionSpec` themselves.
- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
- **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.
@ -137,8 +144,8 @@ Always run commands in the following order to ensure reliability. Do not attempt
- PR `check-changes` path filtering lives in `.github/workflows/pull-request.yml` and must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.) so CI is not skipped for infra-only changes.
- **Runner strategy (three tiers):**
- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). These run only shell scripts or GitHub API calls and benefit from ARM runners' shorter queue times.
- **`ubuntu-24.04`** — Gradle-heavy jobs (CI host-check, android-check, release builds, Dokka, CodeQL, publish, dependency-submission). Pinned for reproducibility; avoid `ubuntu-latest` to prevent breakage when GitHub rolls the alias forward.
- **Desktop release matrix** — `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]` for cross-platform native packaging (DMG, MSI, deb/rpm/AppImage for x64 and ARM).
- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `host-check`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin where possible for reproducibility.
- **Desktop runners:** Reusable CI uses `ubuntu-latest` for the `build-desktop` job in `.github/workflows/reusable-check.yml`; release packaging matrix remains `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]`.
- **CI JVM tuning:** `gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI workflows override via `GRADLE_OPTS` env var to fit the 7GB RAM budget of standard runners: `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon, VFS watching disabled, workers capped at 4.
- **KMP Smoke Compile:** Use `./gradlew kmpSmokeCompile` instead of listing individual module compile tasks. The `kmpSmokeCompile` lifecycle task (registered in `RootConventionPlugin`) auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
- **`mavenLocal()` gated:** The `mavenLocal()` repository is disabled by default to prevent CI cache poisoning. For local JitPack testing, pass `-PuseMavenLocal` to Gradle.

View file

@ -51,10 +51,10 @@ You can generate the documentation locally to preview your changes.
1. **Run the Dokka task:**
```bash
./gradlew :app:dokkaHtml
./gradlew dokkaGeneratePublicationHtml
```
2. **View the output:**
The generated HTML files will be located in the `app/build/dokka/html` directory. You can open the `index.html` file in your browser to view the documentation.
The generated HTML files will be located in the `build/dokka/html` directory. You can open the `index.html` file in your browser to view the documentation.
## Architecture

View file

@ -6,7 +6,7 @@ The `:app` module is the entry point for the Meshtastic Android application. It
## Key Components
### 1. `MainActivity` & `Main.kt`
The single Activity of the application. It hosts the `NavHost` and manages the root UI structure (Navigation Bar, Rail, etc.).
The single Activity of the application. It hosts the shared `MeshtasticNavDisplay` navigation shell and manages the root UI structure (Navigation Bar, Rail, etc.).
### 2. `MeshService`
The core background service that manages long-running communication with the mesh radio. While it is declared in the `:app` manifest for system visibility, its implementation resides in the `:core:service` module. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background.

View file

@ -189,12 +189,10 @@ 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",
"-Xskip-prerelease-check",
"-Xjvm-default=all",
)
}
}
@ -215,12 +213,11 @@ 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",
"-Xskip-prerelease-check",
"-Xjvm-default=all",
"-jvm-default=no-compatibility",
)
}
}

View file

@ -58,6 +58,7 @@ actual fun getInMemoryDatabaseBuilder(): RoomDatabase.Builder<MeshtasticDatabase
actual fun getDatabaseDirectory(): Path = documentDirectory().toPath()
/** Deletes the database and its Room-associated files on iOS. */
@OptIn(ExperimentalForeignApi::class)
actual fun deleteDatabase(dbName: String) {
val dir = documentDirectory()
NSFileManager.defaultManager.removeItemAtPath(dir + "/$dbName.db", null)
@ -83,6 +84,7 @@ private object PreferencesSerializer : OkioSerializer<Preferences> {
}
/** Creates an iOS DataStore for database preferences. */
@OptIn(ExperimentalForeignApi::class)
actual fun createDatabaseDataStore(name: String): DataStore<Preferences> {
val dir = documentDirectory() + "/datastore"
NSFileManager.defaultManager.createDirectoryAtPath(dir, true, null, null)

View file

@ -1,24 +1,35 @@
# `:core:navigation`
## Overview
The `:core:navigation` module defines the type-safe navigation structure for the entire application using Kotlin Serialization and the Jetpack Navigation library.
The `:core:navigation` module defines the type-safe Navigation 3 route model for Android and Desktop using Kotlin Serialization.
## Key Components
### 1. `Routes.kt`
Contains all the serializable classes and objects that represent destinations in the app.
Contains serializable `NavKey` route classes/objects used by shared feature graphs.
### 2. `DeepLinkRouter.kt`
Parses Meshtastic deep-link URIs and synthesizes a typed backstack (for example `/nodes/1234/device-metrics`).
### 3. `NavigationConfig.kt`
Defines `MeshtasticNavSavedStateConfig` so Navigation 3 backstacks can be persisted/restored safely.
## Features
- **Type-Safety**: Leverages Kotlin Serialization to pass data between screens without fragile Bundle keys.
- **Centralized Definition**: All routes are defined in one place to prevent circular dependencies between feature modules.
- **Type-Safety**: Uses serializable `NavKey` routes instead of ad-hoc string routes.
- **Deep-link synthesis**: Converts incoming URIs into typed backstacks via `DeepLinkRouter`.
- **Centralized definition**: Routes and saved-state serializers are declared in one place to avoid feature-module cycles.
## Usage
Feature modules depend on this module to define their entry points and navigate to other features.
Feature modules depend on this module to define their entry points and navigate via `NavBackStack<NavKey>`.
```kotlin
import org.meshtastic.core.navigation.MessagingRoutes
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.NodesRoutes
navController.navigate(MessagingRoutes.Chat(nodeId = 12345))
fun openNodeDetail(backStack: NavBackStack<NavKey>, destNum: Int) {
backStack.add(NodesRoutes.NodeDetail(destNum))
}
```
## Module dependency graph

View file

@ -276,6 +276,21 @@
<string name="match_all">Match All | Any</string>
<string name="debug_clear_logs_confirm">This will remove all log packets and database entries from your device - It is a full reset, and is permanent.</string>
<string name="clear">Clear</string>
<string name="search_emoji">Search emoji...</string>
<string name="more_reactions">More reactions</string>
<string name="channel_label">Channel</string>
<string name="a11y_label_value">%1$s: %2$s</string>
<string name="a11y_message_from">Message from %1$s: %2$s</string>
<string name="preview_header">Header</string>
<string name="preview_item">Item %1$d</string>
<string name="preview_footer">Footer</string>
<string name="preview_pill">Pill</string>
<string name="preview_dot">Dot</string>
<string name="preview_text">Text</string>
<string name="preview_gauge">Gauge</string>
<string name="preview_gradient">Gradient</string>
<string name="preview_custom_composable_line_one">This is a custom composable</string>
<string name="preview_custom_composable_line_two">With multiple lines and styles</string>
<string name="message_delivery_status">Message delivery status</string>
<string name="new_messages_below">New messages below</string>
<string name="meshtastic_messages_notifications">Direct message notifications</string>
@ -783,6 +798,7 @@
<string name="timestamp">Timestamp</string>
<string name="heading">Heading</string>
<string name="speed">Speed</string>
<string name="speed_kmh">%1$d Km/h</string>
<string name="sats">Sats</string>
<string name="alt">Alt</string>
<string name="freq">Freq</string>

View file

@ -26,6 +26,7 @@ kotlin {
android {
namespace = "org.meshtastic.core.ui"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
@ -75,6 +76,6 @@ kotlin {
implementation(libs.kotest.property)
}
androidUnitTest.dependencies { implementation(libs.androidx.test.runner) }
val androidHostTest by getting { dependencies { implementation(libs.androidx.test.runner) } }
}
}

View file

@ -43,7 +43,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
@ -54,6 +53,11 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_quality_icon
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.indoor_air_quality_iaq
import org.meshtastic.core.resources.preview_dot
import org.meshtastic.core.resources.preview_gauge
import org.meshtastic.core.resources.preview_gradient
import org.meshtastic.core.resources.preview_pill
import org.meshtastic.core.resources.preview_text
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.ThumbUp
import org.meshtastic.core.ui.icon.Warning
@ -112,8 +116,6 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
}
var isLegendOpen by remember { mutableStateOf(false) }
val iaqEnum = getIaq(iaq)
val gradient = Brush.linearGradient(colors = Iaq.entries.map { it.color })
if (iaqEnum != null) {
Column {
when (displayMode) {
@ -166,7 +168,7 @@ fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pil
strokeWidth = 8.dp,
color = iaqEnum.color,
)
Text(text = "${iaqEnum.description}")
Text(text = iaqEnum.description)
}
IaqDisplayMode.Gradient -> {
@ -230,7 +232,7 @@ private fun IndoorAirQualityPreview() {
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Pill", style = MaterialTheme.typography.titleLarge)
Text(stringResource(Res.string.preview_pill), style = MaterialTheme.typography.titleLarge)
Row {
IndoorAirQuality(iaq = 6)
IndoorAirQuality(iaq = 51)
@ -244,7 +246,7 @@ private fun IndoorAirQualityPreview() {
IndoorAirQuality(iaq = 351)
}
Text("Dot", style = MaterialTheme.typography.titleLarge)
Text(stringResource(Res.string.preview_dot), style = MaterialTheme.typography.titleLarge)
Row {
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Dot)
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Dot)
@ -254,7 +256,7 @@ private fun IndoorAirQualityPreview() {
IndoorAirQuality(iaq = 351, displayMode = IaqDisplayMode.Dot)
}
Text("Text", style = MaterialTheme.typography.titleLarge)
Text(stringResource(Res.string.preview_text), style = MaterialTheme.typography.titleLarge)
Row {
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Text)
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Text)
@ -266,7 +268,7 @@ private fun IndoorAirQualityPreview() {
IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Text)
}
Text("Gauge", style = MaterialTheme.typography.titleLarge)
Text(stringResource(Res.string.preview_gauge), style = MaterialTheme.typography.titleLarge)
Row {
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Gauge)
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Gauge)
@ -284,7 +286,7 @@ private fun IndoorAirQualityPreview() {
IndoorAirQuality(iaq = 500, displayMode = IaqDisplayMode.Gauge)
}
Text("Gradient", style = MaterialTheme.typography.titleLarge)
Text(stringResource(Res.string.preview_gradient), style = MaterialTheme.typography.titleLarge)
IndoorAirQuality(iaq = 6, displayMode = IaqDisplayMode.Gradient)
IndoorAirQuality(iaq = 51, displayMode = IaqDisplayMode.Gradient)
IndoorAirQuality(iaq = 101, displayMode = IaqDisplayMode.Gradient)

View file

@ -58,6 +58,11 @@ import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.preview_footer
import org.meshtastic.core.resources.preview_header
import org.meshtastic.core.resources.preview_item
// Derived in part from:
// https://github.com/androidx/androidx/blob/c92ad2941368202b2d78b8d14c71bf81e9525944/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
@ -80,15 +85,15 @@ fun LazyColumnDragAndDropDemo() {
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { Text("Header", Modifier.fillMaxWidth().padding(20.dp)) }
item { Text(stringResource(Res.string.preview_header), Modifier.fillMaxWidth().padding(20.dp)) }
itemsIndexed(list, key = { _, item -> item }) { index, item ->
DraggableItem(dragDropState, index + 1) { isDragging ->
Card { Text("Item $item", Modifier.fillMaxWidth().padding(20.dp)) }
DraggableItem(dragDropState, index + 1) {
Card { Text(stringResource(Res.string.preview_item, item), Modifier.fillMaxWidth().padding(20.dp)) }
}
}
item { Text("Footer", Modifier.fillMaxWidth().padding(20.dp)) }
item { Text(stringResource(Res.string.preview_footer), Modifier.fillMaxWidth().padding(20.dp)) }
}
}

View file

@ -29,7 +29,7 @@ import org.meshtastic.core.ui.viewmodel.UIViewModel
* - System-wide alerts and snackbar hosts
* - Deep link navigation interception logic
*
* Platform hosts (Main.kt) should invoke this at the root of their theme before rendering the main NavDisplay.
* Platform hosts should invoke this near the root before rendering `MeshtasticNavDisplay`.
*/
@Composable
fun MeshtasticCommonAppSetup(

View file

@ -100,7 +100,7 @@ fun RegularPreference(
Box {
Icon(
imageVector = trailingIcon,
contentDescription = "trailingIcon",
contentDescription = null,
modifier = Modifier.padding(start = 8.dp).wrapContentWidth(Alignment.End),
tint = color,
)

View file

@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.baro_pressure
import org.meshtastic.core.resources.env_metrics_log
import org.meshtastic.core.resources.hardware_model
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.node_id
@ -225,7 +226,7 @@ fun HardwareInfo(
IconInfo(
modifier = modifier,
icon = MeshtasticIcons.HardwareModel,
contentDescription = "Hardware Model",
contentDescription = stringResource(Res.string.hardware_model),
text = hwModel,
style = MaterialTheme.typography.labelSmall,
contentColor = contentColor,

View file

@ -74,7 +74,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.search_emoji
import org.meshtastic.core.ui.component.BottomSheetDialog
// ── Constants ──────────────────────────────────────────────────────────────────
@ -207,7 +211,7 @@ private fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
modifier = Modifier.fillMaxWidth().height(52.dp),
placeholder = {
Text(
text = "Search emoji\u2026",
text = stringResource(Res.string.search_emoji),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@ -221,7 +225,7 @@ private fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
IconButton(onClick = { onQueryChange("") }) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = "Clear",
contentDescription = stringResource(Res.string.clear),
modifier = Modifier.size(20.dp),
)
}

View file

@ -26,6 +26,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.preview_custom_composable_line_one
import org.meshtastic.core.resources.preview_custom_composable_line_two
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.AppTheme
@ -120,8 +124,8 @@ fun PreviewComposableAlert() {
title = "Custom Content",
composableMessage = {
Column(modifier = Modifier.fillMaxWidth()) {
Text("This is a custom composable")
Text("With multiple lines and styles")
Text(stringResource(Res.string.preview_custom_composable_line_one))
Text(stringResource(Res.string.preview_custom_composable_line_two))
}
},
),

View file

@ -39,7 +39,7 @@ kotlin {
}
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.add("-Xjvm-default=all")
freeCompilerArgs.add("-jvm-default=no-compatibility")
}
}

View file

@ -48,12 +48,14 @@ import androidx.compose.ui.window.rememberTrayState
import androidx.compose.ui.window.rememberWindowState
import co.touchlab.kermit.Logger
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.setSingletonImageLoaderFactory
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.first
import okio.Path.Companion.toPath
import org.jetbrains.skia.Image
@ -90,12 +92,14 @@ private fun classpathPainterResource(path: String): Painter {
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalCoilApi::class)
fun main(args: Array<String>) = application(exitProcessOnExit = false) {
Logger.i { "Meshtastic Desktop — Starting" }
val koinApp = remember { startKoin { modules(desktopPlatformModule(), desktopModule()) } }
val systemLocale = remember { Locale.getDefault() }
val uiViewModel = remember { koinApp.koin.get<UIViewModel>() }
val httpClient = remember { koinApp.koin.get<HttpClient>() }
LaunchedEffect(args) {
args.forEach { arg ->
@ -247,8 +251,10 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
val cacheDir = System.getProperty("user.home") + "/.meshtastic/image_cache_v3"
ImageLoader.Builder(context)
.components {
add(KtorNetworkFetcherFactory())
add(SvgDecoder.Factory(renderToBitmap = false))
add(KtorNetworkFetcherFactory(httpClient = httpClient))
// Render SVGs to a bitmap on Desktop to avoid Skiko vector rendering artifacts
// that show up as solid/black hardware images.
add(SvgDecoder.Factory(renderToBitmap = true))
}
.memoryCache { MemoryCache.Builder().maxSizeBytes(MEMORY_CACHE_MAX_BYTES).build() }
.diskCache {

View file

@ -9,12 +9,12 @@ Use `AGENTS.md` as the source of truth for architecture boundaries and required
When checking upstream docs/examples, match these repository-pinned versions from `gradle/libs.versions.toml`:
- Kotlin: `2.3.20`
- Koin: `4.2.0` (`koin-annotations` `2.1.0`, compiler plugin `0.4.1`)
- JetBrains Navigation 3: `1.1.0-alpha04` (`org.jetbrains.androidx.navigation3`)
- JetBrains Lifecycle (multiplatform): `2.10.0-beta01` (`org.jetbrains.androidx.lifecycle`)
- Koin: `4.2.0` (`koin-annotations` `4.2.0`, compiler plugin `0.4.1`)
- JetBrains Navigation 3: `1.1.0-beta01` (`org.jetbrains.androidx.navigation3`)
- JetBrains Lifecycle (multiplatform): `2.11.0-alpha02` (`org.jetbrains.androidx.lifecycle`)
- AndroidX Lifecycle (Android-only): `2.10.0` (`androidx.lifecycle`)
- Kotlin Coroutines: `1.10.2`
- Compose Multiplatform: `1.11.0-alpha04`
- Compose Multiplatform: `1.11.0-beta01`
- JetBrains Material 3 Adaptive: `1.3.0-alpha06` (`org.jetbrains.compose.material3.adaptive`)
Prefer versioned docs pages that match those versions (for example, Koin `4.2` docs rather than older `4.0/4.1` pages).

View file

@ -19,8 +19,9 @@ This document captures discoverable patterns that are already used in the reposi
## 3) Navigation conventions (Navigation 3)
- Use Navigation 3 types (`NavKey`, `NavBackStack`, entry providers) instead of legacy controller-first patterns.
- Example graph using `EntryProviderScope<NavKey>` and `backStack.add/removeLastOrNull`: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`.
- Example feature flow using `rememberNavBackStack` and `NavDisplay<NavKey>`: `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt`.
- Example graph using `EntryProviderScope<NavKey>` and `backStack.add/removeLastOrNull`: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`.
- Hosts should render navigation via `MeshtasticNavDisplay` from `core:ui/commonMain` (not raw `NavDisplay`) so entry decorators, scene strategies, and transitions stay consistent.
- Host examples: `app/src/main/kotlin/org/meshtastic/app/ui/Main.kt`, `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`.
## 4) UI and resources

View file

@ -44,7 +44,7 @@ Version note: align guidance with repository-pinned versions in `gradle/libs.ver
- 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`
- App root backstack + `MeshtasticNavDisplay`: `app/src/main/kotlin/org/meshtastic/app/ui/Main.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 assembly: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt`

View file

@ -19,12 +19,12 @@ Reference examples:
1. Implement or extend base ViewModel logic in `feature/<name>/src/commonMain/...`.
2. Keep shared class free of Android framework dependencies.
3. Keep Android framework dependencies out of shared logic; if the module already uses Koin annotations in `commonMain`, keep patterns consistent and ensure app root inclusion.
4. Update navigation entry points in `feature/*/src/androidMain/kotlin/org/meshtastic/feature/*/navigation/...` to resolve ViewModels with `koinViewModel()`.
4. Update shared navigation entry points in `feature/*/src/commonMain/kotlin/org/meshtastic/feature/*/navigation/...` to resolve ViewModels with `koinViewModel()`.
Reference examples:
- Shared base: `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt`
- Shared base UI ViewModel: `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/BaseUIViewModel.kt`
- Navigation usage: `feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
- Navigation usage: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
- Desktop navigation usage: `desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopSettingsNavigation.kt`
## Playbook C: Add a new dependency or service binding
@ -50,7 +50,8 @@ Reference examples:
Reference examples:
- 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`
- Shared graph content: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt`
- Android-specific content actual: `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`
@ -77,7 +78,7 @@ Reference examples:
4. Add `kotlinx-coroutines-swing` (JVM/Desktop) or the equivalent platform coroutines dispatcher module. Without it, `Dispatchers.Main` is unavailable and any code using `lifecycle.coroutineScope` will crash at runtime.
5. Progressively replace stubs with real implementations (e.g., serial transport for desktop, CoreBluetooth for iOS).
6. Add `<platform>()` target to feature modules as needed (all `core:*` modules already declare `jvm()`).
7. Update CI JVM smoke compile step in `.github/workflows/reusable-check.yml` to include new modules.
7. Ensure the new module applies the expected KMP convention plugin so root `kmpSmokeCompile` auto-discovers and validates it in CI.
8. If `commonMain` code fails to compile for the new target, it's a KMP migration debt — fix the shared code, not the target.
Reference examples:

View file

@ -30,8 +30,8 @@ Notes:
- `feature/commonMain logic` changes:
- `spotlessCheck`, `detekt`, `test`, `assembleDebug`.
- `navigation/DI wiring` changes (app graph, Koin module/wrapper changes):
- `spotlessCheck`, `detekt`, `assembleDebug`, `test`, plus `testDebugUnitTest` if available locally.
- If touching any KMP module, also run the relevant `:compileKotlinJvm` task. CI validates all 22 KMP modules + `desktop:test`.
- `spotlessCheck`, `detekt`, `assembleDebug`, `test`, plus `testFdroidDebugUnitTest` and `testGoogleDebugUnitTest` when available locally.
- If touching any KMP module, also run `kmpSmokeCompile`.
- `worker/service/background` changes:
- `spotlessCheck`, `detekt`, `assembleDebug`, `test`, and targeted tests around WorkManager/service behavior.
- `BLE/networking/core repository` changes:
@ -57,8 +57,8 @@ Current reusable check workflow includes:
`app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug mesh_service_example:lintDebug`
- Host tests plus coverage aggregation:
`test koverXmlReport app:koverXmlReportFdroidDebug app:koverXmlReportGoogleDebug core:api:koverXmlReportDebug core:barcode:koverXmlReportFdroidDebug core:barcode:koverXmlReportGoogleDebug mesh_service_example:koverXmlReportDebug desktop:koverXmlReport`
- JVM smoke compile for all KMP JVM targets (all compile-only modules remain explicit):
`: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`
- KMP smoke compile lifecycle task (auto-discovers KMP modules and runs JVM + iOS simulator compile checks):
`kmpSmokeCompile`
- Android build tasks:
`app:assembleFdroidDebug app:assembleGoogleDebug mesh_service_example:assembleDebug`
- Instrumented tests (when emulator tests are enabled):

View file

@ -30,46 +30,36 @@
### 1. NavDisplay — Scene Architecture (available since `1.1.0-alpha04`, stable in `beta01`)
**Available APIs we're NOT using:**
**Remaining APIs we're NOT using broadly yet:**
| API | Purpose | Status in project |
|---|---|---|
| `sceneStrategies: List<SceneStrategy<T>>` | Allows NavDisplay to render multi-pane Scenes | ❌ Not used — defaulting to `SinglePaneSceneStrategy` |
| `sceneStrategies: List<SceneStrategy<T>>` | Allows NavDisplay to render multi-pane Scenes | ⚠️ Partially used — `MeshtasticNavDisplay` applies dialog/list-detail/supporting/single strategies |
| `SceneStrategy<T>` interface | Custom scene calculation from backstack entries | ❌ Not used |
| `DialogSceneStrategy` | Renders `entry<T>(metadata = dialog())` entries as overlay Dialogs | ❌ Not used — dialogs handled manually |
| `DialogSceneStrategy` | Renders `entry<T>(metadata = dialog())` entries as overlay Dialogs | ✅ Enabled in shared host wrapper |
| `SceneDecoratorStrategy<T>` | Wraps/decorates scenes with additional UI | ❌ Not used |
| `NavEntry.metadata` | Attaches typed metadata to entries (transitions, dialog hints, Scene classification) | ❌ Not used |
| `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` | Per-entry custom transitions via metadata | ❌ Not used |
| `transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` params | Default transition animations for NavDisplay | ❌ Not used — no transitions at all |
| `transitionSpec` / `popTransitionSpec` / `predictivePopTransitionSpec` params | Default transition animations for NavDisplay | ⚠️ Partially used — shared forward/pop crossfade adopted; predictive-pop custom spec not yet used |
| `sharedTransitionScope: SharedTransitionScope?` | Shared element transitions between scenes | ❌ Not used |
| `entryDecorators: List<NavEntryDecorator<T>>` | Wraps entry content with additional behavior | ❌ Not used (defaulting to `SaveableStateHolderNavEntryDecorator`) |
| `entryDecorators: List<NavEntryDecorator<T>>` | Wraps entry content with additional behavior | ✅ Used via `MeshtasticNavDisplay` (`SaveableStateHolder` + `ViewModelStore`) |
**APIs we ARE using correctly:**
| API | Usage |
|---|---|
| `NavDisplay(backStack, entryProvider, modifier)` | Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` |
| `MeshtasticNavDisplay(...)` wrapper around `NavDisplay` | Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` |
| `rememberNavBackStack(SavedStateConfiguration, startKey)` | Backstack persistence |
| `entryProvider<NavKey> { entry<T> { ... } }` | All feature graph registrations |
| `NavigationBackHandler` from `navigationevent-compose` | Used in `AdaptiveListDetailScaffold` |
### 2. ViewModel Scoping (`lifecycle-viewmodel-navigation3` `2.11.0-alpha02`)
**Key finding:** The `ViewModelStoreNavEntryDecorator` is available and provides automatic per-entry ViewModel scoping tied to backstack lifetime. The project declares this dependency in `desktop/build.gradle.kts` but does **not** pass it as an `entryDecorator` to `NavDisplay`.
Currently, `koinViewModel()` calls inside `entry<T>` blocks use the nearest `ViewModelStoreOwner` from the composition — which is the Activity/Window level. This means:
- ViewModels are **not** automatically cleared when their entry is popped from the backstack.
- The project works around this with manual `key = "metrics-$destNum"` parameter keying.
**Opportunity:** Adding `rememberViewModelStoreNavEntryDecorator()` to `NavDisplay.entryDecorators` would give each backstack entry its own `ViewModelStoreOwner`, so `koinViewModel()` calls would be automatically scoped to the entry's lifetime.
**Current status:** Adopted. `MeshtasticNavDisplay` applies `rememberViewModelStoreNavEntryDecorator()` with `rememberSaveableStateHolderNavEntryDecorator()`, so `koinViewModel()` instances are entry-scoped and clear on pop.
### 3. Material 3 Adaptive — Nav3 Scene Integration
**Key finding:** The JetBrains `adaptive-navigation` artifact at `1.3.0-alpha06` does **NOT** include `MaterialListDetailSceneStrategy`. That API only exists in the Google AndroidX version (`androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha09+`).
This means the project **cannot** currently use the official M3 Adaptive Scene bridge through `NavDisplay(sceneStrategies = ...)`. The current approach of hosting `ListDetailPaneScaffold` inside `entry<T>` blocks (via `AdaptiveListDetailScaffold`) is the correct pattern for the JetBrains fork at this version.
**When to revisit:** Monitor the JetBrains adaptive fork for `MaterialListDetailSceneStrategy` inclusion. It will likely arrive when the JetBrains fork catches up to the AndroidX `1.3.0-alpha09+` feature set.
**Current status:** Adopted for shared host-level strategies. `MeshtasticNavDisplay` uses adaptive Navigation 3 scene strategies (`rememberListDetailSceneStrategy`, `rememberSupportingPaneSceneStrategy`) with draggable pane expansion handles, while feature-level scaffold composition remains valid for route-specific layouts.
### 4. NavigationSuiteScaffold (`1.11.0-alpha05`)
@ -99,7 +89,7 @@ This means the project **cannot** currently use the official M3 Adaptive Scene b
**Status:** ✅ Adopted (2026-03-26). A new `MeshtasticNavDisplay` composable in `core:ui/commonMain` encapsulates the standard `NavDisplay` configuration:
- Entry decorators: `rememberSaveableStateHolderNavEntryDecorator` + `rememberViewModelStoreNavEntryDecorator`
- Scene strategies: `DialogSceneStrategy` + `SinglePaneSceneStrategy`
- Scene strategies: `DialogSceneStrategy` + adaptive list-detail/supporting pane strategies + `SinglePaneSceneStrategy`
- Transition specs: 350 ms crossfade (forward + pop)
Both `app/Main.kt` and `desktop/DesktopMainScreen.kt` now call `MeshtasticNavDisplay` instead of configuring `NavDisplay` directly. The `lifecycle-viewmodel-navigation3` dependency was moved from host modules to `core:ui`.
@ -112,7 +102,7 @@ Individual entries can declare custom transitions via `entry<T>(metadata = NavDi
### Deferred: Scene-based multi-pane layout
The `MaterialListDetailSceneStrategy` is not available in the JetBrains adaptive fork at `1.3.0-alpha06`. The project's `AdaptiveListDetailScaffold` wrapper is the correct approach for now. Revisit when the JetBrains fork includes the Scene bridge, or consider writing a custom `SceneStrategy` that integrates with the existing `ListDetailPaneScaffold`.
Additional route-level Scene metadata adoption is deferred. The project now applies shared adaptive scene strategies in `MeshtasticNavDisplay`, and feature-level `AdaptiveListDetailScaffold` remains valid for route-specific layouts. Revisit custom per-route `SceneStrategy` policies when multi-pane route classification needs expand.
## Decision

View file

@ -48,15 +48,15 @@ Source reviewed: Navigation 3 `1.1.0-beta01` (JetBrains fork), CMP `1.11.0-beta0
- New `sharedTransitionScope: SharedTransitionScope?` parameter for shared element transitions.
- Existing shell patterns in `app` and `desktop` remain valid using the default `SinglePaneSceneStrategy`.
2. **Entry-scoped ViewModel lifecycle adopted.**
- Both `app` and `desktop` now pass `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator` as explicit `entryDecorators` to `NavDisplay`.
- Both `app` and `desktop` now use `MeshtasticNavDisplay` (`core:ui/commonMain`), which applies `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator` per active backstack.
- ViewModels obtained via `koinViewModel()` inside `entry<T>` blocks are now scoped to the entry's backstack lifetime.
3. **No direct Navigation 3 API breakage.**
- Release is beta (API stabilized). No migration from alpha04 was required for existing usage patterns.
4. **Primary risk is dependency wiring drift, not runtime behavior.**
- JetBrains Navigation 3 currently publishes `navigation3-ui` coordinates (no separate `navigation3-runtime` artifact in Maven Central). The `jetbrains-navigation3-runtime` alias intentionally points to `navigation3-ui` and is documented in the version catalog.
- Note: The `remember*` composable factory functions from `navigation3-runtime` are not visible in non-KMP Android modules due to Kotlin metadata resolution. Use direct class constructors instead (as done in `app/Main.kt`).
5. **Saved-state and typed-route parity risk remains unchanged.**
- Desktop still uses manual serializer registration; this is an existing risk and not introduced by beta01.
5. **Saved-state and typed-route parity improved.**
- Both hosts share `MeshtasticNavSavedStateConfig` from `core:navigation/commonMain` via `MultiBackstack`, reducing platform drift risk in serializer registration.
6. **Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`).**
### Actions Taken
@ -66,7 +66,7 @@ Source reviewed: Navigation 3 `1.1.0-beta01` (JetBrains fork), CMP `1.11.0-beta0
- `jetbrains-navigation3-runtime`, `jetbrains-navigation3-ui`
- Documented in the version catalog that `jetbrains-navigation3-runtime` intentionally maps to `navigation3-ui` until a separate runtime artifact is published.
- Migrated `core:data` `commonMain` from `androidx.lifecycle:lifecycle-runtime` (Google) to `org.jetbrains.androidx.lifecycle:lifecycle-runtime` (JetBrains fork) for full consistency.
- Updated active docs to reflect the current dependency baseline (`1.11.0-alpha04`, `1.1.0-alpha04`, `1.3.0-alpha06`, `2.10.0-beta01`).
- Updated active docs to reflect the current dependency baseline (`1.11.0-beta01`, `1.1.0-beta01`, `1.3.0-alpha06`, `2.11.0-alpha02`).
- Consolidated `app` adaptive dependencies to JetBrains Material 3 Adaptive coordinates (`org.jetbrains.compose.material3.adaptive:*`) so Android and Desktop consume the same adaptive artifact family. The Android-only navigation suite remains on `androidx.compose.material3:material3-adaptive-navigation-suite`.
### Deferred Follow-ups

View file

@ -107,7 +107,7 @@ Based on the latest codebase investigation, the following steps are proposed to
| Navigation 3 parity model (shared `TopLevelDestination` + platform adapters) | ✅ Done | Both shells use shared enum + parity tests. See [`decisions/navigation3-parity-2026-03.md`](./decisions/navigation3-parity-2026-03.md) |
| Hilt → Koin | ✅ Done | See [`decisions/koin-migration.md`](./decisions/koin-migration.md) |
| BLE abstraction (Kable) | ✅ Done | See [`decisions/ble-strategy.md`](./decisions/ble-strategy.md) |
| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-alpha04`; supports Large (1200dp) and Extra-large (1600dp) breakpoints |
| Material 3 Adaptive (JetBrains) | ✅ Done | Version `1.3.0-alpha06` aligned with CMP `1.11.0-beta01`; supports Large (1200dp) and Extra-large (1600dp) breakpoints |
| JetBrains lifecycle/nav3 alias alignment | ✅ Done | All forked deps use `jetbrains-*` prefix in version catalog; `core:data` commonMain uses JetBrains lifecycle runtime |
| Expect/actual consolidation | ✅ Done | 7 pairs eliminated; 15+ genuinely platform-specific retained |
| Transport deduplication | ✅ Done | `StreamFrameCodec`, `TcpTransport`, and `SerialTransport` shared in `core:network` |
@ -131,7 +131,7 @@ Based on the latest codebase investigation, the following steps are proposed to
All major ViewModels have now been extracted to `commonMain` and no longer rely on Android-specific subclasses. Platform-specific dependencies (like `android.net.Uri` or Location permissions) have been successfully isolated behind injected `core:repository` interfaces (e.g., `FileService`, `LocationService`).
**The extraction of all feature-specific navigation graphs, background services, and widgets out of `:app` is complete.** The `:app` module now only serves as the root DI assembler and NavHost container.
**The extraction of all feature-specific navigation graphs, background services, and widgets out of `:app` is complete.** The `:app` module now only serves as the root DI assembler and shared Navigation 3 host shell (`MeshtasticNavDisplay`) container.
Extracted to shared `commonMain` (no longer app-only):
- `SettingsViewModel``feature:settings/commonMain`

View file

@ -50,9 +50,11 @@ kotlin {
androidMain.dependencies { implementation(libs.usb.serial.android) }
androidUnitTest.dependencies {
implementation(libs.androidx.test.core)
implementation(libs.robolectric)
val androidHostTest by getting {
dependencies {
implementation(libs.androidx.test.core)
implementation(libs.robolectric)
}
}
}
}

View file

@ -39,13 +39,15 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.androidx.test.core)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
val androidHostTest by getting {
dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.androidx.test.core)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
}
}
}
}

View file

@ -46,12 +46,14 @@ kotlin {
androidMain.dependencies { implementation(libs.material) }
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.test.core)
val androidHostTest by getting {
dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
implementation(project.dependencies.platform(libs.androidx.compose.bom))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.test.core)
}
}
}
}

View file

@ -56,10 +56,12 @@ kotlin {
androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) }
androidUnitTest.dependencies {
implementation(libs.androidx.work.testing)
implementation(libs.androidx.test.core)
implementation(libs.robolectric)
val androidHostTest by getting {
dependencies {
implementation(libs.androidx.work.testing)
implementation(libs.androidx.test.core)
implementation(libs.robolectric)
}
}
}
}

View file

@ -50,7 +50,9 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.device_metrics_label_value
import org.meshtastic.core.resources.message_delivery_status
import org.meshtastic.core.resources.more_reactions
import org.meshtastic.core.resources.reply
import org.meshtastic.core.resources.select
@ -78,7 +80,9 @@ fun MessageActionsContent(
val statusText = statusString?.second?.let { stringResource(it) }
ListItem(
headlineContent = { Text("$title : $statusText") },
headlineContent = {
Text(stringResource(Res.string.device_metrics_label_value, title, statusText.orEmpty()))
},
leadingContent = { MessageStatusIcon(status = status) },
modifier = Modifier.clickable(onClick = onStatus),
)
@ -140,7 +144,7 @@ private fun QuickEmojiRow(quickEmojis: List<String>, onReact: (String) -> Unit,
) {
Icon(
Icons.Rounded.AddReaction,
contentDescription = "More reactions",
contentDescription = stringResource(Res.string.more_reactions),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)

View file

@ -62,6 +62,7 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.a11y_message_from
import org.meshtastic.core.resources.filter_message_label
import org.meshtastic.core.resources.reply
import org.meshtastic.core.ui.component.AutoLinkText
@ -209,6 +210,8 @@ fun MessageItem(
Modifier
},
)
val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name
val messageA11yText = stringResource(Res.string.a11y_message_from, senderName, message.text)
if (showUserName && !message.fromLocal) {
Row(
modifier = Modifier.padding(horizontal = 8.dp),
@ -242,10 +245,7 @@ fun MessageItem(
onDoubleClick = onDoubleClick,
)
.then(messageModifier)
.semantics(mergeDescendants = true) {
val senderName = if (message.fromLocal) ourNode.user.long_name else node.user.long_name
contentDescription = "Message from $senderName: ${message.text}"
},
.semantics(mergeDescendants = true) { contentDescription = messageA11yText },
color = containerColor,
contentColor = contentColorFor(containerColor),
shape = messageShape,

View file

@ -68,13 +68,15 @@ kotlin {
implementation(libs.markdown.renderer.android)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
implementation(libs.turbine)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.test.ext.junit)
val androidHostTest by getting {
dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
implementation(libs.turbine)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.test.ext.junit)
}
}
}
}

View file

@ -22,6 +22,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_label
@Composable
fun ChannelInfo(
@ -32,7 +35,7 @@ fun ChannelInfo(
IconInfo(
modifier = modifier,
icon = Icons.Rounded.Tsunami,
contentDescription = "Channel",
contentDescription = stringResource(Res.string.channel_label),
text = channel.toString(),
contentColor = contentColor,
)

View file

@ -47,6 +47,7 @@ import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.a11y_label_value
import org.meshtastic.core.resources.copy
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.core.ui.util.thenIf
@ -65,6 +66,7 @@ fun InfoCard(
val coroutineScope = rememberCoroutineScope()
val shape = MaterialTheme.shapes.medium
val copyLabel = stringResource(Res.string.copy)
val contentDescriptionText = stringResource(Res.string.a11y_label_value, text, value)
Card(
modifier =
@ -77,7 +79,7 @@ fun InfoCard(
onClick = {},
role = Role.Button,
)
.semantics(mergeDescendants = true) { contentDescription = "$text: $value" },
.semantics(mergeDescendants = true) { contentDescription = contentDescriptionText },
shape = shape,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
) {

View file

@ -52,6 +52,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.a11y_label_value
import org.meshtastic.core.resources.copy
import org.meshtastic.core.ui.util.createClipEntry
@ -94,6 +95,7 @@ internal fun InfoItem(
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val copyLabel = stringResource(Res.string.copy)
val contentDescriptionText = stringResource(Res.string.a11y_label_value, label, value)
Column(
modifier =
@ -109,7 +111,7 @@ internal fun InfoItem(
.padding(horizontal = 20.dp, vertical = 8.dp)
.semantics(mergeDescendants = true) {
// Screen readers read as a unified data unit
contentDescription = "$label: $value"
contentDescription = contentDescriptionText
},
) {
Row(verticalAlignment = Alignment.CenterVertically) {

View file

@ -57,6 +57,7 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.a11y_label_value
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.details
import org.meshtastic.core.resources.encryption_error
@ -323,6 +324,7 @@ private fun PublicKeyItem(publicKeyBytes: ByteArray) {
}
val label = stringResource(Res.string.public_key)
val copyLabel = stringResource(Res.string.copy)
val contentDescriptionText = stringResource(Res.string.a11y_label_value, label, publicKeyBase64)
Column(
modifier =
@ -339,7 +341,7 @@ private fun PublicKeyItem(publicKeyBytes: ByteArray) {
role = Role.Button,
)
.padding(horizontal = 20.dp, vertical = 8.dp)
.semantics(mergeDescendants = true) { contentDescription = "$label: $publicKeyBase64" },
.semantics(mergeDescendants = true) { contentDescription = contentDescriptionText },
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(

View file

@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.Color
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.env_metrics_log
import org.meshtastic.core.resources.hardware_model
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.node_id
@ -167,7 +168,7 @@ fun HardwareInfo(
IconInfo(
modifier = modifier,
icon = Icons.Rounded.Router,
contentDescription = "Hardware Model",
contentDescription = stringResource(Res.string.hardware_model),
text = hwModel,
style = MaterialTheme.typography.labelSmall,
contentColor = contentColor,

View file

@ -42,6 +42,7 @@ import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.speed_kmh
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.proto.Config
@ -92,7 +93,7 @@ fun PositionItem(compactWidth: Boolean, position: Position, system: Config.Displ
PositionText(position.sats_in_view.toString(), WEIGHT_10)
PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15)
if (!compactWidth) {
PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15)
PositionText(stringResource(Res.string.speed_kmh, position.ground_speed ?: 0), WEIGHT_15)
PositionText(formatString("%.0f°", (position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
}
PositionText(position.formatPositionTime(), WEIGHT_40)

View file

@ -56,15 +56,6 @@ kotlin {
implementation(libs.androidx.appcompat)
}
androidUnitTest.dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
implementation(libs.turbine)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.androidx.compose.ui.test.junit4)
implementation(libs.androidx.test.ext.junit)
}
commonTest.dependencies {
implementation(project(":core:testing"))
implementation(project(":core:datastore"))

View file

@ -104,7 +104,6 @@ import org.meshtastic.core.resources.role_tracker_desc
import org.meshtastic.core.resources.router_role_confirmation_text
import org.meshtastic.core.resources.time_zone
import org.meshtastic.core.resources.triple_click_adhoc_ping
import org.meshtastic.core.resources.unrecognized
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.InsetDivider
@ -137,7 +136,6 @@ private val Config.DeviceConfig.Role.description: StringResource
Config.DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc
Config.DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc
Config.DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc
else -> Res.string.unrecognized
}
private val Config.DeviceConfig.RebroadcastMode.description: StringResource
@ -150,8 +148,6 @@ private val Config.DeviceConfig.RebroadcastMode.description: StringResource
Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc
Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY ->
Res.string.rebroadcast_mode_core_portnums_only_desc
else -> Res.string.unrecognized
}
@Suppress("DEPRECATION", "LongMethod")