diff --git a/.copilotignore b/.copilotignore
new file mode 100644
index 000000000..02ec3ad1d
--- /dev/null
+++ b/.copilotignore
@@ -0,0 +1,27 @@
+# Ignore build artifacts and generated files from Copilot indexing
+# This saves context window tokens and prevents Copilot from hallucinating off of minified code.
+
+# Build directories
+**/build/**
+.gradle/
+.idea/
+
+# Android generated files
+**/generated/**
+.cxx/
+.externalNativeBuild/
+
+# Git history & worktrees
+.git/
+.worktrees/
+
+# Protobuf (Prevents Copilot from suggesting raw protobuf byte buffers)
+core/proto/
+
+# Environment and secrets
+local.properties
+secrets.properties
+*.jks
+
+# Agent References (Prevents pollution of project space with external code)
+.agent_refs/
diff --git a/.gemini/settings.json b/.gemini/settings.json
new file mode 100644
index 000000000..5e535b215
--- /dev/null
+++ b/.gemini/settings.json
@@ -0,0 +1,5 @@
+{
+ "context": {
+ "fileName": ["AGENTS.md", "GEMINI.md"]
+ }
+}
diff --git a/.github/actions/gradle-setup/action.yml b/.github/actions/gradle-setup/action.yml
new file mode 100644
index 000000000..a42959190
--- /dev/null
+++ b/.github/actions/gradle-setup/action.yml
@@ -0,0 +1,40 @@
+name: Gradle Setup
+description: Setup Java and Gradle for KMP builds
+inputs:
+ cache_read_only:
+ description: 'Whether Gradle cache is read-only'
+ default: 'true'
+ jdk_distribution:
+ description: 'JDK distribution (temurin or jetbrains)'
+ default: 'temurin'
+ gradle_encryption_key:
+ description: 'Encryption key for Gradle remote cache'
+ required: false
+runs:
+ using: composite
+ steps:
+ - name: Copy CI Gradle properties
+ shell: bash
+ run: mkdir -p ~/.gradle && cp .github/ci-gradle.properties ~/.gradle/gradle.properties
+
+ - name: Validate Gradle Wrapper
+ uses: gradle/actions/wrapper-validation@v6
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v5
+ with:
+ java-version: '21'
+ distribution: ${{ inputs.jdk_distribution }}
+ token: ${{ github.token }}
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v6
+ with:
+ cache-read-only: ${{ inputs.cache_read_only }}
+ cache-encryption-key: ${{ inputs.gradle_encryption_key }}
+ cache-cleanup: on-success
+ add-job-summary: always
+ gradle-home-cache-includes: |
+ caches
+ notifications
+ ~/.m2/repository/org/robolectric
\ No newline at end of file
diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties
new file mode 100644
index 000000000..e4d203ef7
--- /dev/null
+++ b/.github/ci-gradle.properties
@@ -0,0 +1,52 @@
+#
+# CI-specific Gradle properties.
+#
+# This file is copied to ~/.gradle/gradle.properties by the gradle-setup
+# composite action, overriding the dev-oriented values in the repo-root
+# gradle.properties. Inspired by the nowinandroid & sqldelight patterns.
+#
+
+# ── Daemon ────────────────────────────────────────────────────────────
+# Single-use CI runners never reuse a daemon, so the startup cost is pure
+# overhead. Disabling it also avoids "daemon disappeared" warnings.
+org.gradle.daemon=false
+
+# ── Memory ────────────────────────────────────────────────────────────
+# Standard GitHub runners have 7 GB RAM. Keep Gradle + Kotlin daemon
+# within budget (4g Gradle + 2g Kotlin daemon + 1g OS/tooling headroom).
+org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
+kotlin.daemon.jvm.options=-Xmx2g -XX:+UseParallelGC
+
+# ── Parallelism ───────────────────────────────────────────────────────
+org.gradle.parallel=true
+org.gradle.workers.max=4
+
+# ── Caching & Configuration ──────────────────────────────────────────
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+org.gradle.configureondemand=false
+org.gradle.vfs.watch=false
+org.gradle.isolated-projects=true
+
+# ── Kotlin ────────────────────────────────────────────────────────────
+# Incremental compilation is wasted on fresh CI checkouts (no prior build
+# state to diff against). Disabling avoids the overhead of maintaining
+# incremental state that will never be reused.
+kotlin.incremental=false
+kotlin.code.style=official
+kotlin.parallel.tasks.in.project=true
+
+# ── KSP ──────────────────────────────────────────────────────────────
+# In CI, KSP incremental processing adds overhead without benefit (fresh
+# checkouts). Keep intermodule incremental off (no prior state).
+ksp.incremental=false
+ksp.run.in.process=true
+
+# ── Android ──────────────────────────────────────────────────────────
+android.experimental.lint.analysisPerComponent=true
+# Disable unused build features to reduce build time
+android.defaults.buildfeatures.resvalues=false
+android.defaults.buildfeatures.shaders=false
+
+# ── Misc ─────────────────────────────────────────────────────────────
+org.gradle.welcome=never
diff --git a/.github/copilot-commit-message-instructions.md b/.github/copilot-commit-message-instructions.md
new file mode 100644
index 000000000..93c242d16
--- /dev/null
+++ b/.github/copilot-commit-message-instructions.md
@@ -0,0 +1,27 @@
+# GitHub Copilot Commit Message Instructions
+
+
+You are an expert Git maintainer enforcing Conventional Commits.
+
+
+
+1. **Format:** Use the Conventional Commits format: `(): ` (Replace angle brackets with actual text, do NOT output angle brackets).
+2. **Types allowed:**
+ - `feat` (new feature for the user, not a new feature for build script)
+ - `fix` (bug fix for the user, not a fix to a build script)
+ - `docs` (changes to the documentation)
+ - `style` (formatting, missing semi colons, etc; no production code change)
+ - `refactor` (refactoring production code, e.g. KMP migration, extracting to commonMain)
+ - `test` (adding missing tests, refactoring tests; no production code change)
+ - `chore` (updating grunt tasks etc; no production code change)
+3. **Scope:** Use the module or logical component as the scope (e.g., `ui`, `navigation`, `ble`, `firmware`, `deps`, `ai`).
+4. **Subject line:**
+ - Use the imperative, present tense: "change" not "changed" nor "changes".
+ - Do not capitalize the first letter.
+ - Do not use a period (.) at the end.
+ - Keep it under 50 characters if possible.
+5. **Body (Optional but recommended for large diffs):**
+ - Leave one blank line after the subject.
+ - Explain *why* the change was made, not just *what* changed.
+ - If migrating to KMP or extracting to `commonMain`, explicitly state "Decoupled from Android framework".
+
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index aae64c1a2..e856cbe8f 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,145 +1,6 @@
-# Meshtastic Android - Agent Guide
+# Meshtastic Android - GitHub Copilot Guide
-This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project.
+> **Note:** The canonical instructions for all AI Agents have been deduplicated.
-For execution-focused recipes, see `docs/agent-playbooks/README.md`.
-
-## 1. Project Vision & Architecture
-Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience.
-
-- **Language:** Kotlin (primary), AIDL.
-- **Build System:** Gradle (Kotlin DSL). JDK 17 is REQUIRED.
-- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
-- **Flavors:**
- - `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()`, `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`.
- - **Navigation:** JetBrains Navigation 3 (Multiplatform fork) with shared backstack state.
- - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- - **Database:** Room KMP.
-
-## 2. Codebase Map
-
-| 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`). |
-| `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. |
-| `core:proto` | Protobuf definitions (Git submodule). |
-| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
-| `core:database` | Room KMP database implementation. |
-| `core:datastore` | Multiplatform DataStore for preferences. |
-| `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:di` | Common DI qualifiers and dispatchers. |
-| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
-| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
-| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
-| `core:api` | Public AIDL/API integration module for external clients. |
-| `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 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()` 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)`). 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.
-- **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.
-- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
-
-### B. Logic & Data Layer
-- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module.
-- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives:
- - `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` or `expect`/`actual`.
- - `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- - `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`.
-- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
-- **Concurrency:** Use Kotlin Coroutines and Flow.
-- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
-- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
-- **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`.
-- **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
-- **Standard:** Use the `org.meshtastic.*` namespace for all code.
-- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
-
-## 4. Execution Protocol
-
-### A. Environment Setup
-1. **JDK 17 MUST be used** to prevent Gradle sync/build failures.
-2. **Secrets:** You must copy `secrets.defaults.properties` to `local.properties`:
- ```properties
- MAPS_API_KEY=dummy_key
- datadogApplicationId=dummy_id
- datadogClientToken=dummy_token
- ```
-
-### B. Strict Execution Commands
-Always run commands in the following order to ensure reliability. Do not attempt to bypass `clean` if you are facing build issues.
-
-**Baseline (recommended order):**
-```bash
-./gradlew clean
-./gradlew spotlessCheck
-./gradlew spotlessApply
-./gradlew detekt
-./gradlew assembleDebug
-./gradlew test
-```
-
-**Testing:**
-```bash
-./gradlew test # Run local unit tests
-./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest # CI-aligned Android unit tests (flavor-explicit)
-./gradlew connectedAndroidTest # Run instrumented tests
-./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests
-./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks
-```
-*Note: If testing Compose UI on the JVM (Robolectric) with Java 17, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
-
-**CI workflow conventions (GitHub Actions):**
-- Reusable CI is split into a host job and an Android matrix job in `.github/workflows/reusable-check.yml`.
-- Host job runs style/static checks, explicit Android lint tasks, unit tests, and Kover XML coverage uploads once.
-- Android matrix job runs explicit assemble tasks for `app` and `mesh_service_example`; instrumentation is enabled by input and matrix API.
-- Prefer explicit Gradle task paths in CI (for example `app:lintFdroidDebug`, `app:connectedGoogleDebugAndroidTest`) instead of shorthand tasks like `lintDebug`.
-- Pull request CI is main-only (`.github/workflows/pull-request.yml` targets `main` branch).
-- Gradle cache writes are trusted on `main` and merge queue runs (`merge_group` / `gh-readonly-queue/*`); other refs use read-only cache mode in reusable CI.
-- 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.
-
-### C. Documentation Sync
-Update documentation continuously as part of the same change. If you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update the relevant docs (`AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md`).
-
-## 5. Troubleshooting
-- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
-- **Missing Secrets:** Check `local.properties`.
-- **JDK Version:** JDK 17 is required.
-- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist.
-- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`).
\ No newline at end of file
+You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`.
+After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.
diff --git a/.github/copilot-pull-request-instructions.md b/.github/copilot-pull-request-instructions.md
new file mode 100644
index 000000000..8e79d63d2
--- /dev/null
+++ b/.github/copilot-pull-request-instructions.md
@@ -0,0 +1,18 @@
+# GitHub Copilot Pull Request Instructions
+
+
+You are an expert open-source maintainer. Your goal is to write clear, professional, and highly structured Pull Request descriptions based on the provided diffs.
+
+
+
+1. **Remove Boilerplate:** Always delete the "tips" section at the top of the `PULL_REQUEST_TEMPLATE.md` before generating your text.
+2. **Context First:** Start with a clear, 1-2 sentence summary of *why* this change is being made. If the branch name or commits reference an issue (e.g., `fix-1234`), explicitly add `Fixes #1234` or `Resolves #1234`.
+3. **Structured Changes:** Break down the code changes into bullet points categorized by:
+ - 🌟 **New Features** (UI, modules, logic)
+ - 🛠️ **Refactoring & Architecture** (KMP migrations, Koin DI updates)
+ - 🐛 **Bug Fixes**
+ - 🧹 **Chores** (Dependencies, formatting, docs)
+4. **Architecture Callouts:** If the diff includes moving files from `androidMain` to `commonMain`, or migrating from Android Views to Compose, highlight this as a "KMP Migration Milestone".
+5. **Testing Callouts:** If the diff includes changes to `commonTest` or mentions tests, add a section called "Testing Performed" and list the tests that were added/modified.
+6. **No "Magic" Text:** Do not invent URLs or insert fake image placeholders. Leave the HTML comment block for images intact so the user can manually add their screenshots.
+
diff --git a/.github/instructions/android-source-set.instructions.md b/.github/instructions/android-source-set.instructions.md
new file mode 100644
index 000000000..6179bc61a
--- /dev/null
+++ b/.github/instructions/android-source-set.instructions.md
@@ -0,0 +1,11 @@
+---
+applyTo: "**/androidMain/**/*.kt"
+---
+
+# Android Source-Set Rules
+
+- This is `androidMain` — Android framework imports (`android.*`, `java.*`) are allowed here.
+- Do NOT put business logic here. Business logic belongs in `commonMain`.
+- If you find identical pure-Kotlin logic in both `androidMain` and `jvmMain`, extract it to `commonMain`.
+- Use `expect`/`actual` only for small platform primitives. Prefer interfaces + DI.
+- Keep `expect` declarations in `FileIo.kt` and shared helpers in `FileIoUtils.kt` to avoid JVM duplicate class errors.
diff --git a/.github/instructions/build-logic.instructions.md b/.github/instructions/build-logic.instructions.md
new file mode 100644
index 000000000..d61fa34b8
--- /dev/null
+++ b/.github/instructions/build-logic.instructions.md
@@ -0,0 +1,10 @@
+---
+applyTo: "build-logic/**/*.kt"
+---
+
+# Build-Logic Convention Plugin Rules
+
+- Prefer lazy Gradle configuration (`configureEach`, `withPlugin`, provider APIs).
+- Avoid `afterEvaluate` unless there is no viable lazy alternative.
+- Check `gradle/libs.versions.toml` for version catalog aliases before adding new ones.
+- Convention plugins: `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`.
diff --git a/.github/instructions/ci-workflows.instructions.md b/.github/instructions/ci-workflows.instructions.md
new file mode 100644
index 000000000..55a72b328
--- /dev/null
+++ b/.github/instructions/ci-workflows.instructions.md
@@ -0,0 +1,14 @@
+---
+applyTo: "**/*.yml"
+excludeAgent: "code-review"
+---
+
+# CI Workflow Rules
+
+- Prefer explicit Gradle task paths (`app:lintFdroidDebug`) over shorthand (`lintDebug`).
+- CI uses `.github/ci-gradle.properties` — don't assume local `gradle.properties` values.
+- CI passes `-Pci=true` to enable full processor usage via `maxParallelForks`.
+- Use `fetch-depth: 0` only where needed (spotless ratcheting, version code). Use `fetch-depth: 1` otherwise.
+- Desktop build matrix: `macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`.
+- Lightweight jobs (labelers, triage, stale): use `ubuntu-24.04-arm` runners.
+- Gradle-heavy jobs: use `ubuntu-24.04` runners.
diff --git a/.github/instructions/kmp-common.instructions.md b/.github/instructions/kmp-common.instructions.md
new file mode 100644
index 000000000..7dac915bc
--- /dev/null
+++ b/.github/instructions/kmp-common.instructions.md
@@ -0,0 +1,20 @@
+---
+applyTo: "**/commonMain/**/*.kt"
+---
+
+# KMP commonMain Rules
+
+- NEVER import `java.*` or `android.*` in `commonMain`.
+- Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO`.
+- Use Okio (`BufferedSource`/`BufferedSink`) instead of `java.io.*`.
+- Use `kotlinx.coroutines.sync.Mutex` instead of `java.util.concurrent.locks.*`.
+- Use `atomicfu` or Mutex-guarded `mutableMapOf()` instead of `ConcurrentHashMap`.
+- Use `jetbrains-*` catalog aliases for lifecycle/navigation dependencies.
+- Use `compose-multiplatform-*` catalog aliases for CMP dependencies.
+- Never use plain `androidx.compose` dependencies in `commonMain`.
+- Strings: use `stringResource(Res.string.key)` from `core:resources`. No hardcoded strings.
+- CMP `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()`.
+- Use `MetricFormatter` from `core:common` for display strings (temperature, voltage, percent, signal). Avoid scattered `formatString("%.1f°C", val)` calls.
+- Check `gradle/libs.versions.toml` before adding dependencies.
+- Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. Keep `runCatching` only in cleanup/teardown code.
+- Use `kotlinx.coroutines.CancellationException`, not `kotlin.coroutines.cancellation.CancellationException`.
diff --git a/.github/labeler.yml b/.github/labeler.yml
deleted file mode 100644
index c3c2fa6cf..000000000
--- a/.github/labeler.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-# Auto Labeler rulse using https://github.com/actions/labeler
-#
-
-# 'fix' in title/branch -> bug
-# 'feat' in title/branch -> enhancement
-# 'repo' in title/branch OR changes to ~/.github/ -> repo
-# 'bug_fallthrough' for everything else except auto
-#
-# - [ ] need to look at title. waiting on https://github.com/actions/labeler/pull/866
-
-# Add 'enhancement' label to any PR where the head branch name contains `feat`
-enhancement:
- - head-branch: [feat, Feat, FEAT]
-
- # Add 'repo' label to any PR where the head branch name contains `repo`
- # or files in the .github dir
-repo:
-- any:
- - head-branch: [repo, Repo, REPO, ci, CI]
- - changed-files:
- - any-glob-to-any-file: .github
-
- # Add 'bug' label to any PR where the head branch name contains `fix` or `bug` as the prefix.
-bugfix:
- - head-branch: [^fix, ^bug, ^Fix, ^FIX, ^Bug, ^BUG]
-
-# Add `refactor` label to any PR where the head branch name contains `refactor` or `Refactor` as the prefix.
-refactor:
- - head-branch: [^refactor, ^Refactor]
-
-# our fallback - bug except repo, feat, or automated pipelines
-# bug_fallthrough:
-# - all:
-# - head-branch: ['^((?!feat).)*$', '^((?!repo).)*$', '^((?!renovate).)*$', '^((?!scheduled).)*$']
-
diff --git a/.github/lsp.json b/.github/lsp.json
new file mode 100644
index 000000000..983ecf785
--- /dev/null
+++ b/.github/lsp.json
@@ -0,0 +1,12 @@
+{
+ "lspServers": {
+ "kotlin": {
+ "command": "kotlin-language-server",
+ "args": [],
+ "fileExtensions": {
+ ".kt": "kotlin",
+ ".kts": "kotlin"
+ }
+ }
+ }
+}
diff --git a/.github/renovate.json b/.github/renovate.json
index c9993abac..1faa1a4ad 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -49,236 +49,31 @@
"automerge": true
},
{
+ "description": "Meshtastic Protobufs changelog link",
"matchPackageNames": [
"https://github.com/meshtastic/protobufs.git"
],
"changelogUrl": "https://github.com/meshtastic/protobufs/compare/{{currentDigest}}...{{newDigest}}",
- "groupName": "Meshtastic Protobufs",
- "groupSlug": "meshtastic-protobufs",
"automerge": true
},
{
- "description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)",
- "groupName": "AndroidX (General)",
- "groupSlug": "androidx-general",
+ "description": "Group CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)",
+ "groupName": "compose-multiplatform",
"matchPackageNames": [
- "/^androidx\\./",
- "!/^androidx\\.room/",
- "!/^androidx\\.lifecycle/",
- "!/^androidx\\.navigation/",
- "!/^androidx\\.datastore/",
- "!/^androidx\\.compose\\.material3\\.adaptive/",
- "!/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/",
- "!/^androidx\\.test\\.espresso/",
- "!/^androidx\\.test\\.ext/",
- "!/^androidx\\.compose\\.ui:ui-test-junit4$/",
- "!/^androidx\\.hilt/"
+ "/^org\\.jetbrains\\.compose/",
+ "androidx.compose.runtime:runtime-tracing",
+ "androidx.compose.ui:ui-test-manifest"
]
},
{
- "description": "Group Kotlin standard library, coroutines, and serialization",
- "groupName": "Kotlin Ecosystem",
- "groupSlug": "kotlin",
- "matchPackageNames": [
- "/^org\\.jetbrains\\.kotlin/",
- "/^org\\.jetbrains\\.kotlinx/"
- ]
- },
- {
- "description": "Group Dagger and Hilt dependencies",
- "groupName": "Dagger & Hilt",
- "groupSlug": "hilt",
- "matchPackageNames": [
- "/^com\\.google\\.dagger/",
- "/^androidx\\.hilt/"
- ]
- },
- {
- "description": "Group Accompanist libraries",
- "groupName": "Accompanist",
- "groupSlug": "accompanist",
- "matchPackageNames": [
- "/^com\\.google\\.accompanist/"
- ]
- },
- {
- "description": "Group JVM testing libraries (JUnit, Mockito, Robolectric)",
- "groupName": "JVM Testing Libraries",
- "groupSlug": "jvm-testing",
- "matchPackageNames": [
- "/^junit:junit$/",
- "/^org\\.mockito:/",
- "/^org\\.robolectric:robolectric$/"
- ],
- "automerge": true
- },
- {
- "description": "Group AndroidX Testing libraries",
- "groupName": "AndroidX Testing",
- "groupSlug": "androidx-testing",
- "matchPackageNames": [
- "/^androidx\\.test\\.espresso/",
- "/^androidx\\.test\\.ext/",
- "/^androidx\\.compose\\.ui:ui-test-junit4$/"
- ],
- "automerge": true
- },
- {
- "description": "Group Static Analysis tools (Detekt, Spotless)",
- "groupName": "Static Analysis",
- "groupSlug": "static-analysis",
- "matchPackageNames": [
- "/^io\\.gitlab\\.arturbosch\\.detekt/",
- "/^io\\.nlopez\\.compose\\.rules/",
- "/^com\\.diffplug\\.spotless/"
- ],
- "automerge": true
- },
- {
- "description": "Group Square networking libraries (OkHttp, Retrofit)",
- "groupName": "Square Networking",
- "groupSlug": "square-network",
- "matchPackageNames": [
- "/^com\\.squareup\\.okhttp3/",
- "/^com\\.squareup\\.retrofit2/"
- ],
- "automerge": true
- },
- {
- "description": "Group Coil image loading library",
- "groupName": "Coil",
- "groupSlug": "coil",
- "matchPackageNames": [
- "/^io\\.coil-kt\\.coil3/"
- ],
- "automerge": true
- },
- {
- "description": "Group ZXing barcode scanning libraries",
- "groupName": "ZXing",
- "groupSlug": "zxing",
- "matchPackageNames": [
- "/^com\\.journeyapps:zxing-android-embedded/",
- "/^com\\.google\\.zxing:core/"
- ],
- "automerge": true
- },
- {
- "description": "Group Eclipse Paho MQTT client libraries",
- "groupName": "MQTT Paho Client",
- "groupSlug": "mqtt-paho",
- "matchPackageNames": [
- "/^org\\.eclipse\\.paho/"
- ],
- "automerge": true
- },
- {
- "description": "Group Mike Penz Markdown renderer libraries",
- "groupName": "Markdown Renderer (Mike Penz)",
- "groupSlug": "markdown-renderer-mikepenz",
- "matchPackageNames": [
- "/^com\\.mikepenz/"
- ],
- "automerge": true
- },
- {
- "description": "Group Firebase libraries",
- "groupName": "Firebase",
- "groupSlug": "firebase",
- "matchPackageNames": [
- "/^com\\.google\\.firebase/"
- ],
- "automerge": true
- },
- {
- "description": "Group Datadog libraries",
- "groupName": "Datadog",
- "groupSlug": "datadog",
- "matchPackageNames": [
- "/^com\\.datadoghq/"
- ],
- "automerge": true
- },
- {
- "description": "Group OpenStreetMap (OSM) libraries",
- "groupName": "OSM Libraries",
- "groupSlug": "osm-libraries",
- "matchPackageNames": [
- "/^org\\.osmdroid/",
- "/^com\\.github\\.MKergall\\.osmbonuspack/",
- "/^mil\\.nga/"
- ],
- "automerge": true
- },
- {
- "description": "Group Google Maps Compose libraries",
- "groupName": "Google Maps Compose",
- "groupSlug": "google-maps-compose",
- "matchPackageNames": [
- "/^com\\.google\\.android\\.gms:play-services-location/",
- "/^com\\.google\\.maps\\.android/"
- ],
- "automerge": true
- },
- {
- "description": "Group Google Protobuf runtime libraries",
- "groupName": "Protobuf Runtime",
- "groupSlug": "protobuf-runtime",
- "matchPackageNames": [
- "/^com\\.google\\.protobuf/",
- "!https://github.com/meshtastic/protobufs.git"
- ]
- },
- {
- "description": "Group AndroidX Room libraries",
- "groupName": "AndroidX Room",
- "groupSlug": "androidx-room",
- "matchPackageNames": [
- "/^androidx\\.room/"
- ],
- "automerge": true
- },
- {
- "description": "Group AndroidX Lifecycle libraries",
- "groupName": "AndroidX Lifecycle",
- "groupSlug": "androidx-lifecycle",
- "matchPackageNames": [
- "/^androidx\\.lifecycle/"
- ]
- },
- {
- "description": "Group AndroidX Navigation libraries",
- "groupName": "AndroidX Navigation",
- "groupSlug": "androidx-navigation",
- "matchPackageNames": [
- "/^androidx\\.navigation/"
- ]
- },
- {
- "description": "Group AndroidX DataStore libraries",
- "groupName": "AndroidX DataStore",
- "groupSlug": "androidx-datastore",
- "matchPackageNames": [
- "/^androidx\\.datastore/"
- ]
- },
- {
- "description": "Group AndroidX Adaptive UI libraries",
- "groupName": "AndroidX Adaptive UI",
- "groupSlug": "androidx-adaptive-ui",
- "matchPackageNames": [
- "/^androidx\\.compose\\.material3\\.adaptive/",
- "/^androidx\\.compose\\.material3:material3-adaptive-navigation-suite$/"
- ]
- },
- {
- "description": "Restrict sensitive infrastructure to patch updates only (manual minor)",
+ "description": "Restrict sensitive infrastructure to manual minor updates",
"matchUpdateTypes": [
"minor"
],
"matchPackageNames": [
"/^org\\.jetbrains\\.kotlin/",
"/^org\\.jetbrains\\.kotlinx/",
+ "/^org\\.jetbrains\\.compose/",
"/^com\\.google\\.dagger/",
"/^androidx\\.hilt/",
"/^com\\.google\\.protobuf/",
@@ -298,4 +93,4 @@
"automerge": false
}
]
-}
\ No newline at end of file
+}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100644
index c7ad60add..000000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,107 +0,0 @@
-# For most projects, this workflow file will not need changing; you simply need
-# to commit it to your repository.
-#
-# You may wish to alter this file to override the set of languages analyzed,
-# or to provide custom queries or build logic.
-#
-# ******** NOTE ********
-# We have attempted to detect the languages in your repository. Please check
-# the `language` matrix defined below to confirm you have the correct set of
-# supported CodeQL languages.
-#
-name: "CodeQL Advanced"
-
-on:
- # push:
- # branches: [ "main" ]
- # pull_request:
- # branches: [ "main" ]
- schedule:
- - cron: '0 0 * * 0'
- workflow_dispatch:
-
-jobs:
- analyze:
- name: Analyze (${{ matrix.language }})
- # Runner size impacts CodeQL analysis time. To learn more, please see:
- # - https://gh.io/recommended-hardware-resources-for-running-codeql
- # - https://gh.io/supported-runners-and-hardware-resources
- # - https://gh.io/using-larger-runners (GitHub.com only)
- # Consider using larger runners or machines with greater resources for possible analysis time improvements.
- runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
- if: github.repository == 'meshtastic/Meshtastic-Android'
- permissions:
- # required for all workflows
- security-events: write
-
- # required to fetch internal or private CodeQL packs
- packages: read
-
- # only required for workflows in private repositories
- actions: read
- contents: read
-
- strategy:
- fail-fast: false
- matrix:
- include:
- - language: actions
- build-mode: none
- - language: java-kotlin
- build-mode: autobuild
- # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
- # Use `c-cpp` to analyze code written in C, C++ or both
- # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
- # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
- # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
- # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
- # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
- # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- # Add any setup steps before running the `github/codeql-action/init` action.
- # This includes steps like installing compilers or runtimes (`actions/setup-node`
- # or others). This is typically only required for manual builds.
- # - name: Setup runtime (example)
- # uses: actions/setup-example@v1
- - name: Java Setup
- uses: actions/setup-java@v5
- with:
- distribution: 'temurin' # See 'Supported distributions' for available options
- java-version: '17'
-
- # Initializes the CodeQL tools for scanning.
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v4
- with:
- languages: ${{ matrix.language }}
- build-mode: ${{ matrix.build-mode }}
- # If you wish to specify custom queries, you can do so here or in a config file.
- # By default, queries listed here will override any specified in a config file.
- # Prefix the list here with "+" to use these queries and those in the config file.
-
- # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
- # queries: security-extended,security-and-quality
-
- # If the analyze step fails for one of the languages you are analyzing with
- # "We were unable to automatically build your code", modify the matrix above
- # to set the build mode to "manual" for that language. Then modify this step
- # to build your code.
- # ℹ️ Command-line programs to run using the OS shell.
- # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- - if: matrix.build-mode == 'manual'
- shell: bash
- run: |
- echo 'If you are using a "manual" build mode for one or more of the' \
- 'languages you are analyzing, replace this with the commands to build' \
- 'your code, for example:'
- echo ' make bootstrap'
- echo ' make release'
- exit 1
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v4
- with:
- category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/create-or-promote-release.yml b/.github/workflows/create-or-promote-release.yml
index 053174c00..3c6ddd61a 100644
--- a/.github/workflows/create-or-promote-release.yml
+++ b/.github/workflows/create-or-promote-release.yml
@@ -20,6 +20,11 @@ on:
required: true
type: boolean
default: false
+ build_desktop:
+ description: 'Whether to build the desktop distribution'
+ required: true
+ type: boolean
+ default: false
permissions:
contents: write
@@ -29,7 +34,7 @@ permissions:
jobs:
determine-tags:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
outputs:
tag_to_process: ${{ steps.calculate_tags.outputs.tag_to_process }}
release_name: ${{ steps.calculate_tags.outputs.release_name }}
@@ -124,6 +129,7 @@ jobs:
tag_name: ${{ needs.determine-tags.outputs.final_tag }}
channel: ${{ inputs.channel }}
base_version: ${{ inputs.base_version }}
+ build_desktop: ${{ inputs.build_desktop }}
secrets: inherit
call-promote-workflow:
@@ -142,7 +148,7 @@ jobs:
cleanup-on-failure:
needs: [determine-tags, call-release-workflow]
if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml
index 3a633a090..10535d723 100644
--- a/.github/workflows/dependency-submission.yml
+++ b/.github/workflows/dependency-submission.yml
@@ -10,18 +10,19 @@ permissions:
jobs:
dependency-submission:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
if: github.repository == 'meshtastic/Meshtastic-Android'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
- distribution: jetbrains
- java-version: 17
+ distribution: temurin
+ java-version: 21
+ token: ${{ github.token }}
- name: Generate and submit dependency graph
- uses: gradle/actions/dependency-submission@v5
+ uses: gradle/actions/dependency-submission@v6
with:
build-scan-publish: true
build-scan-terms-of-use-url: "https://gradle.com/terms-of-service"
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index bf239c5de..f7c8151c7 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -6,6 +6,16 @@ on:
push:
branches:
- main
+ paths:
+ # Only rebuild docs when source code changes (Dokka generates from KDoc)
+ - 'app/src/**'
+ - 'core/**/src/**'
+ - 'feature/**/src/**'
+ - 'desktop/src/**'
+ - 'build-logic/**'
+ - 'build.gradle.kts'
+ - 'settings.gradle.kts'
+ - '.github/workflows/docs.yml'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -29,16 +39,16 @@ permissions:
pages: write
id-token: write
-# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
-# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
+# Allow only one concurrent deployment; cancel queued runs since only the latest
+# main state matters for documentation.
concurrency:
group: "pages"
- cancel-in-progress: false
+ cancel-in-progress: true
jobs:
build-docs:
if: github.repository == 'meshtastic/Meshtastic-Android'
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -47,20 +57,16 @@ jobs:
submodules: 'recursive'
ref: ${{ inputs.ref || '' }}
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build Dokka HTML documentation
run: ./gradlew dokkaGeneratePublicationHtml
- name: Upload artifact
- uses: actions/upload-pages-artifact@v4
+ uses: actions/upload-pages-artifact@v5
with:
path: build/dokka/html
@@ -69,9 +75,9 @@ jobs:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
needs: build-docs
steps:
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml
index d32d2de37..eaf3f54d3 100644
--- a/.github/workflows/main-check.yml
+++ b/.github/workflows/main-check.yml
@@ -7,6 +7,9 @@ on:
- '**/*.md'
- 'docs/**'
+permissions:
+ contents: read
+
concurrency:
group: main-${{ github.ref }}
cancel-in-progress: true
@@ -17,8 +20,7 @@ jobs:
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
- run_unit_tests: true
- run_instrumented_tests: true
- api_levels: '[35]' # One API level is enough for post-merge sanity check
+ run_unit_tests: false
+ run_desktop_builds: false
upload_artifacts: true
secrets: inherit
diff --git a/.github/workflows/main-push-changelog.yml b/.github/workflows/main-push-changelog.yml
index ff1513535..da161e44e 100644
--- a/.github/workflows/main-push-changelog.yml
+++ b/.github/workflows/main-push-changelog.yml
@@ -5,6 +5,10 @@ on:
branches:
- main
+permissions:
+ contents: write
+ pull-requests: read
+
concurrency:
group: main-push-${{ github.ref }}
cancel-in-progress: true
@@ -12,7 +16,7 @@ concurrency:
jobs:
main-push-changelog:
name: Generate main push changelog
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -35,6 +39,10 @@ jobs:
fromTag: ${{ steps.last_prod_tag.outputs.tag }}
toTag: ${{ github.sha }}
outputFile: main-push-changelog.md
+ fetchViaCommits: true
+ fetchReviewers: false
+ fetchReleaseInformation: false
+ fetchReviews: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml
index 7bc267819..44d31183d 100644
--- a/.github/workflows/merge-queue.yml
+++ b/.github/workflows/merge-queue.yml
@@ -4,6 +4,9 @@ on:
merge_group:
types: [checks_requested]
+permissions:
+ contents: read
+
concurrency:
group: build-mq-${{ github.ref }}
cancel-in-progress: true
@@ -15,14 +18,13 @@ jobs:
with:
run_lint: true
run_unit_tests: true
- run_instrumented_tests: true
- api_levels: '[26, 35]' # Comprehensive testing for Merge Queue
upload_artifacts: false
secrets: inherit
check-workflow-status:
name: Check Workflow Status
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
+ permissions: {}
needs:
- android-check
if: always()
diff --git a/.github/workflows/models_issue_triage.yml b/.github/workflows/models_issue_triage.yml
index f61a15fe6..a02fb8ed8 100644
--- a/.github/workflows/models_issue_triage.yml
+++ b/.github/workflows/models_issue_triage.yml
@@ -14,8 +14,8 @@ concurrency:
jobs:
triage:
- if: ${{ github.repository == 'meshtastic/firmware' && github.event.issue.user.type != 'Bot' }}
- runs-on: ubuntu-latest
+ if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.issue.user.type != 'Bot' }}
+ runs-on: ubuntu-24.04-arm
steps:
# ─────────────────────────────────────────────────────────────────────────
# Step 1: Quality check (spam/AI-slop detection) - runs first, exits early if spam
@@ -38,7 +38,7 @@ jobs:
- name: Apply quality label if needed
if: steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
env:
QUALITY_LABEL: ${{ steps.quality.outputs.response }}
with:
@@ -80,7 +80,7 @@ jobs:
# ─────────────────────────────────────────────────────────────────────────
- name: Determine if completeness check should be skipped
if: steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == ''
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
id: check-skip
with:
script: |
@@ -98,20 +98,20 @@ jobs:
continue-on-error: true
with:
prompt: |
- Analyze this GitHub issue for completeness and determine if it needs labels.
+ Analyze this GitHub issue for the Meshtastic Android app and determine if it needs labels.
- If this looks like a bug on the device/firmware (crash, reboot, lockup, radio issues, GPS issues, display issues, power/sleep issues), request device logs and explain how to get them:
+ If this looks like a bug in the Android app (crash, ANR, UI glitch, connection failure, Bluetooth issues, notification problems, map issues), request app logs and explain how to get them:
- Web Flasher logs:
- - Go to https://flasher.meshtastic.org
- - Connect the device via USB and click Connect
- - Open the device console/log output, reproduce the problem, then copy/download and attach/paste the logs
+ Android app debug logs:
+ - Open the Meshtastic app, go to Settings > Debug > Save Logs
+ - Reproduce the problem, then share/attach the exported log file
- Meshtastic CLI logs:
- - Run: meshtastic --port --noproto
- - Reproduce the problem, then copy/paste the terminal output
+ Android logcat (if app logs are insufficient):
+ - Connect phone via USB with USB debugging enabled
+ - Run: adb logcat -s Meshtastic:* *:E
+ - Reproduce the problem, then copy/paste the relevant output
- Also request key context if missing: device model/variant, firmware version, region, steps to reproduce, expected vs actual.
+ Also request key context if missing: Android version, phone model, app version, Meshtastic device model, firmware version, connection type (BLE/USB/TCP), steps to reproduce, expected vs actual.
Respond ONLY with JSON:
{
@@ -120,7 +120,7 @@ jobs:
"label": "needs-logs" | "needs-info" | "none"
}
- Use "needs-logs" if this is a device bug AND no logs are attached.
+ Use "needs-logs" if this is an app bug AND no logs are attached.
Use "needs-info" if basic info like firmware version or steps to reproduce are missing.
Use "none" if the issue is complete or is a feature request.
@@ -131,7 +131,7 @@ jobs:
- name: Process analysis result
if: (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '') && steps.check-skip.outputs.should_skip != 'true' && steps.analysis.outputs.response != ''
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
id: process
env:
AI_RESPONSE: ${{ steps.analysis.outputs.response }}
@@ -165,7 +165,7 @@ jobs:
- name: Apply triage label
if: steps.process.outputs.label != '' && steps.process.outputs.label != 'none'
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
env:
LABEL_NAME: ${{ steps.process.outputs.label }}
with:
@@ -191,7 +191,7 @@ jobs:
- name: Comment on issue
if: steps.process.outputs.should_comment == 'true'
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
env:
COMMENT_BODY: ${{ steps.process.outputs.comment_body }}
with:
diff --git a/.github/workflows/models_pr_triage.yml b/.github/workflows/models_pr_triage.yml
index ef303c02a..c2a1aaf25 100644
--- a/.github/workflows/models_pr_triage.yml
+++ b/.github/workflows/models_pr_triage.yml
@@ -15,19 +15,19 @@ concurrency:
jobs:
triage:
- if: ${{ github.repository == 'meshtastic/firmware' && github.event.pull_request.user.type != 'Bot' }}
- runs-on: ubuntu-latest
+ if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && github.event.pull_request.user.type != 'Bot' }}
+ runs-on: ubuntu-24.04-arm
steps:
# ─────────────────────────────────────────────────────────────────────────
# Step 1: Check if PR already has automation/type labels (skip if so)
# ─────────────────────────────────────────────────────────────────────────
- name: Check existing labels
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
id: check-labels
with:
script: |
- const skipLabels = new Set(['automation']);
- const typeLabels = new Set(['bugfix', 'hardware-support', 'enhancement', 'dependencies', 'submodules', 'github_actions', 'trunk', 'cleanup']);
+ const skipLabels = new Set(['automation', 'release']);
+ const typeLabels = new Set(['bugfix', 'enhancement', 'dependencies', 'repo', 'refactor']);
const prLabels = context.payload.pull_request.labels.map(l => l.name);
const shouldSkipAll = prLabels.some(l => skipLabels.has(l));
@@ -44,13 +44,16 @@ jobs:
uses: actions/ai-inference@v2
id: quality
continue-on-error: true
+ env:
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ PR_BODY: ${{ github.event.pull_request.body }}
with:
max-tokens: 20
prompt: |
Is this GitHub pull request spam, AI-generated slop, or low quality?
- Title: ${{ github.event.pull_request.title }}
- Body: ${{ github.event.pull_request.body }}
+ Title: ${{ env.PR_TITLE }}
+ Body: ${{ env.PR_BODY }}
Respond with exactly one of: spam, ai-generated, needs-review, ok
system-prompt: You detect spam and low-quality contributions. Be conservative - only flag obvious spam or AI slop.
@@ -58,7 +61,7 @@ jobs:
- name: Apply quality label if needed
if: steps.check-labels.outputs.skip_all != 'true' && steps.quality.outputs.response != '' && steps.quality.outputs.response != 'ok'
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
id: quality-label
env:
QUALITY_LABEL: ${{ steps.quality.outputs.response }}
@@ -87,32 +90,35 @@ jobs:
core.setOutput('is_spam', 'true');
# ─────────────────────────────────────────────────────────────────────────
- # Step 3: Auto-label PR type (bugfix/hardware-support/enhancement)
+ # Step 3: Auto-label PR type (bugfix/enhancement/refactor)
# ─────────────────────────────────────────────────────────────────────────
- name: Classify PR for labeling
if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && (steps.quality.outputs.response == 'ok' || steps.quality.outputs.response == '')
uses: actions/ai-inference@v2
id: classify
continue-on-error: true
+ env:
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ PR_BODY: ${{ github.event.pull_request.body }}
with:
max-tokens: 30
prompt: |
- Classify this pull request into exactly one category.
+ Classify this pull request for the Meshtastic Android app into exactly one category.
- Return exactly one of: bugfix, hardware-support, enhancement
+ Return exactly one of: bugfix, enhancement, refactor
Use bugfix if it fixes a bug, crash, or incorrect behavior.
- Use hardware-support if it adds or improves support for a specific hardware device/variant.
- Use enhancement if it adds a new feature, improves performance, or refactors code.
+ Use enhancement if it adds a new feature, improves performance, or adds new functionality.
+ Use refactor if it restructures code without changing behavior, cleans up code, or improves architecture.
- Title: ${{ github.event.pull_request.title }}
- Body: ${{ github.event.pull_request.body }}
+ Title: ${{ env.PR_TITLE }}
+ Body: ${{ env.PR_BODY }}
system-prompt: You classify pull requests into categories. Be conservative and pick the most appropriate single label.
model: openai/gpt-4o-mini
- name: Apply type label
if: steps.check-labels.outputs.skip_all != 'true' && steps.check-labels.outputs.has_type_label != 'true' && steps.classify.outputs.response != ''
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
env:
TYPE_LABEL: ${{ steps.classify.outputs.response }}
with:
@@ -120,8 +126,8 @@ jobs:
const label = (process.env.TYPE_LABEL || '').trim().toLowerCase();
const labelMeta = {
'bugfix': { color: 'd73a4a', description: 'Bug fix' },
- 'hardware-support': { color: '0e8a16', description: 'Hardware support addition or improvement' },
'enhancement': { color: 'a2eeef', description: 'New feature or enhancement' },
+ 'refactor': { color: 'c5def5', description: 'Code restructuring without behavior change' },
};
const meta = labelMeta[label];
if (!meta) return;
diff --git a/.github/workflows/moderate.yml b/.github/workflows/moderate.yml
index b576ad9a6..4b8f94bfa 100644
--- a/.github/workflows/moderate.yml
+++ b/.github/workflows/moderate.yml
@@ -9,7 +9,8 @@ on:
jobs:
spam-detection:
- runs-on: ubuntu-latest
+ if: github.repository == 'meshtastic/Meshtastic-Android'
+ runs-on: ubuntu-24.04-arm
permissions:
issues: write
pull-requests: write
diff --git a/.github/workflows/post-release-cleanup.yml b/.github/workflows/post-release-cleanup.yml
index 925d265fa..d62c36ed9 100644
--- a/.github/workflows/post-release-cleanup.yml
+++ b/.github/workflows/post-release-cleanup.yml
@@ -18,7 +18,7 @@ permissions:
jobs:
cleanup_prereleases:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
environment: Release
steps:
- name: Checkout code
diff --git a/.github/workflows/pr_enforce_labels.yml b/.github/workflows/pr_enforce_labels.yml
index 8669b3c43..fa68a597b 100644
--- a/.github/workflows/pr_enforce_labels.yml
+++ b/.github/workflows/pr_enforce_labels.yml
@@ -4,29 +4,34 @@ on:
pull_request:
types: [edited, labeled]
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
permissions:
pull-requests: read
contents: read
jobs:
- check-label:
- runs-on: ubuntu-latest
+ check-label:
+ # Skip bot PRs — they already have labels from the workflows/bots that create them
+ if: >-
+ github.event.pull_request.user.login != 'renovate[bot]' &&
+ github.event.pull_request.user.login != 'github-actions[bot]' &&
+ github.event.pull_request.user.login != 'dependabot[bot]' &&
+ github.event.pull_request.head.ref != 'scheduled-updates' &&
+ github.event.pull_request.head.ref != 'l10n_main'
+ runs-on: ubuntu-24.04-arm
steps:
- name: Check for PR labels
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
with:
script: |
- // Always fetch the latest labels from the GitHub API to avoid stale context
- const prNumber = context.payload.pull_request.number;
- const { data: pr } = await github.rest.pulls.get({
- owner: context.repo.owner,
- repo: context.repo.repo,
- pull_number: prNumber,
- });
- const latestLabels = pr.labels.map(label => label.name);
+ // Extract labels from the payload directly to avoid extra API calls
+ const latestLabels = context.payload.pull_request.labels.map(label => label.name);
const requiredLabels = ['bugfix', 'enhancement', 'automation', 'dependencies', 'repo', 'release', 'refactor'];
+ console.log('Labels from payload:', latestLabels);
const hasRequiredLabel = latestLabels.some(label => requiredLabels.includes(label));
- console.log('Latest labels:', latestLabels);
if (!hasRequiredLabel) {
core.setFailed(`PR must have at least one of the following labels before it can be merged: ${requiredLabels.join(', ')}.`);
}
diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml
index 0345e1a1b..df16866f3 100644
--- a/.github/workflows/promote.yml
+++ b/.github/workflows/promote.yml
@@ -65,9 +65,9 @@ permissions:
jobs:
prepare-build-info:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
outputs:
- APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }}
+ APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
steps:
- name: Checkout code
@@ -77,9 +77,14 @@ jobs:
fetch-depth: 0
submodules: 'recursive'
- - name: Determine Version Name from Tag
- id: get_version_name
- run: echo "APP_VERSION_NAME=$(echo ${{ inputs.tag_name }} | sed 's/-.*//' | sed 's/v//')" >> $GITHUB_OUTPUT
+ - name: Prep APP_VERSION_NAME
+ id: prep_version
+ env:
+ INPUT_TAG_NAME: ${{ inputs.tag_name }}
+ run: |
+ VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//')
+ echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT
+ echo "Parsed Version: $VERSION_NAME"
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
@@ -97,7 +102,7 @@ jobs:
shell: bash
promote-release:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
environment: Release
needs: [ prepare-build-info ]
steps:
@@ -111,7 +116,7 @@ jobs:
user-fraction: ${{ (inputs.channel == 'production' && '0.1') || (inputs.channel == 'open' && '0.5') || '1.0' }}
update-github-release:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
needs: [ prepare-build-info, promote-release ]
steps:
- name: Checkout code
@@ -134,6 +139,7 @@ jobs:
gh release edit ${{ inputs.tag_name }} \
--tag ${{ inputs.final_tag }} \
--title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \
+ --draft=false \
--prerelease=${{ inputs.channel != 'production' }}
- name: Notify Discord
diff --git a/.github/workflows/publish-core.yml b/.github/workflows/publish-core.yml
index 9e55cd4f9..6bbf344f0 100644
--- a/.github/workflows/publish-core.yml
+++ b/.github/workflows/publish-core.yml
@@ -12,7 +12,7 @@ on:
jobs:
publish:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
@@ -23,29 +23,25 @@ jobs:
with:
submodules: 'recursive'
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Configure Version
id: version
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ VERSION_SUFFIX: ${{ inputs.version_suffix }}
run: |
- if [[ "${{ github.event_name }}" == "release" ]]; then
- echo "VERSION_NAME=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
+ if [[ "$EVENT_NAME" == "release" ]]; then
+ echo "VERSION_NAME=$RELEASE_TAG" >> $GITHUB_ENV
else
# Use a timestamp-based version for manual/branch builds to avoid collisions
# or use the base version + suffix
BASE_VERSION=$(grep "VERSION_NAME_BASE" config.properties | cut -d'=' -f2)
- echo "VERSION_NAME=${BASE_VERSION}${{ inputs.version_suffix }}" >> $GITHUB_ENV
+ echo "VERSION_NAME=${BASE_VERSION}${VERSION_SUFFIX}" >> $GITHUB_ENV
fi
- name: Publish to GitHub Packages
diff --git a/.github/workflows/pull-request-target.yml b/.github/workflows/pull-request-target.yml
index cebe7e588..d37cecf43 100644
--- a/.github/workflows/pull-request-target.yml
+++ b/.github/workflows/pull-request-target.yml
@@ -1,15 +1,67 @@
name: "Pull Request Labeler"
on:
-- pull_request_target
-# Do not execute arbitary code on this workflow.
+ pull_request_target:
+ types: [opened, synchronize]
+# Do not execute arbitrary code on this workflow.
# See warnings at https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#pull_request_target
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
jobs:
labeler:
permissions:
contents: read
pull-requests: write
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
steps:
- - id: label-the-PR
- uses: actions/labeler@v6
\ No newline at end of file
+ - name: Auto-label PR
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const branch = context.payload.pull_request.head.ref;
+ const labels = new Set();
+
+ // enhancement: branch contains feat
+ if (/feat/i.test(branch)) labels.add('enhancement');
+
+ // bugfix: branch starts with fix or bug
+ if (/^(fix|bug)/i.test(branch)) labels.add('bugfix');
+
+ // refactor: branch starts with refactor
+ if (/^refactor/i.test(branch)) labels.add('refactor');
+
+ // repo: branch contains repo or ci
+ if (/repo|ci/i.test(branch)) {
+ labels.add('repo');
+ } else {
+ // Also label 'repo' if .github files were changed (needs one API call)
+ try {
+ const files = await github.paginate(
+ github.rest.pulls.listFiles,
+ { owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request.number, per_page: 100 },
+ (res) => res.data.map(f => f.filename)
+ );
+ if (files.some(f => f.startsWith('.github/'))) labels.add('repo');
+ } catch (e) {
+ core.warning(`Could not list PR files (rate limited?): ${e.message}`);
+ }
+ }
+
+ if (labels.size > 0) {
+ const labelArray = [...labels];
+ core.info(`Applying labels: ${labelArray.join(', ')}`);
+ try {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.payload.pull_request.number,
+ labels: labelArray,
+ });
+ } catch (e) {
+ core.warning(`Could not apply labels (rate limited?): ${e.message}`);
+ }
+ } else {
+ core.info('No labels matched for this PR.');
+ }
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index e8cfb68c6..d450711ce 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -3,10 +3,9 @@ name: Pull Request CI
on:
pull_request:
branches: [ main ]
- paths-ignore:
- - '**/*.md'
- - 'docs/**'
- - '.gitignore'
+
+permissions:
+ contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -16,7 +15,7 @@ jobs:
# 1. CHANGE DETECTION: Prevents unnecessary builds
check-changes:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
outputs:
android: ${{ steps.filter.outputs.android }}
steps:
@@ -36,7 +35,6 @@ jobs:
- 'desktop/**'
- 'core/**'
- 'feature/**'
- - 'mesh_service_example/**'
# Shared build infrastructure
- 'build-logic/**'
- 'config/**'
@@ -54,7 +52,7 @@ jobs:
# 1b. FILTER DRIFT CHECK: Ensures check-changes stays aligned with module roots
verify-check-changes-filter:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v6
- name: Verify module roots are represented in check-changes filter
@@ -96,23 +94,26 @@ jobs:
PY
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
- # We disable instrumented tests for PRs to keep feedback fast (< 10 mins).
+ # We disable coverage and desktop builds for PRs to keep feedback fast
+ # (< 10 mins). Desktop compilation is already covered by the :desktop:test
+ # task in the shard-app test shard.
validate-and-build:
- needs: [check-changes, verify-check-changes-filter]
+ needs: check-changes
if: needs.check-changes.outputs.android == 'true'
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
run_unit_tests: true
- run_instrumented_tests: false
- api_levels: '[35]'
+ run_coverage: false
+ run_desktop_builds: false
upload_artifacts: true
secrets: inherit
# 3. WORKFLOW STATUS: Ensures required checks are satisfied
check-workflow-status:
name: Check Workflow Status
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
+ permissions: {}
needs: [check-changes, verify-check-changes-filter, validate-and-build]
if: always()
steps:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 809f05448..40d8e40f3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,6 +19,11 @@ on:
description: 'The channel to create a release for or promote to'
required: true
type: string
+ build_desktop:
+ description: 'Whether to build the desktop distribution'
+ required: false
+ type: boolean
+ default: false
secrets:
GSERVICES:
required: true
@@ -61,9 +66,9 @@ permissions:
jobs:
prepare-build-info:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
outputs:
- APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }}
+ APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
@@ -76,9 +81,14 @@ jobs:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- - name: Determine Version Name from Tag
- id: get_version_name
- run: echo "APP_VERSION_NAME=$(echo ${{ inputs.tag_name }} | sed 's/-.*//' | sed 's/v//')" >> $GITHUB_OUTPUT
+ - name: Prep APP_VERSION_NAME
+ id: prep_version
+ env:
+ INPUT_TAG_NAME: ${{ inputs.tag_name }}
+ run: |
+ VERSION_NAME=$(echo $INPUT_TAG_NAME | sed 's/-.*//' | sed 's/v//')
+ echo "APP_VERSION_NAME=$VERSION_NAME" >> $GITHUB_OUTPUT
+ echo "Parsed Version: $VERSION_NAME"
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
@@ -96,7 +106,7 @@ jobs:
shell: bash
release-google:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
needs: [prepare-build-info]
environment: Release
env:
@@ -110,18 +120,12 @@ jobs:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: 'false'
- name: Load secrets
env:
@@ -187,7 +191,7 @@ jobs:
subject-path: app/build/outputs/apk/google/release/*.apk
release-fdroid:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
needs: [prepare-build-info]
environment: Release
env:
@@ -201,18 +205,12 @@ jobs:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: 'false'
- name: Load secrets
env:
@@ -253,13 +251,14 @@ jobs:
subject-path: app/build/outputs/apk/fdroid/release/*.apk
release-desktop:
+ if: ${{ inputs.build_desktop }}
runs-on: ${{ matrix.os }}
needs: [prepare-build-info]
environment: Release
strategy:
fail-fast: false
matrix:
- os: [macos-latest, windows-latest, ubuntu-22.04, ubuntu-22.04-arm]
+ os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
@@ -271,20 +270,12 @@ jobs:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
-
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: 'false'
- name: Install dependencies for AppImage
if: runner.os == 'Linux'
@@ -294,7 +285,7 @@ jobs:
env:
ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
APPIMAGE_EXTRACT_AND_RUN: 1
- run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS --no-daemon
+ run: ./gradlew :desktop:packageReleaseDistributionForCurrentOS -PaboutLibraries.release=true --no-daemon
- name: List Desktop Binaries
if: runner.os == 'Linux'
@@ -316,7 +307,8 @@ jobs:
if-no-files-found: ignore
github-release:
- runs-on: ubuntu-latest
+ if: ${{ !cancelled() && !failure() }}
+ runs-on: ubuntu-24.04-arm
needs: [prepare-build-info, release-google, release-fdroid, release-desktop]
env:
INTERNAL_BUILDS_HOST: ${{ secrets.INTERNAL_BUILDS_HOST }}
@@ -336,7 +328,7 @@ jobs:
path: ./artifacts
- name: Create or Update GitHub Release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
tag_name: ${{ inputs.tag_name }}
target_commitish: ${{ inputs.commit_sha || github.sha }}
@@ -349,7 +341,7 @@ jobs:
- name: Create or Update internal GitHub Release
continue-on-error: true
if: ${{ env.INTERNAL_BUILDS_HOST != '' }}
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
repository: ${{ secrets.INTERNAL_BUILDS_HOST }}
token: ${{ secrets.INTERNAL_BUILDS_HOST_PAT }}
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 6fdbecfb8..632bf1ea4 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -9,12 +9,12 @@ on:
run_unit_tests:
type: boolean
default: true
- run_instrumented_tests:
+ run_coverage:
+ type: boolean
+ default: true
+ run_desktop_builds:
type: boolean
default: true
- api_levels:
- type: string
- default: '[35]'
upload_artifacts:
type: boolean
default: true
@@ -44,216 +44,272 @@ env:
GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }}
GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }}
GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }}
+ # Fallback VERSION_CODE for the lint-check job itself (which computes the real
+ # value from git). Downstream jobs override this with the git-derived value.
+ VERSION_CODE: ${{ github.run_number }}
jobs:
- host-check:
- runs-on: ubuntu-latest
+ # ── Lint & Static Analysis ──────────────────────────────────────────
+ lint-check:
+ runs-on: ubuntu-24.04
permissions:
contents: read
- timeout-minutes: 60
+ timeout-minutes: 30
+ outputs:
+ cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
+ version_code: ${{ steps.version_code.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- submodules: 'recursive'
+ filter: 'blob:none'
+ submodules: true
- - name: Validate Gradle Wrapper
- uses: gradle/actions/wrapper-validation@v5
+ - name: Determine cache read-only setting
+ id: cache_config
+ shell: bash
+ run: |
+ if [[ "${{ github.ref }}" == "refs/heads/main" ]] || [[ "${{ github.event_name }}" == "merge_group" ]] || [[ "${{ github.ref }}" == gh-readonly-queue/* ]]; then
+ echo "cache_read_only=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "cache_read_only=true" >> "$GITHUB_OUTPUT"
+ fi
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+ - name: Calculate version code from git commit count
+ id: version_code
+ shell: bash
+ run: |
+ COMMIT_COUNT=$(git rev-list --count HEAD)
+ OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2 || echo 0)
+ VERSION_CODE=$((COMMIT_COUNT + OFFSET))
+ echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: ${{ steps.cache_config.outputs.cache_read_only }}
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ - name: Lint, Analysis & KMP Smoke Compile
+ if: inputs.run_lint == true
+ run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan
+
+ - name: KMP Smoke Compile (lint skipped)
+ if: inputs.run_lint == false
+ run: ./gradlew kmpSmokeCompile -Pci=true --continue --scan
+
+ # ── Sharded Unit Tests ──────────────────────────────────────────────
+ # Tests are split into 3 shards that run in parallel:
+ # shard-core: core:* KMP module tests (allTests)
+ # shard-feature: feature:* KMP module tests (allTests)
+ # shard-app: Pure-Android/JVM tests (app, desktop, core:barcode, etc.)
+ test-shards:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ timeout-minutes: 45
+ needs: lint-check
+ if: inputs.run_unit_tests == true
+ env:
+ VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
+ strategy:
+ fail-fast: false
+ matrix:
+ shard:
+ - name: shard-core
+ tasks: >-
+ :core:ble:allTests
+ :core:common:allTests
+ :core:data:allTests
+ :core:database:allTests
+ :core:domain:allTests
+ :core:model:allTests
+ :core:navigation:allTests
+ :core:network:allTests
+ :core:prefs:allTests
+ :core:repository:allTests
+ :core:service:allTests
+ :core:takserver:allTests
+ :core:testing:allTests
+ :core:ui:allTests
+ kover: >-
+ :core:ble:koverXmlReport
+ :core:common:koverXmlReport
+ :core:data:koverXmlReport
+ :core:database:koverXmlReport
+ :core:domain:koverXmlReport
+ :core:model:koverXmlReport
+ :core:navigation:koverXmlReport
+ :core:network:koverXmlReport
+ :core:prefs:koverXmlReport
+ :core:repository:koverXmlReport
+ :core:service:koverXmlReport
+ :core:takserver:koverXmlReport
+ :core:testing:koverXmlReport
+ :core:ui:koverXmlReport
+ - name: shard-feature
+ tasks: >-
+ :feature:connections:allTests
+ :feature:firmware:allTests
+ :feature:intro:allTests
+ :feature:map:allTests
+ :feature:messaging:allTests
+ :feature:node:allTests
+ :feature:settings:allTests
+ kover: >-
+ :feature:connections:koverXmlReport
+ :feature:firmware:koverXmlReport
+ :feature:intro:koverXmlReport
+ :feature:map:koverXmlReport
+ :feature:messaging:koverXmlReport
+ :feature:node:koverXmlReport
+ :feature:settings:koverXmlReport
+ - name: shard-app
+ tasks: >-
+ :app:testFdroidDebugUnitTest
+ :app:testGoogleDebugUnitTest
+ :desktop:test
+ :core:barcode:testFdroidDebugUnitTest
+ :core:barcode:testGoogleDebugUnitTest
+ kover: >-
+ :app:koverXmlReportFdroidDebug
+ :app:koverXmlReportGoogleDebug
+ :core:barcode:koverXmlReportFdroidDebug
+ :core:barcode:koverXmlReportGoogleDebug
+ :desktop:koverXmlReport
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
with:
- cache-read-only: ${{ github.ref != 'refs/heads/main' && github.event_name != 'merge_group' && !startsWith(github.ref, 'refs/heads/gh-readonly-queue/') }}
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- cache-cleanup: on-success
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
- add-job-summary: always
+ fetch-depth: 1
+ submodules: true
- - name: Code Style & Static Analysis
- if: inputs.run_lint == true
- run: ./gradlew spotlessCheck detekt -Pci=true --scan
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
+ with:
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- - name: Android Lint
- if: inputs.run_lint == true
- run: ./gradlew app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug mesh_service_example:lintDebug -Pci=true --continue --scan
+ - name: Run Tests & Coverage (${{ matrix.shard.name }})
+ run: |
+ kover_tasks=""
+ if [[ "${{ inputs.run_coverage }}" == "true" ]]; then
+ kover_tasks="${{ matrix.shard.kover }}"
+ fi
+ ./gradlew ${{ matrix.shard.tasks }} $kover_tasks -Pci=true --continue --scan
- - name: Shared Unit Tests & Coverage
- 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 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 }}
- uses: codecov/codecov-action@v5
+ - name: Upload test results to Codecov
+ if: ${{ !cancelled() }}
+ uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
- flags: host-unit
- fail_ci_if_error: false
- files: "**/build/reports/kover/report*.xml"
-
- - name: Upload unit test results to Codecov
- if: ${{ !cancelled() && inputs.run_unit_tests }}
- uses: codecov/codecov-action@v5
- with:
- token: ${{ secrets.CODECOV_TOKEN }}
- slug: meshtastic/Meshtastic-Android
- flags: host-unit
+ flags: ${{ matrix.shard.name }}
fail_ci_if_error: false
report_type: test_results
files: "**/build/test-results/**/*.xml"
- - name: Upload host reports
+ - name: Upload coverage to Codecov
+ if: ${{ !cancelled() && inputs.run_coverage }}
+ uses: codecov/codecov-action@v6
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
+ slug: meshtastic/Meshtastic-Android
+ flags: ${{ matrix.shard.name }}
+ fail_ci_if_error: false
+ files: "**/build/reports/kover/report*.xml"
+
+ - name: Upload shard reports
if: ${{ always() && inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
- name: reports-host
+ name: reports-${{ matrix.shard.name }}
path: |
**/build/reports
**/build/test-results
retention-days: 7
+ # ── Android Build ────────────────────────────────────────────────────
android-check:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
permissions:
contents: read
timeout-minutes: 60
- strategy:
- fail-fast: true
- matrix:
- api_level: ${{ fromJson(inputs.api_levels) }}
+ needs: lint-check
+ env:
+ VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
- fetch-depth: 0
- submodules: 'recursive'
+ fetch-depth: 1
+ submodules: true
- - name: Validate Gradle Wrapper
- uses: gradle/actions/wrapper-validation@v5
-
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-read-only: ${{ github.ref != 'refs/heads/main' && github.event_name != 'merge_group' && !startsWith(github.ref, 'refs/heads/gh-readonly-queue/') }}
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- cache-cleanup: on-success
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
- add-job-summary: always
-
- - name: Determine matrix metadata
- id: matrix_meta
- shell: bash
- run: |
- first_api=$(python3 - <<'PY'
- import json
- print(json.loads('${{ inputs.api_levels }}')[0])
- PY
- )
-
- if [[ "${{ matrix.api_level }}" == "$first_api" ]]; then
- echo "is_first_api=true" >> "$GITHUB_OUTPUT"
- else
- echo "is_first_api=false" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Determine Android tasks
- id: tasks
- shell: bash
- run: |
- tasks=(
- "app:assembleFdroidDebug"
- "app:assembleGoogleDebug"
- "mesh_service_example:assembleDebug"
- )
-
- if [[ "${{ inputs.run_instrumented_tests }}" == "true" ]]; then
- tasks+=(
- "app:connectedFdroidDebugAndroidTest"
- "app:connectedGoogleDebugAndroidTest"
- "core:barcode:connectedFdroidDebugAndroidTest"
- "core:barcode:connectedGoogleDebugAndroidTest"
- )
- fi
-
- printf 'tasks=%s\n' "${tasks[*]}" >> "$GITHUB_OUTPUT"
-
- - name: Enable KVM group perms
- if: inputs.run_instrumented_tests == true
- run: |
- echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
- sudo udevadm control --reload-rules
- sudo udevadm trigger --name-match=kvm
-
- - name: Run Android Build & Instrumented Tests
- if: inputs.run_instrumented_tests == true
- uses: reactivecircus/android-emulator-runner@v2
- with:
- api-level: ${{ matrix.api_level }}
- arch: x86_64
- force-avd-creation: false
- emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
- disable-animations: true
- script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan
-
- - name: Run Android Build
- if: inputs.run_instrumented_tests == false
- run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan
-
- - name: Upload instrumented test results to Codecov
- if: ${{ !cancelled() && inputs.run_instrumented_tests && steps.matrix_meta.outputs.is_first_api == 'true' }}
- uses: codecov/codecov-action@v5
- with:
- token: ${{ secrets.CODECOV_TOKEN }}
- slug: meshtastic/Meshtastic-Android
- flags: android-instrumented
- fail_ci_if_error: false
- report_type: test_results
- files: "**/build/outputs/androidTest-results/**/*.xml"
+ - name: Build Android APKs
+ run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
- name: Upload debug artifact
- if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
+ if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: app-debug-apks
path: app/build/outputs/apk/*/debug/*.apk
- retention-days: 14
+ retention-days: 7
- name: Report App Size
- if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }}
+ if: always()
run: |
- echo "### 📦 App Size Report" >> $GITHUB_STEP_SUMMARY
+ echo "### App Size Report" >> $GITHUB_STEP_SUMMARY
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
- - name: Upload Android reports
- if: ${{ always() && inputs.upload_artifacts }}
+ # ── Desktop Build ───────────────────────────────────────────────────
+ build-desktop:
+ name: Build Desktop Debug (${{ matrix.os }})
+ if: inputs.run_desktop_builds == true
+ runs-on: ${{ matrix.os }}
+ permissions:
+ contents: read
+ timeout-minutes: 60
+ needs: lint-check
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
+ env:
+ VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+ submodules: true
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
+ with:
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
+
+ - name: Build Desktop
+ run: ./gradlew :desktop:createDistributable -Pci=true --scan
+
+ - name: Upload Desktop artifact
+ if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
- name: reports-android-api-${{ matrix.api_level }}
- path: |
- **/build/outputs/androidTest-results
+ name: desktop-app-${{ runner.os }}-${{ runner.arch }}
+ path: desktop/build/compose/binaries/main/app/
retention-days: 7
- if-no-files-found: ignore
diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml
index a965f7f04..2399d1f88 100644
--- a/.github/workflows/scheduled-updates.yml
+++ b/.github/workflows/scheduled-updates.yml
@@ -2,12 +2,12 @@ name: Scheduled Updates (Firmware, Hardware, Translations)
on:
schedule:
- - cron: '0 * * * *' # Run every hour
- workflow_dispatch: # Allow manual triggering
+ - cron: '0 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost)
+ workflow_dispatch: # Allow manual triggering
jobs:
update_assets:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04
if: github.repository == 'meshtastic/Meshtastic-Android'
permissions:
contents: write # To commit files and push branches
@@ -81,22 +81,11 @@ jobs:
- name: Fix file permissions
run: sudo chown -R $USER:$USER .
- - name: Set up JDK 17
- uses: actions/setup-java@v5
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
with:
- java-version: '17'
- distribution: 'jetbrains'
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
- with:
- cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- build-scan-publish: true
- build-scan-terms-of-use-url: 'https://gradle.com/terms-of-service'
- build-scan-terms-of-use-agree: 'yes'
- add-job-summary: always
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: 'false'
- name: Update Graphs
run: ./gradlew graphUpdate
@@ -143,7 +132,8 @@ jobs:
check-workflow-status:
name: Check Workflow Status
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
+ permissions: {}
needs:
- update_assets
if: always()
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index e0647e27e..f1ae45660 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -12,7 +12,7 @@ permissions:
jobs:
stale_issues:
name: Close Stale Issues
- runs-on: ubuntu-latest
+ runs-on: ubuntu-24.04-arm
if: github.repository == 'meshtastic/Meshtastic-Android'
steps:
@@ -20,7 +20,7 @@ jobs:
uses: actions/stale@v10.2.0
with:
days-before-stale: 30
- stale-issue-message: This issue hasn not had any comment or update in the last 30 days. If it is still relevant, please post update comments. If no comments are made, this issue will be closed in 7 days.
+ stale-issue-message: This issue has not had any comment or update in the last 30 days. If it is still relevant, please post update comments. If no comments are made, this issue will be closed in 7 days.
exempt-issue-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies'
exempt-pr-labels: 'has sponsor,needs sponsor,help wanted,backlog,security issue,l10n,dependencies'
operations-per-run: 100
diff --git a/.gitignore b/.gitignore
index 97dbb7b24..447d8a28e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,6 @@ wireless-install.sh
.worktrees/
/firebase-debug.log.jdk/
firebase-debug.log
+.agent_plans/
+.agent_refs/
+.agent_artifacts/
diff --git a/.pr5167.diff b/.pr5167.diff
new file mode 100644
index 000000000..d0a809449
--- /dev/null
+++ b/.pr5167.diff
@@ -0,0 +1,295 @@
+diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
+new file mode 100644
+index 0000000000..2a27b96906
+--- /dev/null
++++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
+@@ -0,0 +1,39 @@
++/*
++ * Copyright (c) 2026 Meshtastic LLC
++ *
++ * This program is free software: you can redistribute it and/or modify
++ * it under the terms of the GNU General Public License as published by
++ * the Free Software Foundation, either version 3 of the License, or
++ * (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program. If not, see .
++ */
++package org.meshtastic.core.common.di
++
++import kotlinx.coroutines.CoroutineScope
++import kotlinx.coroutines.SupervisorJob
++import org.koin.core.annotation.Single
++import org.meshtastic.core.common.util.ioDispatcher
++
++/**
++ * A process-wide [CoroutineScope] that outlives individual ViewModels and UI components.
++ *
++ * Use this scope for fire-and-forget cleanup work that must continue after a ViewModel's own scope has been cancelled
++ * (for example, deleting temporary files in `onCleared()`). Backed by a [SupervisorJob] so failures in one child do not
++ * cancel siblings, and by [ioDispatcher] so work runs off the main thread.
++ *
++ * Prefer scoping work to a more specific scope (like `viewModelScope`) whenever possible; this scope is an escape hatch
++ * and should be used sparingly.
++ */
++interface ApplicationCoroutineScope : CoroutineScope
++
++@Single(binds = [ApplicationCoroutineScope::class])
++internal class ApplicationCoroutineScopeImpl : ApplicationCoroutineScope {
++ override val coroutineContext = SupervisorJob() + ioDispatcher
++}
+diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+index 231c84d401..5365ab95e2 100644
+--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
++++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+@@ -37,12 +37,12 @@ import androidx.lifecycle.compose.LifecycleEventEffect
+ import co.touchlab.kermit.Logger
+ import com.eygraber.uri.toAndroidUri
+ import com.eygraber.uri.toKmpUri
+-import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.withContext
+ import org.jetbrains.compose.resources.StringResource
+ import org.jetbrains.compose.resources.getString
+ import org.meshtastic.core.common.gpsDisabled
+ import org.meshtastic.core.common.util.CommonUri
++import org.meshtastic.core.common.util.ioDispatcher
+ import java.net.URLEncoder
+
+ @Composable
+@@ -146,7 +146,7 @@ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) ->
+ val context = LocalContext.current
+ return remember(context) {
+ { uri, maxChars ->
+- withContext(Dispatchers.IO) {
++ withContext(ioDispatcher) {
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ val androidUri = uri.toAndroidUri()
+diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+index 031e1fe35d..a938f92ea6 100644
+--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
++++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt
+@@ -20,10 +20,10 @@ package org.meshtastic.core.ui.util
+
+ import androidx.compose.runtime.Composable
+ import co.touchlab.kermit.Logger
+-import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.withContext
+ import org.jetbrains.compose.resources.StringResource
+ import org.meshtastic.core.common.util.CommonUri
++import org.meshtastic.core.common.util.ioDispatcher
+ import java.awt.Desktop
+ import java.awt.FileDialog
+ import java.awt.Frame
+@@ -89,7 +89,7 @@ actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeT
+ /** JVM — Reads text from a file URI. */
+ @Composable
+ actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { uri, maxChars ->
+- withContext(Dispatchers.IO) {
++ withContext(ioDispatcher) {
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ val file = File(URI(uri.toString()))
+diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+index dc1c459716..f8ff9fcac8 100644
+--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
++++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+@@ -35,6 +35,7 @@ import kotlinx.coroutines.launch
+ import kotlinx.coroutines.withTimeoutOrNull
+ import org.jetbrains.compose.resources.StringResource
+ import org.koin.core.annotation.KoinViewModel
++import org.meshtastic.core.common.di.ApplicationCoroutineScope
+ import org.meshtastic.core.common.util.CommonUri
+ import org.meshtastic.core.common.util.safeCatching
+ import org.meshtastic.core.database.entity.FirmwareRelease
+@@ -91,6 +92,7 @@ class FirmwareUpdateViewModel(
+ private val firmwareUpdateManager: FirmwareUpdateManager,
+ private val usbManager: FirmwareUsbManager,
+ private val fileHandler: FirmwareFileHandler,
++ private val applicationScope: ApplicationCoroutineScope,
+ ) : ViewModel() {
+
+ private val _state = MutableStateFlow(FirmwareUpdateState.Idle)
+@@ -124,12 +126,10 @@ class FirmwareUpdateViewModel(
+
+ override fun onCleared() {
+ super.onCleared()
+- // viewModelScope is already cancelled when onCleared() runs, so launch cleanup in a
+- // standalone scope. SupervisorJob prevents the coroutine from propagating failures to a
+- // shared parent, and NonCancellable on the launch keeps cleanup running even if the scope
+- // is cancelled concurrently.
+- @OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
+- kotlinx.coroutines.GlobalScope.launch(NonCancellable) {
++ // viewModelScope is already cancelled when onCleared() runs, so launch cleanup on the
++ // application-wide scope (SupervisorJob + ioDispatcher). NonCancellable keeps cleanup
++ // running even if something tries to cancel it mid-flight.
++ applicationScope.launch(NonCancellable) {
+ tempFirmwareFile = cleanupTemporaryFiles(fileHandler, tempFirmwareFile)
+ }
+ }
+diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+index 4c48a1ced5..030d84effd 100644
+--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+@@ -108,6 +108,7 @@ class FirmwareUpdateIntegrationTest {
+ firmwareUpdateManager,
+ usbManager,
+ fileHandler,
++ TestApplicationCoroutineScope(testDispatcher),
+ )
+
+ @Test
+diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+index 7032ed4088..a8eddff838 100644
+--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+@@ -124,6 +124,7 @@ class FirmwareUpdateViewModelTest {
+ firmwareUpdateManager,
+ usbManager,
+ fileHandler,
++ TestApplicationCoroutineScope(testDispatcher),
+ )
+
+ @Test
+diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
+new file mode 100644
+index 0000000000..3ef5c44ef4
+--- /dev/null
++++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
+@@ -0,0 +1,26 @@
++/*
++ * Copyright (c) 2026 Meshtastic LLC
++ *
++ * This program is free software: you can redistribute it and/or modify
++ * it under the terms of the GNU General Public License as published by
++ * the Free Software Foundation, either version 3 of the License, or
++ * (at your option) any later version.
++ *
++ * This program is distributed in the hope that it will be useful,
++ * but WITHOUT ANY WARRANTY; without even the implied warranty of
++ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ * GNU General Public License for more details.
++ *
++ * You should have received a copy of the GNU General Public License
++ * along with this program. If not, see .
++ */
++package org.meshtastic.feature.firmware
++
++import kotlinx.coroutines.CoroutineDispatcher
++import kotlinx.coroutines.CoroutineScope
++import kotlinx.coroutines.SupervisorJob
++import org.meshtastic.core.common.di.ApplicationCoroutineScope
++
++internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) :
++ ApplicationCoroutineScope,
++ CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher)
+diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+index acb1545bdd..23a0d03ab2 100644
+--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
++++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+@@ -116,6 +116,7 @@ class FirmwareUpdateViewModelFileTest {
+ firmwareUpdateManager,
+ usbManager,
+ fileHandler,
++ TestApplicationCoroutineScope(testDispatcher),
+ )
+
+ // -----------------------------------------------------------------------
+diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+index c251b4d5ef..315ad1da85 100644
+--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+@@ -27,6 +27,7 @@ import co.touchlab.kermit.Logger
+ import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.launch
+ import kotlinx.coroutines.withContext
++import org.meshtastic.core.common.util.ioDispatcher
+ import org.meshtastic.core.resources.Res
+ import org.meshtastic.core.resources.debug_export_failed
+ import org.meshtastic.core.resources.debug_export_success
+@@ -48,7 +49,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List) =
+- withContext(Dispatchers.IO) {
++ withContext(ioDispatcher) {
+ try {
+ if (logs.isEmpty()) {
+ withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, "No logs to export") }
+diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+index 9afde85e5f..a28a576788 100644
+--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
++++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/tak/PrefExporter.kt
+@@ -24,9 +24,9 @@ import androidx.compose.runtime.Composable
+ import androidx.compose.runtime.rememberCoroutineScope
+ import androidx.compose.ui.platform.LocalContext
+ import co.touchlab.kermit.Logger
+-import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.launch
+ import kotlinx.coroutines.withContext
++import org.meshtastic.core.common.util.ioDispatcher
+
+ @Composable
+ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteArray): (fileName: String) -> Unit {
+@@ -41,7 +41,7 @@ actual fun rememberDataPackageExporter(dataPackageProvider: suspend () -> ByteAr
+ return { fileName -> exportLauncher.launch(fileName) }
+ }
+
+-private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(Dispatchers.IO) {
++private suspend fun exportZipToUri(context: Context, targetUri: Uri, data: ByteArray) = withContext(ioDispatcher) {
+ try {
+ context.contentResolver.openOutputStream(targetUri)?.use { os -> os.write(data) }
+ Logger.i { "TAK data package exported successfully to $targetUri" }
+diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+index 5b63cc90a3..a9a7285593 100644
+--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
++++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/debugging/LogExporter.kt
+@@ -19,9 +19,9 @@ package org.meshtastic.feature.settings.debugging
+ import androidx.compose.runtime.Composable
+ import androidx.compose.runtime.rememberCoroutineScope
+ import co.touchlab.kermit.Logger
+-import kotlinx.coroutines.Dispatchers
+ import kotlinx.coroutines.launch
+ import kotlinx.coroutines.withContext
++import org.meshtastic.core.common.util.ioDispatcher
+ import java.awt.FileDialog
+ import java.awt.Frame
+ import java.io.File
+@@ -41,7 +41,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List ByteAr
+ if (directory != null && file != null) {
+ val targetFile = File(directory, file)
+ val data = dataPackageProvider()
+- withContext(Dispatchers.IO) { targetFile.writeBytes(data) }
++ withContext(ioDispatcher) { targetFile.writeBytes(data) }
+ Logger.i { "TAK data package exported successfully to ${targetFile.absolutePath}" }
+ }
+ }
diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md
new file mode 100644
index 000000000..acab253d5
--- /dev/null
+++ b/.skills/code-review/SKILL.md
@@ -0,0 +1,66 @@
+# Skill: Code Review
+
+## Description
+Perform comprehensive code reviews for `Meshtastic-Android`, ensuring changes adhere to KMP architecture, Kotlin Multiplatform conventions, MAD standards, and CMP best practices.
+
+## Code Review Checklist
+
+When reviewing code, meticulously verify the following categories. Flag any deviations and propose the canonical project pattern as a fix.
+
+### 1. KMP Architecture & Source Set Boundaries
+- [ ] **No Platform Bleed:** Ensure absolutely no `java.*` or `android.*` imports exist in `commonMain` source sets.
+- [ ] **KMP Native Alternatives:** Verify the use of KMP alternatives for standard JVM libraries:
+ - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`
+ - `java.util.concurrent.ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`
+ - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`)
+ - `java.util.Locale` -> Kotlin `uppercase()`/`lowercase()` (purged from `commonMain`)
+- [ ] **Coroutine Safety:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` silently swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction). Use `kotlinx.coroutines.CancellationException` (not `kotlin.coroutines.cancellation.CancellationException`).
+- [ ] **Shared Helpers:** If `androidMain` and `jvmMain` contain identical pure-Kotlin logic, mandate extracting it to a shared function in `commonMain`.
+- [ ] **File Naming Conflicts:** For `expect`/`actual` declarations, ensure files sharing the same package namespace have distinct names (e.g., keep `expect` in `LogExporter.kt` and shared helpers in `LogFormatter.kt`) to avoid duplicate class errors on the JVM target.
+- [ ] **Interface & DI Over `expect`/`actual`:** Check that `expect`/`actual` is reserved for small platform primitives. Interfaces + DI should be preferred for larger capabilities.
+
+### 2. UI & Compose Multiplatform (CMP)
+- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine.
+- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI).
+- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`.
+- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules.
+- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
+
+### 3. Navigation & State
+- [ ] **Shared Navigation Graphs:** Feature navigation graphs must be defined as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(...)`). Flag any graphs defined in platform-specific source sets.
+- [ ] **Navigation Host:** Ensure `MeshtasticNavDisplay` (from `core:ui/commonMain`) is used as the host instead of invoking `NavDisplay` directly. Host modules should not configure `entryDecorators` themselves.
+- [ ] **ViewModel Scoping:** ViewModels obtained via `koinViewModel()` must be inside `entry` blocks to correctly tie to the backstack lifetime.
+
+### 4. Dependency Injection (Koin Annotations)
+- [ ] **Annotation Usage:** Ensure Koin is configured via annotations (`@Single`, `@Factory`, `@KoinViewModel`).
+- [ ] **Root Assembly:** Confirm that the root Koin DI graph is only assembled in host shells (`app` and `desktop`).
+
+### 5. Networking, DB & I/O
+- [ ] **Ktor Strictly:** Check that Ktor is used for all HTTP networking. Flag and reject any usage of OkHttp.
+- [ ] **HTTP Configuration:** Verify timeouts and base URLs use `HttpClientDefaults` from `core:network`. Never hardcode timeouts in feature modules. `DefaultRequest` sets the base URL; feature API services use relative paths.
+- [ ] **Image Loading (Coil):** Coil must use `coil-network-ktor3` in host modules. Feature modules should ONLY depend on `libs.coil` (coil-compose) and never configure fetchers.
+- [ ] **Room KMP:** Ensure `factory = { MeshtasticDatabaseConstructor.initialize() }` is used in `Room.databaseBuilder`. DAOs and Entities must reside in `commonMain`.
+- [ ] **Room Patterns:** Verify use of `@Upsert` for insert-or-update logic. Check for `LIMIT 1` on single-row queries. Flag N+1 query patterns (loops calling single-row queries) — batch with chunked `WHERE IN` instead.
+- [ ] **Bluetooth (BLE):** All Bluetooth communication must be routed through `core:ble` using Kable abstractions.
+
+### 6. Dependency Catalog Aliases
+- [ ] **JetBrains vs. AndroidX:**
+ - In `commonMain`: Must use `jetbrains-*` aliases (e.g., `jetbrains-lifecycle-*`, `jetbrains-navigation3-ui`).
+ - In `androidMain`: Can use `androidx-*` or `jetbrains-*` as appropriate, but do not mix them up in `commonMain`.
+- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules.
+
+### 7. Testing
+- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` from `androidx.compose.ui.test.v2` (not the deprecated v1 `androidx.compose.ui.test` package) + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests.
+- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`.
+- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking.
+- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues.
+
+### 8. ProGuard / R8 Rules
+- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned.
+- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed.
+
+## Review Output Guidelines
+1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern.
+2. **Reference the Docs:** Cite `AGENTS.md` and project architecture playbooks to justify change requests (e.g., "Per AGENTS.md, `java.io.*` cannot be used in `commonMain`; please migrate to Okio").
+3. **Enforce Build Health:** Remind authors to run `./gradlew test allTests` locally to verify changes, especially since KMP `test` tasks are ambiguous.
+4. **Praise Good Patterns:** Acknowledge correct usage of complex architecture requirements, like proper Navigation 3 scene transitions or elegant `commonMain` helper extractions.
diff --git a/.skills/compose-ui/SKILL.md b/.skills/compose-ui/SKILL.md
new file mode 100644
index 000000000..22fe1b489
--- /dev/null
+++ b/.skills/compose-ui/SKILL.md
@@ -0,0 +1,61 @@
+# Skill: Compose Multiplatform (CMP) UI
+
+## Description
+Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive.
+
+## 1. UI Components & Layouts
+- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktop/tablets.
+- **Dialogs & Alerts:** Use centralized components like `AlertHost(alertManager)` from `core:ui/commonMain`. Do NOT trigger alerts inline or duplicate alert logic. Use `SharedDialogs(uiViewModel)` for general popups.
+- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features.
+- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`.
+- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `app` or `desktop` shells. Do not tightly couple Google Maps/osmdroid dependencies to `commonMain`.
+
+## 2. Strings & Resources
+- **Multiplatform Resources:** MUST use `core:resources` (e.g., `stringResource(Res.string.your_key)`). Never use hardcoded strings.
+- **ViewModels/Coroutines:** Use the asynchronous `getStringSuspend(Res.string.your_key)`. NEVER use blocking `getString()` in a coroutine context.
+- **Formatting Constraints:** CMP `stringResource` only supports `%N$s` (string) and `%N$d` (integer).
+ - **No Float formatting:** Formats like `%N$.1f` pass through unsubstituted. Pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass as a string argument (`%N$s`):
+ ```kotlin
+ val formatted = NumberFormatter.format(batteryLevel, 1) // "73.5"
+ stringResource(Res.string.battery_percent, formatted) // uses %1$s
+ ```
+ - **Percent Literals:** Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings.
+
+### String Formatting Decision Tree
+Choose the right tool for the job:
+
+| Scenario | Tool | Example |
+|----------|------|---------|
+| **Metric display** (temp, voltage, %, signal) | `MetricFormatter.*` | `MetricFormatter.temperature(25.0f, isFahrenheit)` → `"77.0°F"` |
+| **Simple number + unit** | `NumberFormatter` + interpolation | `"${NumberFormatter.format(val, 1)} dB"` |
+| **Localized template from strings.xml** | `stringResource(Res.string.key, preFormattedArgs)` | `stringResource(Res.string.battery, formatted)` |
+| **Non-composable template** (notifications, plain functions) | `formatString(template, args)` | `formatString(template, label, value)` |
+| **Hex formatting** | `formatString` | `formatString("!%08x", nodeNum)` |
+| **Date/time** | `DateFormatter` | `DateFormatter.format(instant)` |
+
+**Rules:**
+1. **NEVER use `%.Nf` in strings.xml** — CMP cannot substitute them. Use `%N$s` and pre-format floats.
+2. **Prefer `MetricFormatter`** over scattered `formatString("%.1f°C", temp)` calls.
+3. **`formatString` (pure Kotlin)** is a pure-Kotlin `commonMain` implementation for: hex formats, multi-arg templates fetched at runtime, and chart axis formatters. Located in `core:common` `Formatter.kt`.
+4. **`NumberFormatter`** always uses `.` as decimal separator — intentional for mesh networking precision.
+
+- **Workflow to Add a String:**
+ 1. Add to `core/resources/src/commonMain/composeResources/values/strings.xml`.
+ 2. Use the generated `org.meshtastic.core.resources.` symbol.
+ 3. Validate UI presentation.
+
+## 3. Tooling & Capabilities
+- **Image Loading:** Use `libs.coil` (Coil Compose) in feature modules. Configuration/Networking for Coil (`coil-network-ktor3`) happens strictly in the `app` and `desktop` host modules.
+- **QR Codes:** Use `rememberQrCodePainter` from `core:ui/commonMain` powered by `qrcode-kotlin`. No ZXing or Android Bitmap APIs in shared code.
+
+## 4. Compose Previews
+- **Preview in commonMain:** CMP 1.11+ supports `@Preview` in `commonMain` via `compose-multiplatform-ui-tooling-preview`. Place preview functions alongside their composables.
+- **Import:** Use `androidx.compose.ui.tooling.preview.Preview`. The JetBrains-prefixed import (`org.jetbrains.compose.ui.tooling.preview.Preview`) is deprecated.
+
+## 5. Dialog & State Patterns
+- **Dialog State Preservation:** Use `rememberSaveable` for dialog state (search queries, selected tabs, expanded flags) to preserve across configuration changes. Boolean and String types are auto-saveable — no custom `Saver` needed.
+
+## Reference Anchors
+- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml`
+- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
+- **Provider wiring:** `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`
diff --git a/.skills/implement-feature/SKILL.md b/.skills/implement-feature/SKILL.md
new file mode 100644
index 000000000..0277bee10
--- /dev/null
+++ b/.skills/implement-feature/SKILL.md
@@ -0,0 +1,41 @@
+# Skill: Implement a Feature
+
+## Description
+A step-by-step workflow for implementing a new feature in the Meshtastic-Android codebase, ensuring KMP compatibility and proper architecture.
+
+## Workflow
+
+### 1. Update Dependencies & Aliases
+- Check `gradle/libs.versions.toml` before adding libraries.
+- Use `jetbrains-*` aliases for lifecycle/navigation/adaptive dependencies in `commonMain`.
+- Use `compose-multiplatform-*` aliases for CMP dependencies.
+
+### 2. Define the State & ViewModels
+- Follow MVI/UDF patterns.
+- Extend shared ViewModel logic in `feature//src/commonMain/kotlin/org/meshtastic/feature//ViewModel.kt`.
+- Use `stateInWhileSubscribed` (from `core:ui`) for sharing state flows.
+- Keep the ViewModel free of Android framework dependencies.
+
+### 3. Build the UI
+- Use Jetpack Compose Multiplatform (CMP).
+- Define strings in `core:resources` (see the `compose-ui` skill).
+- Support adaptive layouts (Large/XL breakpoints).
+
+### 4. Wire Navigation & DI
+- Define typed route objects in `core:navigation`.
+- Export the navigation graph as an extension function on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.myFeatureGraph()`).
+- Add the required DI bindings via Koin Annotations (`@Factory`, `@Single`, `@KoinViewModel`) in `commonMain`.
+- **CRITICAL:** Ensure the module is registered in the app root graphs (`AppKoinModule.kt`, `DesktopKoinModule.kt`) and the navigation is injected into the root entry provider in the host shell.
+
+### 5. Validate Platform Separation
+- If you need a platform-specific API (like camera or specific mapping SDK), define an interface in `commonMain`, implement it in the host shell, and inject it via `CompositionLocal` or Koin.
+
+### 6. Verify Locally
+- Run the baseline checks (see `testing-ci` skill):
+ ```bash
+ ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
+ ```
+- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds:
+ ```bash
+ ./gradlew assembleFdroidRelease :desktop:runRelease
+ ```
diff --git a/.skills/kmp-architecture/SKILL.md b/.skills/kmp-architecture/SKILL.md
new file mode 100644
index 000000000..46602c430
--- /dev/null
+++ b/.skills/kmp-architecture/SKILL.md
@@ -0,0 +1,61 @@
+# Skill: KMP Architecture & Source-Set Bridging
+
+## Description
+Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstractions, networking, database, and platform integration rules.
+
+## 1. Source-Set Boundaries
+- **`commonMain`:** All business logic, DB entities, API network logic, ViewModels, and UI rendering. NO `java.*` or `android.*` imports.
+- **`androidMain`:** Android framework integration (`Context`, system services, NFC hardware, BLE Android bindings).
+- **`jvmMain` / `jvmAndroidMain`:** Shared JVM code between Android and Desktop. Uses the `meshtastic.kmp.jvm.android` convention plugin to bridge `jvm` and `android` source sets without manual `dependsOn` hacks.
+- **`app` / `desktop`:** Host shells. Responsible for Koin DI root wiring, `MainKoinModule`, host-level UI themes, and running the `MeshtasticNavDisplay`.
+
+## 2. Bridging Strategies
+- **Interface + DI (Preferred):** Expose an interface in `core:repository` or `core:ui` (e.g. `LocationRepository`, `MapViewProvider`), implement it in `androidMain` or the host `app`, and bind it via Koin or `CompositionLocal`.
+- **`expect`/`actual` (Restricted):** Use only when a platform API cannot be abstracted cleanly (e.g. low-level File I/O mappings, `uppercase()` Locale helpers). Avoid deep class hierarchies using `expect`/`actual`.
+ - **Naming:** Keep `expect` in `FileIo.kt`, but put shared helpers in `FileIoUtils.kt` to prevent JVM duplicate class errors.
+- **Shared Helpers:** Do not duplicate pure Kotlin logic between `androidMain` and `jvmMain`. Extract to a `commonMain` helper.
+
+## 3. Core Libraries & Constraints
+- **Concurrency:** `kotlinx.coroutines`. Use `org.meshtastic.core.common.util.ioDispatcher` over `Dispatchers.IO` directly. Inject `CoroutineDispatchers` from `core:di` into classes that need dispatchers — never reference `Dispatchers.IO`/`Main`/`Default` directly in business logic.
+- **Error Handling:** Use `safeCatching {}` from `core:common` instead of `runCatching {}` in coroutine/suspend contexts. `runCatching` swallows `CancellationException`, breaking structured concurrency. Keep `runCatching` only in cleanup/teardown code (abort, close, eviction loops).
+- **Standard Library Replacements:**
+ - `ConcurrentHashMap` -> `atomicfu` or Mutex-guarded `mutableMapOf()`.
+ - `java.util.concurrent.locks.*` -> `kotlinx.coroutines.sync.Mutex`.
+ - `java.io.*` -> `Okio` (`BufferedSource`/`BufferedSink`).
+- **Networking:** Pure **Ktor**. No OkHttp. Ktor `Logging` plugin for debugging.
+- **HTTP Configuration:** Use `HttpClientDefaults` from `core:network` for shared base URL (`API_BASE_URL`), timeouts, and retry constants. Both Android (`NetworkModule`) and Desktop (`DesktopKoinModule`) HttpClient instances must use these. Feature API services use relative paths; `DefaultRequest` sets the base URL.
+- **BLE:** Route through `core:ble` using **Kable**.
+- **Room KMP:** Use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder`.
+
+## 4. Hierarchy & Source-Set Conventions
+- **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.
+- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract to `commonMain`. Examples: `formatLogsTo()`, `handleNodeAction()`, `findNodeByNameSuffix()`, `MeshtasticAppShell`, `BaseRadioTransportFactory`.
+
+## 5. Dependency Catalog Aliases
+- **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 `commonMain`.
+- **Dependencies:** Always check `gradle/libs.versions.toml` before assuming a library is available.
+
+## 6. I/O & Serialization
+- **Okio standard:** This project standardizes on Okio (`BufferedSource`/`BufferedSink`). JetBrains recommends `kotlinx-io` (built on Okio), but this project has not migrated. Do not introduce `kotlinx-io` without an explicit decision.
+- **Room KMP:** Always use `factory = { MeshtasticDatabaseConstructor.initialize() }` in `Room.databaseBuilder` and `inMemoryDatabaseBuilder`. DAOs and Entities reside in `commonMain`.
+- **Room Patterns:**
+ - Use `@Upsert` for insert-or-update operations instead of manual `INSERT OR IGNORE` + `UPDATE` logic.
+ - Use `LIMIT 1` on `@Query` methods that expect a single row.
+ - Prevent N+1 queries: batch operations with `@Upsert fun putAll(items: List)` or chunked `WHERE IN` queries (chunk size ≤ 999 to respect SQLite bind parameter limit).
+
+## 7. 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.
+
+## 8. Onboarding a New Target (Desktop/iOS)
+1. Ensure all new logic compiles against the KMP core (`jvm()`, `iosArm64()`, etc.).
+2. Do not use platform-specific constructs in `commonMain` or you break the iOS/Desktop builds.
+3. Test using `kmpSmokeCompile` to verify cross-platform compilation.
+4. For desktop wiring, copy the pattern in `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` and use `NoopStubs.kt` to temporarily mock missing platform implementations.
+
+## Reference Anchors
+- **Shared Okio I/O:** `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt`
+- **Desktop DI Stubs:** `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt`
+- **Version Catalog:** `gradle/libs.versions.toml`
+- **Convention Plugins:** `build-logic/convention/`
diff --git a/.skills/navigation-and-di/SKILL.md b/.skills/navigation-and-di/SKILL.md
new file mode 100644
index 000000000..c9d7336a6
--- /dev/null
+++ b/.skills/navigation-and-di/SKILL.md
@@ -0,0 +1,56 @@
+# Skill: DI and Navigation 3 Architecture
+
+## Description
+This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Navigation 3 (1.1.x) architecture, constraints, and anti-patterns within the Meshtastic-Android KMP codebase.
+
+## Dependency Injection (Koin)
+
+### Guidelines
+1. **Annotations First:** Use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules to encapsulate dependency graphs per feature.
+2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/.../DesktopKoinModule.kt`.
+3. **No Platform Bleed:** Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. Inject interfaces instead.
+4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
+
+### Anti-Patterns
+- **A1 Module Compile Safety:** Do **not** enable `compileSafety`. It is a single boolean that enables A1 per-module checks — there is no separate A3 full-graph mode. Runtime graph verification is handled by `KoinVerificationTest` and `DesktopKoinTest` instead.
+- **Default Parameters:** Do **not** expect Koin to inject default parameters automatically. The K2 plugin's `skipDefaultValues = true` behavior skips parameters with default Kotlin values.
+
+### Koin Startup Pattern (K2 Compiler Plugin)
+The project uses the **K2 Compiler Plugin** (`koin-compiler-plugin`, not KSP). The canonical startup uses the plugin's typed `startKoin()` stub, which the plugin transforms at compile time via IR:
+```kotlin
+// Bootstrap class — separate from @Module, references the root module graph
+@KoinApplication(modules = [AppKoinModule::class])
+object AndroidKoinApp
+
+// In Application.onCreate()
+startKoin {
+ androidContext(this@MeshUtilApplication)
+ workManagerFactory()
+}
+```
+- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class.
+- `startKoin()` (from `org.koin.plugin.module.dsl`) is a compiler plugin stub — if the plugin isn't applied, it throws `NotImplementedError`.
+- `stopKoin()` uses the standard runtime API (`org.koin.core.context.stopKoin`).
+- `compileSafety` must stay **disabled** — it enables A1 per-module checks that break our inverted-dependency architecture. There is no separate A3 full-graph flag.
+
+## Navigation 3
+
+### Guidelines
+1. **Types:** Use Navigation 3 types consistently (`NavKey`, `NavBackStack`, `EntryProviderScope`).
+2. **Typed Routes:** Keep route definitions in `core:navigation/src/commonMain/.../Routes.kt` as `@Serializable sealed interface` hierarchies. Don't use ad-hoc strings.
+3. **Graph Assembly:** Define feature navigation graphs as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(backStack)`).
+4. **Host Integration:** Use `MeshtasticNavDisplay` (from `core:ui/commonMain`) as the Navigation 3 host. Do not configure decorators manually inside feature modules.
+5. **Back Handlers:** Use `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` for back gestures in multiplatform code. Do not use Android's `BackHandler`.
+6. **Deep Links:** Use `DeepLinkRouter.route()` in `core:navigation` to synthesize typed backstacks from RESTful paths.
+
+### Anti-Patterns
+- **Single Backstack for Multiple Tabs:** Do **not** use a single `NavBackStack` list for multiple tabs. Use `MultiBackstack` (from `core:navigation`).
+- **Decorator Reuse Across Tabs:** Do **not** reuse the same `NavEntryDecorator` instances across different backstacks. When rendering an active tab in `MeshtasticNavDisplay`, you **must** supply a fresh set of decorators (using `remember(backStack) { ... }`) bound to the active backstack instance to prevent permanent `ViewModelStore` destruction.
+- **Custom Backstack Mutation:** Do **not** mutate back navigation with custom stacks disconnected from the app backstack. Mutate `NavBackStack` directly with `add(...)` and `removeLastOrNull()`.
+
+## Reference Anchors
+- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`
+- **DI Bootstrap Object:** `app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt`
+- **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`
+- **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
+- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`
diff --git a/.skills/new-branch/SKILL.md b/.skills/new-branch/SKILL.md
new file mode 100644
index 000000000..d63f3f4c2
--- /dev/null
+++ b/.skills/new-branch/SKILL.md
@@ -0,0 +1,79 @@
+# Skill: New Branch Bootstrap
+
+## Description
+Canonical recipe for spinning up a fresh working branch off the latest upstream `main`. Use this skill
+whenever the user says things like *"make a new branch off fetched origin/main"*, *"peel off a fresh
+branch"*, *"dust off #NNNN"*, or otherwise signals the start of a new unit of work.
+
+This replaces the ad-hoc prose that used to be retyped at the start of every session.
+
+## When to Use
+- Starting any new feature, fix, chore, or refactor.
+- Rebasing a stale PR onto current `main` (see [Rebase variant](#rebase-variant)).
+- Reproducing a CI failure from a clean baseline.
+
+## Preconditions (verify before branching)
+1. **Clean worktree.** If `git status --porcelain` is non-empty, ask the user before proceeding.
+2. **Upstream remote present.** `git remote -v` must list `upstream` pointing at
+ `meshtastic/Meshtastic-Android`. If only `origin` exists on a fork, treat `origin` as upstream.
+3. **Submodules initialised.** `core/proto/src/main/proto` must be populated — see AGENTS.md
+ workspace bootstrap rules.
+4. **Secrets bootstrapped.** If `local.properties` is missing, copy `secrets.defaults.properties`
+ (required for `google` flavor builds).
+
+## Standard Recipe
+
+```bash
+# 1. Fetch latest upstream
+git fetch upstream --prune --tags
+
+# 2. Create the branch from upstream/main (never from a local stale main)
+git switch -c upstream/main
+
+# 3. Ensure submodules track the new base
+git submodule update --init --recursive
+
+# 4. Sanity check
+git --no-pager log -1 --oneline
+```
+
+## Branch Naming
+Use conventional-commit style prefixes that match the PR title convention in AGENTS.md
+``:
+
+| Prefix | Use for |
+| :--- | :--- |
+| `feat/` | New user-visible behavior |
+| `fix/` | Bug fixes |
+| `refactor/` | Code structure changes, no behavior change |
+| `chore/` | Tooling, deps, CI, cleanup |
+| `docs/` | Documentation only |
+
+Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`.
+
+## Rebase Variant
+When the user says *"rebase #NNNN"* or *"dust off PR NNNN"*:
+
+```bash
+git fetch upstream --prune
+gh pr checkout # checks out the PR head locally
+git rebase upstream/main
+git submodule update --init --recursive
+# Resolve conflicts, then:
+git push --force-with-lease
+```
+
+Never use plain `--force`. Always `--force-with-lease` to avoid clobbering collaborator pushes.
+
+## Post-Branch Checklist
+- [ ] Branch name follows conventional prefix.
+- [ ] Submodules up to date.
+- [ ] `local.properties` exists.
+- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap).
+- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing.
+
+## Tip: Prefer `/delegate` for Long Audits
+If the user's opening prompt is a sweeping audit or investigation (e.g. *"audit changes since
+v2.7.13 for regressions"*, *"investigate why animations are broken on release"*), consider
+suggesting `/delegate` — the GitHub cloud agent can execute the branch + investigation + PR
+end-to-end while the user keeps working locally. See AGENTS.md ``.
diff --git a/.skills/project-overview/SKILL.md b/.skills/project-overview/SKILL.md
new file mode 100644
index 000000000..2224fa7ad
--- /dev/null
+++ b/.skills/project-overview/SKILL.md
@@ -0,0 +1,83 @@
+# Skill: Project Overview & Codebase Map
+
+## Description
+Module directory, namespacing conventions, environment setup, and troubleshooting for Meshtastic-Android.
+
+- **Build System:** Gradle (Kotlin DSL). JDK 21 REQUIRED. Target SDK: API 36. Min SDK: API 26.
+- **Flavors:** `fdroid` (OSS only) · `google` (Maps + DataDog analytics)
+- **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX). Shared contracts abstracted into `core:ui/commonMain`.
+
+## Codebase Map
+
+| 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.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/kmp-status.md` and `docs/roadmap.md` for current status. |
+| `core/model` | Domain models and common data structures. |
+| `core:proto` | Protobuf definitions (Git submodule). |
+| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
+| `core:database` | Room KMP database implementation. |
+| `core:datastore` | Multiplatform DataStore for preferences. |
+| `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`, `BleRadioInterface`). |
+| `core:di` | Common DI qualifiers and dispatchers. |
+| `core:navigation` | Shared navigation keys/routes for Navigation 3 using `@Serializable sealed interface` hierarchies. `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic 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. |
+| `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 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`, `wifi-provision`, `widget`). All are KMP except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
+| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. |
+| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
+| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. |
+
+## Namespacing
+- **Standard:** Use the `org.meshtastic.*` namespace for all code.
+- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
+
+## Environment Setup
+1. **JDK 21 MUST be used** to prevent Gradle sync/build failures.
+2. **Secrets:** Copy `secrets.defaults.properties` to `local.properties`:
+ ```properties
+ MAPS_API_KEY=dummy_key
+ datadogApplicationId=dummy_id
+ datadogClientToken=dummy_token
+ ```
+
+## Workspace Bootstrap (MUST run before any build)
+Agents **MUST** perform these steps automatically at the start of every session before running any Gradle task. Do not wait for the user to tell you.
+
+1. **Android SDK:** `ANDROID_HOME` may not be set in agent workspaces. Detect and export it:
+ ```bash
+ # Check common macOS/Linux locations in order of preference
+ if [ -z "$ANDROID_HOME" ]; then
+ for dir in "$HOME/Library/Android/sdk" "$HOME/Android/Sdk" "/opt/android-sdk"; do
+ if [ -d "$dir" ]; then export ANDROID_HOME="$dir"; break; fi
+ done
+ fi
+ ```
+ All `./gradlew` invocations must include `ANDROID_HOME` in the environment. If the SDK cannot be found, ask the user for the path.
+
+2. **Proto submodule:** `core/proto/src/main/proto` is a Git submodule containing Protobuf definitions. It must be initialized or builds will fail with proto generation errors:
+ ```bash
+ git submodule update --init
+ ```
+
+3. **Init secrets:** If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails:
+ ```bash
+ [ -f local.properties ] || cp secrets.defaults.properties local.properties
+ ```
+
+## Troubleshooting
+- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
+- **Configuration Cache:** Add `--no-configuration-cache` if cache-related issues persist.
+- **Koin Injection Failures:** Verify the component is included in `AppKoinModule`.
diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md
new file mode 100644
index 000000000..1c8b7b901
--- /dev/null
+++ b/.skills/testing-ci/SKILL.md
@@ -0,0 +1,85 @@
+# Skill: Testing and CI Verification
+
+## Description
+Guidelines and commands for verifying code changes locally and understanding the Meshtastic-Android CI pipeline. Use this to determine which testing matrix is needed based on the change type.
+
+## 1) Baseline local verification order
+
+Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation:
+
+```bash
+./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
+```
+
+> **Why no `clean`?** Incremental builds are safe and significantly faster. Only use `clean` when debugging stale cache issues.
+
+> **Why `test allTests` and not just `test`:**
+> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
+> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
+> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP plugin.
+> Conversely, `allTests` does **not** cover pure-Android modules (`:app`, `:core:api`, etc.), which is why both `test` and `allTests` are needed.
+
+*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
+
+## 2) Change-type verification matrix
+
+- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical.
+- `UI text/resource` changes: `spotlessCheck`, `detekt`, `assembleDebug`.
+- `feature/commonMain logic` changes: `spotlessCheck`, `detekt`, `test allTests`, `assembleDebug`.
+- `navigation/DI wiring` changes: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`, plus flavor unit tests if available.
+ - If touching any KMP module, also run `kmpSmokeCompile`.
+- `worker/service/background` changes: Broad tests, targeted WorkManager checks.
+- `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`.
+
+## 3) Flavor checks
+
+Run these when relevant to map, provider, or flavor-specific behavior:
+
+```bash
+./gradlew lintFdroidDebug lintGoogleDebug
+./gradlew testFdroidDebug testGoogleDebug
+```
+
+## 4) CI Pipeline Architecture
+
+CI is defined in `.github/workflows/reusable-check.yml` and structured as four parallel job groups:
+
+1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Uses `fetch-depth: 0` (full clone) for spotless ratcheting and version code calculation. Produces `cache_read_only` output and computed `version_code` for downstream jobs.
+2. **`test-shards`** — A 3-shard matrix that runs unit tests in parallel (depends on `lint-check`):
+ - `shard-core`: `allTests` for all `core:*` KMP modules.
+ - `shard-feature`: `allTests` for all `feature:*` KMP modules.
+ - `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`).
+ Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
+ Downstream jobs use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
+3. **`android-check`** — Builds APKs for all flavors (depends on `lint-check`).
+4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-check`).
+
+### Runner Strategy (Three Tiers)
+- **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times.
+- **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, publish, dependency-submission). Pin for reproducibility.
+- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job and release packaging.
+
+### CI Gradle Properties
+`gradle.properties` is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses `.github/ci-gradle.properties`, which the `gradle-setup` composite action copies to `~/.gradle/gradle.properties`. Key CI overrides:
+- `org.gradle.daemon=false` (single-use runners)
+- `kotlin.incremental=false` (fresh checkouts)
+- `-Xmx4g` Gradle heap, `-Xmx2g` Kotlin daemon
+- VFS watching disabled, workers capped at 4
+- `org.gradle.isolated-projects=true` for better parallelism
+- Disables unused Android build features (`resvalues`, `shaders`)
+
+### CI Conventions
+- **KMP Smoke Compile:** `./gradlew kmpSmokeCompile` is a lifecycle task (registered in `RootConventionPlugin`) that auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
+- **`maxParallelForks` CI logic:** `ProjectExtensions.kt` checks `project.findProperty("ci") == "true"` and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass `-Pci=true`.
+- **Detekt report formats:** Detekt.kt checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations.
+- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` on SDK downloads. Cache key is `robolectric-{version}-sdk{level}` — update when bumping version or SDK level.
+- **`mavenLocal()` gated:** Disabled by default to prevent CI cache poisoning. Pass `-PuseMavenLocal` for local JitPack testing.
+- **JUnit parallel execution:** Enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races. Cross-module parallelism comes from Gradle forks (`maxParallelForks`).
+- **`test-retry` plugin:** Applied to all module types (maxRetries=2, maxFailures=10).
+- **`fail-fast: false`:** Test sharding does not cancel other shards on failure.
+- **Explicit Gradle task paths:** Prefer `app:lintFdroidDebug` over shorthand `lintDebug` in CI.
+- **Pull request CI:** Main-only (`.github/workflows/pull-request.yml` targets `main`).
+- **Cache writes:** Trusted on `main` and merge queue runs; other refs use read-only cache.
+- **Path filtering:** `check-changes` in `pull-request.yml` must include module dirs plus build/workflow entrypoints (`build-logic/**`, `gradle/**`, `.github/workflows/**`, `gradlew`, `settings.gradle.kts`, etc.).
+- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone.
+
diff --git a/AGENTS.md b/AGENTS.md
index aae64c1a2..c1bafdd96 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,145 +1,108 @@
-# Meshtastic Android - Agent Guide
+# Meshtastic Android - Unified Agent & Developer Guide
-This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project.
+
+You are an expert Android and Kotlin Multiplatform (KMP) engineer working on Meshtastic-Android, a decentralized mesh networking application. You must maintain strict architectural boundaries, use Modern Android Development (MAD) standards, and adhere to Compose Multiplatform and JetBrains Navigation 3 patterns.
+
-For execution-focused recipes, see `docs/agent-playbooks/README.md`.
+
+- **Project Goal:** Decouple business logic from the Android framework for seamless multi-platform execution (Android, Desktop, iOS) while maintaining a high-performance native Android experience.
+- **Language & Tech:** Kotlin 2.3+ (JDK 21 REQUIRED), Gradle Kotlin DSL, Ktor, Okio, Room KMP.
+- **Core Architecture:**
+ - `commonMain` is pure KMP. `androidMain` is strictly for Android framework bindings.
+ - App root DI and graph assembly live in the `app` and `desktop` host shells.
+- **Skills Directory:** You **MUST** consult the relevant `.skills/` module before executing work:
+ - `.skills/project-overview/` - Codebase map, module directory, namespacing, environment setup, troubleshooting.
+ - `.skills/kmp-architecture/` - Bridging, expect/actual, source-sets, catalog aliases, build-logic conventions.
+ - `.skills/compose-ui/` - Adaptive UI, placeholders, string resources.
+ - `.skills/navigation-and-di/` - JetBrains Navigation 3 & Koin 4.2+ annotations.
+ - `.skills/testing-ci/` - Validation commands, CI pipeline architecture, CI Gradle properties.
+ - `.skills/implement-feature/` - Step-by-step feature workflow.
+ - `.skills/code-review/` - PR validation checklist.
+ - `.skills/new-branch/` - Canonical recipe for branching off upstream/main and rebasing stale PRs.
+- **Active Status:** Read `docs/kmp-status.md` and `docs/roadmap.md` to understand the current KMP migration epoch.
+
-## 1. Project Vision & Architecture
-Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience.
+
+- **Workspace Bootstrap (MUST run first):** Before executing any Gradle task in a new workspace, agents MUST automatically:
+ 1. **Find the Android SDK** — `ANDROID_HOME` is often unset in agent worktrees. Probe `~/Library/Android/sdk`, `~/Android/Sdk`, and `/opt/android-sdk`. Export the first one found. If none exist, ask the user.
+ 2. **Init the proto submodule** — Run `git submodule update --init`. The `core/proto/src/main/proto` submodule contains Protobuf definitions required for builds.
+ 3. **Init secrets** — If `local.properties` does not exist, copy `secrets.defaults.properties` to `local.properties`. Without this the `google` flavor build fails.
+- **Think First:** Reason through the problem before writing code. For complex KMP tasks involving multiple modules or source sets, outline your approach step-by-step before executing.
+- **Plan Before Execution:** Use the git-ignored `.agent_plans/` directory to write markdown implementation plans (`plan.md`) and Mermaid diagrams (`.mmd`) for complex refactors before modifying code.
+- **Atomic Execution:** Follow your plan step-by-step. Do not jump ahead. Use TDD where feasible (write `commonTest` fakes first).
+- **Baseline Verification:** Always instruct the user (or use your CLI tools) to run the baseline check before finishing:
+ ```
+ ./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
+ ```
+ > **Why both `test` and `allTests`?** In KMP modules, `test` is ambiguous and Gradle silently skips them. `allTests` is the KMP lifecycle task that covers KMP modules. Conversely, `allTests` does NOT cover pure-Android modules (`:app`, `:core:api`), so both tasks are required.
+ > For KMP cross-platform compilation, also run `./gradlew kmpSmokeCompile` (compiles all KMP modules for JVM + iOS Simulator — used by CI's `lint-check` job).
+
-- **Language:** Kotlin (primary), AIDL.
-- **Build System:** Gradle (Kotlin DSL). JDK 17 is REQUIRED.
-- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
-- **Flavors:**
- - `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()`, `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`.
- - **Navigation:** JetBrains Navigation 3 (Multiplatform fork) with shared backstack state.
- - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- - **Database:** Room KMP.
+
+- **Codebase Search:** Use whatever search and navigation tools your environment provides (file search, grep/ripgrep, symbol lookup, semantic search, etc.) to map out project boundaries before coding. Prefer `rg` (ripgrep) over `grep` or `find` for raw text search.
+- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent getting stuck in an interactive prompt.
+- **Fetch Up-to-Date Docs:** If your environment supports web search, MCP servers, or documentation lookup tools, actively query them for the latest documentation on Koin 4.x, JetBrains Navigation 3, and Compose Multiplatform 1.11.
+- **Clone Reference Repos:** If documentation is insufficient, use shell commands to clone bleeding-edge KMP dependency repositories into the local `.agent_refs/` directory (git-ignored) to inspect their source and test suites. Recommended:
+ - `https://github.com/JetBrains/kotlin-multiplatform-dev-docs` (Official Docs)
+ - `https://github.com/InsertKoinIO/koin` (Koin Annotations 4.x)
+ - `https://github.com/JetBrains/compose-multiplatform` (Navigation 3, Adaptive UI)
+ - `https://github.com/JuulLabs/kable` (BLE)
+ - `https://github.com/coil-kt/coil` (Coil 3 KMP)
+ - `https://github.com/ktorio/ktor` (Ktor Networking)
+- **Formatting Hooks:** Always run `./gradlew spotlessApply` as an automatic formatting hook to fix style violations after editing.
+
-## 2. Codebase Map
+
+`AGENTS.md` is the single source of truth for agent instructions. Agent-specific files redirect here:
+- `.github/copilot-instructions.md` — Copilot redirect to `AGENTS.md`.
+- `CLAUDE.md` — Claude Code entry point; imports `AGENTS.md` via `@AGENTS.md` and adds Claude-specific instructions.
+- `GEMINI.md` — Gemini redirect to `AGENTS.md`. Gemini CLI also configured via `.gemini/settings.json` to read `AGENTS.md` directly.
-| 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`). |
-| `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. |
-| `core:proto` | Protobuf definitions (Git submodule). |
-| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
-| `core:database` | Room KMP database implementation. |
-| `core:datastore` | Multiplatform DataStore for preferences. |
-| `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:di` | Common DI qualifiers and dispatchers. |
-| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
-| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
-| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
-| `core:api` | Public AIDL/API integration module for external clients. |
-| `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 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()` 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. |
+Do NOT duplicate content into agent-specific files. When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `.skills/`, and `docs/kmp-status.md` as needed.
+
-## 3. Development Guidelines & Coding Standards
+
+- **No Lazy Coding:** DO NOT use placeholders like `// ... existing code ...`. Always provide complete, valid code blocks for the sections you modify to ensure correct diff application.
+- **No Framework Bleed:** NEVER import `java.*` or `android.*` in `commonMain`. Use KMP equivalents: `Okio` for `java.io.*`, `kotlinx.coroutines.sync.Mutex` for `java.util.concurrent.locks.*`, `atomicfu` or Mutex-guarded `mutableMapOf()` for `ConcurrentHashMap`. Use `org.meshtastic.core.common.util.ioDispatcher` instead of `Dispatchers.IO` directly.
+- **Koin Annotations:** Use `@Single`, `@Factory`, and `@KoinViewModel` inside `commonMain` instead of manual constructor trees. Do not enable A1 module compile safety — A3 full-graph validation (`VerifyModule`) is the correct approach because interfaces and implementations live in separate modules. Always register new feature modules in **both** `AppKoinModule.kt` and `DesktopKoinModule.kt`; they are not auto-activated.
+- **CMP Over Android:** Use `compose-multiplatform` constraints. `stringResource` only supports `%N$s` and `%N$d` — pre-format floats with `NumberFormatter.format()` from `core:common` and pass as `%N$s`. In ViewModels/coroutines use `getStringSuspend(Res.string.key)`; never blocking `getString()`. Always use `MeshtasticNavDisplay` (not raw `NavDisplay`) as the navigation host, and `NavigationBackHandler` (not Android's `BackHandler`) for back gestures in shared code.
+- **ProGuard:** When adding a reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro` and verify release builds.
+- **Always Check Docs:** If unsure about an abstraction, search `core:ui/commonMain` or `core:navigation/commonMain` before assuming it doesn't exist.
+- **Privacy First:** Never log or expose PII, location data, or cryptographic keys. Meshtastic is used for sensitive off-grid communication — treat all user data with extreme caution.
+- **Dependency Discipline:** Never add a library without first checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity goals. Prefer removing dependencies over adding them.
+- **Zero Lint Tolerance:** A task is incomplete if `detekt` fails or `spotlessCheck` does not pass for touched modules.
+- **Read Before Refactoring:** When a pattern contradicts best practices, analyze whether it is legacy debt or a deliberate architectural choice before proposing a change.
+
-### 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)`). 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.
-- **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.
-- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
+
+These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this
+section.
-### B. Logic & Data Layer
-- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module.
-- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives:
- - `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` or `expect`/`actual`.
- - `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- - `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`.
-- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
-- **Concurrency:** Use Kotlin Coroutines and Flow.
-- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
-- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
-- **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`.
-- **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.
+- **Delegate long autonomous work.** For sweeping audits, multi-hour investigations, or "fleet"
+ prompts (*"investigate why X is broken on release"*, *"audit the diff since tag vX.Y.Z"*,
+ *"review the codebase for best practices against spec Z"*), prefer `/delegate` so the GitHub
+ cloud agent opens a PR while the user keeps working locally. Don't tie up an interactive
+ session on work that can run unattended.
+- **Use `/research` for "latest hotness" prompts.** When the user asks for *"the latest scoop"*
+ on Kotlin / KMP / Compose / Koin trends, the built-in `/research` slash command performs deep
+ research across GitHub and the web with better source grounding than an ad-hoc prompt.
+- **Use `/plan` mode for "noodle it out" prompts.** When the user asks for an implementation
+ plan, a "walk me through next steps", or explicitly says "don't do anything yet" — switch to
+ plan mode (Shift+Tab or `/plan`). Plans persist in the session workspace and keep the agent
+ from prematurely editing files. Continue to write long-form plans and Mermaid diagrams to
+ `.agent_plans/` (git-ignored) for multi-module refactors.
+- **`/share` audit and review outputs.** After large audits, PR safety reviews, or release-cycle
+ quality passes, offer `/share` to export the findings to a gist or markdown file. These
+ reports are valuable artifacts — don't let them die in session history.
+- **Prefer `/rewind` or `ctrl+s` over retyping.** If a turn went sideways, `/rewind` reverts
+ file changes and the turn; `ctrl+s` submits while preserving the input for quick iteration.
+ Avoid re-issuing the same prompt verbatim.
+- **New-branch flow lives in a skill.** When the user says "fresh branch off fetched origin/main"
+ or "rebase PR #NNNN", consult `.skills/new-branch/SKILL.md` rather than re-deriving the recipe.
+
-### C. Namespacing
-- **Standard:** Use the `org.meshtastic.*` namespace for all code.
-- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
-
-## 4. Execution Protocol
-
-### A. Environment Setup
-1. **JDK 17 MUST be used** to prevent Gradle sync/build failures.
-2. **Secrets:** You must copy `secrets.defaults.properties` to `local.properties`:
- ```properties
- MAPS_API_KEY=dummy_key
- datadogApplicationId=dummy_id
- datadogClientToken=dummy_token
- ```
-
-### B. Strict Execution Commands
-Always run commands in the following order to ensure reliability. Do not attempt to bypass `clean` if you are facing build issues.
-
-**Baseline (recommended order):**
-```bash
-./gradlew clean
-./gradlew spotlessCheck
-./gradlew spotlessApply
-./gradlew detekt
-./gradlew assembleDebug
-./gradlew test
-```
-
-**Testing:**
-```bash
-./gradlew test # Run local unit tests
-./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest # CI-aligned Android unit tests (flavor-explicit)
-./gradlew connectedAndroidTest # Run instrumented tests
-./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests
-./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks
-```
-*Note: If testing Compose UI on the JVM (Robolectric) with Java 17, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
-
-**CI workflow conventions (GitHub Actions):**
-- Reusable CI is split into a host job and an Android matrix job in `.github/workflows/reusable-check.yml`.
-- Host job runs style/static checks, explicit Android lint tasks, unit tests, and Kover XML coverage uploads once.
-- Android matrix job runs explicit assemble tasks for `app` and `mesh_service_example`; instrumentation is enabled by input and matrix API.
-- Prefer explicit Gradle task paths in CI (for example `app:lintFdroidDebug`, `app:connectedGoogleDebugAndroidTest`) instead of shorthand tasks like `lintDebug`.
-- Pull request CI is main-only (`.github/workflows/pull-request.yml` targets `main` branch).
-- Gradle cache writes are trusted on `main` and merge queue runs (`merge_group` / `gh-readonly-queue/*`); other refs use read-only cache mode in reusable CI.
-- 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.
-
-### C. Documentation Sync
-Update documentation continuously as part of the same change. If you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update the relevant docs (`AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md`).
-
-## 5. Troubleshooting
-- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
-- **Missing Secrets:** Check `local.properties`.
-- **JDK Version:** JDK 17 is required.
-- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist.
-- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`).
\ No newline at end of file
+
+- **Commit Hygiene:** Squash fixup/polish/review-feedback commits before opening a PR. Each commit should represent a logical, self-contained unit of work — not a back-and-forth conversation.
+- **PR Descriptions:** Keep PR descriptions concise and scannable. State *what changed* and *why*, not a per-commit play-by-play. Use a short summary paragraph followed by a bullet list of changes. Avoid tables, headers-per-commit, or verbose breakdowns. Reference the `meshtastic/firmware` repo PRs for tone and style.
+- **PR Titles:** Use conventional commit format: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `chore(scope):`. Keep titles under ~72 characters.
+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..eb5cd5e5c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,9 @@
+# Meshtastic Android - Claude Code Guide
+
+@AGENTS.md
+
+## Claude-Specific Instructions
+
+- **Think First:** Always outline your step-by-step reasoning inside `` tags before writing code or shell commands. Claude models perform significantly better on complex KMP tasks when they "think out loud" first.
+- **Skills:** The `.skills/` directory contains task-specific instruction modules. Load them as needed — only the skill relevant to your current task.
+- **Plan Mode:** Use plan mode for architectural changes spanning multiple modules. Write plans to `.agent_plans/` (git-ignored). The Copilot-CLI-specific `/plan`, `/delegate`, `/research`, and `/share` guidance in `AGENTS.md` does not apply to Claude Code — skip the `` section.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d64fe9976..d4fe0b740 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -48,7 +48,7 @@ Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI t
- **Unit tests** are located in the `src/test/` directory of each module.
- **Compose UI Tests (JVM)** are preferred for component testing and are also located in `src/test/` using **Robolectric**.
- - Note: If using Java 17, pin your Robolectric tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility issues.
+ - Note: If using Java 21, pin your Robolectric tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility issues.
- **Instrumented tests** (including full E2E UI tests) are located in `src/androidTest/`. For Compose UI, use the [Jetpack Compose Testing APIs](https://developer.android.com/jetpack/compose/testing).
#### Guidelines for Testing
diff --git a/GEMINI.md b/GEMINI.md
index aae64c1a2..72a350afb 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -1,145 +1,6 @@
-# Meshtastic Android - Agent Guide
+# Meshtastic Android - Google Gemini Guide
-This file serves as a comprehensive guide for AI agents and developers working on the `Meshtastic-Android` codebase. Use this as your primary reference for understanding the architecture, conventions, and strict rules of this project.
+> **Note:** The canonical instructions for all AI Agents have been deduplicated.
-For execution-focused recipes, see `docs/agent-playbooks/README.md`.
-
-## 1. Project Vision & Architecture
-Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, decentralized mesh networks. The goal is to decouple business logic from the Android framework, enabling future expansion to iOS and other platforms while maintaining a high-performance native Android experience.
-
-- **Language:** Kotlin (primary), AIDL.
-- **Build System:** Gradle (Kotlin DSL). JDK 17 is REQUIRED.
-- **Target SDK:** API 36. Min SDK: API 26 (Android 8.0).
-- **Flavors:**
- - `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()`, `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`.
- - **Navigation:** JetBrains Navigation 3 (Multiplatform fork) with shared backstack state.
- - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- - **Database:** Room KMP.
-
-## 2. Codebase Map
-
-| 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`). |
-| `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. |
-| `core:proto` | Protobuf definitions (Git submodule). |
-| `core:common` | Low-level utilities, I/O abstractions (Okio), and common types. |
-| `core:database` | Room KMP database implementation. |
-| `core:datastore` | Multiplatform DataStore for preferences. |
-| `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:di` | Common DI qualifiers and dispatchers. |
-| `core:navigation` | Shared navigation keys/routes for Navigation 3. |
-| `core:ui` | Shared Compose UI components (`AlertHost`, `SharedDialogs`, `PlaceholderScreen`, `MainAppBar`, dialogs, preferences) and platform abstractions. |
-| `core:service` | KMP service layer; Android bindings stay in `androidMain`. |
-| `core:api` | Public AIDL/API integration module for external clients. |
-| `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 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()` 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)`). 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.
-- **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.
-- **Platform/Flavor UI:** Inject platform-specific behavior (e.g., map providers) via `CompositionLocal` from `app`.
-
-### B. Logic & Data Layer
-- **KMP Focus:** All business logic must reside in `commonMain` of the respective `core` module.
-- **Platform purity:** Never import `java.*` or `android.*` in `commonMain`. Use KMP alternatives:
- - `java.util.Locale` → Kotlin `uppercase()` / `lowercase()` or `expect`/`actual`.
- - `java.util.concurrent.ConcurrentHashMap` → `atomicfu` or `Mutex`-guarded `mutableMapOf()`.
- - `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`.
-- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
-- **Concurrency:** Use Kotlin Coroutines and Flow.
-- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
-- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
-- **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`.
-- **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
-- **Standard:** Use the `org.meshtastic.*` namespace for all code.
-- **Legacy:** Maintain the `com.geeksville.mesh` Application ID.
-
-## 4. Execution Protocol
-
-### A. Environment Setup
-1. **JDK 17 MUST be used** to prevent Gradle sync/build failures.
-2. **Secrets:** You must copy `secrets.defaults.properties` to `local.properties`:
- ```properties
- MAPS_API_KEY=dummy_key
- datadogApplicationId=dummy_id
- datadogClientToken=dummy_token
- ```
-
-### B. Strict Execution Commands
-Always run commands in the following order to ensure reliability. Do not attempt to bypass `clean` if you are facing build issues.
-
-**Baseline (recommended order):**
-```bash
-./gradlew clean
-./gradlew spotlessCheck
-./gradlew spotlessApply
-./gradlew detekt
-./gradlew assembleDebug
-./gradlew test
-```
-
-**Testing:**
-```bash
-./gradlew test # Run local unit tests
-./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest # CI-aligned Android unit tests (flavor-explicit)
-./gradlew connectedAndroidTest # Run instrumented tests
-./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests
-./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks
-```
-*Note: If testing Compose UI on the JVM (Robolectric) with Java 17, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
-
-**CI workflow conventions (GitHub Actions):**
-- Reusable CI is split into a host job and an Android matrix job in `.github/workflows/reusable-check.yml`.
-- Host job runs style/static checks, explicit Android lint tasks, unit tests, and Kover XML coverage uploads once.
-- Android matrix job runs explicit assemble tasks for `app` and `mesh_service_example`; instrumentation is enabled by input and matrix API.
-- Prefer explicit Gradle task paths in CI (for example `app:lintFdroidDebug`, `app:connectedGoogleDebugAndroidTest`) instead of shorthand tasks like `lintDebug`.
-- Pull request CI is main-only (`.github/workflows/pull-request.yml` targets `main` branch).
-- Gradle cache writes are trusted on `main` and merge queue runs (`merge_group` / `gh-readonly-queue/*`); other refs use read-only cache mode in reusable CI.
-- 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.
-
-### C. Documentation Sync
-Update documentation continuously as part of the same change. If you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update the relevant docs (`AGENTS.md`, `.github/copilot-instructions.md`, `GEMINI.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md`).
-
-## 5. Troubleshooting
-- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
-- **Missing Secrets:** Check `local.properties`.
-- **JDK Version:** JDK 17 is required.
-- **Configuration Cache:** Add `--no-configuration-cache` flag if cache-related issues persist.
-- **Koin Injection Failures:** Verify the KMP component is included in `app` root module wiring (`AppKoinModule`).
\ No newline at end of file
+You MUST immediately read and internalize the unified instructions located at the root of the repository in `AGENTS.md`.
+After reading `AGENTS.md`, consult the `.skills/` directory for task-specific playbooks.
diff --git a/Gemfile.lock b/Gemfile.lock
index de497cc4a..cf6a1b9c0 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
- addressable (2.8.8)
+ addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
- aws-partitions (1.1213.0)
- aws-sdk-core (3.242.0)
+ aws-partitions (1.1240.0)
+ aws-sdk-core (3.245.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,11 +17,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
- aws-sdk-kms (1.121.0)
- aws-sdk-core (~> 3, >= 3.241.4)
+ aws-sdk-kms (1.123.0)
+ aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.213.0)
- aws-sdk-core (~> 3, >= 3.241.4)
+ aws-sdk-s3 (1.219.0)
+ aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -29,7 +29,7 @@ GEM
babosa (1.0.4)
base64 (0.2.0)
benchmark (0.5.0)
- bigdecimal (4.0.1)
+ bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -68,11 +68,11 @@ GEM
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
- faraday-retry (1.0.3)
+ faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
- fastimage (2.4.0)
- fastlane (2.232.2)
+ fastimage (2.4.1)
+ fastlane (2.233.0)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
@@ -92,7 +92,7 @@ GEM
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
- fastlane-sirp (>= 1.0.0)
+ fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@@ -122,10 +122,9 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
- fastlane-sirp (1.0.0)
- sysrandom (~> 1.0)
+ fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
- google-apis-androidpublisher_v3 (0.95.0)
+ google-apis-androidpublisher_v3 (0.99.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@@ -139,15 +138,15 @@ GEM
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
- google-apis-storage_v1 (0.59.0)
+ google-apis-storage_v1 (0.61.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
- google-cloud-errors (1.5.0)
- google-cloud-storage (1.58.0)
+ google-cloud-errors (1.6.0)
+ google-cloud-storage (1.59.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@@ -169,13 +168,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
- json (2.18.1)
+ json (2.19.4)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
- multi_json (1.19.1)
+ multi_json (1.20.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -185,13 +184,13 @@ GEM
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
- public_suffix (7.0.2)
- rake (13.3.1)
+ public_suffix (7.0.5)
+ rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
- retriable (3.1.2)
+ retriable (3.4.1)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
@@ -205,7 +204,6 @@ GEM
simctl (1.6.10)
CFPropertyList
naturally
- sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
diff --git a/README.md b/README.md
index b0e9ec1c7..2cc1ffe1c 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -64,7 +64,7 @@ The app follows modern Android development practices, built on top of a shared K
- **UI:** JetBrains Compose Multiplatform (Material 3) using Compose Multiplatform resources.
- **State Management:** Unidirectional Data Flow (UDF) with ViewModels, Coroutines, and Flow.
- **Dependency Injection:** Koin with Koin Annotations (K2 Compiler Plugin).
-- **Navigation:** JetBrains Navigation 3 (Multiplatform routing).
+- **Navigation:** JetBrains Navigation 3 (Multiplatform routing with RESTful deep linking).
- **Data Layer:** Repository pattern with Room KMP (local DB), DataStore (prefs), and Protobuf (device comms).
### Bluetooth Low Energy (BLE)
@@ -80,6 +80,8 @@ Developers can integrate with the Meshtastic Android app using our published API
For detailed integration instructions, see [core/api/README.md](core/api/README.md).
+Additionally, the app includes a built-in **Local TAK Server** feature that can be enabled in settings. This runs a local TCP server on port 8089 to allow ATAK clients to connect directly and route their traffic over the mesh.
+
## Building the Android App
> [!WARNING]
> Debug and release builds can be installed concurrently. This is solely to enable smoother development, and you should avoid running both apps simultaneously. To ensure proper function, force quit the app not in use.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..dc4df33df
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,12 @@
+# Security Policy
+
+## Supported Versions
+
+| App Version | Supported |
+| ---------------- | ------------------ |
+| 2.7.x | :white_check_mark: |
+| <= 2.6.x | :x: |
+
+## Reporting a Vulnerability
+
+We support the private reporting of potential security vulnerabilities. Please go to the Security tab to file a report with a description of the potential vulnerability and reproduction scripts (preferred) or steps, and our developers will review.
diff --git a/SOUL.md b/SOUL.md
deleted file mode 100644
index 793387334..000000000
--- a/SOUL.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Meshtastic-Android: AI Agent Soul (SOUL.md)
-
-This file defines the personality, values, and behavioral framework of the AI agent for this repository.
-
-## 1. Core Identity
-I am an **Android Architect**. My primary purpose is to evolve the Meshtastic-Android codebase while maintaining its integrity as a secure, decentralized communication tool. I am not just a "helpful assistant"; I am a senior peer programmer who takes ownership of the technical stack.
-
-## 2. Core Truths & Values
-- **Privacy is Paramount:** Meshtastic is used for off-grid, often sensitive communication. I treat user data, location info, and cryptographic keys with extreme caution. I will never suggest logging PII or secrets.
-- **Code is a Liability:** I prefer simple, readable code over clever abstractions. I remove dead code and minimize dependencies wherever possible.
-- **Decentralization First:** I prioritize architectural patterns that support offline-first and peer-to-peer logic.
-- **MAD & KMP are the Standard:** Modern Android Development (Compose, Koin, Coroutines) and Kotlin Multiplatform are not suggestions; they are the foundation. I resist introducing legacy patterns unless absolutely required for OS compatibility.
-
-## 3. Communication Style (The "Vibe")
-- **Direct & Concise:** I skip the fluff. I provide technical rationale first.
-- **Opinionated but Grounded:** I provide clear technical recommendations based on established project conventions.
-- **Action-Oriented:** I don't just "talk" about code; I implement, test, and format it.
-
-## 4. Operational Boundaries
-- **Zero Lint Tolerance (for code changes):** I consider a coding task incomplete if `detekt` fails or `spotlessCheck` is not passing for touched modules.
-- **Test-Driven Execution (where feasible):** For bug fixes, I should reproduce the issue with a test before fixing it when practical. For new features, I should add appropriate verification logic.
-- **Dependency Discipline:** I never add a library without checking `libs.versions.toml` and justifying its inclusion against the project's size and complexity.
-- **No Hardcoded Strings:** I will refuse to add hardcoded UI strings, strictly adhering to the `:core:resources` KMP resource system.
-
-## 5. Evolution
-I learn from the existing codebase. If I see a pattern in a module that contradicts my "soul," I will first analyze if it's a legacy debt or a deliberate choice before proposing a change. I adapt my technical opinions to align with the specific architectural direction set by the Meshtastic maintainers.
-
-For architecture, module boundaries, and build/test commands, I treat `AGENTS.md` as the source of truth.
-For implementation recipes and verification scope, I use `docs/agent-playbooks/README.md`.
-
-
diff --git a/app/README.md b/app/README.md
index cbd045e77..ff6f5542f 100644
--- a/app/README.md
+++ b/app/README.md
@@ -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.
@@ -42,6 +42,7 @@ graph TB
:app -.-> :core:resources
:app -.-> :core:ui
:app -.-> :core:barcode
+ :app -.-> :core:takserver
:app -.-> :feature:intro
:app -.-> :feature:messaging
:app -.-> :feature:connections
@@ -49,6 +50,7 @@ graph TB
:app -.-> :feature:node
:app -.-> :feature:settings
:app -.-> :feature:firmware
+ :app -.-> :feature:wifi-provision
:app -.-> :feature:widget
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
@@ -60,6 +62,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
+classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index d8018c588..d239d0530 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -150,7 +150,7 @@ configure {
includeInBundle = false
}
- testInstrumentationRunner = "org.meshtastic.app.TestRunner"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// Configure existing product flavors (defined by convention plugin)
@@ -171,8 +171,6 @@ configure {
} else {
signingConfig = signingConfigs.getByName("debug")
}
- isMinifyEnabled = true
- isShrinkResources = true
isDebuggable = false
}
}
@@ -219,6 +217,7 @@ dependencies {
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
+ implementation(libs.jetbrains.lifecycle.viewmodel.navigation3)
implementation(projects.core.network)
implementation(projects.core.nfc)
implementation(projects.core.prefs)
@@ -227,6 +226,7 @@ dependencies {
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.barcode)
+ implementation(projects.core.takserver)
implementation(projects.feature.intro)
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
@@ -234,38 +234,36 @@ dependencies {
implementation(projects.feature.node)
implementation(projects.feature.settings)
implementation(projects.feature.firmware)
+ implementation(projects.feature.wifiProvision)
implementation(projects.feature.widget)
implementation(libs.jetbrains.compose.material3.adaptive)
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
- implementation(libs.androidx.compose.material3.navigationSuite)
implementation(libs.material)
- implementation(libs.androidx.compose.material3)
- implementation(libs.androidx.compose.material.iconsExtended)
- implementation(libs.androidx.compose.ui.tooling.preview)
- implementation(libs.androidx.compose.ui.text)
+ implementation(libs.compose.multiplatform.animation)
+ implementation(libs.compose.multiplatform.material3)
+ implementation(libs.compose.multiplatform.ui.tooling.preview)
+ implementation(libs.compose.multiplatform.ui)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.appwidget.preview)
implementation(libs.androidx.glance.material3)
implementation(libs.androidx.lifecycle.process)
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.jetbrains.lifecycle.runtime.compose)
- implementation(libs.jetbrains.navigation3.runtime)
implementation(libs.jetbrains.navigation3.ui)
- implementation(libs.androidx.paging.compose)
- implementation(libs.ktor.client.okhttp)
+ implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
- implementation(libs.coil.network.okhttp)
+ implementation(libs.ktor.client.logging)
+ implementation(libs.coil)
+ implementation(libs.coil.network.ktor3)
implementation(libs.coil.svg)
implementation(libs.androidx.core.splashscreen)
implementation(libs.kotlinx.serialization.json)
- implementation(libs.okhttp3.logging.interceptor)
implementation(libs.usb.serial.android)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.koin.android)
- implementation(libs.koin.androidx.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.androidx.workmanager)
implementation(libs.koin.annotations)
@@ -281,10 +279,10 @@ dependencies {
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
- googleImplementation(libs.dd.sdk.android.okhttp)
- googleImplementation(libs.dd.sdk.android.compose)
googleImplementation(libs.dd.sdk.android.logs)
googleImplementation(libs.dd.sdk.android.rum)
+ googleImplementation(libs.dd.sdk.android.session.replay)
+ googleImplementation(libs.dd.sdk.android.session.replay.material)
googleImplementation(libs.dd.sdk.android.timber)
googleImplementation(libs.dd.sdk.android.trace)
googleImplementation(libs.dd.sdk.android.trace.otel)
@@ -296,34 +294,29 @@ dependencies {
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)
- androidTestImplementation(libs.androidx.test.runner)
- androidTestImplementation(libs.androidx.test.ext.junit)
- androidTestImplementation(libs.kotlinx.coroutines.test)
- androidTestImplementation(libs.androidx.compose.ui.test.junit4)
- androidTestImplementation(libs.koin.test)
-
+ testImplementation(kotlin("test-junit"))
testImplementation(libs.androidx.work.testing)
testImplementation(libs.koin.test)
- testImplementation(libs.junit)
+ testRuntimeOnly(libs.junit.vintage.engine)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
- testImplementation(libs.androidx.compose.ui.test.junit4)
+ testImplementation(libs.compose.multiplatform.ui.test)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.glance.appwidget)
}
aboutLibraries {
- // Fetch full license text + funding info from GitHub API when on CI with a token
- val isCi =
- providers
- .gradleProperty("ci")
- .map { it.toBoolean() }
- .getOrElse(providers.environmentVariable("CI").map { it.toBoolean() }.getOrElse(false))
+ // Run offline by default to avoid burning GitHub API calls on every build.
+ // Release builds pass -PaboutLibraries.release=true to fetch full license text + funding info.
+ val isReleaseBuild = providers.gradleProperty("aboutLibraries.release").map { it.toBoolean() }.getOrElse(false)
val ghToken = providers.environmentVariable("GITHUB_TOKEN")
+
+ offlineMode = !isReleaseBuild
+
collect {
- fetchRemoteLicense = isCi && ghToken.isPresent
- fetchRemoteFunding = isCi && ghToken.isPresent
+ fetchRemoteLicense = isReleaseBuild && ghToken.isPresent
+ fetchRemoteFunding = isReleaseBuild && ghToken.isPresent
if (ghToken.isPresent) {
gitHubApiToken = ghToken.get()
}
diff --git a/app/dependencies/googleReleaseRuntimeClasspath.txt b/app/dependencies/googleReleaseRuntimeClasspath.txt
deleted file mode 100644
index c6b9b1427..000000000
--- a/app/dependencies/googleReleaseRuntimeClasspath.txt
+++ /dev/null
@@ -1,415 +0,0 @@
-androidx.activity:activity-compose:1.12.3
-androidx.activity:activity-ktx:1.12.3
-androidx.activity:activity:1.12.3
-androidx.annotation:annotation-experimental:1.5.1
-androidx.annotation:annotation-jvm:1.9.1
-androidx.annotation:annotation:1.9.1
-androidx.appcompat:appcompat-resources:1.7.1
-androidx.appcompat:appcompat:1.7.1
-androidx.arch.core:core-common:2.2.0
-androidx.arch.core:core-runtime:2.2.0
-androidx.autofill:autofill:1.0.0
-androidx.cardview:cardview:1.0.0
-androidx.collection:collection-jvm:1.5.0
-androidx.collection:collection-ktx:1.5.0
-androidx.collection:collection:1.5.0
-androidx.compose.animation:animation-android:1.11.0-alpha04
-androidx.compose.animation:animation-core-android:1.11.0-alpha04
-androidx.compose.animation:animation-core:1.11.0-alpha04
-androidx.compose.animation:animation:1.11.0-alpha04
-androidx.compose.foundation:foundation-android:1.11.0-alpha04
-androidx.compose.foundation:foundation-layout-android:1.11.0-alpha04
-androidx.compose.foundation:foundation-layout:1.11.0-alpha04
-androidx.compose.foundation:foundation:1.11.0-alpha04
-androidx.compose.material3.adaptive:adaptive-android:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive-layout-android:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive-layout:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive-navigation-android:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive-navigation:1.3.0-alpha07
-androidx.compose.material3.adaptive:adaptive:1.3.0-alpha07
-androidx.compose.material3:material3-adaptive-navigation-suite-android:1.5.0-alpha13
-androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-alpha13
-androidx.compose.material3:material3-android:1.5.0-alpha13
-androidx.compose.material3:material3:1.5.0-alpha13
-androidx.compose.material:material-android:1.11.0-alpha04
-androidx.compose.material:material-icons-core-android:1.7.8
-androidx.compose.material:material-icons-core:1.7.8
-androidx.compose.material:material-icons-extended-android:1.7.8
-androidx.compose.material:material-icons-extended:1.7.8
-androidx.compose.material:material-ripple-android:1.11.0-alpha04
-androidx.compose.material:material-ripple:1.11.0-alpha04
-androidx.compose.material:material:1.11.0-alpha04
-androidx.compose.runtime:runtime-android:1.11.0-alpha04
-androidx.compose.runtime:runtime-annotation-android:1.11.0-alpha04
-androidx.compose.runtime:runtime-annotation:1.11.0-alpha04
-androidx.compose.runtime:runtime-livedata:1.11.0-alpha04
-androidx.compose.runtime:runtime-retain-android:1.11.0-alpha04
-androidx.compose.runtime:runtime-retain:1.11.0-alpha04
-androidx.compose.runtime:runtime-saveable-android:1.11.0-alpha04
-androidx.compose.runtime:runtime-saveable:1.11.0-alpha04
-androidx.compose.runtime:runtime-tracing:1.11.0-alpha04
-androidx.compose.runtime:runtime:1.11.0-alpha04
-androidx.compose.ui:ui-android:1.11.0-alpha04
-androidx.compose.ui:ui-geometry-android:1.11.0-alpha04
-androidx.compose.ui:ui-geometry:1.11.0-alpha04
-androidx.compose.ui:ui-graphics-android:1.11.0-alpha04
-androidx.compose.ui:ui-graphics:1.11.0-alpha04
-androidx.compose.ui:ui-text-android:1.11.0-alpha04
-androidx.compose.ui:ui-text:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-android:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-data-android:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-data:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-preview-android:1.11.0-alpha04
-androidx.compose.ui:ui-tooling-preview:1.11.0-alpha04
-androidx.compose.ui:ui-tooling:1.11.0-alpha04
-androidx.compose.ui:ui-unit-android:1.11.0-alpha04
-androidx.compose.ui:ui-unit:1.11.0-alpha04
-androidx.compose.ui:ui-util-android:1.11.0-alpha04
-androidx.compose.ui:ui-util:1.11.0-alpha04
-androidx.compose.ui:ui:1.11.0-alpha04
-androidx.compose:compose-bom-alpha:2026.01.01
-androidx.compose:compose-bom:2026.01.00
-androidx.concurrent:concurrent-futures-ktx:1.1.0
-androidx.concurrent:concurrent-futures:1.1.0
-androidx.constraintlayout:constraintlayout-core:1.0.0
-androidx.constraintlayout:constraintlayout:2.1.0
-androidx.coordinatorlayout:coordinatorlayout:1.1.0
-androidx.core:core-ktx:1.17.0
-androidx.core:core-location-altitude-external-protobuf:1.0.0-beta01
-androidx.core:core-location-altitude-proto:1.0.0-beta01
-androidx.core:core-location-altitude:1.0.0-beta01
-androidx.core:core-splashscreen:1.2.0
-androidx.core:core-viewtree:1.0.0
-androidx.core:core:1.17.0
-androidx.cursoradapter:cursoradapter:1.0.0
-androidx.customview:customview-poolingcontainer:1.0.0
-androidx.customview:customview:1.1.0
-androidx.databinding:viewbinding:8.13.2
-androidx.datastore:datastore-android:1.2.0
-androidx.datastore:datastore-core-android:1.2.0
-androidx.datastore:datastore-core-okio-jvm:1.2.0
-androidx.datastore:datastore-core-okio:1.2.0
-androidx.datastore:datastore-core:1.2.0
-androidx.datastore:datastore-preferences-android:1.2.0
-androidx.datastore:datastore-preferences-core-android:1.2.0
-androidx.datastore:datastore-preferences-core:1.2.0
-androidx.datastore:datastore-preferences-external-protobuf:1.2.0
-androidx.datastore:datastore-preferences-proto:1.2.0
-androidx.datastore:datastore-preferences:1.2.0
-androidx.datastore:datastore:1.2.0
-androidx.documentfile:documentfile:1.0.0
-androidx.drawerlayout:drawerlayout:1.1.1
-androidx.dynamicanimation:dynamicanimation:1.1.0
-androidx.emoji2:emoji2-emojipicker:1.6.0
-androidx.emoji2:emoji2-views-helper:1.6.0
-androidx.emoji2:emoji2:1.6.0
-androidx.exifinterface:exifinterface:1.4.1
-androidx.fragment:fragment-ktx:1.6.2
-androidx.fragment:fragment:1.6.2
-androidx.graphics:graphics-path:1.0.1
-androidx.graphics:graphics-shapes-android:1.0.1
-androidx.graphics:graphics-shapes:1.0.1
-androidx.hilt:hilt-common:1.3.0
-androidx.hilt:hilt-lifecycle-viewmodel-compose:1.3.0
-androidx.hilt:hilt-lifecycle-viewmodel:1.3.0
-androidx.hilt:hilt-work:1.3.0
-androidx.interpolator:interpolator:1.0.0
-androidx.legacy:legacy-support-core-utils:1.0.0
-androidx.lifecycle:lifecycle-common-java8:2.10.0
-androidx.lifecycle:lifecycle-common-jvm:2.10.0
-androidx.lifecycle:lifecycle-common:2.10.0
-androidx.lifecycle:lifecycle-livedata-core-ktx:2.10.0
-androidx.lifecycle:lifecycle-livedata-core:2.10.0
-androidx.lifecycle:lifecycle-livedata-ktx:2.10.0
-androidx.lifecycle:lifecycle-livedata:2.10.0
-androidx.lifecycle:lifecycle-process:2.10.0
-androidx.lifecycle:lifecycle-runtime-android:2.10.0
-androidx.lifecycle:lifecycle-runtime-compose-android:2.10.0
-androidx.lifecycle:lifecycle-runtime-compose:2.10.0
-androidx.lifecycle:lifecycle-runtime-ktx-android:2.10.0
-androidx.lifecycle:lifecycle-runtime-ktx:2.10.0
-androidx.lifecycle:lifecycle-runtime:2.10.0
-androidx.lifecycle:lifecycle-service:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-android:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-compose-android:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.10.0
-androidx.lifecycle:lifecycle-viewmodel-savedstate:2.10.0
-androidx.lifecycle:lifecycle-viewmodel:2.10.0
-androidx.loader:loader:1.0.0
-androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
-androidx.metrics:metrics-performance:1.0.0-beta03
-androidx.navigation3:navigation3-runtime-android:1.0.0
-androidx.navigation3:navigation3-runtime:1.0.0
-androidx.navigation3:navigation3-ui-android:1.0.0
-androidx.navigation3:navigation3-ui:1.0.0
-androidx.navigation:navigation-common-android:2.9.7
-androidx.navigation:navigation-common:2.9.7
-androidx.navigation:navigation-compose-android:2.9.7
-androidx.navigation:navigation-compose:2.9.7
-androidx.navigation:navigation-fragment:2.9.7
-androidx.navigation:navigation-runtime-android:2.9.7
-androidx.navigation:navigation-runtime:2.9.7
-androidx.navigationevent:navigationevent-android:1.0.2
-androidx.navigationevent:navigationevent-compose-android:1.0.2
-androidx.navigationevent:navigationevent-compose:1.0.2
-androidx.navigationevent:navigationevent:1.0.2
-androidx.paging:paging-common-android:3.4.0
-androidx.paging:paging-common:3.4.0
-androidx.paging:paging-compose-android:3.4.0
-androidx.paging:paging-compose:3.4.0
-androidx.print:print:1.0.0
-androidx.privacysandbox.ads:ads-adservices-java:1.1.0-beta11
-androidx.privacysandbox.ads:ads-adservices:1.1.0-beta11
-androidx.profileinstaller:profileinstaller:1.4.1
-androidx.recyclerview:recyclerview:1.3.2
-androidx.resourceinspection:resourceinspection-annotation:1.0.1
-androidx.room:room-common-jvm:2.8.4
-androidx.room:room-common:2.8.4
-androidx.room:room-paging-android:2.8.4
-androidx.room:room-paging:2.8.4
-androidx.room:room-runtime-android:2.8.4
-androidx.room:room-runtime:2.8.4
-androidx.savedstate:savedstate-android:1.4.0
-androidx.savedstate:savedstate-compose-android:1.4.0
-androidx.savedstate:savedstate-compose:1.4.0
-androidx.savedstate:savedstate-ktx:1.4.0
-androidx.savedstate:savedstate:1.4.0
-androidx.slidingpanelayout:slidingpanelayout:1.2.0
-androidx.sqlite:sqlite-android:2.6.2
-androidx.sqlite:sqlite-framework-android:2.6.2
-androidx.sqlite:sqlite-framework:2.6.2
-androidx.sqlite:sqlite:2.6.2
-androidx.startup:startup-runtime:1.2.0
-androidx.tracing:tracing-ktx:1.2.0
-androidx.tracing:tracing-perfetto:1.0.1
-androidx.tracing:tracing:1.2.0
-androidx.transition:transition:1.6.0
-androidx.vectordrawable:vectordrawable-animated:1.1.0
-androidx.vectordrawable:vectordrawable:1.1.0
-androidx.versionedparcelable:versionedparcelable:1.1.1
-androidx.viewpager2:viewpager2:1.1.0-beta02
-androidx.viewpager:viewpager:1.0.0
-androidx.window:window-core-android:1.5.0
-androidx.window:window-core:1.5.0
-androidx.window:window:1.5.0
-androidx.work:work-runtime-ktx:2.11.1
-androidx.work:work-runtime:2.11.1
-co.touchlab:kermit-android:2.0.8
-co.touchlab:kermit-core-android:2.0.8
-co.touchlab:kermit-core:2.0.8
-co.touchlab:kermit:2.0.8
-com.caverock:androidsvg-aar:1.4
-com.datadoghq:dd-sdk-android-compose:3.6.0
-com.datadoghq:dd-sdk-android-core:3.6.0
-com.datadoghq:dd-sdk-android-internal:3.6.0
-com.datadoghq:dd-sdk-android-logs:3.6.0
-com.datadoghq:dd-sdk-android-okhttp:3.6.0
-com.datadoghq:dd-sdk-android-rum:3.6.0
-com.datadoghq:dd-sdk-android-session-replay-compose:3.6.0
-com.datadoghq:dd-sdk-android-session-replay:3.6.0
-com.datadoghq:dd-sdk-android-timber:3.6.0
-com.datadoghq:dd-sdk-android-trace-api:3.6.0
-com.datadoghq:dd-sdk-android-trace-internal:3.6.0
-com.datadoghq:dd-sdk-android-trace-otel:3.6.0
-com.datadoghq:dd-sdk-android-trace:3.6.0
-com.github.mik3y:usb-serial-for-android:3.10.0
-com.google.accompanist:accompanist-drawablepainter:0.37.3
-com.google.accompanist:accompanist-permissions:0.37.3
-com.google.android.datatransport:transport-api:3.2.0
-com.google.android.datatransport:transport-backend-cct:3.3.0
-com.google.android.datatransport:transport-runtime:3.3.0
-com.google.android.gms:play-services-ads-identifier:18.0.0
-com.google.android.gms:play-services-base:18.5.0
-com.google.android.gms:play-services-basement:18.9.0
-com.google.android.gms:play-services-location:21.3.0
-com.google.android.gms:play-services-maps:20.0.0
-com.google.android.gms:play-services-measurement-api:23.0.0
-com.google.android.gms:play-services-measurement-base:23.0.0
-com.google.android.gms:play-services-measurement-impl:23.0.0
-com.google.android.gms:play-services-measurement-sdk-api:23.0.0
-com.google.android.gms:play-services-measurement-sdk:23.0.0
-com.google.android.gms:play-services-measurement:23.0.0
-com.google.android.gms:play-services-stats:17.0.2
-com.google.android.gms:play-services-tasks:18.4.0
-com.google.android.material:material:1.13.0
-com.google.auto.value:auto-value-annotations:1.6.3
-com.google.code.findbugs:jsr305:3.0.2
-com.google.code.gson:gson:2.13.2
-com.google.dagger:dagger-lint-aar:2.59
-com.google.dagger:dagger:2.59
-com.google.dagger:hilt-android:2.59
-com.google.dagger:hilt-core:2.59
-com.google.errorprone:error_prone_annotations:2.41.0
-com.google.firebase:firebase-analytics:23.0.0
-com.google.firebase:firebase-annotations:17.0.0
-com.google.firebase:firebase-bom:34.8.0
-com.google.firebase:firebase-common:22.0.1
-com.google.firebase:firebase-components:19.0.0
-com.google.firebase:firebase-config-interop:16.0.1
-com.google.firebase:firebase-crashlytics:20.0.4
-com.google.firebase:firebase-datatransport:19.0.0
-com.google.firebase:firebase-encoders-json:18.0.1
-com.google.firebase:firebase-encoders-proto:16.0.0
-com.google.firebase:firebase-encoders:17.0.0
-com.google.firebase:firebase-installations-interop:17.2.0
-com.google.firebase:firebase-installations:19.0.1
-com.google.firebase:firebase-measurement-connector:20.0.1
-com.google.firebase:firebase-sessions:3.0.4
-com.google.guava:failureaccess:1.0.3
-com.google.guava:guava:33.5.0-android
-com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
-com.google.j2objc:j2objc-annotations:3.1
-com.google.maps.android:android-maps-utils:4.0.0
-com.google.maps.android:maps-compose-utils:8.0.0
-com.google.maps.android:maps-compose-widgets:8.0.0
-com.google.maps.android:maps-compose:8.0.0
-com.google.maps.android:maps-ktx:6.0.0
-com.google.maps.android:maps-utils-ktx:6.0.0
-com.google.re2j:re2j:1.7
-com.google.zxing:core:3.5.4
-com.jakewharton.timber:timber:5.0.1
-com.journeyapps:zxing-android-embedded:4.3.0
-com.lyft.kronos:kronos-android:0.0.1-alpha11
-com.lyft.kronos:kronos-java:0.0.1-alpha11
-com.mikepenz:aboutlibraries-compose-core-android:13.2.1
-com.mikepenz:aboutlibraries-compose-core:13.2.1
-com.mikepenz:aboutlibraries-compose-m3-android:13.2.1
-com.mikepenz:aboutlibraries-compose-m3:13.2.1
-com.mikepenz:aboutlibraries-core-android:13.2.1
-com.mikepenz:aboutlibraries-core:13.2.1
-com.mikepenz:multiplatform-markdown-renderer-android:0.39.2
-com.mikepenz:multiplatform-markdown-renderer-m3-android:0.39.2
-com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2
-com.mikepenz:multiplatform-markdown-renderer:0.39.2
-com.patrykandpatrick.vico:compose-android:3.0.0-beta.3
-com.patrykandpatrick.vico:compose-m2-android:3.0.0-beta.3
-com.patrykandpatrick.vico:compose-m2:3.0.0-beta.3
-com.patrykandpatrick.vico:compose-m3-android:3.0.0-beta.3
-com.patrykandpatrick.vico:compose-m3:3.0.0-beta.3
-com.patrykandpatrick.vico:compose:3.0.0-beta.3
-com.squareup.okhttp3:logging-interceptor:5.3.2
-com.squareup.okhttp3:okhttp-android:5.3.2
-com.squareup.okhttp3:okhttp:5.3.2
-com.squareup.okio:okio-jvm:3.16.4
-com.squareup.okio:okio:3.16.4
-com.squareup.wire:wire-runtime-jvm:5.2.1
-com.squareup.wire:wire-runtime:5.2.1
-io.coil-kt.coil3:coil-android:3.3.0
-io.coil-kt.coil3:coil-compose-android:3.3.0
-io.coil-kt.coil3:coil-compose-core-android:3.3.0
-io.coil-kt.coil3:coil-compose-core:3.3.0
-io.coil-kt.coil3:coil-compose:3.3.0
-io.coil-kt.coil3:coil-core-android:3.3.0
-io.coil-kt.coil3:coil-core:3.3.0
-io.coil-kt.coil3:coil-network-core-android:3.3.0
-io.coil-kt.coil3:coil-network-core:3.3.0
-io.coil-kt.coil3:coil-network-okhttp-jvm:3.3.0
-io.coil-kt.coil3:coil-network-okhttp:3.3.0
-io.coil-kt.coil3:coil-svg-android:3.3.0
-io.coil-kt.coil3:coil-svg:3.3.0
-io.coil-kt.coil3:coil:3.3.0
-io.ktor:ktor-client-content-negotiation-jvm:3.4.0
-io.ktor:ktor-client-content-negotiation:3.4.0
-io.ktor:ktor-client-core-jvm:3.4.0
-io.ktor:ktor-client-core:3.4.0
-io.ktor:ktor-client-okhttp-jvm:3.4.0
-io.ktor:ktor-client-okhttp:3.4.0
-io.ktor:ktor-events-jvm:3.4.0
-io.ktor:ktor-events:3.4.0
-io.ktor:ktor-http-cio-jvm:3.4.0
-io.ktor:ktor-http-cio:3.4.0
-io.ktor:ktor-http-jvm:3.4.0
-io.ktor:ktor-http:3.4.0
-io.ktor:ktor-io-jvm:3.4.0
-io.ktor:ktor-io:3.4.0
-io.ktor:ktor-network-jvm:3.4.0
-io.ktor:ktor-network:3.4.0
-io.ktor:ktor-serialization-jvm:3.4.0
-io.ktor:ktor-serialization-kotlinx-json-jvm:3.4.0
-io.ktor:ktor-serialization-kotlinx-json:3.4.0
-io.ktor:ktor-serialization-kotlinx-jvm:3.4.0
-io.ktor:ktor-serialization-kotlinx:3.4.0
-io.ktor:ktor-serialization:3.4.0
-io.ktor:ktor-sse-jvm:3.4.0
-io.ktor:ktor-sse:3.4.0
-io.ktor:ktor-utils-jvm:3.4.0
-io.ktor:ktor-utils:3.4.0
-io.ktor:ktor-websocket-serialization-jvm:3.4.0
-io.ktor:ktor-websocket-serialization:3.4.0
-io.ktor:ktor-websockets-jvm:3.4.0
-io.ktor:ktor-websockets:3.4.0
-io.opentelemetry:opentelemetry-api:1.40.0
-io.opentelemetry:opentelemetry-context:1.40.0
-jakarta.inject:jakarta.inject-api:2.0.1
-javax.inject:javax.inject:1
-no.nordicsemi.android:dfu:2.10.1
-no.nordicsemi.kotlin.ble:client-android:2.0.0-alpha12
-no.nordicsemi.kotlin.ble:client-core-android:2.0.0-alpha12
-no.nordicsemi.kotlin.ble:client-core:2.0.0-alpha12
-no.nordicsemi.kotlin.ble:core:2.0.0-alpha12
-org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5
-org.jctools:jctools-core:3.3.0
-org.jetbrains.androidx.lifecycle:lifecycle-common:2.9.6
-org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.6
-org.jetbrains.androidx.lifecycle:lifecycle-runtime:2.9.6
-org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.6
-org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.6
-org.jetbrains.androidx.savedstate:savedstate-compose:1.3.6
-org.jetbrains.androidx.savedstate:savedstate:1.3.6
-org.jetbrains.compose.animation:animation-core:1.10.0
-org.jetbrains.compose.animation:animation:1.10.0
-org.jetbrains.compose.annotation-internal:annotation:1.10.0
-org.jetbrains.compose.collection-internal:collection:1.10.0
-org.jetbrains.compose.components:components-resources-android:1.10.0
-org.jetbrains.compose.components:components-resources:1.10.0
-org.jetbrains.compose.foundation:foundation-layout:1.10.0
-org.jetbrains.compose.foundation:foundation:1.10.0
-org.jetbrains.compose.material3:material3:1.9.0
-org.jetbrains.compose.material:material-ripple:1.10.0
-org.jetbrains.compose.material:material:1.10.0
-org.jetbrains.compose.runtime:runtime-saveable:1.10.0
-org.jetbrains.compose.runtime:runtime:1.10.0
-org.jetbrains.compose.ui:ui-backhandler-android:1.9.1
-org.jetbrains.compose.ui:ui-backhandler:1.9.1
-org.jetbrains.compose.ui:ui-geometry:1.10.0
-org.jetbrains.compose.ui:ui-graphics:1.10.0
-org.jetbrains.compose.ui:ui-text:1.10.0
-org.jetbrains.compose.ui:ui-tooling-preview:1.10.0-rc02
-org.jetbrains.compose.ui:ui-unit:1.10.0
-org.jetbrains.compose.ui:ui-util:1.10.0
-org.jetbrains.compose.ui:ui:1.10.0
-org.jetbrains.kotlin:kotlin-bom:1.8.22
-org.jetbrains.kotlin:kotlin-parcelize-runtime:2.3.0
-org.jetbrains.kotlin:kotlin-stdlib-common:2.3.0
-org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.3.0
-org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21
-org.jetbrains.kotlin:kotlin-stdlib:2.3.0
-org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0
-org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0
-org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2
-org.jetbrains.kotlinx:kotlinx-coroutines-slf4j:1.10.2
-org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1-0.6.x-compat
-org.jetbrains.kotlinx:kotlinx-datetime:0.7.1-0.6.x-compat
-org.jetbrains.kotlinx:kotlinx-io-bytestring-jvm:0.8.2
-org.jetbrains.kotlinx:kotlinx-io-bytestring:0.8.2
-org.jetbrains.kotlinx:kotlinx-io-core-jvm:0.8.2
-org.jetbrains.kotlinx:kotlinx-io-core:0.8.2
-org.jetbrains.kotlinx:kotlinx-serialization-bom:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-core:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-json-io-jvm:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-json-io:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.10.0
-org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0
-org.jetbrains:annotations:23.0.0
-org.jetbrains:markdown-jvm:0.7.3
-org.jetbrains:markdown:0.7.3
-org.jspecify:jspecify:1.0.0
-org.slf4j:slf4j-api:2.0.17
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 732f4ae2d..de2b3144c 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,50 +1,45 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.kts.
+# ============================================================================
+# Meshtastic Android — ProGuard / R8 rules for release minification
+# ============================================================================
+# Open-source project: obfuscation and optimization are disabled. We rely on
+# tree-shaking (unused code removal) for APK size reduction.
#
-# For more details, see
-# http://developer.android.com/guide/developing/tools/proguard.html
+# Cross-platform library rules (Koin, kotlinx-serialization, Wire, Room,
+# Ktor, Coil, Kable, Kermit, Okio, DataStore, Paging, Lifecycle, Navigation 3,
+# AboutLibraries, Markdown, QRCode, CMP resources, core model) live in
+# config/proguard/shared-rules.pro and are wired in by the
+# AndroidApplicationConventionPlugin. This file holds only Android-specific
+# rules and R8-only directives.
+# ============================================================================
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-# public *;
-#}
+# ---- General ----------------------------------------------------------------
-# Uncomment this to preserve the line number information for
-# debugging stack traces.
--keepattributes SourceFile,LineNumberTable
+# Open-source — no need to obfuscate
+-dontobfuscate
-# If you keep the line number information, uncomment this to
-# hide the original source file name.
-#-renamesourcefileattribute SourceFile
+# Disable R8 optimization passes. Tree-shaking (unused code removal) still
+# runs — only method-body rewrites and call-site transformations are suppressed.
+#
+# Why: CMP 1.11 ships consumer rules with -assumenosideeffects on
+# Composer.() and ComposerImpl.(), plus -assumevalues on
+# ComposeRuntimeFlags and ComposeStackTraceMode. These optimization directives
+# let R8 rewrite *call sites* (class-init triggers, flag reads) even when the
+# target classes are preserved by -keep rules. The result is that the Compose
+# recomposer/frame-clock/animation state machines silently freeze on their
+# first frame in release builds. -dontoptimize is the only directive that
+# disables processing of -assumenosideeffects/-assumevalues. See #5146.
+-dontoptimize
-# Room KMP: preserve generated database constructor (required for R8/ProGuard)
--keep class * extends androidx.room.RoomDatabase { (); }
+# Dump the full merged R8 configuration (app rules + all library consumer rules)
+# for auditing. Inspect this file after a release build to see what libraries inject.
+-printconfiguration build/outputs/mapping/r8-merged-config.txt
-# Needed for protobufs
--keep class com.google.protobuf.** { *; }
--keep class org.meshtastic.proto.** { *; }
+# ---- Networking (transitive references from Ktor on Android) ----------------
-# OkHttp
--dontwarn okhttp3.internal.platform.**
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-# ?
--dontwarn java.lang.reflect.**
--dontwarn com.google.errorprone.annotations.**
-
-# Our app is opensource no need to obsfucate
--dontobfuscate
--optimizations !code/simplification/arithmetic,!field/*,!class/merging/*,!code/allocation/variable
-
-# R8 optimization for Kotlin null checks (AGP 9.0+)
--processkotlinnullchecks remove
-
-# Nordic BLE
--dontwarn no.nordicsemi.kotlin.ble.environment.android.mock.**
--keep class no.nordicsemi.kotlin.ble.environment.android.mock.** { *; }
--keep class no.nordicsemi.kotlin.ble.environment.android.compose.** { *; }
+# Compose runtime/ui/animation/foundation/material3 keep rules now live in
+# config/proguard/shared-rules.pro so both Android (R8) and desktop (ProGuard)
+# get the same defence-in-depth coverage against CMP 1.11 optimizer folding.
diff --git a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt
deleted file mode 100644
index 4cbf88356..000000000
--- a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.filter
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.koin.test.KoinTest
-import org.koin.test.inject
-import org.meshtastic.core.repository.FilterPrefs
-import org.meshtastic.core.repository.MessageFilter
-
-@RunWith(AndroidJUnit4::class)
-class MessageFilterIntegrationTest : KoinTest {
-
- private val filterPrefs: FilterPrefs by inject()
-
- private val filterService: MessageFilter by inject()
-
- @org.junit.Ignore("Flaky integration test, needs Koin test rule setup")
- @Test
- fun filterPrefsIntegration() = runTest {
- filterPrefs.setFilterEnabled(true)
- filterPrefs.setFilterWords(setOf("test", "spam"))
- // Wait briefly for DataStore to process the writes and flows to emit
- kotlinx.coroutines.delay(100)
- filterService.rebuildPatterns()
-
- assertTrue(filterService.shouldFilter("this is a test message"))
- assertTrue(filterService.shouldFilter("spam content"))
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt b/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
index 42f1f9a88..fba7a417f 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/di/FDroidNetworkModule.kt
@@ -16,11 +16,8 @@
*/
package org.meshtastic.app.di
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
-import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.network.service.ApiService
@@ -28,18 +25,6 @@ import org.meshtastic.core.network.service.ApiService
@Module
class FDroidNetworkModule {
- @Single
- fun provideOkHttpClient(buildConfigProvider: BuildConfigProvider): OkHttpClient = OkHttpClient.Builder()
- .addInterceptor(
- interceptor =
- HttpLoggingInterceptor().apply {
- if (buildConfigProvider.isDebug) {
- setLevel(HttpLoggingInterceptor.Level.BODY)
- }
- },
- )
- .build()
-
@Single
fun provideApiService(): ApiService = object : ApiService {
override suspend fun getDeviceHardware(): List =
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
index 99f184efc..21c2d4fde 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/FdroidMapViewProvider.kt
@@ -23,31 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
+/** OSMDroid implementation of [MapViewProvider]. */
@Single
class FdroidMapViewProvider : MapViewProvider {
@Composable
- override fun MapView(
- modifier: Modifier,
- viewModel: Any,
- navigateToNodeDetails: (Int) -> Unit,
- focusedNodeNum: Int?,
- nodeTracks: List?,
- tracerouteOverlay: Any?,
- tracerouteNodePositions: Map,
- onTracerouteMappableCountChanged: (Int, Int) -> Unit,
- waypointId: Int?,
- ) {
+ override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
- focusedNodeNum = focusedNodeNum,
- nodeTracks = nodeTracks as? List,
- tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
- tracerouteNodePositions = tracerouteNodePositions as? Map ?: emptyMap(),
- onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index afbedfa0b..b4d0e1bbd 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -17,10 +17,8 @@
package org.meshtastic.app.map
import android.Manifest
-import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@@ -32,24 +30,17 @@ import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Layers
-import androidx.compose.material.icons.outlined.MyLocation
-import androidx.compose.material.icons.outlined.Tune
-import androidx.compose.material.icons.rounded.Check
-import androidx.compose.material.icons.rounded.Lens
-import androidx.compose.material.icons.rounded.LocationDisabled
-import androidx.compose.material.icons.rounded.PinDrop
-import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -58,6 +49,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -65,8 +57,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -87,7 +77,6 @@ import org.meshtastic.app.map.cluster.RadiusMarkerClusterer
import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.component.EditWaypointDialog
-import org.meshtastic.app.map.component.MapButton
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
@@ -105,6 +94,7 @@ import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
@@ -114,7 +104,6 @@ import org.meshtastic.core.resources.map_clear_tiles
import org.meshtastic.core.resources.map_download_complete
import org.meshtastic.core.resources.map_download_errors
import org.meshtastic.core.resources.map_download_region
-import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_node_popup_details
import org.meshtastic.core.resources.map_offline_manager
import org.meshtastic.core.resources.map_purge_fail
@@ -123,21 +112,25 @@ import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.only_favorites
-import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
-import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.resources.waypoint_delete
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.ListItem
-import org.meshtastic.core.ui.theme.TracerouteColors
+import org.meshtastic.core.ui.icon.Check
+import org.meshtastic.core.ui.icon.Favorite
+import org.meshtastic.core.ui.icon.Layers
+import org.meshtastic.core.ui.icon.Lens
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.showToast
+import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
-import org.meshtastic.feature.map.model.TracerouteOverlay
-import org.meshtastic.feature.map.tracerouteNodeSelection
-import org.meshtastic.proto.Position
+import org.meshtastic.feature.map.component.MapButton
+import org.meshtastic.feature.map.component.MapControlsOverlay
+import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
@@ -156,38 +149,23 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
-import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import java.io.File
-import kotlin.math.abs
-import kotlin.math.asin
-import kotlin.math.atan2
-import kotlin.math.cos
-import kotlin.math.sin
+import kotlin.math.roundToInt
private fun MapView.updateMarkers(
nodeMarkers: List,
waypointMarkers: List,
- trackMarkers: List,
- trackPolylines: List,
nodeClusterer: RadiusMarkerClusterer,
) {
- Logger.d {
- "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints ${trackMarkers.size} tracks"
- }
-
- val trackOverlayIds = (trackMarkers + trackPolylines).toSet()
+ Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" }
overlays.removeAll { overlay ->
- overlay is MarkerWithLabel ||
- (overlay is Marker && overlay !in nodeClusterer.items && overlay !in trackOverlayIds) ||
- (overlay is Polyline && overlay !in trackOverlayIds)
+ overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items)
}
overlays.addAll(waypointMarkers)
- overlays.addAll(trackPolylines)
- overlays.addAll(trackMarkers)
nodeClusterer.items.clear()
nodeClusterer.items.addAll(nodeMarkers)
@@ -225,17 +203,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
*/
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
-@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod")
+@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun MapView(
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
navigateToNodeDetails: (Int) -> Unit,
- focusedNodeNum: Int? = null,
- nodeTracks: List? = null,
- tracerouteOverlay: TracerouteOverlay? = null,
- tracerouteNodePositions: Map = emptyMap(),
- onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
) {
var mapFilterExpanded by remember { mutableStateOf(false) }
@@ -334,6 +307,16 @@ fun MapView(
}
}
+ // Keep screen on while location tracking is active
+ LaunchedEffect(myLocationOverlay) {
+ val activity = context as? android.app.Activity ?: return@LaunchedEffect
+ if (myLocationOverlay != null) {
+ activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ } else {
+ activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+ }
+
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
@@ -349,77 +332,21 @@ fun MapView(
}
}
- val tracerouteSelection =
- remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
- mapViewModel.tracerouteNodeSelection(
- tracerouteOverlay = tracerouteOverlay,
- tracerouteNodePositions = tracerouteNodePositions,
- nodes = nodes,
- )
- }
- val overlayNodeNums = tracerouteSelection.overlayNodeNums
- val nodeLookup = tracerouteSelection.nodeLookup
- val nodesForMarkers = tracerouteSelection.nodesForMarkers
- val tracerouteForwardPoints =
- remember(tracerouteOverlay, nodeLookup) {
- tracerouteOverlay?.forwardRoute?.mapNotNull {
- nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
- } ?: emptyList()
- }
- val tracerouteReturnPoints =
- remember(tracerouteOverlay, nodeLookup) {
- tracerouteOverlay?.returnRoute?.mapNotNull {
- nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
- } ?: emptyList()
- }
- LaunchedEffect(tracerouteOverlay, nodesForMarkers) {
- if (tracerouteOverlay != null) {
- onTracerouteMappableCountChanged(nodesForMarkers.size, tracerouteOverlay.relatedNodeNums.size)
- }
- }
- val tracerouteHeadingReferencePoints =
- remember(tracerouteForwardPoints, tracerouteReturnPoints) {
- when {
- tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
- tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
- else -> emptyList()
- }
- }
- val tracerouteForwardOffsetPoints =
- remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(
- points = tracerouteForwardPoints,
- offsetMeters = TRACEROUTE_OFFSET_METERS,
- headingReferencePoints = tracerouteHeadingReferencePoints,
- sideMultiplier = 1.0,
- )
- }
- val tracerouteReturnOffsetPoints =
- remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(
- points = tracerouteReturnPoints,
- offsetMeters = TRACEROUTE_OFFSET_METERS,
- headingReferencePoints = tracerouteHeadingReferencePoints,
- sideMultiplier = -1.0,
- )
- }
- val traceroutePolylines = remember { mutableStateListOf() }
- var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
-
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
fun MapView.onNodesChanged(nodes: Collection): List {
val nodesWithPosition = nodes.filter { it.validPosition != null }
val ourNode = mapViewModel.ourNodeInfo.value
- val displayUnits =
- mapViewModel.config.display?.units ?: org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.METRIC
+ val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node ->
+ if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
+ return@mapNotNull null
+ }
if (
- mapFilterStateValue.onlyFavorites &&
- !node.isFavorite &&
- !overlayNodeNums.contains(node.num) &&
- !node.equals(ourNode)
+ mapFilterStateValue.lastHeardFilter.seconds != 0L &&
+ (nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds &&
+ node.num != ourNode?.num
) {
return@mapNotNull null
}
@@ -449,7 +376,7 @@ fun MapView(
if (!mapFilterStateValue.showPrecisionCircle) {
setPrecisionBits(0)
} else {
- setPrecisionBits(p.precision_bits ?: 0)
+ setPrecisionBits(p.precision_bits)
}
setOnLongClickListener {
navigateToNodeDetails(node.num)
@@ -469,7 +396,7 @@ fun MapView(
Logger.d { "User deleted waypoint ${waypoint.id} for me" }
mapViewModel.deleteWaypoint(waypoint.id)
}
- if ((waypoint.locked_to ?: 0) in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
+ if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
builder.setPositiveButton(getString(Res.string.delete_for_everyone)) { _, _ ->
Logger.d { "User deleted waypoint ${waypoint.id} for everyone" }
mapViewModel.sendWaypoint(waypoint.copy(expire = 1))
@@ -497,7 +424,7 @@ fun MapView(
Logger.d { "marker long pressed id=$id" }
val waypoint = waypoints[id]?.waypoint ?: return
// edit only when unlocked or lockedTo myNodeNum
- if ((waypoint.locked_to ?: 0) in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
+ if (waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected) {
showEditWaypointDialog = waypoint
} else {
showDeleteMarkerDialog(waypoint)
@@ -515,15 +442,15 @@ fun MapView(
return waypoints.mapNotNull { waypoint ->
val pt = waypoint.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
- val lock = if ((pt.locked_to ?: 0) != 0) "\uD83D\uDD12" else ""
+ val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else ""
val time = DateFormatter.formatDateTime(waypoint.time)
- val label = (pt.name ?: "") + " " + formatAgo((waypoint.time / 1000).toInt())
- val emoji = String(Character.toChars(if ((pt.icon ?: 0) == 0) 128205 else pt.icon!!))
+ val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt())
+ val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
val now = nowMillis
- val expireTimeMillis = (pt.expire ?: 0) * 1000L
+ val expireTimeMillis = pt.expire * 1000L
val expireTimeStr =
when {
- (pt.expire ?: 0) == 0 || pt.expire == Int.MAX_VALUE -> "Never"
+ pt.expire == 0 || pt.expire == Int.MAX_VALUE -> "Never"
expireTimeMillis <= now -> "Expired"
else -> DateFormatter.formatRelativeTime(expireTimeMillis)
}
@@ -580,53 +507,6 @@ fun MapView(
invalidate()
}
- fun MapView.updateTracerouteOverlay(forwardPoints: List, returnPoints: List) {
- overlays.removeAll(traceroutePolylines)
- traceroutePolylines.clear()
-
- fun buildPolyline(points: List, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
- setPoints(points)
- outlinePaint.apply {
- this.color = color
- this.strokeWidth = strokeWidth
- strokeCap = Paint.Cap.ROUND
- strokeJoin = Paint.Join.ROUND
- style = Paint.Style.STROKE
- }
- }
-
- forwardPoints
- .takeIf { it.size >= 2 }
- ?.let { points ->
- traceroutePolylines.add(
- buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }),
- )
- }
- returnPoints
- .takeIf { it.size >= 2 }
- ?.let { points ->
- traceroutePolylines.add(
- buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }),
- )
- }
- overlays.addAll(traceroutePolylines)
- invalidate()
- }
-
- LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
- if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
- val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
- if (allPoints.isNotEmpty()) {
- if (allPoints.size == 1) {
- map.controller.setCenter(allPoints.first())
- map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
- } else {
- map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true)
- }
- hasCenteredTraceroute = true
- }
- }
-
fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon }
val zoomFactor = 1.3
@@ -689,51 +569,6 @@ fun MapView(
}
}
- fun MapView.onTracksChanged(nodeTracks: List?, focusedNodeNum: Int?): Pair, List> {
- if (nodeTracks == null || focusedNodeNum == null) return emptyList() to emptyList()
-
- val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
- val timeFilteredPositions =
- nodeTracks.filter {
- lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
- }
- val sortedPositions = timeFilteredPositions.sortedBy { it.time }
-
- val focusedNode = nodes.find { it.num == focusedNodeNum } ?: return emptyList() to emptyList()
- val color = focusedNode.colors.second
-
- val trackPolylines = mutableListOf()
- if (sortedPositions.size > 1) {
- val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
- segments.forEachIndexed { index, segmentPoints ->
- val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
- val polyline =
- Polyline().apply {
- setPoints(
- segmentPoints.map { GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) },
- )
- outlinePaint.color = Color(color).copy(alpha = alpha).toArgb()
- outlinePaint.strokeWidth = 8f
- }
- trackPolylines.add(polyline)
- }
- }
-
- val trackMarkers =
- sortedPositions.mapIndexedNotNull { index, position ->
- if (index == sortedPositions.lastIndex) return@mapIndexedNotNull null
-
- Marker(this).apply {
- this.position = GeoPoint((position.latitude_i ?: 0) * 1e-7, (position.longitude_i ?: 0) * 1e-7)
- icon = AppCompatResources.getDrawable(context, R.drawable.ic_map_location_dot)
- setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
- title = getString(Res.string.position)
- snippet = formatAgo(position.time)
- }
- }
- return trackMarkers to trackPolylines
- }
-
Scaffold(
modifier = modifier,
floatingActionButton = {
@@ -750,14 +585,10 @@ fun MapView(
},
modifier = Modifier.fillMaxSize(),
update = { mapView ->
- mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints)
- val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum)
with(mapView) {
updateMarkers(
- onNodesChanged(nodesForMarkers),
+ onNodesChanged(nodes),
onWaypointChanged(waypoints.values, selectedWaypointId),
- trackMarkers,
- trackPolylines,
nodeClusterer,
)
}
@@ -776,122 +607,34 @@ fun MapView(
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
- @Suppress("MagicNumber")
- Column(
- modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- MapButton(
- onClick = { showMapStyleDialog = true },
- icon = Icons.Outlined.Layers,
- contentDescription = Res.string.map_style_selection,
- )
- Box(modifier = Modifier) {
- MapButton(
- onClick = { mapFilterExpanded = true },
- icon = Icons.Outlined.Tune,
- contentDescription = stringResource(Res.string.map_filter),
- )
- DropdownMenu(
+ MapControlsOverlay(
+ modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
+ onToggleFilterMenu = { mapFilterExpanded = true },
+ filterDropdownContent = {
+ FdroidMainMapFilterDropdown(
expanded = mapFilterExpanded,
onDismissRequest = { mapFilterExpanded = false },
- modifier = Modifier.background(MaterialTheme.colorScheme.surface),
- ) {
- DropdownMenuItem(
- text = {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = Icons.Rounded.Star,
- contentDescription = null,
- modifier = Modifier.padding(end = 8.dp),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = stringResource(Res.string.only_favorites),
- modifier = Modifier.weight(1f),
- )
- Checkbox(
- checked = mapFilterState.onlyFavorites,
- onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
- modifier = Modifier.padding(start = 8.dp),
- )
- }
- },
- onClick = { mapViewModel.toggleOnlyFavorites() },
- )
- DropdownMenuItem(
- text = {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = Icons.Rounded.PinDrop,
- contentDescription = null,
- modifier = Modifier.padding(end = 8.dp),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = stringResource(Res.string.show_waypoints),
- modifier = Modifier.weight(1f),
- )
- Checkbox(
- checked = mapFilterState.showWaypoints,
- onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
- modifier = Modifier.padding(start = 8.dp),
- )
- }
- },
- onClick = { mapViewModel.toggleShowWaypointsOnMap() },
- )
- DropdownMenuItem(
- text = {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Icon(
- imageVector = Icons.Rounded.Lens,
- contentDescription = null,
- modifier = Modifier.padding(end = 8.dp),
- tint = MaterialTheme.colorScheme.onSurface,
- )
- Text(
- text = stringResource(Res.string.show_precision_circle),
- modifier = Modifier.weight(1f),
- )
- @Suppress("MagicNumber")
- Checkbox(
- checked = mapFilterState.showPrecisionCircle,
- onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
- modifier = Modifier.padding(start = 8.dp),
- )
- }
- },
- onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
- )
- }
- }
- MapButton(
- icon =
- if (myLocationOverlay == null) {
- Icons.Outlined.MyLocation
- } else {
- Icons.Rounded.LocationDisabled
- },
- contentDescription = stringResource(Res.string.toggle_my_position),
- ) {
+ mapFilterState = mapFilterState,
+ mapViewModel = mapViewModel,
+ )
+ },
+ mapTypeContent = {
+ MapButton(
+ icon = MeshtasticIcons.Layers,
+ contentDescription = stringResource(Res.string.map_style_selection),
+ onClick = { showMapStyleDialog = true },
+ )
+ },
+ isLocationTrackingEnabled = myLocationOverlay != null,
+ onToggleLocationTracking = {
if (locationPermissionsState.allPermissionsGranted) {
map.toggleMyLocation()
} else {
triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest()
}
- }
- }
+ },
+ )
}
}
}
@@ -941,12 +684,11 @@ fun MapView(
Logger.d { "User clicked send waypoint ${waypoint.id}" }
showEditWaypointDialog = null
- val newId =
- if (waypoint.id == 0) mapViewModel.generatePacketId() ?: return@EditWaypointDialog else waypoint.id
+ val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id
val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name
- val newExpire = if ((waypoint.expire ?: 0) == 0) Int.MAX_VALUE else (waypoint.expire ?: Int.MAX_VALUE)
- val newLockedTo = if ((waypoint.locked_to ?: 0) != 0) mapViewModel.myNodeNum ?: 0 else 0
- val newIcon = if ((waypoint.icon ?: 0) == 0) 128205 else waypoint.icon
+ val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire
+ val newLockedTo = if (waypoint.locked_to != 0) mapViewModel.myNodeNum ?: 0 else 0
+ val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon
mapViewModel.sendWaypoint(
waypoint.copy(
@@ -971,6 +713,103 @@ fun MapView(
}
}
+/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */
+@Composable
+private fun FdroidMainMapFilterDropdown(
+ expanded: Boolean,
+ onDismissRequest: () -> Unit,
+ mapFilterState: MapFilterState,
+ mapViewModel: MapViewModel,
+) {
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = onDismissRequest,
+ modifier = Modifier.background(MaterialTheme.colorScheme.surface),
+ ) {
+ DropdownMenuItem(
+ text = {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = MeshtasticIcons.Favorite,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
+ Checkbox(
+ checked = mapFilterState.onlyFavorites,
+ onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ },
+ onClick = { mapViewModel.toggleOnlyFavorites() },
+ )
+ DropdownMenuItem(
+ text = {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = MeshtasticIcons.PinDrop,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
+ Checkbox(
+ checked = mapFilterState.showWaypoints,
+ onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ },
+ onClick = { mapViewModel.toggleShowWaypointsOnMap() },
+ )
+ DropdownMenuItem(
+ text = {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = MeshtasticIcons.Lens,
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
+ Checkbox(
+ checked = mapFilterState.showPrecisionCircle,
+ onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ },
+ onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
+ )
+ HorizontalDivider()
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ val filterOptions = LastHeardFilter.entries
+ val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
+ var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
+ Text(
+ text =
+ stringResource(
+ Res.string.last_heard_filter_label,
+ stringResource(mapFilterState.lastHeardFilter.label),
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Slider(
+ value = sliderPosition,
+ onValueChange = { sliderPosition = it },
+ onValueChangeFinished = {
+ val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
+ mapViewModel.setLastHeardFilter(filterOptions[newIndex])
+ },
+ valueRange = 0f..(filterOptions.size - 1).toFloat(),
+ steps = filterOptions.size - 2,
+ )
+ }
+ }
+}
+
@Composable
private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) {
val selected = remember { mutableStateOf(selectedMapStyle) }
@@ -979,7 +818,7 @@ private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelec
CustomTileSource.mTileSources.values.forEachIndexed { index, style ->
ListItem(
text = style,
- trailingIcon = if (index == selected.value) Icons.Rounded.Check else null,
+ trailingIcon = if (index == selected.value) MeshtasticIcons.Check else null,
onClick = {
selected.value = index
onSelectMapStyle(index)
@@ -1022,15 +861,9 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) {
onDismiss = onDismiss,
negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } },
) {
- Text(
- modifier = Modifier.padding(16.dp),
- text =
- stringResource(
- Res.string.map_cache_info,
- cacheCapacity / (1024.0 * 1024.0),
- currentCacheUsage / (1024.0 * 1024.0),
- ),
- )
+ val capacityMb = (cacheCapacity / (1024 * 1024)).toLong()
+ val usageMb = (currentCacheUsage / (1024 * 1024)).toLong()
+ Text(modifier = Modifier.padding(16.dp), text = stringResource(Res.string.map_cache_info, capacityMb, usageMb))
}
}
@@ -1126,57 +959,4 @@ private fun MapsDialog(
}
}
-private const val EARTH_RADIUS_METERS = 6_371_000.0
-private const val TRACEROUTE_OFFSET_METERS = 100.0
-private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
-private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
private const val WAYPOINT_ZOOM = 15.0
-
-@Suppress("MagicNumber")
-private fun Double.toRad(): Double = this * Math.PI / 180.0
-
-private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
- val lat1 = from.latitude.toRad()
- val lat2 = to.latitude.toRad()
- val dLon = (to.longitude - from.longitude).toRad()
- return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
-}
-
-private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
- val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
- val lat1 = latitude.toRad()
- val lon1 = longitude.toRad()
- val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
- val lon2 =
- lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
- return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2))
-}
-
-private fun offsetPolyline(
- points: List,
- offsetMeters: Double,
- headingReferencePoints: List = points,
- sideMultiplier: Double = 1.0,
-): List {
- val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
- if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
-
- val headings =
- headingPoints.mapIndexed { index, _ ->
- when (index) {
- 0 -> bearingRad(headingPoints[0], headingPoints[1])
- headingPoints.lastIndex ->
- bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
-
- else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
- }
- }
-
- return points.mapIndexed { index, point ->
- val heading = headings[index.coerceIn(0, headings.lastIndex)]
-
- @Suppress("MagicNumber")
- val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier)
- point.offsetPoint(perpendicularHeading, abs(offsetMeters))
- }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
index a5f27e8e9..3cc0dbaf0 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
@@ -124,17 +124,17 @@ fun MapView.addPolyline(density: Density, geoPoints: List, onClick: ()
return polyline
}
-fun MapView.addPositionMarkers(positions: List, onClick: () -> Unit): List {
+fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List {
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation)
val markers =
- positions.map {
+ positions.map { pos ->
Marker(this).apply {
icon = navIcon
- rotation = ((it.ground_track ?: 0) * 1e-5).toFloat()
+ rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat()
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
- position = GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7)
+ position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7)
setOnMarkerClickListener { _, _ ->
- onClick()
+ onClick(pos.time)
true
}
}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
index d6e84d19b..c16d87163 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewWithLifecycle.kt
@@ -16,9 +16,6 @@
*/
package org.meshtastic.app.map
-import android.annotation.SuppressLint
-import android.content.Context
-import android.os.PowerManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -32,7 +29,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
-import co.touchlab.kermit.Logger
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
@@ -41,29 +37,6 @@ import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
-@SuppressLint("WakelockTimeout")
-private fun PowerManager.WakeLock.safeAcquire() {
- if (!isHeld) {
- try {
- acquire()
- } catch (e: SecurityException) {
- Logger.e { "WakeLock permission exception: ${e.message}" }
- } catch (e: IllegalStateException) {
- Logger.e { "WakeLock acquire() exception: ${e.message}" }
- }
- }
-}
-
-private fun PowerManager.WakeLock.safeRelease() {
- if (isHeld) {
- try {
- release()
- } catch (e: IllegalStateException) {
- Logger.e { "WakeLock release() exception: ${e.message}" }
- }
- }
-}
-
private const val MIN_ZOOM_LEVEL = 1.5
private const val MAX_ZOOM_LEVEL = 20.0
private const val DEFAULT_ZOOM_LEVEL = 15.0
@@ -136,22 +109,13 @@ internal fun rememberMapViewWithLifecycle(
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
- val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
-
- @Suppress("DEPRECATION")
- val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock")
-
- wakeLock.safeAcquire()
-
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
- wakeLock.safeRelease()
mapView.onPause()
}
Lifecycle.Event.ON_RESUME -> {
- wakeLock.safeAcquire()
mapView.onResume()
}
@@ -166,10 +130,7 @@ internal fun rememberMapViewWithLifecycle(
lifecycle.addObserver(observer)
- onDispose {
- lifecycle.removeObserver(observer)
- wakeLock.safeRelease()
- }
+ onDispose { lifecycle.removeObserver(observer) }
}
return mapView
}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
index 7b12f70b9..7568d695a 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/DownloadButton.kt
@@ -21,8 +21,6 @@ import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Download
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -32,6 +30,8 @@ import androidx.compose.ui.draw.scale
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_download_region
+import org.meshtastic.core.ui.icon.Download
+import org.meshtastic.core.ui.icon.MeshtasticIcons
@Composable
fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
@@ -50,7 +50,7 @@ fun DownloadButton(enabled: Boolean, onClick: () -> Unit) {
) {
FloatingActionButton(onClick = onClick, contentColor = MaterialTheme.colorScheme.primary) {
Icon(
- imageVector = Icons.Rounded.Download,
+ imageVector = MeshtasticIcons.Download,
contentDescription = stringResource(Res.string.map_download_region),
modifier = Modifier.scale(1.25f),
)
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
index 83dc24880..c41798bf0 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
@@ -34,9 +34,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.CalendarMonth
-import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
@@ -60,7 +57,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.Month
import kotlinx.datetime.toInstant
@@ -82,9 +78,13 @@ import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
+import org.meshtastic.core.ui.icon.CalendarMonth
+import org.meshtastic.core.ui.icon.Lock
+import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours
+import kotlin.time.Instant
@Suppress("LongMethod", "CyclomaticComplexMethod")
@OptIn(ExperimentalLayoutApi::class)
@@ -100,7 +100,7 @@ fun EditWaypointDialog(
val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit
@Suppress("MagicNumber")
- val emoji = if ((waypointInput.icon ?: 0) == 0) 128205 else waypointInput.icon!!
+ val emoji = if (waypointInput.icon == 0) 128205 else waypointInput.icon
var showEmojiPickerView by remember { mutableStateOf(false) }
// Get current context for dialogs
@@ -115,11 +115,11 @@ fun EditWaypointDialog(
val currentInstant =
remember(waypointInput.expire) {
- val expire = waypointInput.expire ?: 0
+ val expire = waypointInput.expire
if (expire != 0 && expire != Int.MAX_VALUE) {
- Instant.fromEpochSeconds(expire.toLong())
+ kotlin.time.Instant.fromEpochSeconds(expire.toLong())
} else {
- kotlinx.datetime.Clock.System.now() + 8.hours
+ kotlin.time.Clock.System.now() + 8.hours
}
}
@@ -127,7 +127,7 @@ fun EditWaypointDialog(
var selectedDate by
remember(currentInstant) {
mutableStateOf(
- if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
+ if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
dateFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
@@ -137,7 +137,7 @@ fun EditWaypointDialog(
var selectedTime by
remember(currentInstant) {
mutableStateOf(
- if ((waypointInput.expire ?: 0) != 0 && waypointInput.expire != Int.MAX_VALUE) {
+ if (waypointInput.expire != 0 && waypointInput.expire != Int.MAX_VALUE) {
timeFormat.format(java.util.Date(currentInstant.toEpochMilliseconds()))
} else {
""
@@ -162,7 +162,7 @@ fun EditWaypointDialog(
)
EditTextPreference(
title = stringResource(Res.string.name),
- value = waypointInput.name ?: "",
+ value = waypointInput.name,
maxSize = 29,
enabled = true,
isError = false,
@@ -185,7 +185,7 @@ fun EditWaypointDialog(
)
EditTextPreference(
title = stringResource(Res.string.description),
- value = waypointInput.description ?: "",
+ value = waypointInput.description,
maxSize = 99,
enabled = true,
isError = false,
@@ -198,11 +198,14 @@ fun EditWaypointDialog(
modifier = Modifier.fillMaxWidth().size(48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Image(imageVector = Icons.Rounded.Lock, contentDescription = stringResource(Res.string.locked))
+ Image(
+ imageVector = MeshtasticIcons.Lock,
+ contentDescription = stringResource(Res.string.locked),
+ )
Text(stringResource(Res.string.locked))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
- checked = (waypointInput.locked_to ?: 0) != 0,
+ checked = waypointInput.locked_to != 0,
onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
)
}
@@ -225,7 +228,7 @@ fun EditWaypointDialog(
waypointInput = waypointInput.copy(expire = newLdt.toInstant(tz).epochSeconds.toInt())
},
ldt.year,
- ldt.monthNumber - 1,
+ ldt.month.ordinal,
ldt.day,
)
@@ -255,13 +258,13 @@ fun EditWaypointDialog(
verticalAlignment = Alignment.CenterVertically,
) {
Image(
- imageVector = Icons.Rounded.CalendarMonth,
+ imageVector = MeshtasticIcons.CalendarMonth,
contentDescription = stringResource(Res.string.expires),
)
Text(stringResource(Res.string.expires))
Switch(
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End),
- checked = waypointInput.expire != Int.MAX_VALUE && (waypointInput.expire ?: 0) != 0,
+ checked = waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0,
onCheckedChange = { isChecked ->
if (isChecked) {
waypointInput = waypointInput.copy(expire = currentInstant.epochSeconds.toInt())
@@ -272,7 +275,7 @@ fun EditWaypointDialog(
)
}
- if (waypointInput.expire != Int.MAX_VALUE && (waypointInput.expire ?: 0) != 0) {
+ if (waypointInput.expire != Int.MAX_VALUE && waypointInput.expire != 0) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt
deleted file mode 100644
index 5bffb830d..000000000
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/component/MapButton.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Layers
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.Icon
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.tooling.preview.PreviewLightDark
-import androidx.compose.ui.unit.dp
-import org.jetbrains.compose.resources.StringResource
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.map_style_selection
-import org.meshtastic.core.ui.theme.AppTheme
-
-@Composable
-fun MapButton(
- icon: ImageVector,
- contentDescription: StringResource,
- modifier: Modifier = Modifier,
- onClick: () -> Unit = {},
-) {
- MapButton(
- icon = icon,
- contentDescription = stringResource(contentDescription),
- modifier = modifier,
- onClick = onClick,
- )
-}
-
-@Composable
-fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
- FloatingActionButton(onClick = onClick, modifier = modifier) {
- Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
- }
-}
-
-@PreviewLightDark
-@Composable
-private fun MapButtonPreview() {
- AppTheme { MapButton(icon = Icons.Outlined.Layers, contentDescription = Res.string.map_style_selection) }
-}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
index 668f17413..b7795180f 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
@@ -17,48 +17,38 @@
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.meshtastic.app.map.addCopyright
-import org.meshtastic.app.map.addPolyline
-import org.meshtastic.app.map.addPositionMarkers
-import org.meshtastic.app.map.addScaleBarOverlay
-import org.meshtastic.app.map.model.CustomTileSource
-import org.meshtastic.app.map.rememberMapViewWithLifecycle
+import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
-import org.osmdroid.util.BoundingBox
-import org.osmdroid.util.GeoPoint
-
-private const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
- val density = LocalDensity.current
- val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
- val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
- val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
- val mapView =
- rememberMapViewWithLifecycle(
- applicationId = nodeMapViewModel.applicationId,
- box = cameraView,
- tileSource = CustomTileSource.getTileSource(nodeMapViewModel.mapStyleId),
- )
+ val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
+ val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
- AndroidView(
- modifier = Modifier.fillMaxSize(),
- factory = { mapView },
- update = { map ->
- map.overlays.clear()
- map.addCopyright()
- map.addScaleBarOverlay(density)
-
- map.addPolyline(density, geoPoints) {}
- map.addPositionMarkers(positionLogs) {}
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = node?.user?.long_name ?: "",
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onNavigateUp,
+ actions = {},
+ onClickChip = {},
+ )
},
- )
+ ) { paddingValues ->
+ NodeTrackOsmMap(
+ positions = positions,
+ applicationId = nodeMapViewModel.applicationId,
+ mapStyleId = nodeMapViewModel.mapStyleId,
+ modifier = Modifier.fillMaxSize().padding(paddingValues),
+ )
+ }
}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
new file mode 100644
index 000000000..77b595d88
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.map.node
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.feature.map.node.NodeMapViewModel
+import org.meshtastic.proto.Position
+
+/**
+ * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain
+ * [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation
+ * ([NodeTrackOsmMap]).
+ *
+ * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
+ */
+@Composable
+fun NodeTrackMap(
+ destNum: Int,
+ positions: List,
+ modifier: Modifier = Modifier,
+ selectedPositionTime: Int? = null,
+ onPositionSelected: ((Int) -> Unit)? = null,
+) {
+ val vm = koinViewModel()
+ vm.setDestNum(destNum)
+ NodeTrackOsmMap(
+ positions = positions,
+ applicationId = vm.applicationId,
+ mapStyleId = vm.mapStyleId,
+ modifier = modifier,
+ selectedPositionTime = selectedPositionTime,
+ onPositionSelected = onPositionSelected,
+ )
+}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt
new file mode 100644
index 000000000..a6aec4c2d
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.map.node
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.app.map.MapViewModel
+import org.meshtastic.app.map.addCopyright
+import org.meshtastic.app.map.addPolyline
+import org.meshtastic.app.map.addPositionMarkers
+import org.meshtastic.app.map.addScaleBarOverlay
+import org.meshtastic.app.map.model.CustomTileSource
+import org.meshtastic.app.map.rememberMapViewWithLifecycle
+import org.meshtastic.core.common.util.nowSeconds
+import org.meshtastic.core.model.util.GeoConstants.DEG_D
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.last_heard_filter_label
+import org.meshtastic.feature.map.LastHeardFilter
+import org.meshtastic.feature.map.component.MapControlsOverlay
+import org.meshtastic.proto.Position
+import org.osmdroid.util.BoundingBox
+import org.osmdroid.util.GeoPoint
+import kotlin.math.roundToInt
+
+/**
+ * A focused OSMDroid map composable that renders **only** a node's position track — a dashed polyline with directional
+ * markers for each historical position.
+ *
+ * Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter]
+ * from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a
+ * minimal [MapControlsOverlay][org.meshtastic.feature.map.component.MapControlsOverlay] with a track time filter slider
+ * so users can adjust the time range directly from the map.
+ *
+ * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
+ *
+ * Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or
+ * location tracking. It is designed to be embedded inside the position-log adaptive layout.
+ */
+@Composable
+fun NodeTrackOsmMap(
+ positions: List,
+ applicationId: String,
+ mapStyleId: Int,
+ modifier: Modifier = Modifier,
+ selectedPositionTime: Int? = null,
+ onPositionSelected: ((Int) -> Unit)? = null,
+ mapViewModel: MapViewModel = koinViewModel(),
+) {
+ val density = LocalDensity.current
+ val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
+ val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
+
+ val filteredPositions =
+ remember(positions, lastHeardTrackFilter) {
+ positions.filter {
+ lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
+ }
+ }
+
+ val geoPoints =
+ remember(filteredPositions) {
+ filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
+ }
+ val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) }
+ val mapView =
+ rememberMapViewWithLifecycle(
+ applicationId = applicationId,
+ box = cameraView,
+ tileSource = CustomTileSource.getTileSource(mapStyleId),
+ )
+
+ var filterMenuExpanded by remember { mutableStateOf(false) }
+
+ Box(modifier = modifier) {
+ AndroidView(
+ modifier = Modifier.matchParentSize(),
+ factory = { mapView },
+ update = { map ->
+ map.overlays.clear()
+ map.addCopyright()
+ map.addScaleBarOverlay(density)
+ map.addPolyline(density, geoPoints) {}
+ map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) }
+ // Center on selected position
+ if (selectedPositionTime != null) {
+ val selected = filteredPositions.find { it.time == selectedPositionTime }
+ if (selected != null) {
+ val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D)
+ map.controller.animateTo(point)
+ }
+ }
+ },
+ )
+
+ // Track filter controls overlay
+ MapControlsOverlay(
+ modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
+ onToggleFilterMenu = { filterMenuExpanded = true },
+ filterDropdownContent = {
+ DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ val filterOptions = LastHeardFilter.entries
+ val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter)
+ var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
+
+ Text(
+ text =
+ stringResource(
+ Res.string.last_heard_filter_label,
+ stringResource(lastHeardTrackFilter.label),
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Slider(
+ value = sliderPosition,
+ onValueChange = { sliderPosition = it },
+ onValueChangeFinished = {
+ val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
+ mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex])
+ },
+ valueRange = 0f..(filterOptions.size - 1).toFloat(),
+ steps = filterOptions.size - 2,
+ )
+ }
+ }
+ },
+ )
+ }
+}
diff --git a/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
similarity index 50%
rename from feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
rename to app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
index 33925cbc9..fcf1d47e9 100644
--- a/feature/node/src/iosMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025-2026 Meshtastic LLC
+ * 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
@@ -14,22 +14,28 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.node.detail
+package org.meshtastic.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import org.meshtastic.core.navigation.Route
-import org.meshtastic.feature.node.compass.CompassViewModel
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.proto.Position
+/**
+ * Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation
+ * ([TracerouteOsmMap]).
+ */
@Composable
-actual fun NodeDetailScreen(
- nodeId: Int,
- modifier: Modifier,
- viewModel: NodeDetailViewModel,
- navigateToMessages: (String) -> Unit,
- onNavigate: (Route) -> Unit,
- onNavigateUp: () -> Unit,
- compassViewModel: CompassViewModel?,
+fun TracerouteMap(
+ tracerouteOverlay: TracerouteOverlay?,
+ tracerouteNodePositions: Map,
+ onMappableCountChanged: (shown: Int, total: Int) -> Unit,
+ modifier: Modifier = Modifier,
) {
- // TODO: Implement iOS node detail screen
+ TracerouteOsmMap(
+ tracerouteOverlay = tracerouteOverlay,
+ tracerouteNodePositions = tracerouteNodePositions,
+ onMappableCountChanged = onMappableCountChanged,
+ modifier = modifier,
+ )
}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt
new file mode 100644
index 000000000..55b49154a
--- /dev/null
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/traceroute/TracerouteOsmMap.kt
@@ -0,0 +1,288 @@
+/*
+ * 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 .
+ */
+@file:Suppress("MagicNumber")
+
+package org.meshtastic.app.map.traceroute
+
+import android.graphics.Paint
+import androidx.appcompat.content.res.AppCompatResources
+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.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.app.R
+import org.meshtastic.app.map.MapViewModel
+import org.meshtastic.app.map.addCopyright
+import org.meshtastic.app.map.addScaleBarOverlay
+import org.meshtastic.app.map.model.CustomTileSource
+import org.meshtastic.app.map.model.MarkerWithLabel
+import org.meshtastic.app.map.rememberMapViewWithLifecycle
+import org.meshtastic.app.map.zoomIn
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS
+import org.meshtastic.core.ui.theme.TracerouteColors
+import org.meshtastic.core.ui.util.formatAgo
+import org.meshtastic.feature.map.tracerouteNodeSelection
+import org.meshtastic.proto.Position
+import org.osmdroid.util.BoundingBox
+import org.osmdroid.util.GeoPoint
+import org.osmdroid.views.overlay.Marker
+import org.osmdroid.views.overlay.Polyline
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+
+private const val TRACEROUTE_OFFSET_METERS = 100.0
+private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
+private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
+
+/**
+ * A focused OSMDroid map composable that renders **only** traceroute visualization — node markers for each hop and
+ * forward/return offset polylines with auto-centering camera.
+ *
+ * Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any
+ * map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold.
+ */
+@Composable
+fun TracerouteOsmMap(
+ tracerouteOverlay: TracerouteOverlay?,
+ tracerouteNodePositions: Map,
+ onMappableCountChanged: (shown: Int, total: Int) -> Unit,
+ modifier: Modifier = Modifier,
+ mapViewModel: MapViewModel = koinViewModel(),
+) {
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
+ val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
+
+ // Resolve which nodes to display for the traceroute
+ val tracerouteSelection =
+ remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
+ mapViewModel.tracerouteNodeSelection(
+ tracerouteOverlay = tracerouteOverlay,
+ tracerouteNodePositions = tracerouteNodePositions,
+ nodes = nodes,
+ )
+ }
+ val displayNodes = tracerouteSelection.nodesForMarkers
+ val nodeLookup = tracerouteSelection.nodeLookup
+
+ // Report mappable count
+ LaunchedEffect(tracerouteOverlay, displayNodes) {
+ if (tracerouteOverlay != null) {
+ onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
+ }
+ }
+
+ // Compute polyline GeoPoints from node positions
+ val forwardPoints =
+ remember(tracerouteOverlay, nodeLookup) {
+ tracerouteOverlay?.forwardRoute?.mapNotNull {
+ nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
+ } ?: emptyList()
+ }
+ val returnPoints =
+ remember(tracerouteOverlay, nodeLookup) {
+ tracerouteOverlay?.returnRoute?.mapNotNull {
+ nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
+ } ?: emptyList()
+ }
+
+ // Compute offset polylines for visual separation
+ val headingReferencePoints =
+ remember(forwardPoints, returnPoints) {
+ when {
+ forwardPoints.size >= 2 -> forwardPoints
+ returnPoints.size >= 2 -> returnPoints
+ else -> emptyList()
+ }
+ }
+ val forwardOffsetPoints =
+ remember(forwardPoints, headingReferencePoints) {
+ offsetPolyline(
+ points = forwardPoints,
+ offsetMeters = TRACEROUTE_OFFSET_METERS,
+ headingReferencePoints = headingReferencePoints,
+ sideMultiplier = 1.0,
+ )
+ }
+ val returnOffsetPoints =
+ remember(returnPoints, headingReferencePoints) {
+ offsetPolyline(
+ points = returnPoints,
+ offsetMeters = TRACEROUTE_OFFSET_METERS,
+ headingReferencePoints = headingReferencePoints,
+ sideMultiplier = -1.0,
+ )
+ }
+
+ // Camera auto-center
+ var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) }
+
+ // Build initial camera from all traceroute points
+ val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() }
+ val initialCameraView =
+ remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) }
+
+ val mapView =
+ rememberMapViewWithLifecycle(
+ applicationId = mapViewModel.applicationId,
+ box = initialCameraView ?: BoundingBox(),
+ tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId),
+ )
+
+ // Center camera on traceroute bounds
+ LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) {
+ if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect
+ if (allPoints.isNotEmpty()) {
+ if (allPoints.size == 1) {
+ mapView.controller.setCenter(allPoints.first())
+ mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
+ } else {
+ mapView.zoomToBoundingBox(
+ BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS),
+ true,
+ )
+ }
+ hasCentered = true
+ }
+ }
+
+ AndroidView(
+ modifier = modifier,
+ factory = { mapView.apply { setDestroyMode(false) } },
+ update = { map ->
+ map.overlays.clear()
+ map.addCopyright()
+ map.addScaleBarOverlay(density)
+
+ // Render traceroute polylines
+ buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) }
+
+ // Render simple node markers
+ displayNodes.forEach { node ->
+ val position = GeoPoint(node.latitude, node.longitude)
+ val marker =
+ MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}")
+ .apply {
+ id = node.user.id
+ title = node.user.long_name
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
+ this.position = position
+ icon = markerIcon
+ setNodeColors(node.colors)
+ }
+ map.overlays.add(marker)
+ }
+
+ map.invalidate()
+ },
+ )
+}
+
+private fun buildTraceroutePolylines(
+ forwardPoints: List,
+ returnPoints: List,
+ density: androidx.compose.ui.unit.Density,
+): List {
+ val polylines = mutableListOf()
+
+ fun buildPolyline(points: List, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
+ setPoints(points)
+ outlinePaint.apply {
+ this.color = color
+ this.strokeWidth = strokeWidth
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
+ style = Paint.Style.STROKE
+ }
+ }
+
+ forwardPoints
+ .takeIf { it.size >= 2 }
+ ?.let { points ->
+ polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }))
+ }
+ returnPoints
+ .takeIf { it.size >= 2 }
+ ?.let { points ->
+ polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }))
+ }
+ return polylines
+}
+
+// --- Haversine offset math for OSMDroid (no SphericalUtil available) ---
+
+private fun Double.toRad(): Double = this * PI / 180.0
+
+private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
+ val lat1 = from.latitude.toRad()
+ val lat2 = to.latitude.toRad()
+ val dLon = (to.longitude - from.longitude).toRad()
+ return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
+}
+
+private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
+ val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
+ val lat1 = latitude.toRad()
+ val lon1 = longitude.toRad()
+ val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
+ val lon2 =
+ lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
+ return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI)
+}
+
+private fun offsetPolyline(
+ points: List,
+ offsetMeters: Double,
+ headingReferencePoints: List = points,
+ sideMultiplier: Double = 1.0,
+): List {
+ val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
+ if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
+
+ val headings =
+ headingPoints.mapIndexed { index, _ ->
+ when (index) {
+ 0 -> bearingRad(headingPoints[0], headingPoints[1])
+ headingPoints.lastIndex ->
+ bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
+
+ else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
+ }
+ }
+
+ return points.mapIndexed { index, point ->
+ val heading = headings[index.coerceIn(0, headings.lastIndex)]
+ val perpendicularHeading = heading + (PI / 2 * sideMultiplier)
+ point.offsetPoint(perpendicularHeading, abs(offsetMeters))
+ }
+}
diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt
index 638dcead9..447765522 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/node/component/InlineMap.kt
@@ -16,11 +16,49 @@
*/
package org.meshtastic.app.node.component
+import android.view.ViewGroup
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
import org.meshtastic.core.model.Node
+import org.osmdroid.tileprovider.tilesource.TileSourceFactory
+import org.osmdroid.util.GeoPoint
+import org.osmdroid.views.MapView
+import org.osmdroid.views.overlay.Marker
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
- // No-op for F-Droid builds
+ val context = androidx.compose.ui.platform.LocalContext.current
+
+ val map = remember {
+ MapView(context).apply {
+ layoutParams =
+ ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
+
+ // Default osmdroid tile source.
+ setTileSource(TileSourceFactory.MAPNIK)
+ setMultiTouchControls(false)
+
+ controller.setZoom(15.0)
+ }
+ }
+
+ LaunchedEffect(node.num) {
+ val point = GeoPoint(node.latitude, node.longitude)
+
+ map.overlays.clear()
+
+ val marker =
+ Marker(map).apply {
+ position = point
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
+ }
+ map.overlays.add(marker)
+
+ map.controller.animateTo(point)
+ }
+
+ AndroidView(factory = { map }, modifier = modifier)
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
index a41eae2d3..0583dd78e 100644
--- a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
@@ -33,7 +33,11 @@ import com.datadog.android.log.LogsConfiguration
import com.datadog.android.privacy.TrackingConsent
import com.datadog.android.rum.GlobalRumMonitor
import com.datadog.android.rum.Rum
+import com.datadog.android.rum.RumActionType
import com.datadog.android.rum.RumConfiguration
+import com.datadog.android.sessionreplay.SessionReplay
+import com.datadog.android.sessionreplay.SessionReplayConfiguration
+import com.datadog.android.sessionreplay.TextAndInputPrivacy
import com.datadog.android.trace.Trace
import com.datadog.android.trace.TraceConfiguration
import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry
@@ -68,7 +72,7 @@ import co.touchlab.kermit.Logger as KermitLogger
class GooglePlatformAnalytics(private val context: Context, private val analyticsPrefs: AnalyticsPrefs) :
PlatformAnalytics {
- private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate
+ private val sampleRate = 100f // Match Apple: 100% sampling for cross-platform DataDog comparison
private var datadogLogger: Logger? = null
private var isFirebaseInitialized = false
@@ -137,7 +141,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
val configuration =
Configuration.Builder(
clientToken = BuildConfig.datadogClientToken,
- env = if (BuildConfig.DEBUG) "debug" else "release",
+ env = if (BuildConfig.DEBUG) "Local" else "Production",
variant = BuildConfig.FLAVOR,
)
.useSite(DatadogSite.US5)
@@ -151,7 +155,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
val rumConfiguration =
RumConfiguration.Builder(BuildConfig.datadogApplicationId)
.trackAnonymousUser(true)
- .trackBackgroundEvents(false) // Disable background noise
+ .trackBackgroundEvents(true) // Match Apple: track background events for cross-platform parity
.trackFrustrations(false) // Disable click-tracking based frustration detection
.trackLongTasks()
.trackNonFatalAnrs(true)
@@ -162,9 +166,19 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
val logsConfig = LogsConfiguration.Builder().build()
Logs.enable(logsConfig)
- val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(false).build()
+ val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(true).build()
Trace.enable(traceConfig)
+ // Session Replay for debug builds only, matching Apple's TestFlight-only gating.
+ // Masks all text inputs to protect message content.
+ if (BuildConfig.DEBUG) {
+ val sessionReplayConfig =
+ SessionReplayConfiguration.Builder(sampleRate)
+ .setTextAndInputPrivacy(TextAndInputPrivacy.MASK_ALL_INPUTS)
+ .build()
+ SessionReplay.enable(sessionReplayConfig)
+ }
+
GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME))
}
@@ -233,6 +247,24 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
GlobalRumMonitor.get().addAttribute("device_hardware", model)
}
+ override fun trackConnect(
+ firmwareVersion: String?,
+ transportType: String?,
+ hardwareModel: String?,
+ nodes: Int,
+ connectionRestored: Boolean,
+ ) {
+ if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
+ val attributes = buildMap {
+ firmwareVersion?.let { put("firmwareVersion", it) }
+ transportType?.let { put("transportType", it) }
+ hardwareModel?.let { put("hardwareModel", it) }
+ put("nodes", nodes)
+ if (connectionRestored) put("connectionRestored", true)
+ }
+ GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, "connect", attributes)
+ }
+
private val isGooglePlayAvailable: Boolean
get() =
GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let {
diff --git a/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt b/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt
index 0e88cb0fe..eede9d6e3 100644
--- a/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/di/GoogleNetworkModule.kt
@@ -16,43 +16,13 @@
*/
package org.meshtastic.app.di
-import android.content.Context
-import com.datadog.android.okhttp.DatadogEventListener
-import com.datadog.android.okhttp.DatadogInterceptor
-import okhttp3.Cache
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
-import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.network.service.ApiService
import org.meshtastic.core.network.service.ApiServiceImpl
-import java.io.File
@Module
class GoogleNetworkModule {
@Single fun bindApiService(apiServiceImpl: ApiServiceImpl): ApiService = apiServiceImpl
-
- @Single
- fun provideOkHttpClient(context: Context, buildConfigProvider: BuildConfigProvider): OkHttpClient =
- OkHttpClient.Builder()
- .cache(
- cache =
- Cache(
- directory = File(context.applicationContext.cacheDir, "http_cache"),
- maxSize = 50L * 1024L * 1024L, // 50 MiB
- ),
- )
- .addInterceptor(
- interceptor =
- HttpLoggingInterceptor().apply {
- if (buildConfigProvider.isDebug) {
- setLevel(HttpLoggingInterceptor.Level.BODY)
- }
- },
- )
- .addInterceptor(interceptor = DatadogInterceptor.Builder(tracedHosts = listOf("meshtastic.org")).build())
- .eventListenerFactory(eventListenerFactory = DatadogEventListener.Factory())
- .build()
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
index c228297a3..940c4ab5a 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/GoogleMapViewProvider.kt
@@ -23,31 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
+/** Google Maps implementation of [MapViewProvider]. */
@Single
class GoogleMapViewProvider : MapViewProvider {
@Composable
- override fun MapView(
- modifier: Modifier,
- viewModel: Any,
- navigateToNodeDetails: (Int) -> Unit,
- focusedNodeNum: Int?,
- nodeTracks: List?,
- tracerouteOverlay: Any?,
- tracerouteNodePositions: Map,
- onTracerouteMappableCountChanged: (Int, Int) -> Unit,
- waypointId: Int?,
- ) {
+ override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
- focusedNodeNum = focusedNodeNum,
- nodeTracks = nodeTracks as? List,
- tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
- tracerouteNodePositions = tracerouteNodePositions as? Map ?: emptyMap(),
- onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index bbda314d9..c8f2f3fee 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -21,8 +21,6 @@ package org.meshtastic.app.map
import android.Manifest
import android.app.Activity
import android.content.Intent
-import android.graphics.Canvas
-import android.graphics.Paint
import android.net.Uri
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -35,8 +33,8 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.material.icons.rounded.TripOrigin
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -57,7 +55,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
-import androidx.core.graphics.createBitmap
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -68,13 +65,12 @@ import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.maps.CameraUpdateFactory
-import com.google.android.gms.maps.model.BitmapDescriptor
-import com.google.android.gms.maps.model.BitmapDescriptorFactory
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.JointType
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.maps.android.SphericalUtil
+import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapEffect
@@ -86,10 +82,13 @@ import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.MarkerInfoWindowComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
+import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import com.google.maps.android.compose.widgets.ScaleBar
+import com.google.maps.android.data.Layer
import com.google.maps.android.data.geojson.GeoJsonLayer
import com.google.maps.android.data.kml.KmlLayer
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.json.JSONObject
@@ -98,13 +97,18 @@ import org.meshtastic.app.map.component.ClusterItemsListDialog
import org.meshtastic.app.map.component.CustomMapLayersSheet
import org.meshtastic.app.map.component.CustomTileProviderManagerSheet
import org.meshtastic.app.map.component.EditWaypointDialog
-import org.meshtastic.app.map.component.MapControlsOverlay
+import org.meshtastic.app.map.component.MapFilterDropdown
+import org.meshtastic.app.map.component.MapTypeDropdown
import org.meshtastic.app.map.component.NodeClusterMarkers
+import org.meshtastic.app.map.component.NodeMapFilterDropdown
import org.meshtastic.app.map.component.WaypointMarkers
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.core.model.util.GeoConstants.DEG_D
+import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
@@ -114,17 +118,25 @@ import org.meshtastic.core.resources.alt
import org.meshtastic.core.resources.heading
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
+import org.meshtastic.core.resources.manage_map_layers
+import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
import org.meshtastic.core.ui.component.NodeChip
+import org.meshtastic.core.ui.icon.Layers
+import org.meshtastic.core.ui.icon.Map
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.TripOrigin
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.formatPositionTime
+import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
-import org.meshtastic.feature.map.model.TracerouteOverlay
+import org.meshtastic.feature.map.component.MapButton
+import org.meshtastic.feature.map.component.MapControlsOverlay
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Position
@@ -132,9 +144,35 @@ import org.meshtastic.proto.Waypoint
import kotlin.math.abs
import kotlin.math.max
-private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
-private const val DEG_D = 1e-7
-private const val HEADING_DEG = 1e-5
+// region --- Map Mode ---
+
+/**
+ * Discriminated mode for [MapView] — replaces the original pile of nullable parameters with a type-safe sealed
+ * hierarchy. Each mode carries only the data it needs; the shared infrastructure (location tracking, tile providers,
+ * controls overlay) is available in every mode.
+ */
+sealed interface GoogleMapMode {
+ /** Standard map: node clusters, waypoints, custom layers, waypoint editing. */
+ data object Main : GoogleMapMode
+
+ /** Focused node position track: polyline + gradient markers for historical positions. */
+ data class NodeTrack(
+ val focusedNode: Node?,
+ val positions: List,
+ val selectedPositionTime: Int? = null,
+ val onPositionSelected: ((Int) -> Unit)? = null,
+ ) : GoogleMapMode
+
+ /** Traceroute visualization: offset forward/return polylines + hop markers. */
+ data class Traceroute(
+ val overlay: TracerouteOverlay?,
+ val nodePositions: Map,
+ val onMappableCountChanged: (shown: Int, total: Int) -> Unit,
+ ) : GoogleMapMode
+}
+
+// endregion
+
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
@@ -144,28 +182,22 @@ private const val TRACEROUTE_BOUNDS_PADDING_PX = 120
fun MapView(
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
- navigateToNodeDetails: (Int) -> Unit,
- focusedNodeNum: Int? = null,
- nodeTracks: List? = null,
- tracerouteOverlay: TracerouteOverlay? = null,
- tracerouteNodePositions: Map = emptyMap(),
- onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
+ navigateToNodeDetails: (Int) -> Unit = {},
+ mode: GoogleMapMode = GoogleMapMode.Main,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
- val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
- // Location permissions state
+ // --- Location permissions ---
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
var triggerLocationToggleAfterPermission by remember { mutableStateOf(false) }
- // Location tracking state
+ // --- Location tracking ---
var isLocationTrackingEnabled by remember { mutableStateOf(false) }
var followPhoneBearing by remember { mutableStateOf(false) }
- // Effect to toggle location tracking after permission is granted
LaunchedEffect(locationPermissionsState.allPermissionsGranted) {
if (locationPermissionsState.allPermissionsGranted && triggerLocationToggleAfterPermission) {
isLocationTrackingEnabled = true
@@ -173,9 +205,10 @@ fun MapView(
}
}
+ // --- File picker for map layers (Main mode) ---
val filePickerLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) { result ->
- if (result.resultCode == android.app.Activity.RESULT_OK) {
+ if (result.resultCode == Activity.RESULT_OK) {
result.data?.data?.let { uri ->
val fileName = uri.getFileName(context)
mapViewModel.addMapLayer(uri, fileName)
@@ -183,6 +216,7 @@ fun MapView(
}
}
+ // --- UI state ---
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val ourNodeInfo by mapViewModel.ourNodeInfo.collectAsStateWithLifecycle()
@@ -194,16 +228,20 @@ fun MapView(
var mapTypeMenuExpanded by remember { mutableStateOf(false) }
var showCustomTileManagerSheet by remember { mutableStateOf(false) }
- val cameraPositionState = mapViewModel.cameraPositionState
+ // --- Camera ---
+ // Main mode persists camera; NodeTrack/Traceroute use ephemeral state with auto-centering.
+ val cameraPositionState =
+ if (mode is GoogleMapMode.Main) mapViewModel.cameraPositionState else rememberCameraPositionState()
- // Save camera position when it stops moving
- LaunchedEffect(cameraPositionState.isMoving) {
- if (!cameraPositionState.isMoving) {
- mapViewModel.saveCameraPosition(cameraPositionState.position)
+ if (mode is GoogleMapMode.Main) {
+ LaunchedEffect(cameraPositionState.isMoving) {
+ if (!cameraPositionState.isMoving) {
+ mapViewModel.saveCameraPosition(cameraPositionState.position)
+ }
}
}
- // Location tracking functionality
+ // --- FusedLocation ---
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) }
val locationCallback = remember {
object : LocationCallback() {
@@ -242,14 +280,12 @@ fun MapView(
}
}
- // Start/stop location tracking based on state
LaunchedEffect(isLocationTrackingEnabled, locationPermissionsState.allPermissionsGranted) {
if (isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted) {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L)
.setMinUpdateIntervalMillis(2000L)
.build()
-
try {
@Suppress("MissingPermission")
fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, null)
@@ -266,20 +302,12 @@ fun MapView(
DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } }
+ // --- Node & waypoint data ---
val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf())
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint }
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
- val tracerouteSelection =
- remember(tracerouteOverlay, tracerouteNodePositions, allNodes) {
- mapViewModel.tracerouteNodeSelection(
- tracerouteOverlay = tracerouteOverlay,
- tracerouteNodePositions = tracerouteNodePositions,
- nodes = allNodes,
- )
- }
-
val filteredNodes =
allNodes
.filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num }
@@ -289,30 +317,7 @@ fun MapView(
node.num == ourNodeInfo?.num
}
- val displayNodes =
- if (tracerouteOverlay != null) {
- tracerouteSelection.nodesForMarkers
- } else {
- filteredNodes
- }
- LaunchedEffect(tracerouteOverlay, displayNodes) {
- if (tracerouteOverlay != null) {
- onTracerouteMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
- }
- }
-
val myNodeNum = mapViewModel.myNodeNum
- val nodeClusterItems =
- displayNodes.map { node ->
- val latLng = LatLng((node.position.latitude_i ?: 0) * DEG_D, (node.position.longitude_i ?: 0) * DEG_D)
- NodeClusterItem(
- node = node,
- nodePosition = latLng,
- nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
- nodeSnippet = "${node.user.long_name}",
- myNodeNum = myNodeNum,
- )
- }
val isConnected by mapViewModel.isConnected.collectAsStateWithLifecycle()
val theme by mapViewModel.theme.collectAsStateWithLifecycle()
val dark =
@@ -322,20 +327,69 @@ fun MapView(
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme()
else -> isSystemInDarkTheme()
}
- val mapColorScheme =
- when (dark) {
- true -> ComposeMapColorScheme.DARK
- else -> ComposeMapColorScheme.LIGHT
+ val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT
+
+ // --- Mode-specific data ---
+ // Node track: apply time filter
+ val sortedTrackPositions =
+ if (mode is GoogleMapMode.NodeTrack) {
+ val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
+ remember(mode.positions, lastHeardTrackFilter) {
+ mode.positions
+ .filter {
+ lastHeardTrackFilter == LastHeardFilter.Any ||
+ it.time > nowSeconds - lastHeardTrackFilter.seconds
+ }
+ .sortedBy { it.time }
+ }
+ } else {
+ emptyList()
}
- val tracerouteForwardPoints =
- remember(tracerouteOverlay, displayNodes) {
- val nodeLookup = displayNodes.associateBy { it.num }
- tracerouteOverlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
+
+ // Traceroute: resolve node selection + polylines. Collected unconditionally per Compose rules
+ // (composable calls cannot be conditional), but only consumed in Traceroute mode. Uses all
+ // nodes, not just those with positions, so getNodeOrFallback can resolve metadata for hops
+ // whose positions come from snapshots.
+ val allNodesForTraceroute by mapViewModel.nodes.collectAsStateWithLifecycle(listOf())
+ val tracerouteSelection =
+ if (mode is GoogleMapMode.Traceroute) {
+ remember(mode.overlay, mode.nodePositions, allNodesForTraceroute) {
+ mapViewModel.tracerouteNodeSelection(
+ tracerouteOverlay = mode.overlay,
+ tracerouteNodePositions = mode.nodePositions,
+ nodes = allNodesForTraceroute,
+ )
+ }
+ } else {
+ null
}
- val tracerouteReturnPoints =
- remember(tracerouteOverlay, displayNodes) {
- val nodeLookup = displayNodes.associateBy { it.num }
- tracerouteOverlay?.returnRoute?.mapNotNull { nodeLookup[it]?.toLatLng() } ?: emptyList()
+ val tracerouteDisplayNodes = tracerouteSelection?.nodesForMarkers ?: emptyList()
+
+ if (mode is GoogleMapMode.Traceroute) {
+ LaunchedEffect(mode.overlay, tracerouteDisplayNodes) {
+ if (mode.overlay != null) {
+ mode.onMappableCountChanged(tracerouteDisplayNodes.size, mode.overlay.relatedNodeNums.size)
+ }
+ }
+ }
+
+ val tracerouteForwardPoints: List =
+ if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) {
+ val nodeLookup = tracerouteSelection.nodeLookup
+ remember(mode.overlay, nodeLookup) {
+ mode.overlay?.forwardRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()
+ }
+ } else {
+ emptyList()
+ }
+ val tracerouteReturnPoints: List =
+ if (mode is GoogleMapMode.Traceroute && tracerouteSelection != null) {
+ val nodeLookup = tracerouteSelection.nodeLookup
+ remember(mode.overlay, nodeLookup) {
+ mode.overlay?.returnRoute?.mapNotNull { nodeLookup[it]?.position?.toLatLng() } ?: emptyList()
+ }
+ } else {
+ emptyList()
}
val tracerouteHeadingReferencePoints =
remember(tracerouteForwardPoints, tracerouteReturnPoints) {
@@ -347,24 +401,75 @@ fun MapView(
}
val tracerouteForwardOffsetPoints =
remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(
- points = tracerouteForwardPoints,
- offsetMeters = TRACEROUTE_OFFSET_METERS,
- headingReferencePoints = tracerouteHeadingReferencePoints,
- sideMultiplier = 1.0,
- )
+ offsetPolyline(tracerouteForwardPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, 1.0)
}
val tracerouteReturnOffsetPoints =
remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
- offsetPolyline(
- points = tracerouteReturnPoints,
- offsetMeters = TRACEROUTE_OFFSET_METERS,
- headingReferencePoints = tracerouteHeadingReferencePoints,
- sideMultiplier = -1.0,
- )
+ offsetPolyline(tracerouteReturnPoints, TRACEROUTE_OFFSET_METERS, tracerouteHeadingReferencePoints, -1.0)
}
- var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
+ // Auto-centering for NodeTrack / Traceroute modes
+ var hasCentered by remember(mode) { mutableStateOf(false) }
+
+ if (mode is GoogleMapMode.NodeTrack) {
+ LaunchedEffect(sortedTrackPositions, hasCentered) {
+ if (hasCentered || sortedTrackPositions.isEmpty()) return@LaunchedEffect
+ val points = sortedTrackPositions.map { it.toLatLng() }
+ val cameraUpdate =
+ if (points.size == 1) {
+ CameraUpdateFactory.newLatLngZoom(points.first(), max(cameraPositionState.position.zoom, 12f))
+ } else {
+ val bounds = LatLngBounds.builder()
+ points.forEach { bounds.include(it) }
+ CameraUpdateFactory.newLatLngBounds(bounds.build(), 80)
+ }
+ try {
+ cameraPositionState.animate(cameraUpdate)
+ hasCentered = true
+ } catch (e: IllegalStateException) {
+ Logger.d { "Error centering track map: ${e.message}" }
+ }
+ }
+
+ // Animate to selected position marker when card is tapped in the list
+ LaunchedEffect(mode.selectedPositionTime) {
+ val selectedTime = mode.selectedPositionTime ?: return@LaunchedEffect
+ val selectedPos = sortedTrackPositions.find { it.time == selectedTime } ?: return@LaunchedEffect
+ try {
+ cameraPositionState.animate(CameraUpdateFactory.newLatLng(selectedPos.toLatLng()))
+ } catch (e: IllegalStateException) {
+ Logger.d { "Error animating to selected position: ${e.message}" }
+ }
+ }
+ }
+
+ if (mode is GoogleMapMode.Traceroute) {
+ LaunchedEffect(mode.overlay, tracerouteForwardPoints, tracerouteReturnPoints) {
+ if (mode.overlay == null || hasCentered) return@LaunchedEffect
+ val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
+ if (allPoints.isNotEmpty()) {
+ val cameraUpdate =
+ if (allPoints.size == 1) {
+ CameraUpdateFactory.newLatLngZoom(
+ allPoints.first(),
+ max(cameraPositionState.position.zoom, 12f),
+ )
+ } else {
+ val bounds = LatLngBounds.builder()
+ allPoints.forEach { bounds.include(it) }
+ CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX)
+ }
+ try {
+ cameraPositionState.animate(cameraUpdate)
+ hasCentered = true
+ } catch (e: IllegalStateException) {
+ Logger.d { "Error centering traceroute overlay: ${e.message}" }
+ }
+ }
+ }
+ }
+
+ // --- Tile & layers state ---
var showLayersBottomSheet by remember { mutableStateOf(false) }
val onAddLayerClicked = {
@@ -387,45 +492,23 @@ fun MapView(
val onRemoveLayer = { layerId: String -> mapViewModel.removeMapLayer(layerId) }
val onToggleVisibility = { layerId: String -> mapViewModel.toggleLayerVisibility(layerId) }
- val effectiveGoogleMapType =
- if (currentCustomTileProviderUrl != null) {
- MapType.NONE
- } else {
- selectedGoogleMapType
- }
+ val effectiveGoogleMapType = if (currentCustomTileProviderUrl != null) MapType.NONE else selectedGoogleMapType
var showClusterItemsDialog by remember { mutableStateOf?>(null) }
+ // --- Keep screen on while location tracking ---
LaunchedEffect(isLocationTrackingEnabled) {
val activity = context as? Activity ?: return@LaunchedEffect
val window = activity.window
-
if (isLocationTrackingEnabled) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
- LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
- if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
- val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
- if (allPoints.isNotEmpty()) {
- val cameraUpdate =
- if (allPoints.size == 1) {
- CameraUpdateFactory.newLatLngZoom(allPoints.first(), max(cameraPositionState.position.zoom, 12f))
- } else {
- val bounds = LatLngBounds.builder()
- allPoints.forEach { bounds.include(it) }
- CameraUpdateFactory.newLatLngBounds(bounds.build(), TRACEROUTE_BOUNDS_PADDING_PX)
- }
- try {
- cameraPositionState.animate(cameraUpdate)
- hasCenteredTraceroute = true
- } catch (e: IllegalStateException) {
- Logger.d { "Error centering traceroute overlay: ${e.message}" }
- }
- }
- }
+
+ // --- Main UI ---
+ val isMainMode = mode is GoogleMapMode.Main
Box(modifier = modifier) {
GoogleMap(
@@ -435,12 +518,12 @@ fun MapView(
uiSettings =
MapUiSettings(
zoomControlsEnabled = true,
- mapToolbarEnabled = true,
+ mapToolbarEnabled = isMainMode,
compassEnabled = false,
myLocationButtonEnabled = false,
rotationGesturesEnabled = true,
scrollGesturesEnabled = true,
- tiltGesturesEnabled = true,
+ tiltGesturesEnabled = isMainMode,
zoomGesturesEnabled = true,
),
properties =
@@ -449,16 +532,16 @@ fun MapView(
isMyLocationEnabled = isLocationTrackingEnabled && locationPermissionsState.allPermissionsGranted,
),
onMapLongClick = { latLng ->
- if (isConnected) {
- val newWaypoint =
+ if (isMainMode && isConnected) {
+ editingWaypoint =
Waypoint(
latitude_i = (latLng.latitude / DEG_D).toInt(),
longitude_i = (latLng.longitude / DEG_D).toInt(),
)
- editingWaypoint = newWaypoint
}
},
) {
+ // Custom tile overlay (all modes)
key(currentCustomTileProviderUrl) {
currentCustomTileProviderUrl?.let { url ->
val config =
@@ -471,180 +554,145 @@ fun MapView(
}
}
- if (tracerouteForwardPoints.size >= 2) {
- Polyline(
- points = tracerouteForwardOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.OutgoingRoute,
- width = 9f,
- zIndex = 3.0f,
- )
- }
- if (tracerouteReturnPoints.size >= 2) {
- Polyline(
- points = tracerouteReturnOffsetPoints,
- jointType = JointType.ROUND,
- color = TracerouteColors.ReturnRoute,
- width = 7f,
- zIndex = 2.5f,
- )
- }
-
- if (nodeTracks != null && focusedNodeNum != null) {
- val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
- val timeFilteredPositions =
- nodeTracks.filter {
- lastHeardTrackFilter == LastHeardFilter.Any ||
- it.time > nowSeconds - lastHeardTrackFilter.seconds
- }
- val sortedPositions = timeFilteredPositions.sortedBy { it.time }
- allNodes
- .find { it.num == focusedNodeNum }
- ?.let { focusedNode ->
- sortedPositions.forEachIndexed { index, position ->
- key(position.time) {
- val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
- val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
- val color = Color(focusedNode.colors.second).copy(alpha = alpha)
- val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
- val activeNodeZIndex = if (isHighPriority) 5f else 4f
-
- if (index == sortedPositions.lastIndex) {
- MarkerComposable(
- state = markerState,
- zIndex = activeNodeZIndex,
- alpha = if (isHighPriority) 1.0f else 0.9f,
- ) {
- NodeChip(node = focusedNode)
- }
- } else {
- MarkerInfoWindowComposable(
- state = markerState,
- title = stringResource(Res.string.position),
- snippet = formatAgo(position.time),
- zIndex = 1f + alpha,
- infoContent = {
- PositionInfoWindowContent(position = position, displayUnits = displayUnits)
- },
- ) {
- Icon(
- imageVector = androidx.compose.material.icons.Icons.Rounded.TripOrigin,
- contentDescription = stringResource(Res.string.track_point),
- tint = color,
- )
- }
- }
- }
- }
-
- if (sortedPositions.size > 1) {
- val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
- segments.forEachIndexed { index, segmentPoints ->
- val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
- Polyline(
- points = segmentPoints.map { it.toLatLng() },
- jointType = JointType.ROUND,
- color = Color(focusedNode.colors.second).copy(alpha = alpha),
- width = 8f,
- zIndex = 0.6f,
+ when (mode) {
+ is GoogleMapMode.Main ->
+ MainMapContent(
+ nodeClusterItems =
+ filteredNodes.map { node ->
+ val latLng =
+ LatLng(
+ (node.position.latitude_i ?: 0) * DEG_D,
+ (node.position.longitude_i ?: 0) * DEG_D,
)
- }
- }
+ NodeClusterItem(
+ node = node,
+ nodePosition = latLng,
+ nodeTitle = "${node.user.short_name} ${formatAgo(node.position.time)}",
+ nodeSnippet = "${node.user.long_name}",
+ myNodeNum = myNodeNum,
+ )
+ },
+ mapFilterState = mapFilterState,
+ navigateToNodeDetails = navigateToNodeDetails,
+ displayableWaypoints = displayableWaypoints,
+ myNodeNum = myNodeNum,
+ isConnected = isConnected,
+ onEditWaypointRequest = { editingWaypoint = it },
+ selectedWaypointId = selectedWaypointId,
+ mapLayers = mapLayers,
+ mapViewModel = mapViewModel,
+ cameraPositionState = cameraPositionState,
+ coroutineScope = coroutineScope,
+ onShowClusterItemsDialog = { showClusterItemsDialog = it },
+ )
+
+ is GoogleMapMode.NodeTrack -> {
+ val displayUnits by mapViewModel.displayUnits.collectAsStateWithLifecycle()
+ if (mode.focusedNode != null && sortedTrackPositions.isNotEmpty()) {
+ NodeTrackOverlay(
+ focusedNode = mode.focusedNode,
+ sortedPositions = sortedTrackPositions,
+ displayUnits = displayUnits,
+ myNodeNum = myNodeNum,
+ selectedPositionTime = mode.selectedPositionTime,
+ onPositionSelected = mode.onPositionSelected,
+ )
}
- } else {
- NodeClusterMarkers(
- nodeClusterItems = nodeClusterItems,
- mapFilterState = mapFilterState,
- navigateToNodeDetails = navigateToNodeDetails,
- onClusterClick = { cluster ->
- val items = cluster.items.toList()
- val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
+ }
- if (allSameLocation) {
- showClusterItemsDialog = items
- } else {
- val bounds = LatLngBounds.builder()
- cluster.items.forEach { bounds.include(it.position) }
- coroutineScope.launch {
- cameraPositionState.animate(
- CameraUpdateFactory.newCameraPosition(
- CameraPosition.Builder()
- .target(bounds.build().center)
- .zoom(cameraPositionState.position.zoom + 1)
- .build(),
- ),
- )
- }
- Logger.d { "Cluster clicked! $cluster" }
- }
- true
- },
- )
+ is GoogleMapMode.Traceroute ->
+ TracerouteMapContent(
+ forwardOffsetPoints = tracerouteForwardOffsetPoints,
+ returnOffsetPoints = tracerouteReturnOffsetPoints,
+ forwardPointCount = tracerouteForwardPoints.size,
+ returnPointCount = tracerouteReturnPoints.size,
+ displayNodes = tracerouteDisplayNodes,
+ )
}
-
- WaypointMarkers(
- displayableWaypoints = displayableWaypoints,
- mapFilterState = mapFilterState,
- myNodeNum = mapViewModel.myNodeNum ?: 0,
- isConnected = isConnected,
- unicodeEmojiToBitmapProvider = ::unicodeEmojiToBitmap,
- onEditWaypointRequest = { waypointToEdit -> editingWaypoint = waypointToEdit },
- selectedWaypointId = selectedWaypointId,
- )
-
- mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
}
+ // Scale bar
ScaleBar(
cameraPositionState = cameraPositionState,
- modifier = Modifier.align(Alignment.BottomStart).padding(bottom = 48.dp),
+ modifier = Modifier.align(Alignment.BottomStart).padding(bottom = if (isMainMode) 48.dp else 16.dp),
)
- editingWaypoint?.let { waypointToEdit ->
- EditWaypointDialog(
- waypoint = waypointToEdit,
- onSendClicked = { updatedWp ->
- var finalWp = updatedWp
- if (updatedWp.id == 0) {
- finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0)
- }
- if ((updatedWp.icon ?: 0) == 0) {
- finalWp = finalWp.copy(icon = 0x1F4CD)
- }
- mapViewModel.sendWaypoint(finalWp)
- editingWaypoint = null
- },
- onDeleteClicked = { wpToDelete ->
- if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) {
- val deleteMarkerWp = wpToDelete.copy(expire = 1)
- mapViewModel.sendWaypoint(deleteMarkerWp)
- }
- mapViewModel.deleteWaypoint(wpToDelete.id)
- editingWaypoint = null
- },
- onDismissRequest = { editingWaypoint = null },
- )
+ // Waypoint edit dialog (Main mode only)
+ if (isMainMode) {
+ editingWaypoint?.let { waypointToEdit ->
+ EditWaypointDialog(
+ waypoint = waypointToEdit,
+ onSendClicked = { updatedWp ->
+ var finalWp = updatedWp
+ if (updatedWp.id == 0) {
+ finalWp = finalWp.copy(id = mapViewModel.generatePacketId() ?: 0)
+ }
+ if ((updatedWp.icon ?: 0) == 0) {
+ finalWp = finalWp.copy(icon = 0x1F4CD)
+ }
+ mapViewModel.sendWaypoint(finalWp)
+ editingWaypoint = null
+ },
+ onDeleteClicked = { wpToDelete ->
+ if ((wpToDelete.locked_to ?: 0) == 0 && isConnected && wpToDelete.id != 0) {
+ mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1))
+ }
+ mapViewModel.deleteWaypoint(wpToDelete.id)
+ editingWaypoint = null
+ },
+ onDismissRequest = { editingWaypoint = null },
+ )
+ }
}
+ // Controls overlay
val visibleNetworkLayers = mapLayers.filter { it.isNetwork && it.isVisible }
val showRefresh = visibleNetworkLayers.isNotEmpty()
val isRefreshingLayers = visibleNetworkLayers.any { it.isRefreshing }
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
- mapFilterMenuExpanded = mapFilterMenuExpanded,
- onMapFilterMenuDismissRequest = { mapFilterMenuExpanded = false },
- onToggleMapFilterMenu = { mapFilterMenuExpanded = true },
- mapViewModel = mapViewModel,
- mapTypeMenuExpanded = mapTypeMenuExpanded,
- onMapTypeMenuDismissRequest = { mapTypeMenuExpanded = false },
- onToggleMapTypeMenu = { mapTypeMenuExpanded = true },
- onManageLayersClicked = { showLayersBottomSheet = true },
- onManageCustomTileProvidersClicked = {
- mapTypeMenuExpanded = false
- showCustomTileManagerSheet = true
+ onToggleFilterMenu = { mapFilterMenuExpanded = true },
+ filterDropdownContent = {
+ if (mode is GoogleMapMode.NodeTrack) {
+ NodeMapFilterDropdown(
+ expanded = mapFilterMenuExpanded,
+ onDismissRequest = { mapFilterMenuExpanded = false },
+ mapViewModel = mapViewModel,
+ )
+ } else {
+ MapFilterDropdown(
+ expanded = mapFilterMenuExpanded,
+ onDismissRequest = { mapFilterMenuExpanded = false },
+ mapViewModel = mapViewModel,
+ )
+ }
+ },
+ mapTypeContent = {
+ Box {
+ MapButton(
+ icon = MeshtasticIcons.Map,
+ contentDescription = stringResource(Res.string.map_tile_source),
+ onClick = { mapTypeMenuExpanded = true },
+ )
+ MapTypeDropdown(
+ expanded = mapTypeMenuExpanded,
+ onDismissRequest = { mapTypeMenuExpanded = false },
+ mapViewModel = mapViewModel,
+ onManageCustomTileProvidersClicked = {
+ mapTypeMenuExpanded = false
+ showCustomTileManagerSheet = true
+ },
+ )
+ }
+ },
+ layersContent = {
+ MapButton(
+ icon = MeshtasticIcons.Layers,
+ contentDescription = stringResource(Res.string.manage_map_layers),
+ onClick = { showLayersBottomSheet = true },
+ )
},
- isNodeMap = focusedNodeNum != null,
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {
if (locationPermissionsState.allPermissionsGranted) {
@@ -680,6 +728,8 @@ fun MapView(
onRefresh = { mapViewModel.refreshAllVisibleNetworkLayers() },
)
}
+
+ // --- Bottom sheets & dialogs ---
if (showLayersBottomSheet) {
ModalBottomSheet(onDismissRequest = { showLayersBottomSheet = false }) {
CustomMapLayersSheet(
@@ -709,92 +759,159 @@ fun MapView(
}
}
+// region --- Main Map Content ---
+
+@Suppress("LongParameterList")
+@OptIn(MapsComposeExperimentalApi::class)
@Composable
-private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
- val context = LocalContext.current
- var currentLayer by remember { mutableStateOf(null) }
-
- MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
- val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
- val layer =
- try {
- when (layerItem.layerType) {
- LayerType.KML -> KmlLayer(map, inputStream, context)
- LayerType.GEOJSON ->
- GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() }))
+private fun MainMapContent(
+ nodeClusterItems: List,
+ mapFilterState: MapFilterState,
+ navigateToNodeDetails: (Int) -> Unit,
+ displayableWaypoints: List,
+ myNodeNum: Int?,
+ isConnected: Boolean,
+ onEditWaypointRequest: (Waypoint) -> Unit,
+ selectedWaypointId: Int?,
+ mapLayers: List,
+ mapViewModel: MapViewModel,
+ cameraPositionState: CameraPositionState,
+ coroutineScope: CoroutineScope,
+ onShowClusterItemsDialog: (List?) -> Unit,
+) {
+ NodeClusterMarkers(
+ nodeClusterItems = nodeClusterItems,
+ mapFilterState = mapFilterState,
+ navigateToNodeDetails = navigateToNodeDetails,
+ onClusterClick = { cluster ->
+ val items = cluster.items.toList()
+ val allSameLocation = items.size > 1 && items.all { it.position == items.first().position }
+ if (allSameLocation) {
+ onShowClusterItemsDialog(items)
+ } else {
+ val bounds = LatLngBounds.builder()
+ cluster.items.forEach { bounds.include(it.position) }
+ coroutineScope.launch {
+ cameraPositionState.animate(
+ CameraUpdateFactory.newCameraPosition(
+ CameraPosition.Builder()
+ .target(bounds.build().center)
+ .zoom(cameraPositionState.position.zoom + 1)
+ .build(),
+ ),
+ )
}
- } catch (e: Exception) {
- Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
- null
+ Logger.d { "Cluster clicked! $cluster" }
}
+ true
+ },
+ )
- layer?.let {
- if (layerItem.isVisible) {
- it.addLayerToMap()
- }
- currentLayer = it
- }
- }
+ WaypointMarkers(
+ displayableWaypoints = displayableWaypoints,
+ mapFilterState = mapFilterState,
+ myNodeNum = myNodeNum ?: 0,
+ isConnected = isConnected,
+ onEditWaypointRequest = onEditWaypointRequest,
+ selectedWaypointId = selectedWaypointId,
+ )
- DisposableEffect(layerItem.id) {
- onDispose {
- currentLayer?.removeLayerFromMap()
- currentLayer = null
- }
- }
-
- // Handle visibility changes without reloading the whole layer if possible,
- // though KmlLayer.addLayerToMap() / removeLayerFromMap() is what we have.
- LaunchedEffect(layerItem.isVisible) {
- val layer = currentLayer ?: return@LaunchedEffect
- if (layerItem.isVisible) {
- if (!layer.isLayerOnMap) layer.addLayerToMap()
- } else {
- if (layer.isLayerOnMap) layer.removeLayerFromMap()
- }
- }
+ mapLayers.forEach { layerItem -> key(layerItem.id) { MapLayerOverlay(layerItem, mapViewModel) } }
}
-internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
- String(Character.toChars(unicodeCodePoint))
-} catch (e: IllegalArgumentException) {
- Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" }
- "\uD83D\uDCCD"
-}
+// endregion
-internal fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
- val unicodeEmoji = convertIntToEmoji(icon)
- val paint =
- Paint(Paint.ANTI_ALIAS_FLAG).apply {
- textSize = 64f
- color = android.graphics.Color.BLACK
- textAlign = Paint.Align.CENTER
- }
+// region --- Node Track Overlay ---
- val baseline = -paint.ascent()
- val width = (paint.measureText(unicodeEmoji) + 0.5f).toInt()
- val height = (baseline + paint.descent() + 0.5f).toInt()
- val image = createBitmap(width, height, android.graphics.Bitmap.Config.ARGB_8888)
- val canvas = Canvas(image)
- canvas.drawText(unicodeEmoji, width / 2f, baseline, paint)
+/**
+ * Renders the position track polyline segments and markers inside a [GoogleMap] content scope. Each marker fades from
+ * transparent (oldest) to opaque (newest). The newest position shows the node's [NodeChip]; older positions show a
+ * [TripOrigin] dot with an info-window on tap.
+ *
+ * When [selectedPositionTime] matches a marker's `Position.time`, that marker is highlighted with the primary color and
+ * elevated z-index. Tapping a marker invokes [onPositionSelected] for list synchronization.
+ */
+@OptIn(MapsComposeExperimentalApi::class)
+@Composable
+@Suppress("LongMethod")
+private fun NodeTrackOverlay(
+ focusedNode: Node,
+ sortedPositions: List,
+ displayUnits: DisplayUnits,
+ myNodeNum: Int?,
+ selectedPositionTime: Int? = null,
+ onPositionSelected: ((Int) -> Unit)? = null,
+) {
+ val isHighPriority = focusedNode.num == myNodeNum || focusedNode.isFavorite
+ val activeNodeZIndex = if (isHighPriority) 5f else 4f
+ val selectedColor = MaterialTheme.colorScheme.primary
- return BitmapDescriptorFactory.fromBitmap(image)
-}
+ sortedPositions.forEachIndexed { index, position ->
+ key(position.time) {
+ val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
+ val alpha =
+ if (sortedPositions.size > 1) {
+ index.toFloat() / (sortedPositions.size.toFloat() - 1)
+ } else {
+ 1f
+ }
+ val isSelected = position.time == selectedPositionTime
+ val color =
+ if (isSelected) {
+ selectedColor
+ } else {
+ Color(focusedNode.colors.second).copy(alpha = alpha)
+ }
-@Suppress("NestedBlockDepth")
-fun Uri.getFileName(context: android.content.Context): String {
- var name = this.lastPathSegment ?: "layer_$nowMillis"
- if (this.scheme == "content") {
- context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
- if (cursor.moveToFirst()) {
- val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
- if (displayNameIndex != -1) {
- name = cursor.getString(displayNameIndex)
+ if (index == sortedPositions.lastIndex) {
+ MarkerComposable(
+ state = markerState,
+ zIndex = activeNodeZIndex,
+ alpha = if (isHighPriority) 1.0f else 0.9f,
+ onClick = {
+ onPositionSelected?.invoke(position.time)
+ false // Allow default info window behavior
+ },
+ ) {
+ NodeChip(node = focusedNode)
+ }
+ } else {
+ MarkerInfoWindowComposable(
+ state = markerState,
+ title = stringResource(Res.string.position),
+ snippet = formatAgo(position.time),
+ zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha,
+ onClick = {
+ onPositionSelected?.invoke(position.time)
+ false // Allow default info window behavior
+ },
+ infoContent = { PositionInfoWindowContent(position = position, displayUnits = displayUnits) },
+ ) {
+ Icon(
+ imageVector = MeshtasticIcons.TripOrigin,
+ contentDescription = stringResource(Res.string.track_point),
+ tint = color,
+ modifier = if (isSelected) Modifier.size(32.dp) else Modifier,
+ )
}
}
}
}
- return name
+
+ // Gradient polyline segments
+ if (sortedPositions.size > 1) {
+ val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
+ segments.forEachIndexed { index, segmentPoints ->
+ val alpha = index.toFloat() / (segments.size.toFloat() - 1)
+ Polyline(
+ points = segmentPoints.map { it.toLatLng() },
+ jointType = JointType.ROUND,
+ color = Color(focusedNode.colors.second).copy(alpha = alpha),
+ width = 8f,
+ zIndex = 0.6f,
+ )
+ }
+ }
}
@Composable
@@ -815,26 +932,20 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayU
label = stringResource(Res.string.latitude),
value = "%.5f".format((position.latitude_i ?: 0) * DEG_D),
)
-
PositionRow(
label = stringResource(Res.string.longitude),
value = "%.5f".format((position.longitude_i ?: 0) * DEG_D),
)
-
- PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view?.toString() ?: "")
-
+ PositionRow(label = stringResource(Res.string.sats), value = position.sats_in_view.toString())
PositionRow(
label = stringResource(Res.string.alt),
value = (position.altitude ?: 0).metersIn(displayUnits).toString(displayUnits),
)
-
PositionRow(label = stringResource(Res.string.speed), value = speedFromPosition(position, displayUnits))
-
PositionRow(
label = stringResource(Res.string.heading),
value = "%.0f°".format((position.ground_track ?: 0) * HEADING_DEG),
)
-
PositionRow(label = stringResource(Res.string.timestamp), value = position.formatPositionTime())
}
}
@@ -844,24 +955,53 @@ private fun PositionInfoWindowContent(position: Position, displayUnits: DisplayU
private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): String {
val speedInMps = position.ground_speed ?: 0
val mpsText = "%d m/s".format(speedInMps)
- val speedText =
- if (speedInMps > 10) {
- when (displayUnits) {
- DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph())
- DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph())
- else -> mpsText
- }
- } else {
- mpsText
+ return if (speedInMps > 10) {
+ when (displayUnits) {
+ DisplayUnits.METRIC -> "%.1f Km/h".format(speedInMps.mpsToKmph())
+ DisplayUnits.IMPERIAL -> "%.1f mph".format(speedInMps.mpsToMph())
+ else -> mpsText
}
- return speedText
+ } else {
+ mpsText
+ }
}
-internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
+// endregion
-private fun Node.toLatLng(): LatLng? = this.position.toLatLng()
+// region --- Traceroute Map Content ---
-private fun Waypoint.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
+@OptIn(MapsComposeExperimentalApi::class)
+@Composable
+private fun TracerouteMapContent(
+ forwardOffsetPoints: List,
+ returnOffsetPoints: List,
+ forwardPointCount: Int,
+ returnPointCount: Int,
+ displayNodes: List,
+) {
+ if (forwardPointCount >= 2) {
+ Polyline(
+ points = forwardOffsetPoints,
+ jointType = JointType.ROUND,
+ color = TracerouteColors.OutgoingRoute,
+ width = 9f,
+ zIndex = 3.0f,
+ )
+ }
+ if (returnPointCount >= 2) {
+ Polyline(
+ points = returnOffsetPoints,
+ jointType = JointType.ROUND,
+ color = TracerouteColors.ReturnRoute,
+ width = 7f,
+ zIndex = 2.5f,
+ )
+ }
+ displayNodes.forEach { node ->
+ val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
+ MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) }
+ }
+}
private fun offsetPolyline(
points: List,
@@ -892,3 +1032,94 @@ private fun offsetPolyline(
SphericalUtil.computeOffset(point, abs(offsetMeters), perpendicularHeading)
}
}
+
+// endregion
+
+// region --- Map Layers ---
+
+@Composable
+private fun MapLayerOverlay(layerItem: MapLayerItem, mapViewModel: MapViewModel) {
+ val context = LocalContext.current
+ var currentLayer by remember { mutableStateOf(null) }
+
+ MapEffect(layerItem.id, layerItem.isRefreshing) { map ->
+ currentLayer?.safeRemoveLayerFromMap()
+ currentLayer = null
+ val inputStream = mapViewModel.getInputStreamFromUri(layerItem) ?: return@MapEffect
+ val layer =
+ try {
+ when (layerItem.layerType) {
+ LayerType.KML -> KmlLayer(map, inputStream, context)
+ LayerType.GEOJSON ->
+ GeoJsonLayer(map, JSONObject(inputStream.bufferedReader().use { it.readText() }))
+ }
+ } catch (e: Exception) {
+ Logger.withTag("MapView").e(e) { "Error loading map layer: ${layerItem.name}" }
+ null
+ }
+ layer?.let {
+ if (layerItem.isVisible) it.safeAddLayerToMap()
+ currentLayer = it
+ }
+ }
+
+ DisposableEffect(layerItem.id) {
+ onDispose {
+ currentLayer?.safeRemoveLayerFromMap()
+ currentLayer = null
+ }
+ }
+
+ LaunchedEffect(layerItem.isVisible) {
+ val layer = currentLayer ?: return@LaunchedEffect
+ if (layerItem.isVisible) layer.safeAddLayerToMap() else layer.safeRemoveLayerFromMap()
+ }
+}
+
+private fun Layer.safeRemoveLayerFromMap() {
+ try {
+ removeLayerFromMap()
+ } catch (e: Exception) {
+ Logger.withTag("MapView").e(e) { "Error removing map layer" }
+ }
+}
+
+private fun Layer.safeAddLayerToMap() {
+ try {
+ if (!isLayerOnMap) addLayerToMap()
+ } catch (e: Exception) {
+ Logger.withTag("MapView").e(e) { "Error adding map layer" }
+ }
+}
+
+// endregion
+
+// region --- Utilities ---
+
+internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
+ String(Character.toChars(unicodeCodePoint))
+} catch (e: IllegalArgumentException) {
+ Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" }
+ "\uD83D\uDCCD"
+}
+
+@Suppress("NestedBlockDepth")
+fun Uri.getFileName(context: android.content.Context): String {
+ var name = this.lastPathSegment ?: "layer_$nowMillis"
+ if (this.scheme == "content") {
+ context.contentResolver.query(this, null, null, null, null)?.use { cursor ->
+ if (cursor.moveToFirst()) {
+ val displayNameIndex = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
+ if (displayNameIndex != -1) {
+ name = cursor.getString(displayNameIndex)
+ }
+ }
+ }
+ }
+ return name
+}
+
+/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
+internal fun Position.toLatLng(): LatLng = LatLng((this.latitude_i ?: 0) * DEG_D, (this.longitude_i ?: 0) * DEG_D)
+
+// endregion
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
index f4b8f775a..e4eabbb76 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -28,7 +28,11 @@ import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
-import kotlinx.coroutines.Dispatchers
+import io.ktor.client.HttpClient
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.http.isSuccess
+import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -45,12 +49,13 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
-import org.meshtastic.core.datastore.UiPreferencesDataSource
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Config
@@ -77,6 +82,8 @@ data class MapCameraPosition(
@KoinViewModel
class MapViewModel(
private val application: Application,
+ private val dispatchers: CoroutineDispatchers,
+ private val httpClient: HttpClient,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
@@ -84,7 +91,7 @@ class MapViewModel(
radioConfigRepository: RadioConfigRepository,
radioController: RadioController,
private val customTileProviderRepository: CustomTileProviderRepository,
- uiPreferencesDataSource: UiPreferencesDataSource,
+ uiPrefs: UiPrefs,
savedStateHandle: SavedStateHandle,
) : BaseMapViewModel(mapPrefs, nodeRepository, packetRepository, radioController) {
@@ -125,7 +132,7 @@ class MapViewModel(
),
)
- val theme: StateFlow = uiPreferencesDataSource.theme
+ val theme: StateFlow = uiPrefs.theme
private val _errorFlow = MutableSharedFlow()
val errorFlow: SharedFlow = _errorFlow.asSharedFlow()
@@ -404,7 +411,7 @@ class MapViewModel(
}
private fun loadPersistedLayers() {
- viewModelScope.launch(Dispatchers.IO) {
+ viewModelScope.launch(dispatchers.io) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
@@ -551,7 +558,7 @@ class MapViewModel(
}
}
- private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
+ private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
val directory = File(application.filesDir, "map_layers")
@@ -622,7 +629,7 @@ class MapViewModel(
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
- withContext(Dispatchers.IO) {
+ withContext(dispatchers.io) {
try {
val file = uri.toFile()
if (file.exists()) {
@@ -637,11 +644,15 @@ class MapViewModel(
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
- return withContext(Dispatchers.IO) {
+ return withContext(dispatchers.io) {
try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
- val url = java.net.URL(uriToLoad.toString())
- java.io.BufferedInputStream(url.openStream())
+ val response = httpClient.get(uriToLoad.toString())
+ if (!response.status.isSuccess()) {
+ Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
+ return@withContext null
+ }
+ response.bodyAsChannel().toInputStream()
} else {
application.contentResolver.openInputStream(uriToLoad)
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
index 85369120c..fd9272579 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomMapLayersSheet.kt
@@ -25,17 +25,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.material.icons.filled.Visibility
-import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -66,6 +62,11 @@ import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.show_layer
import org.meshtastic.core.resources.url
import org.meshtastic.core.ui.component.MeshtasticDialog
+import org.meshtastic.core.ui.icon.Delete
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.Refresh
+import org.meshtastic.core.ui.icon.Visibility
+import org.meshtastic.core.ui.icon.VisibilityOff
@Suppress("LongMethod")
@Composable
@@ -119,19 +120,22 @@ fun CustomMapLayersSheet(
} else {
IconButton(onClick = { onRefreshLayer(layer.id) }) {
Icon(
- imageVector = Icons.Filled.Refresh,
+ imageVector = MeshtasticIcons.Refresh,
contentDescription = stringResource(Res.string.refresh),
)
}
}
}
- IconButton(onClick = { onToggleVisibility(layer.id) }) {
+ IconToggleButton(
+ checked = layer.isVisible,
+ onCheckedChange = { onToggleVisibility(layer.id) },
+ ) {
Icon(
imageVector =
if (layer.isVisible) {
- Icons.Filled.Visibility
+ MeshtasticIcons.Visibility
} else {
- Icons.Filled.VisibilityOff
+ MeshtasticIcons.VisibilityOff
},
contentDescription =
stringResource(
@@ -145,7 +149,7 @@ fun CustomMapLayersSheet(
}
IconButton(onClick = { onRemoveLayer(layer.id) }) {
Icon(
- imageVector = Icons.Filled.Delete,
+ imageVector = MeshtasticIcons.Delete,
contentDescription = stringResource(Res.string.remove_layer),
)
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
index 458de9f56..8082e40d1 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/CustomTileProviderManagerSheet.kt
@@ -27,9 +27,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -71,6 +68,9 @@ import org.meshtastic.core.resources.url_must_contain_placeholders
import org.meshtastic.core.resources.url_template
import org.meshtastic.core.resources.url_template_hint
import org.meshtastic.core.ui.component.MeshtasticDialog
+import org.meshtastic.core.ui.icon.Delete
+import org.meshtastic.core.ui.icon.Edit
+import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.showToast
@Suppress("LongMethod")
@@ -155,13 +155,13 @@ fun CustomTileProviderManagerSheet(mapViewModel: MapViewModel) {
},
) {
Icon(
- Icons.Filled.Edit,
+ MeshtasticIcons.Edit,
contentDescription = stringResource(Res.string.edit_custom_tile_source),
)
}
IconButton(onClick = { mapViewModel.removeCustomTileProvider(config.id) }) {
Icon(
- Icons.Filled.Delete,
+ MeshtasticIcons.Delete,
contentDescription = stringResource(Res.string.delete_custom_tile_source),
)
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
index df808c615..18eb0ac83 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/EditWaypointDialog.kt
@@ -33,9 +33,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.CalendarMonth
-import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -60,7 +57,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.atTime
@@ -82,6 +78,9 @@ import org.meshtastic.core.resources.time
import org.meshtastic.core.resources.waypoint_edit
import org.meshtastic.core.resources.waypoint_new
import org.meshtastic.core.ui.emoji.EmojiPickerDialog
+import org.meshtastic.core.ui.icon.CalendarMonth
+import org.meshtastic.core.ui.icon.Lock
+import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.proto.Waypoint
import kotlin.time.Duration.Companion.hours
@@ -120,12 +119,12 @@ fun EditWaypointDialog(
val expireValue = waypointInput.expire ?: 0
if (isExpiryEnabled) {
if (expireValue != 0 && expireValue != Int.MAX_VALUE) {
- val instant = Instant.fromEpochSeconds(expireValue.toLong())
+ val instant = kotlin.time.Instant.fromEpochSeconds(expireValue.toLong())
val date = java.util.Date(instant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
} else { // If enabled but not set, default to 8 hours from now
- val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours
+ val futureInstant = kotlin.time.Clock.System.now() + 8.hours
val date = java.util.Date(futureInstant.toEpochMilliseconds())
selectedDateString = dateFormat.format(date)
selectedTimeString = timeFormat.format(date)
@@ -190,7 +189,7 @@ fun EditWaypointDialog(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
- imageVector = Icons.Rounded.Lock,
+ imageVector = MeshtasticIcons.Lock,
contentDescription = stringResource(Res.string.locked),
)
Spacer(modifier = Modifier.width(8.dp))
@@ -209,7 +208,7 @@ fun EditWaypointDialog(
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
- imageVector = Icons.Rounded.CalendarMonth,
+ imageVector = MeshtasticIcons.CalendarMonth,
contentDescription = stringResource(Res.string.expires),
)
Spacer(modifier = Modifier.width(8.dp))
@@ -223,7 +222,7 @@ fun EditWaypointDialog(
val expireValue = waypointInput.expire ?: 0
// Default to 8 hours from now if not already set
if (expireValue == 0 || expireValue == Int.MAX_VALUE) {
- val futureInstant = kotlinx.datetime.Clock.System.now() + 8.hours
+ val futureInstant = kotlin.time.Clock.System.now() + 8.hours
waypointInput = waypointInput.copy(expire = futureInstant.epochSeconds.toInt())
}
} else {
@@ -237,9 +236,9 @@ fun EditWaypointDialog(
val currentInstant =
(waypointInput.expire ?: 0).let {
if (it != 0 && it != Int.MAX_VALUE) {
- Instant.fromEpochSeconds(it.toLong())
+ kotlin.time.Instant.fromEpochSeconds(it.toLong())
} else {
- kotlinx.datetime.Clock.System.now() + 8.hours
+ kotlin.time.Clock.System.now() + 8.hours
}
}
val ldt = currentInstant.toLocalDateTime(tz)
@@ -252,9 +251,9 @@ fun EditWaypointDialog(
(waypointInput.expire ?: 0)
.let {
if (it != 0 && it != Int.MAX_VALUE) {
- Instant.fromEpochSeconds(it.toLong())
+ kotlin.time.Instant.fromEpochSeconds(it.toLong())
} else {
- kotlinx.datetime.Clock.System.now() + 8.hours
+ kotlin.time.Clock.System.now() + 8.hours
}
}
.toLocalDateTime(tz)
@@ -287,9 +286,9 @@ fun EditWaypointDialog(
(waypointInput.expire ?: 0)
.let {
if (it != 0 && it != Int.MAX_VALUE) {
- Instant.fromEpochSeconds(it.toLong())
+ kotlin.time.Instant.fromEpochSeconds(it.toLong())
} else {
- kotlinx.datetime.Clock.System.now() + 8.hours
+ kotlin.time.Clock.System.now() + 8.hours
}
}
.toLocalDateTime(tz)
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
deleted file mode 100644
index 19cb41184..000000000
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.map.component
-
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Navigation
-import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.material.icons.outlined.Layers
-import androidx.compose.material.icons.outlined.Map
-import androidx.compose.material.icons.outlined.MyLocation
-import androidx.compose.material.icons.outlined.Navigation
-import androidx.compose.material.icons.outlined.Tune
-import androidx.compose.material.icons.rounded.LocationDisabled
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.rotate
-import androidx.compose.ui.unit.dp
-import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.app.map.MapViewModel
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.manage_map_layers
-import org.meshtastic.core.resources.map_filter
-import org.meshtastic.core.resources.map_tile_source
-import org.meshtastic.core.resources.orient_north
-import org.meshtastic.core.resources.refresh
-import org.meshtastic.core.resources.toggle_my_position
-import org.meshtastic.core.ui.theme.StatusColors.StatusRed
-
-@Composable
-fun MapControlsOverlay(
- modifier: Modifier = Modifier,
- mapFilterMenuExpanded: Boolean,
- onMapFilterMenuDismissRequest: () -> Unit,
- onToggleMapFilterMenu: () -> Unit,
- mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
- mapTypeMenuExpanded: Boolean,
- onMapTypeMenuDismissRequest: () -> Unit,
- onToggleMapTypeMenu: () -> Unit,
- onManageLayersClicked: () -> Unit,
- onManageCustomTileProvidersClicked: () -> Unit, // New parameter
- isNodeMap: Boolean,
- // Location tracking parameters
- isLocationTrackingEnabled: Boolean = false,
- onToggleLocationTracking: () -> Unit = {},
- bearing: Float = 0f,
- onCompassClick: () -> Unit = {},
- followPhoneBearing: Boolean,
- showRefresh: Boolean = false,
- isRefreshing: Boolean = false,
- onRefresh: () -> Unit = {},
-) {
- Row(modifier = modifier) {
- CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
- if (isNodeMap) {
- MapButton(
- icon = Icons.Outlined.Tune,
- contentDescription = stringResource(Res.string.map_filter),
- onClick = onToggleMapFilterMenu,
- )
- NodeMapFilterDropdown(
- expanded = mapFilterMenuExpanded,
- onDismissRequest = onMapFilterMenuDismissRequest,
- mapViewModel = mapViewModel,
- )
- } else {
- Box {
- MapButton(
- icon = Icons.Outlined.Tune,
- contentDescription = stringResource(Res.string.map_filter),
- onClick = onToggleMapFilterMenu,
- )
- MapFilterDropdown(
- expanded = mapFilterMenuExpanded,
- onDismissRequest = onMapFilterMenuDismissRequest,
- mapViewModel = mapViewModel,
- )
- }
- }
-
- Box {
- MapButton(
- icon = Icons.Outlined.Map,
- contentDescription = stringResource(Res.string.map_tile_source),
- onClick = onToggleMapTypeMenu,
- )
- MapTypeDropdown(
- expanded = mapTypeMenuExpanded,
- onDismissRequest = onMapTypeMenuDismissRequest,
- mapViewModel = mapViewModel, // Pass mapViewModel
- onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
- )
- }
-
- MapButton(
- icon = Icons.Outlined.Layers,
- contentDescription = stringResource(Res.string.manage_map_layers),
- onClick = onManageLayersClicked,
- )
-
- if (showRefresh) {
- if (isRefreshing) {
- Box(modifier = Modifier.padding(8.dp)) {
- CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
- }
- } else {
- MapButton(
- icon = Icons.Filled.Refresh,
- contentDescription = stringResource(Res.string.refresh),
- onClick = onRefresh,
- )
- }
- }
-
- // Location tracking button
- MapButton(
- icon =
- if (isLocationTrackingEnabled) {
- Icons.Rounded.LocationDisabled
- } else {
- Icons.Outlined.MyLocation
- },
- contentDescription = stringResource(Res.string.toggle_my_position),
- onClick = onToggleLocationTracking,
- )
- }
-}
-
-@Composable
-private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) {
- val icon = if (isFollowing) Icons.Filled.Navigation else Icons.Outlined.Navigation
-
- MapButton(
- modifier = Modifier.rotate(-bearing),
- icon = icon,
- iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f },
- contentDescription = stringResource(Res.string.orient_north),
- onClick = onClick,
- )
-}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
index 57886edda..d8e29120e 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/MapFilterDropdown.kt
@@ -18,10 +18,6 @@ package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Place
-import androidx.compose.material.icons.filled.Star
-import androidx.compose.material.icons.outlined.RadioButtonUnchecked
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -45,6 +41,10 @@ import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
+import org.meshtastic.core.ui.icon.Favorite
+import org.meshtastic.core.ui.icon.Lens
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.feature.map.LastHeardFilter
import kotlin.math.roundToInt
@@ -56,7 +56,10 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
text = { Text(stringResource(Res.string.only_favorites)) },
onClick = { mapViewModel.toggleOnlyFavorites() },
leadingIcon = {
- Icon(imageVector = Icons.Filled.Star, contentDescription = stringResource(Res.string.only_favorites))
+ Icon(
+ imageVector = MeshtasticIcons.Favorite,
+ contentDescription = stringResource(Res.string.only_favorites),
+ )
},
trailingIcon = {
Checkbox(
@@ -69,7 +72,10 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
text = { Text(stringResource(Res.string.show_waypoints)) },
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = {
- Icon(imageVector = Icons.Filled.Place, contentDescription = stringResource(Res.string.show_waypoints))
+ Icon(
+ imageVector = MeshtasticIcons.PinDrop,
+ contentDescription = stringResource(Res.string.show_waypoints),
+ )
},
trailingIcon = {
Checkbox(
@@ -83,7 +89,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = {
Icon(
- imageVector = Icons.Outlined.RadioButtonUnchecked, // Placeholder icon
+ imageVector = MeshtasticIcons.Lens,
contentDescription = stringResource(Res.string.show_precision_circle),
)
},
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
index 58c728cec..ad4bd58bb 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/MapTypeDropdown.kt
@@ -16,8 +16,6 @@
*/
package org.meshtastic.app.map.component
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
@@ -36,6 +34,8 @@ import org.meshtastic.core.resources.map_type_normal
import org.meshtastic.core.resources.map_type_satellite
import org.meshtastic.core.resources.map_type_terrain
import org.meshtastic.core.resources.selected_map_type
+import org.meshtastic.core.ui.icon.Check
+import org.meshtastic.core.ui.icon.MeshtasticIcons
@Suppress("LongMethod")
@Composable
@@ -67,7 +67,12 @@ internal fun MapTypeDropdown(
},
trailingIcon =
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
- { Icon(Icons.Filled.Check, contentDescription = stringResource(Res.string.selected_map_type)) }
+ {
+ Icon(
+ MeshtasticIcons.Check,
+ contentDescription = stringResource(Res.string.selected_map_type),
+ )
+ }
} else {
null
},
@@ -87,7 +92,7 @@ internal fun MapTypeDropdown(
if (selectedCustomUrl == config.urlTemplate) {
{
Icon(
- Icons.Filled.Check,
+ MeshtasticIcons.Check,
contentDescription = stringResource(Res.string.selected_map_type),
)
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
index fdc5ee262..61cdab9f1 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
@@ -16,30 +16,36 @@
*/
package org.meshtastic.app.map.component
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
-import com.google.android.gms.maps.model.BitmapDescriptor
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import com.google.android.gms.maps.model.LatLng
+import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
+import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
+import org.meshtastic.app.map.convertIntToEmoji
+import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
-private const val DEG_D = 1e-7
-
+@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun WaypointMarkers(
displayableWaypoints: List,
mapFilterState: BaseMapViewModel.MapFilterState,
myNodeNum: Int,
isConnected: Boolean,
- unicodeEmojiToBitmapProvider: (Int) -> BitmapDescriptor,
onEditWaypointRequest: (Waypoint) -> Unit,
selectedWaypointId: Int? = null,
) {
@@ -58,14 +64,16 @@ fun WaypointMarkers(
}
}
+ val iconCodePoint = if ((waypoint.icon ?: 0) == 0) PUSHPIN else waypoint.icon!!
+ val emojiText = convertIntToEmoji(iconCodePoint)
+ val icon =
+ rememberComposeBitmapDescriptor(iconCodePoint) {
+ Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
+ }
+
Marker(
state = markerState,
- icon =
- if ((waypoint.icon ?: 0) == 0) {
- unicodeEmojiToBitmapProvider(PUSHPIN) // Default icon (Round Pushpin)
- } else {
- unicodeEmojiToBitmapProvider(waypoint.icon!!)
- },
+ icon = icon,
title = (waypoint.name ?: "").replace('\n', ' ').replace('\b', ' '),
snippet = (waypoint.description ?: "").replace('\n', ' ').replace('\b', ' '),
visible = true,
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
index f6691b5ce..fa17fedbf 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeMapScreen.kt
@@ -16,13 +16,14 @@
*/
package org.meshtastic.app.map.node
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
@@ -31,7 +32,6 @@ import org.meshtastic.feature.map.node.NodeMapViewModel
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
- val destNum = node?.num
Scaffold(
topBar = {
@@ -46,8 +46,9 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
)
},
) { paddingValues ->
- Box(modifier = Modifier.padding(paddingValues)) {
- MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
- }
+ MapView(
+ modifier = Modifier.fillMaxSize().padding(paddingValues),
+ mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
+ )
}
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
new file mode 100644
index 000000000..2f7244b97
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.map.node
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.koin.compose.viewmodel.koinViewModel
+import org.meshtastic.app.map.GoogleMapMode
+import org.meshtastic.app.map.MapView
+import org.meshtastic.feature.map.node.NodeMapViewModel
+import org.meshtastic.proto.Position
+
+/**
+ * Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a
+ * [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode,
+ * which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track
+ * filter).
+ *
+ * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
+ */
+@Composable
+fun NodeTrackMap(
+ destNum: Int,
+ positions: List,
+ modifier: Modifier = Modifier,
+ selectedPositionTime: Int? = null,
+ onPositionSelected: ((Int) -> Unit)? = null,
+) {
+ val vm = koinViewModel()
+ vm.setDestNum(destNum)
+ val focusedNode by vm.node.collectAsStateWithLifecycle()
+ MapView(
+ modifier = modifier,
+ mode =
+ GoogleMapMode.NodeTrack(
+ focusedNode = focusedNode,
+ positions = positions,
+ selectedPositionTime = selectedPositionTime,
+ onPositionSelected = onPositionSelected,
+ ),
+ )
+}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
index e33fb1f8c..668dedbaa 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/prefs/di/GoogleMapsKoinModule.kt
@@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
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.di.CoroutineDispatchers
@Module
@ComponentScan("org.meshtastic.app.map")
@@ -36,9 +36,10 @@ class GoogleMapsKoinModule {
@Single
@Named("GoogleMapsDataStore")
- fun provideGoogleMapsDataStore(context: Context): DataStore = PreferenceDataStoreFactory.create(
- migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
- scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
- produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
- )
+ fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore =
+ PreferenceDataStoreFactory.create(
+ migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
+ scope = CoroutineScope(dispatchers.io + SupervisorJob()),
+ produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
+ )
}
diff --git a/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
new file mode 100644
index 000000000..d725537c8
--- /dev/null
+++ b/app/src/google/kotlin/org/meshtastic/app/map/traceroute/TracerouteMap.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.app.map.traceroute
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.meshtastic.app.map.GoogleMapMode
+import org.meshtastic.app.map.MapView
+import org.meshtastic.core.model.TracerouteOverlay
+import org.meshtastic.proto.Position
+
+/**
+ * Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute]
+ * mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay).
+ */
+@Composable
+fun TracerouteMap(
+ tracerouteOverlay: TracerouteOverlay?,
+ tracerouteNodePositions: Map,
+ onMappableCountChanged: (shown: Int, total: Int) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ MapView(
+ modifier = modifier,
+ mode =
+ GoogleMapMode.Traceroute(
+ overlay = tracerouteOverlay,
+ nodePositions = tracerouteNodePositions,
+ onMappableCountChanged = onMappableCountChanged,
+ ),
+ )
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a8c0bb94b..f7d2ce900 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -44,11 +44,14 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -264,7 +288,7 @@
+ android:resource="@xml/widget_local_stats_info" />
diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json
index cd3e2889c..b4e3550eb 100644
--- a/app/src/main/assets/device_hardware.json
+++ b/app/src/main/assets/device_hardware.json
@@ -1212,7 +1212,7 @@
"Heltec"
],
"requiresDfu": true,
- "hasMui": false,
+ "hasMui": true,
"partitionScheme": "16MB",
"images": [
"heltec_v4.svg"
@@ -1236,12 +1236,28 @@
"rak_3312.svg"
]
},
+ {
+ "hwModel": 112,
+ "hwModelSlug": "M5STACK_CARDPUTER_ADV",
+ "platformioTarget": "m5stack-cardputer-adv",
+ "architecture": "esp32-s3",
+ "activelySupported": false,
+ "supportLevel": 1,
+ "displayName": "Cardputer Mesh Kit",
+ "tags": [
+ "M5Stack"
+ ],
+ "images": [
+ "m5stack_cardputer.svg"
+ ],
+ "partitionScheme": "8MB"
+ },
{
"hwModel": 113,
"hwModelSlug": "HELTEC_WIRELESS_TRACKER_V2",
"platformioTarget": "heltec-wireless-tracker-v2",
- "architecture": "esp32s3",
- "activelySupported": false,
+ "architecture": "esp32-s3",
+ "activelySupported": true,
"supportLevel": 1,
"displayName": "Heltec Wireless Tracker V2",
"tags": [
@@ -1306,7 +1322,7 @@
"hwModelSlug": "THINKNODE_M4",
"platformioTarget": "thinknode_m4",
"architecture": "nrf52840",
- "activelySupported": false,
+ "activelySupported": true,
"supportLevel": 1,
"displayName": "ThinkNode M4",
"tags": [
@@ -1322,7 +1338,7 @@
"hwModelSlug": "THINKNODE_M6",
"platformioTarget": "thinknode_m6",
"architecture": "nrf52840",
- "activelySupported": false,
+ "activelySupported": true,
"supportLevel": 1,
"displayName": "ThinkNode M6",
"tags": [
@@ -1364,7 +1380,7 @@
"hasMui": false,
"partitionScheme": "8MB",
"images": [
- "t5s3-epaper-pro.svg"
+ "t5s3_epaper.svg"
]
},
{
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 17e7778af..ffdb465d6 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -24,12 +24,26 @@
}
],
"alpha": [
+ {
+ "id": "v2.7.22.96dd647",
+ "title": "Meshtastic Firmware 2.7.22.96dd647 Alpha",
+ "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.22.96dd647",
+ "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.22.96dd647/firmware-2.7.22.96dd647.json",
+ "release_notes": "## 🐛 Bug fixes and maintenance\r\n\r\n- Fix(native): implement BinarySemaphorePosix with proper pthread synchronization by @iannucci in https://github.com/meshtastic/firmware/pull/9895\r\n- Meshtasticd: Add configs for ebyte-ecb41-pge (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10086\r\n- Meshtasticd: Add configs for forlinx-ok3506-s12 (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10087\r\n- Fix Linux Input enable logic by @jp-bennett in https://github.com/meshtastic/firmware/pull/10093\r\n- PPA: Use SFTP method for uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/10138\r\n- Switch PlatformIO deps from PIO Registry to tagged GitHub zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/10142\r\n- Fix display method to use const qualifier for previousBuffer pointer by @vidplace7 in https://github.com/meshtastic/firmware/pull/10146\r\n- Fix last cppcheck issue by @caveman99 in https://github.com/meshtastic/firmware/pull/10154\r\n- Fix heap blowout on TBeams by @thebentern in https://github.com/meshtastic/firmware/pull/10155\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update meshtastic-esp32_https_server digest to 0c71f38 by @app/renovate in https://github.com/meshtastic/firmware/pull/10081\r\n- Update meshtastic-st7789 digest to 222554e by @app/renovate in https://github.com/meshtastic/firmware/pull/10121\r\n- Update actions/github-script action to v9 by @app/renovate in https://github.com/meshtastic/firmware/pull/10122\r\n- Update meshtastic-st7789 digest to 7228c49 by @app/renovate in https://github.com/meshtastic/firmware/pull/10131\r\n- Update pnpm/action-setup action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10132\r\n- Update meshtastic-st7789 digest to 4d957e7 by @app/renovate in https://github.com/meshtastic/firmware/pull/10134\r\n- Update meshtastic-st7789 digest to a787bee by @app/renovate in https://github.com/meshtastic/firmware/pull/10147\r\n- Update softprops/action-gh-release action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/10150\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.21.1370b23...v2.7.22.96dd647"
+ },
+ {
+ "id": "v2.7.21.1370b23",
+ "title": "Meshtastic Firmware 2.7.21.1370b23 Alpha",
+ "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.21.1370b23",
+ "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.21.1370b23/firmware-2.7.21.1370b23.json",
+ "release_notes": "> [!WARNING]\r\n> Due to resource constraints, the HTTP server is deprecated on original-generation ESP32 devices and should not be relied on going forward. \r\n> Support continues on ESP32-S3 and other newer ESP32 generations.\r\n\r\n## 🚀 Enhancements\r\n\r\n- Add T5-4.7-S3 Epaper Pro support. #6625\r\n- Apply Thailand NBTC 920-925 MHz limits (27 dBm, 10% duty cycle). #9827\r\n- Switch nRF52840 builds to C++17. #9874\r\n- Clean up SEN5X warnings. #9884\r\n- Refactor BaseUI emotes. #9896\r\n- Add spoof detection in `UdpMulticastHandler`. #9905\r\n- Enable LNA by default on Heltec v4.3. #9906\r\n- Rotate MUI for the Heltec V4 + TFT expansion kit. #9938\r\n- Make `hexDump()` take a `const` buffer. #9944\r\n- Add `meshtasticd` config metadata. #10001\r\n- Add `MESHTASTIC_EXCLUDE_ACCELEROMETER`. #10004\r\n- Adapt MUI WiFi map tile downloads for Heltec V4. #10011\r\n- Fix Mesh-tab WiFi map and exclude-screen behavior. #10038\r\n- Include Thinknode M5 minor fixes. #10049\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Remove GPS baudrate locking on the Seeed Xiao S3 kit. #9374\r\n- Fix RAK4631 Ethernet gateway API connection loss after W5100S brownouts. #9754\r\n- Fix W5100S socket exhaustion blocking MQTT and additional TCP clients. #9770\r\n- Fix traceroute over MQTT when the uplink node is encrypted. #9798\r\n- Extend Debian sourcedeb cache expiration. #9858\r\n- Fix T-LoRA Pager SPI bus sharing between SX1262 and the SD card. #9870\r\n- Update `ESP8266Audio` to the Meshtastic fork for compatibility. #9872\r\n- Fix `rak_wismeshtag` low-voltage reboot hangs after app configuration. #9897\r\n- Preserve `pki_encrypted` and `public_key` when relaying UDP multicast packets to radio. #9916\r\n- Add the new RAK 13302 power curve. #9929\r\n- Fix MQTT settings not persisting when the broker is unreachable. #9934\r\n- Fix BMP detection by not returning early during BME address scans. #9935\r\n- Enforce infrastructure-role minimums even when scaling is disabled. #9937\r\n- Fix traceroute hop rendering for `ffff` / unknown-dB hops. #9945\r\n- Fix NodeInfo suppression so it only applies to external requests. #9947\r\n- Enable touch-to-backlight on T-Echo, not just T-Echo Plus. #9953\r\n- Prevent licensed users from rebroadcasting packets to or from unlicensed users. #9958\r\n- Add the `heltec_mesh_node_t096` board. #9960\r\n- Add Cardputer-Adv I2S audio support. #9963\r\n- Fix the Cyrillic OLED double-space issue. #9971\r\n- Add `LED_BUILTIN` for `tlora_v1`. #9973\r\n- Add a timeout for PPA uploads. #9989\r\n- Exclude the web server, Paxcounter, and a few other components on original ESP32 boards to avoid IRAM overflow. #10005\r\n- Rework External Notifications logic. #10006\r\n- Improve STM32WL support. #10015\r\n- Configure NFC pins as GPIO for older bootloaders. #10016\r\n- Fix `TransmitHistory` epoch handling. #10017\r\n- Inherit `build_unflags` for `wio-sdk-wm1110`. #10034\r\n- Remove PSRAM from `tbeam` boards to reclaim IRAM. #10036\r\n- Move `t5s3_epaper_inkhud` to `extra`. #10037\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update `meshtastic-esp32_https_server` to digest `b78f12c`. #9851\r\n- Update `meshtastic/device-ui` through digests `622b034`, `f36d2a9`, `7b1485b`, and `1897dd1`. #9864 #9940 #10023 #10044 #10050\r\n- Update `GxEPD2` to `v1.6.8`. #9918\r\n- Update `pnpm/action-setup` to `v5`. #9926\r\n- Update `dorny/test-reporter` to `v3`. #9981\r\n- Clean up LewisHe library references and dependency matching, and tighten Renovate scheduling. #10007 #10008 #10039\r\n- Update `Adafruit_BME680` to `v2.0.6`. #10009\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.21.1370b23\r\n"
+ },
{
"id": "v2.7.20.6658ec2",
"title": "Meshtastic Firmware 2.7.20.6658ec2 Alpha",
"page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.20.6658ec2",
"zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.20.6658ec2/firmware-2.7.20.6658ec2.json",
- "release_notes": "## 🚀 Enhancements\r\n\r\n- Xiao NRF - define suitable i2c pins for the sub-variants by @NomDeTom in https://github.com/meshtastic/firmware/pull/8866\r\n- Fix(MQTT): Send first MapReport as soon as possible by @ndoo in https://github.com/meshtastic/firmware/pull/8872\r\n- Feat/add sfa30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9372\r\n- Improved Periodic class by @harry-iii-lord in https://github.com/meshtastic/firmware/pull/9501\r\n- InkHUD: Allow non-system applets to subscribe to input events by @Vortetty in https://github.com/meshtastic/firmware/pull/9514\r\n- Cardputer Kit by @caveman99 in https://github.com/meshtastic/firmware/pull/9540\r\n- Skip header items when enabling the InkHUD menu cursor by @zeropt in https://github.com/meshtastic/firmware/pull/9552\r\n- ExternalNotification and StatusLED now call AmbientLighting to update… by @jp-bennett in https://github.com/meshtastic/firmware/pull/9554\r\n- BaseUI: Favorite Screen Signal Quality improvement by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9566\r\n- Add battery curve for T-Beam 1 watt by @jp-bennett in https://github.com/meshtastic/firmware/pull/9585\r\n- Add sdl libs for native builds by @jp-bennett in https://github.com/meshtastic/firmware/pull/9595\r\n- Log `rxBad` PacketHeaders with more info (`id`, `relay_node`) like `printPacket` by @compumike in https://github.com/meshtastic/firmware/pull/9614\r\n- Develop to master by @thebentern in https://github.com/meshtastic/firmware/pull/9618\r\n- Fix a lot of low level cppcheck warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9623\r\n- Convert `GPS*` global and some new in gps.cpp to `unique_ptr` by @Jorropo in https://github.com/meshtastic/firmware/pull/9628\r\n- Replace delete in RedirectablePrint.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9642\r\n- Replace delete in EInkDynamicDisplay.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9643\r\n- Replace delete in RadioInterface.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9645\r\n- Replace delete in CryptoEngine.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9649\r\n- Replace delete in AudioThread.h with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9651\r\n- Scaling tweaks by @NomDeTom in https://github.com/meshtastic/firmware/pull/9653\r\n- InkHUD: Favorite Map Applet by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9654\r\n- Fake IAQ values on Non-BSEC2 platforms like Platformio and the original ESP32 by @caveman99 in https://github.com/meshtastic/firmware/pull/9663\r\n- #9623 resolved a local shadow of next_key by converting it to int. by @caveman99 in https://github.com/meshtastic/firmware/pull/9665\r\n- Zip a few gitrefs down by @caveman99 in https://github.com/meshtastic/firmware/pull/9672\r\n- Limit http connections and add free heap check before allocating for SSL by @thebentern in https://github.com/meshtastic/firmware/pull/9693\r\n- Split module includes for AQ module by @oscgonfer in https://github.com/meshtastic/firmware/pull/9711\r\n- Align telemetry broadcast want_response behavior with traceroute by @thebentern in https://github.com/meshtastic/firmware/pull/9717\r\n- InkHUD: Nodelist cleanup by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9737\r\n- Add GPIO_DETECT_PA portduino config, and support 13302 detection with it by @jp-bennett in https://github.com/meshtastic/firmware/pull/9741\r\n- Remove unused global rIf that shadows locals and fails cppcheck by @weebl2000 in https://github.com/meshtastic/firmware/pull/9743\r\n- Add Transmit history persistence for respecting traffic intervals between reboots by @thebentern in https://github.com/meshtastic/firmware/pull/9748\r\n- Unlock 0x8B5 register macro guard for SX162 by @thebentern in https://github.com/meshtastic/firmware/pull/9777\r\n- Enhancement(mesh): remove late packets from tx queue when full by @m1nl in https://github.com/meshtastic/firmware/pull/9779\r\n- Add json file rotation option by @jp-bennett in https://github.com/meshtastic/firmware/pull/9783\r\n- PPA: Remove Ubuntu 25.04, Add 26.04 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9789\r\n- Deb: Handle offline builds more gracefully by @vidplace7 in https://github.com/meshtastic/firmware/pull/9791\r\n- Remove \"x\" permission bits from some source files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9794\r\n- Add some lora parameter clamping logic to coalesce to defaults and enforce some bounds by @thebentern in https://github.com/meshtastic/firmware/pull/9808\r\n- Add back FEM LNA mode configuration for LoRa by @thebentern in https://github.com/meshtastic/firmware/pull/9809\r\n- More RAK6421 work by @jp-bennett in https://github.com/meshtastic/firmware/pull/9813\r\n- Add ROUTER_LATE and TAK_TRACKER to congestion scaling exemption by @h3lix1 in https://github.com/meshtastic/firmware/pull/9818\r\n- Add ROUTER_LATE to telemetry impolite role check by @h3lix1 in https://github.com/meshtastic/firmware/pull/9819\r\n- Add ROUTER_LATE to infrastructure init and config preservation by @h3lix1 in https://github.com/meshtastic/firmware/pull/9820\r\n- Update Heltec Tracker v2 to version KCT8103L. by @Quency-D in https://github.com/meshtastic/firmware/pull/9822\r\n- Add APIPort to native config by @pdxlocations in https://github.com/meshtastic/firmware/pull/9840\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Add agc reset attempt by @jp-bennett in https://github.com/meshtastic/firmware/pull/8163\r\n- Support mini ePaper S3 Kit by @mverch67 in https://github.com/meshtastic/firmware/pull/9335\r\n- Fix heltec v4 tft dependency by @Quency-D in https://github.com/meshtastic/firmware/pull/9507\r\n- Apply SX1262 register 0x8B5 patch for improved GC1109 RX sensitivity by @weebl2000 in https://github.com/meshtastic/firmware/pull/9571\r\n- Hold GC1109 FEM power during deep sleep for LNA RX wake by @weebl2000 in https://github.com/meshtastic/firmware/pull/9572\r\n- Fix some random compiler warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9596\r\n- Add missing openocd_target to custom nrf52 boards by @Stary2001 in https://github.com/meshtastic/firmware/pull/9603\r\n- Fixes on SCD4X admin comands by @oscgonfer in https://github.com/meshtastic/firmware/pull/9607\r\n- Feat/add scd30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9609\r\n- Zero entire public key array instead of only first byte by @weebl2000 in https://github.com/meshtastic/firmware/pull/9619\r\n- Respect DontMqttMeBro flag regardless of channel PSK by @weebl2000 in https://github.com/meshtastic/firmware/pull/9626\r\n- Undefine LED_BUILTIN for Heltec v2 variant by @ericbarch in https://github.com/meshtastic/firmware/pull/9647\r\n- Fix typo in PIN_GPS_SWITCH by @Jorropo in https://github.com/meshtastic/firmware/pull/9648\r\n- Workaround NCP5623 and LP5562 I2C builds by @Jorropo in https://github.com/meshtastic/firmware/pull/9652\r\n- RadioLib edge-triggered interrupts robustness by @compumike in https://github.com/meshtastic/firmware/pull/9658\r\n- Add USB_MODE=1 for Station G2 - Solving all my serial issues. by @h3lix1 in https://github.com/meshtastic/firmware/pull/9660\r\n- Fix detection of SCD30 by checking if the size of the return from a 2 byte register read is correct by @caveman99 in https://github.com/meshtastic/firmware/pull/9664\r\n- Fix/rak3401 button by @LN4CY in https://github.com/meshtastic/firmware/pull/9668\r\n- Undefine LED_BUILTIN for 9m2ibr_aprs_lora_tracker by @mrekin in https://github.com/meshtastic/firmware/pull/9685\r\n- BLE Pairing fix by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9701\r\n- Implement 'agc' reset for SX126x & LR11x0 chip families by @weebl2000 in https://github.com/meshtastic/firmware/pull/9705\r\n- Add explicit dependency on mklittlefs. by @cpatulea in https://github.com/meshtastic/firmware/pull/9708\r\n- Platform: nrf52: Fix typo in BLEDfuSecure filename by @KokoSoft in https://github.com/meshtastic/firmware/pull/9709\r\n- Meshtasticd: Add Luckfox Lyra Hat pinmaps by @vidplace7 in https://github.com/meshtastic/firmware/pull/9730\r\n- Fix WisMesh Tap V2 env mess by @thebentern in https://github.com/meshtastic/firmware/pull/9734\r\n- Hopefully fix remaining cppcheck issues by @caveman99 in https://github.com/meshtastic/firmware/pull/9745\r\n- Add heltec-v4.3 board by @Quency-D in https://github.com/meshtastic/firmware/pull/9753\r\n- Fix Bluetooth on RAK Ethernet Gateway by removing MESHTASTIC_EXCLUDE_… by @thebentern in https://github.com/meshtastic/firmware/pull/9755\r\n- Increase PSRAM malloc threshold from 256 bytes to 2048 bytes by @thebentern in https://github.com/meshtastic/firmware/pull/9758\r\n- Don't launch canned message when waking screen or silencing notification by @jp-bennett in https://github.com/meshtastic/firmware/pull/9762\r\n- Fix nRF52 AsyncUDP multicast TX/RX race on W5100S by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9765\r\n- Avoid memory leak when possibly malformed packet is received by @m1nl in https://github.com/meshtastic/firmware/pull/9781\r\n- Add ADS1115 ADC to recognition as used on RAK6421 Hat by @caveman99 in https://github.com/meshtastic/firmware/pull/9790\r\n- Improve resource cleanup on connection close (and make server API a unique pointer) by @thebentern in https://github.com/meshtastic/firmware/pull/9799\r\n- Spelling fixes by @ldoolitt in https://github.com/meshtastic/firmware/pull/9801\r\n- Spelling fixes in .md files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9810\r\n- Treat ROUTER_LATE like ROUTER for power management and defaults by @h3lix1 in https://github.com/meshtastic/firmware/pull/9815\r\n- Add ROUTER_LATE use the same rebroadcast rules as ROUTER by @h3lix1 in https://github.com/meshtastic/firmware/pull/9816\r\n- Prevent router-like roles from auto-favoriting DM peers by @h3lix1 in https://github.com/meshtastic/firmware/pull/9821\r\n- Fix(t1000e): reclassify P0.04 as sensor power enable GPIO by @weebl2000 in https://github.com/meshtastic/firmware/pull/9826\r\n- Don't double-blink Thinknode-M1 Power LED while charging by @jp-bennett in https://github.com/meshtastic/firmware/pull/9829\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update adafruit mpu6050 to v2.2.9 by @app/renovate in https://github.com/meshtastic/firmware/pull/9611\r\n- Update Sensirion Core to v0.7.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9613\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9616\r\n- Update actions/stale action to v10.2.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9669\r\n- Update meshtastic-GxEPD2 digest to c7eb4c3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9694\r\n- Update radiolib to v7.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9695\r\n- Update sensorlib to v0.3.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9727\r\n- Update meshtastic-st7789 digest to 9ee76d6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9729\r\n- Update adafruit mlx90614 to v2.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9756\r\n- Update adafruit_tsl2561 to v1.1.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9757\r\n- Update platformio/espressif32 to v6.13.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9759\r\n- Update platformio/nordicnrf52 to v10.11.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9760\r\n- Update adafruit dps310 to v1.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9763\r\n- Update platformio/ststm32 to v19.5.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9764\r\n- Update adafruit ahtx0 to v2.0.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9766\r\n- Update github artifact actions (major) by @app/renovate in https://github.com/meshtastic/firmware/pull/9767\r\n- Update crazy-max/ghaction-import-gpg action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9787\r\n- Update arduinojson to v6.21.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9788\r\n- Update dorny/test-reporter action to v2.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9796\r\n- Update docker/login-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9806\r\n- Update docker/setup-qemu-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9807\r\n- Update docker/setup-buildx-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9824\r\n- Update docker/build-push-action action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9832\r\n- Update docker/metadata-action action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9833\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9839\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.19.bb3d6d5...v2.7.20.6658ec2"
+ "release_notes": "## 🚀 Enhancements\r\n\r\n- Xiao NRF - define suitable i2c pins for the sub-variants by @NomDeTom in https://github.com/meshtastic/firmware/pull/8866\r\n- Fix(MQTT): Send first MapReport as soon as possible by @ndoo in https://github.com/meshtastic/firmware/pull/8872\r\n- Feat/add sfa30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9372\r\n- Improved Periodic class by @harry-iii-lord in https://github.com/meshtastic/firmware/pull/9501\r\n- InkHUD: Allow non-system applets to subscribe to input events by @Vortetty in https://github.com/meshtastic/firmware/pull/9514\r\n- Cardputer Kit by @caveman99 in https://github.com/meshtastic/firmware/pull/9540\r\n- Skip header items when enabling the InkHUD menu cursor by @zeropt in https://github.com/meshtastic/firmware/pull/9552\r\n- ExternalNotification and StatusLED now call AmbientLighting to update… by @jp-bennett in https://github.com/meshtastic/firmware/pull/9554\r\n- BaseUI: Favorite Screen Signal Quality improvement by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9566\r\n- Add battery curve for T-Beam 1 watt by @jp-bennett in https://github.com/meshtastic/firmware/pull/9585\r\n- Add sdl libs for native builds by @jp-bennett in https://github.com/meshtastic/firmware/pull/9595\r\n- Log `rxBad` PacketHeaders with more info (`id`, `relay_node`) like `printPacket` by @compumike in https://github.com/meshtastic/firmware/pull/9614\r\n- Develop to master by @thebentern in https://github.com/meshtastic/firmware/pull/9618\r\n- Fix a lot of low level cppcheck warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9623\r\n- Convert `GPS*` global and some new in gps.cpp to `unique_ptr` by @Jorropo in https://github.com/meshtastic/firmware/pull/9628\r\n- Replace delete in RedirectablePrint.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9642\r\n- Replace delete in EInkDynamicDisplay.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9643\r\n- Replace delete in RadioInterface.cpp with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9645\r\n- Replace delete in CryptoEngine.{cpp,h} with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9649\r\n- Replace delete in AudioThread.h with std::unique_ptr by @Jorropo in https://github.com/meshtastic/firmware/pull/9651\r\n- Scaling tweaks by @NomDeTom in https://github.com/meshtastic/firmware/pull/9653\r\n- InkHUD: Favorite Map Applet by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9654\r\n- Fake IAQ values on Non-BSEC2 platforms like Platformio and the original ESP32 by @caveman99 in https://github.com/meshtastic/firmware/pull/9663\r\n- #9623 resolved a local shadow of next_key by converting it to int. by @caveman99 in https://github.com/meshtastic/firmware/pull/9665\r\n- Zip a few gitrefs down by @caveman99 in https://github.com/meshtastic/firmware/pull/9672\r\n- Limit http connections and add free heap check before allocating for SSL by @thebentern in https://github.com/meshtastic/firmware/pull/9693\r\n- Split module includes for AQ module by @oscgonfer in https://github.com/meshtastic/firmware/pull/9711\r\n- Align telemetry broadcast want_response behavior with traceroute by @thebentern in https://github.com/meshtastic/firmware/pull/9717\r\n- InkHUD: Nodelist cleanup by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9737\r\n- Add GPIO_DETECT_PA portduino config, and support 13302 detection with it by @jp-bennett in https://github.com/meshtastic/firmware/pull/9741\r\n- Remove unused global rIf that shadows locals and fails cppcheck by @weebl2000 in https://github.com/meshtastic/firmware/pull/9743\r\n- Add Transmit history persistence for respecting traffic intervals between reboots by @thebentern in https://github.com/meshtastic/firmware/pull/9748\r\n- Unlock 0x8B5 register macro guard for SX162 by @thebentern in https://github.com/meshtastic/firmware/pull/9777\r\n- Enhancement(mesh): remove late packets from tx queue when full by @m1nl in https://github.com/meshtastic/firmware/pull/9779\r\n- Add json file rotation option by @jp-bennett in https://github.com/meshtastic/firmware/pull/9783\r\n- PPA: Remove Ubuntu 25.04, Add 26.04 by @vidplace7 in https://github.com/meshtastic/firmware/pull/9789\r\n- Deb: Handle offline builds more gracefully by @vidplace7 in https://github.com/meshtastic/firmware/pull/9791\r\n- Remove \"x\" permission bits from some source files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9794\r\n- Add some lora parameter clamping logic to coalesce to defaults and enforce some bounds by @thebentern in https://github.com/meshtastic/firmware/pull/9808\r\n- Add back FEM LNA mode configuration for LoRa by @thebentern in https://github.com/meshtastic/firmware/pull/9809\r\n- More RAK6421 work by @jp-bennett in https://github.com/meshtastic/firmware/pull/9813\r\n- Add ROUTER_LATE and TAK_TRACKER to congestion scaling exemption by @h3lix1 in https://github.com/meshtastic/firmware/pull/9818\r\n- Add ROUTER_LATE to telemetry impolite role check by @h3lix1 in https://github.com/meshtastic/firmware/pull/9819\r\n- Add ROUTER_LATE to infrastructure init and config preservation by @h3lix1 in https://github.com/meshtastic/firmware/pull/9820\r\n- Update Heltec Tracker v2 to version KCT8103L. by @Quency-D in https://github.com/meshtastic/firmware/pull/9822\r\n- Add APIPort to native config by @pdxlocations in https://github.com/meshtastic/firmware/pull/9840\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Add agc reset attempt by @jp-bennett in https://github.com/meshtastic/firmware/pull/8163\r\n- Support mini ePaper S3 Kit by @mverch67 in https://github.com/meshtastic/firmware/pull/9335\r\n- Fix heltec v4 tft dependency by @Quency-D in https://github.com/meshtastic/firmware/pull/9507\r\n- Apply SX1262 register 0x8B5 patch for improved GC1109 RX sensitivity by @weebl2000 in https://github.com/meshtastic/firmware/pull/9571\r\n- Hold GC1109 FEM power during deep sleep for LNA RX wake by @weebl2000 in https://github.com/meshtastic/firmware/pull/9572\r\n- Fix some random compiler warnings by @caveman99 in https://github.com/meshtastic/firmware/pull/9596\r\n- Add missing openocd_target to custom nrf52 boards by @Stary2001 in https://github.com/meshtastic/firmware/pull/9603\r\n- Fixes on SCD4X admin comands by @oscgonfer in https://github.com/meshtastic/firmware/pull/9607\r\n- Feat/add scd30 by @oscgonfer in https://github.com/meshtastic/firmware/pull/9609\r\n- Zero entire public key array instead of only first byte by @weebl2000 in https://github.com/meshtastic/firmware/pull/9619\r\n- Respect DontMqttMeBro flag regardless of channel PSK by @weebl2000 in https://github.com/meshtastic/firmware/pull/9626\r\n- Undefine LED_BUILTIN for Heltec v2 variant by @ericbarch in https://github.com/meshtastic/firmware/pull/9647\r\n- Fix typo in PIN_GPS_SWITCH by @Jorropo in https://github.com/meshtastic/firmware/pull/9648\r\n- Workaround NCP5623 and LP5562 I2C builds by @Jorropo in https://github.com/meshtastic/firmware/pull/9652\r\n- RadioLib edge-triggered interrupts robustness by @compumike in https://github.com/meshtastic/firmware/pull/9658\r\n- Add USB_MODE=1 for Station G2 - Solving all my serial issues. by @h3lix1 in https://github.com/meshtastic/firmware/pull/9660\r\n- Fix detection of SCD30 by checking if the size of the return from a 2 byte register read is correct by @caveman99 in https://github.com/meshtastic/firmware/pull/9664\r\n- Fix/rak3401 button by @LN4CY in https://github.com/meshtastic/firmware/pull/9668\r\n- Undefine LED_BUILTIN for 9m2ibr_aprs_lora_tracker by @mrekin in https://github.com/meshtastic/firmware/pull/9685\r\n- BLE Pairing fix by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9701\r\n- Implement 'agc' reset for SX126x & LR11x0 chip families by @weebl2000 in https://github.com/meshtastic/firmware/pull/9705\r\n- Add explicit dependency on mklittlefs. by @cpatulea in https://github.com/meshtastic/firmware/pull/9708\r\n- Platform: nrf52: Fix typo in BLEDfuSecure filename by @KokoSoft in https://github.com/meshtastic/firmware/pull/9709\r\n- Meshtasticd: Add Luckfox Lyra Hat pinmaps by @vidplace7 in https://github.com/meshtastic/firmware/pull/9730\r\n- Fix WisMesh Tap V2 env mess by @thebentern in https://github.com/meshtastic/firmware/pull/9734\r\n- Hopefully fix remaining cppcheck issues by @caveman99 in https://github.com/meshtastic/firmware/pull/9745\r\n- Add heltec-v4.3 board by @Quency-D in https://github.com/meshtastic/firmware/pull/9753\r\n- Fix Bluetooth on RAK Ethernet Gateway by removing MESHTASTIC_EXCLUDE_… by @thebentern in https://github.com/meshtastic/firmware/pull/9755\r\n- Increase PSRAM malloc threshold from 256 bytes to 2048 bytes by @thebentern in https://github.com/meshtastic/firmware/pull/9758\r\n- Don't launch canned message when waking screen or silencing notification by @jp-bennett in https://github.com/meshtastic/firmware/pull/9762\r\n- Fix nRF52 AsyncUDP multicast TX/RX race on W5100S by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9765\r\n- Avoid memory leak when possibly malformed packet is received by @m1nl in https://github.com/meshtastic/firmware/pull/9781\r\n- Add ADS1115 ADC to recognition as used on RAK6421 Hat by @caveman99 in https://github.com/meshtastic/firmware/pull/9790\r\n- Improve resource cleanup on connection close (and make server API a unique pointer) by @thebentern in https://github.com/meshtastic/firmware/pull/9799\r\n- Spelling fixes by @ldoolitt in https://github.com/meshtastic/firmware/pull/9801\r\n- Spelling fixes in .md files by @ldoolitt in https://github.com/meshtastic/firmware/pull/9810\r\n- Treat ROUTER_LATE like ROUTER for power management and defaults by @h3lix1 in https://github.com/meshtastic/firmware/pull/9815\r\n- Add ROUTER_LATE use the same rebroadcast rules as ROUTER by @h3lix1 in https://github.com/meshtastic/firmware/pull/9816\r\n- Prevent router-like roles from auto-favoriting DM peers by @h3lix1 in https://github.com/meshtastic/firmware/pull/9821\r\n- Fix(t1000e): reclassify P0.04 as sensor power enable GPIO by @weebl2000 in https://github.com/meshtastic/firmware/pull/9826\r\n- Don't double-blink Thinknode-M1 Power LED while charging by @jp-bennett in https://github.com/meshtastic/firmware/pull/9829\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update adafruit mpu6050 to v2.2.9 by @app/renovate in https://github.com/meshtastic/firmware/pull/9611\r\n- Update Sensirion Core to v0.7.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9613\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9616\r\n- Update actions/stale action to v10.2.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9669\r\n- Update meshtastic-GxEPD2 digest to c7eb4c3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9694\r\n- Update radiolib to v7.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9695\r\n- Update sensorlib to v0.3.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9727\r\n- Update meshtastic-st7789 digest to 9ee76d6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9729\r\n- Update adafruit mlx90614 to v2.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9756\r\n- Update adafruit_tsl2561 to v1.1.3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9757\r\n- Update platformio/espressif32 to v6.13.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9759\r\n- Update platformio/nordicnrf52 to v10.11.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9760\r\n- Update adafruit dps310 to v1.1.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9763\r\n- Update platformio/ststm32 to v19.5.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9764\r\n- Update adafruit ahtx0 to v2.0.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9766\r\n- Update github artifact actions (major) by @app/renovate in https://github.com/meshtastic/firmware/pull/9767\r\n- Update crazy-max/ghaction-import-gpg action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9787\r\n- Update arduinojson to v6.21.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9788\r\n- Update dorny/test-reporter action to v2.6.0 by @app/renovate in https://github.com/meshtastic/firmware/pull/9796\r\n- Update docker/login-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9806\r\n- Update docker/setup-qemu-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9807\r\n- Update docker/setup-buildx-action action to v4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9824\r\n- Update docker/build-push-action action to v7 by @app/renovate in https://github.com/meshtastic/firmware/pull/9832\r\n- Update docker/metadata-action action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/9833\r\n- Update neopixel to v1.15.4 by @app/renovate in https://github.com/meshtastic/firmware/pull/9839\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.20.6658ec2"
},
{
"id": "v2.7.19.bb3d6d5",
@@ -170,77 +184,8 @@
"page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.8.ef9d0d7",
"zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.8.ef9d0d7/firmware-esp32-2.6.8.ef9d0d7.zip",
"release_notes": "## 🚀 Enhancements\r\n* 20948 compass support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6707\r\n* Update XIAO_NRF_KIT RXEN Pin definition by @NomDeTom in https://github.com/meshtastic/firmware/pull/6717\r\n* Add client notification before role based power saving (sleep) by @thebentern in https://github.com/meshtastic/firmware/pull/6759\r\n* Actions: Fix end to end tests by @vidplace7 in https://github.com/meshtastic/firmware/pull/6776\r\n* Add clarifying note about AHT20 also being included with AHT10 library by @NomDeTom in https://github.com/meshtastic/firmware/pull/6787\r\n* Only send nodes on want_config of 69421 by @thebentern in https://github.com/meshtastic/firmware/pull/6792\r\n* Add contact admin message (for QR code) by @thebentern in https://github.com/meshtastic/firmware/pull/6806\r\n* Crowpanel 4.3, 5.0, 7.0 support by @caveman99 in https://github.com/meshtastic/firmware/pull/6611\r\n* MQTT userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6802\r\n* Unmessagable implementation and defaults by @thebentern in https://github.com/meshtastic/firmware/pull/6811\r\n* Added new map report opt-in for compliance and limit map report (and default) to one hour by @thebentern in https://github.com/meshtastic/firmware/pull/6813\r\n* chore(deps): update meshtastic/device-ui digest to 35576e1 by @renovate in https://github.com/meshtastic/firmware/pull/6747\r\n\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Renovate: fix device-ui match (tiny fix) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6748\r\n* Add some no-nonsense coercion for self-reporting node values by @thebentern in https://github.com/meshtastic/firmware/pull/6793\r\n* Device-install.sh: detect t-eth-elite as s3 device by @chri2 in https://github.com/meshtastic/firmware/pull/6767\r\n* Fixes BUG #6243 by @Richard3366 in https://github.com/meshtastic/firmware/pull/6781\r\n* Update Seeed Solar Node by @rcarteraz in https://github.com/meshtastic/firmware/pull/6763\r\n* Protect T-Echo's touch button against phantom presses in OLED UI by @todd-herbert in https://github.com/meshtastic/firmware/pull/6735\r\n* Don't run `test-native` for event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6749\r\n* Fix EVENT_MODE on mqttless targets by @vidplace7 in https://github.com/meshtastic/firmware/pull/6750\r\n* Fix event templates (names, PSKs) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6753\r\n* Add suppport for Quectel L80 by @fifieldt in https://github.com/meshtastic/firmware/pull/6803\r\n\r\n## New Contributors\r\n* @chri2 made their first contribution in https://github.com/meshtastic/firmware/pull/6767\r\n* @Richard3366 made their first contribution in https://github.com/meshtastic/firmware/pull/6781\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.7.2d6181f...v2.6.8.ef9d0d7"
- },
- {
- "id": "v2.6.7.2d6181f",
- "title": "Meshtastic Firmware 2.6.7.2d6181f Alpha",
- "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.7.2d6181f",
- "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.7.2d6181f/firmware-esp32-2.6.7.2d6181f.zip",
- "release_notes": "## 🚀 Enhancements\r\n* Step one of Linux Sensor support by @jp-bennett in https://github.com/meshtastic/firmware/pull/6673\r\n* PMSA003I: add support for driving SET pin low while not actively taking a telemetry reading by @vogon in https://github.com/meshtastic/firmware/pull/6569\r\n* UDP-multicast: bump platform-native to fix UDP read of unitialized memory bug by @Jorropo in https://github.com/meshtastic/firmware/pull/6686\r\n* UDP-multicast: remove the thread from the multicast thread API by @Jorropo in https://github.com/meshtastic/firmware/pull/6685\r\n* Rate limit waypoints and alerts and increase to allow every 10 seconds instead of 5 by @thebentern in https://github.com/meshtastic/firmware/pull/6699\r\n* Restore InkHUD to defaults on factory reset by @todd-herbert in https://github.com/meshtastic/firmware/pull/6637\r\n* MUI: native frame buffer support by @mverch67 in https://github.com/meshtastic/firmware/pull/6703\r\n* Add PA1010D GPS support by @fmckeogh in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n## 🐛 Bug fixes and maintenance\r\n* Fix: native runs 100% CPU in tft_task_handler() when deviceScreen is null by @jp-bennett in https://github.com/meshtastic/firmware/pull/6695\r\n* Lock SPI bus while in use by InkHUD by @todd-herbert in https://github.com/meshtastic/firmware/pull/6719\r\n* Update template for event userprefs by @vidplace7 in https://github.com/meshtastic/firmware/pull/6720\r\n* Renovate: Add changelogs for device-ui, cleanup by @vidplace7 in https://github.com/meshtastic/firmware/pull/6733\r\n* Update Bosch BSEC2 to v1.8.2610, BME68x to v1.2.40408 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6727\r\n\r\n## New Contributors\r\n* @vogon made their first contribution in https://github.com/meshtastic/firmware/pull/6569\r\n* @fmckeogh made their first contribution in https://github.com/meshtastic/firmware/pull/6691\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.6.54c1423...v2.6.7.2d6181f"
- },
- {
- "id": "v2.6.6.54c1423",
- "title": "Meshtastic Firmware 2.6.6.54c1423 Alpha",
- "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.6.6.54c1423",
- "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.6.6.54c1423/firmware-esp32-2.6.6.54c1423.zip",
- "release_notes": "## 🚀 Enhancements\r\n* DIY v1/v1_1 add TCXO_OPTIONAL make it so that the firmware can try both TCXO and XTAL by @Andrik45719 in https://github.com/meshtastic/firmware/pull/6534\r\n* InkHUD support for LilyGo T3S3 E-Paper by @todd-herbert in https://github.com/meshtastic/firmware/pull/6503\r\n* Feat: Add Electronic Cats variant for Catsniffer by @JahazielLem in https://github.com/meshtastic/firmware/pull/6483\r\n* Add generic thread module by @tavdog in https://github.com/meshtastic/firmware/pull/5484\r\n* Add Meshtastic Linux desktop metadata by @vidplace7 in https://github.com/meshtastic/firmware/pull/6568\r\n* Add new hardware: Heltec MeshPocket by @Heltec-Aaron-Lee in https://github.com/meshtastic/firmware/pull/6533\r\n* Switch to actually maintained thingsboard pubsubclient by @thebentern in https://github.com/meshtastic/firmware/pull/5204\r\n* Make startup screen show the short ID by @Heltec-Aaron-Lee in https://github.com/meshtastic/firmware/pull/6591\r\n* Update platformio.ini to exclude unused modules from t1000-e by @benkyd in https://github.com/meshtastic/firmware/pull/6584\r\n* Debian: use native-tft compile target by @vidplace7 in https://github.com/meshtastic/firmware/pull/6580\r\n* Create lora-piggystick-lr1121.yaml by @markbirss in https://github.com/meshtastic/firmware/pull/6600\r\n* Add TFT docker builds (for CI) by @vidplace7 in https://github.com/meshtastic/firmware/pull/6614\r\n* FlatHub: bump metainfo.xml on release by @ThatKalle in https://github.com/meshtastic/firmware/pull/6578\r\n\r\n## 🐛 Bug fixes and enhancements\r\n* Fix Ublox GPS for Heltec T114 by @todd-herbert in https://github.com/meshtastic/firmware/pull/6497\r\n* Portduino: Set C standard to 17 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6561\r\n* Fix: Correct underlying cause of T-Watch not functioning when set to a 16MB filesystem by @Kealper in https://github.com/meshtastic/firmware/pull/6563\r\n* Trunk fixes for heltec mesh pocket. by @fifieldt in https://github.com/meshtastic/firmware/pull/6588\r\n* Fix T-Echo display light blink on LoRa TX by @todd-herbert in https://github.com/meshtastic/firmware/pull/6590\r\n* Fix: set upload_speed for tlora_v1_3 & tlora_v2_1_16 by @MayNiklas in https://github.com/meshtastic/firmware/pull/6595\r\n* Fix tlora v1 uploadspeed by @MayNiklas in https://github.com/meshtastic/firmware/pull/6601\r\n* Fix uninitialised memory read (adminModule) by @benkyd in https://github.com/meshtastic/firmware/pull/6605\r\n* Add support for Seeed solar panel by @Dylanliacc in https://github.com/meshtastic/firmware/pull/6597\r\n* Fix compiler error in PowerFSM when WiFi is excluded by @benkyd in https://github.com/meshtastic/firmware/pull/6603\r\n* Crowpanel support by @caveman99 in https://github.com/meshtastic/firmware/pull/6355\r\n* Lib Update by @caveman99 in https://github.com/meshtastic/firmware/pull/6510\r\n* Fix crash when clearing NRF52 BLE bonds by @todd-herbert in https://github.com/meshtastic/firmware/pull/6609\r\n* Docker: Fix arg passthrough by @vidplace7 in https://github.com/meshtastic/firmware/pull/6623\r\n* RPM: Build native-tft target by @vidplace7 in https://github.com/meshtastic/firmware/pull/6613\r\n* Docker alpine: Add config templates by @vidplace7 in https://github.com/meshtastic/firmware/pull/6631\r\n* Appdata.xml: Add date to all releases by @vidplace7 in https://github.com/meshtastic/firmware/pull/6632\r\n* Rak13800 Ethernet works on rak11310 too by @Nivek-domo in https://github.com/meshtastic/firmware/pull/6622\r\n* Build and deploy event firmwares by @vidplace7 in https://github.com/meshtastic/firmware/pull/6628\r\n* Publish firmware all together by @vidplace7 in https://github.com/meshtastic/firmware/pull/6642\r\n* Fix: SenseCAP Indicator: remove buzzer definition by @mverch67 in https://github.com/meshtastic/firmware/pull/6652\r\n* Correct a typing error in InkHUD display driver by @todd-herbert in https://github.com/meshtastic/firmware/pull/6651\r\n* Fix preamble detected IRQ flag by @GUVWAF in https://github.com/meshtastic/firmware/pull/6653\r\n* Update meshtastic-device-ui digest to 189ed6c by @renovate in https://github.com/meshtastic/firmware/pull/6657\r\n* Fix building WiPhone variant by @todd-herbert in https://github.com/meshtastic/firmware/pull/6664\r\n* Downgrade web to 2.5.4 by @vidplace7 in https://github.com/meshtastic/firmware/pull/6669\r\n\r\n## New Contributors\r\n* @renovate made their first contribution in https://github.com/meshtastic/firmware/pull/6545\r\n* @JahazielLem made their first contribution in https://github.com/meshtastic/firmware/pull/6483\r\n* @MayNiklas made their first contribution in https://github.com/meshtastic/firmware/pull/6595\r\n* @benkyd made their first contribution in https://github.com/meshtastic/firmware/pull/6584\r\n* @Nivek-domo made their first contribution in https://github.com/meshtastic/firmware/pull/6622\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.6.5.fc3d9f2...v2.6.6.54c1423"
}
]
},
- "pullRequests": [
- {
- "id": "9955",
- "title": "Add Env for Seeed XIAO ESP32-C6 + Wio-SX1262",
- "page_url": "https://github.com/meshtastic/firmware/pull/9955",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9954",
- "title": "fix:[RTC] update time on rp2040",
- "page_url": "https://github.com/meshtastic/firmware/pull/9954",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9953",
- "title": "Fix: Enable touch-to-backlight on T-Echo (not just T-Echo Plus)",
- "page_url": "https://github.com/meshtastic/firmware/pull/9953",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9951",
- "title": "fix: big-endian byte ordering for radio packet header fields",
- "page_url": "https://github.com/meshtastic/firmware/pull/9951",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9949",
- "title": "fix: preserve higher-quality RTC time on system-time refresh",
- "page_url": "https://github.com/meshtastic/firmware/pull/9949",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9939",
- "title": "Fix intermittent busyRx on Portduino SX1262 (stale preamble IRQ)",
- "page_url": "https://github.com/meshtastic/firmware/pull/9939",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9903",
- "title": "feat: Support INA219/INA226 as primary battery sensor without ADC pin",
- "page_url": "https://github.com/meshtastic/firmware/pull/9903",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9895",
- "title": "fix(native): implement BinarySemaphorePosix with proper pthread synchronization",
- "page_url": "https://github.com/meshtastic/firmware/pull/9895",
- "zip_url": "https://discord.com/invite/meshtastic"
- },
- {
- "id": "9891",
- "title": "Refinement on support for Native ESP32 Ethernet and WT32-ETH01 board (LAN8720)",
- "page_url": "https://github.com/meshtastic/firmware/pull/9891",
- "zip_url": "https://discord.com/invite/meshtastic"
- }
- ]
+ "pullRequests": []
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
index c590d11a4..628865010 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -34,6 +34,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.core.content.IntentCompat
@@ -42,10 +43,14 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
+import coil3.ImageLoader
+import coil3.compose.setSingletonImageLoaderFactory
+import com.eygraber.uri.toKmpUri
import kotlinx.coroutines.launch
+import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
-import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
+import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider
@@ -53,9 +58,8 @@ import org.meshtastic.app.node.component.InlineMap
import org.meshtastic.app.node.metrics.getTracerouteMapOverlayInsets
import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
-import org.meshtastic.core.common.util.toMeshtasticUri
-import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
+import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_invalid
@@ -66,18 +70,30 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalInlineMapProvider
+import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
+import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
+import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
+import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
+import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
+import org.meshtastic.feature.map.MapScreen
+import org.meshtastic.feature.map.SharedMapViewModel
+import org.meshtastic.feature.map.node.NodeMapViewModel
+import org.meshtastic.feature.node.metrics.MetricsViewModel
+import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
+ private val usbRepository: UsbRepository by inject()
+
/**
* Activity-lifecycle-aware client that binds to the mesh service. Note: This is used implicitly as it registers
* itself as a LifecycleObserver in its init block.
@@ -106,7 +122,13 @@ class MainActivity : ComponentActivity() {
}
setContent {
+ // Bridge Koin-provided ImageLoader (with flavor-specific HttpClient, SVG, debug logger)
+ // to Coil's singleton so all AsyncImage composables use the custom configuration.
+ setSingletonImageLoaderFactory { get() }
+
val theme by model.theme.collectAsStateWithLifecycle()
+ val contrastLevelValue by model.contrastLevel.collectAsStateWithLifecycle()
+ val contrastLevel = org.meshtastic.core.ui.theme.ContrastLevel.fromValue(contrastLevelValue)
val dynamic = theme == MODE_DYNAMIC
val dark =
when (theme) {
@@ -123,17 +145,8 @@ class MainActivity : ComponentActivity() {
)
}
- CompositionLocalProvider(
- LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
- LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
- LocalBarcodeScannerSupported provides true,
- LocalNfcScannerSupported provides true,
- LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
- LocalMapViewProvider provides getMapViewProvider(),
- LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
- LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
- ) {
- AppTheme(dynamicColor = dynamic, darkTheme = dark) {
+ AppCompositionLocals {
+ AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn"
@@ -141,7 +154,7 @@ class MainActivity : ComponentActivity() {
ReportDrawnWhen { true }
if (appIntroCompleted) {
- MainScreen(uIViewModel = model)
+ MainScreen()
} else {
val introViewModel = koinViewModel()
AppIntroductionScreen(onDone = { model.onAppIntroCompleted() }, viewModel = introViewModel)
@@ -156,6 +169,78 @@ class MainActivity : ComponentActivity() {
handleIntent(intent)
}
+ override fun onResume() {
+ super.onResume()
+ // Belt-and-suspenders for the Android 12+ attach-intent quirk: if the activity is
+ // resumed while a USB device is already attached (e.g. process restart, returning
+ // from another app), the manifest-declared attach intent may have already fired
+ // before UsbRepository was constructed. Re-poll deviceList here so the UI reflects
+ // reality without requiring the user to physically replug.
+ usbRepository.refreshState()
+ }
+
+ @Composable
+ private fun AppCompositionLocals(content: @Composable () -> Unit) {
+ CompositionLocalProvider(
+ LocalBarcodeScannerProvider provides { onResult -> rememberBarcodeScanner(onResult) },
+ LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
+ LocalBarcodeScannerSupported provides true,
+ LocalNfcScannerSupported provides true,
+ LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
+ LocalMapViewProvider provides getMapViewProvider(),
+ LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
+ LocalNodeTrackMapProvider provides
+ { destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
+ org.meshtastic.app.map.node.NodeTrackMap(
+ destNum,
+ positions,
+ modifier,
+ selectedPositionTime,
+ onPositionSelected,
+ )
+ },
+ LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
+ LocalTracerouteMapProvider provides
+ { overlay, nodePositions, onMappableCountChanged, modifier ->
+ org.meshtastic.app.map.traceroute.TracerouteMap(
+ tracerouteOverlay = overlay,
+ tracerouteNodePositions = nodePositions,
+ onMappableCountChanged = onMappableCountChanged,
+ modifier = modifier,
+ )
+ },
+ LocalNodeMapScreenProvider provides
+ { destNum, onNavigateUp ->
+ val vm = koinViewModel()
+ vm.setDestNum(destNum)
+ org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
+ },
+ LocalTracerouteMapScreenProvider provides
+ { destNum, requestId, logUuid, onNavigateUp ->
+ val metricsViewModel = koinViewModel { parametersOf(destNum) }
+ metricsViewModel.setNodeId(destNum)
+
+ TracerouteMapScreen(
+ metricsViewModel = metricsViewModel,
+ requestId = requestId,
+ logUuid = logUuid,
+ onNavigateUp = onNavigateUp,
+ )
+ },
+ LocalMapMainScreenProvider provides
+ { onClickNodeChip, navigateToNodeDetails, waypointId ->
+ val viewModel = koinViewModel()
+ MapScreen(
+ viewModel = viewModel,
+ onClickNodeChip = onClickNodeChip,
+ navigateToNodeDetails = navigateToNodeDetails,
+ waypointId = waypointId,
+ )
+ },
+ content = content,
+ )
+ }
+
@Suppress("NestedBlockDepth")
private fun handleIntent(intent: Intent) {
val appLinkAction = intent.action
@@ -185,6 +270,11 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
Logger.d { "USB device attached" }
+ // Android 12+ delivers ACTION_USB_DEVICE_ATTACHED only to manifest-declared
+ // receivers, so the runtime-registered UsbBroadcastReceiver inside UsbRepository
+ // never sees this event. Forward it explicitly so the serialDevices StateFlow
+ // refreshes and the device shows up in the Connect → Serial tab.
+ usbRepository.refreshState()
showSettingsPage()
}
@@ -205,16 +295,8 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
- if (uri.toString().startsWith(DEEP_LINK_BASE_URI)) {
- model.handleNavigationDeepLink(uri.toMeshtasticUri())
- return
- }
- uri.dispatchMeshtasticUri(
- onChannel = { model.setRequestChannelSet(it) },
- onContact = { model.setSharedContactRequested(it) },
- onInvalid = { lifecycleScope.launch { showToast(Res.string.channel_invalid) } },
- )
+ model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
}
private fun createShareIntent(message: String): PendingIntent {
diff --git a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
index d32cc3df6..9228b6874 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
@@ -28,6 +28,7 @@ import androidx.work.WorkManager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
@@ -36,9 +37,8 @@ import kotlinx.coroutines.withTimeout
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.workmanager.koin.workManagerFactory
-import org.koin.core.context.startKoin
-import org.meshtastic.app.di.AppKoinModule
-import org.meshtastic.app.di.module
+import org.koin.plugin.module.dsl.startKoin
+import org.meshtastic.app.di.AndroidKoinApp
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.MeshPrefs
@@ -57,16 +57,15 @@ open class MeshUtilApplication :
Application(),
Configuration.Provider {
- private val applicationScope = CoroutineScope(Dispatchers.Default)
+ private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onCreate() {
super.onCreate()
ContextServices.app = this
- startKoin {
+ startKoin {
androidContext(this@MeshUtilApplication)
workManagerFactory()
- modules(AppKoinModule().module())
}
// Schedule periodic MeshLog cleanup
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
similarity index 66%
rename from core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
index 7669a66b0..04f0350c8 100644
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
@@ -14,12 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.common.util
+package org.meshtastic.app.di
-import android.net.Uri
+import org.koin.core.annotation.KoinApplication
-/** Converts a multiplatform [MeshtasticUri] into an Android [Uri]. */
-fun MeshtasticUri.toAndroidUri(): Uri = Uri.parse(this.uriString)
-
-/** Converts an Android [Uri] into a multiplatform [MeshtasticUri]. */
-fun Uri.toMeshtasticUri(): MeshtasticUri = MeshtasticUri(this.toString())
+/**
+ * Root Koin bootstrap for Android. The K2 compiler plugin uses this to discover the full module graph when
+ * [org.koin.plugin.module.dsl.startKoin] is called with this type parameter.
+ */
+@KoinApplication(modules = [AppKoinModule::class])
+object AndroidKoinApp
diff --git a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt
index d25619d70..09f38eaef 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt
@@ -44,6 +44,7 @@ import org.meshtastic.core.prefs.di.CorePrefsAndroidModule
import org.meshtastic.core.prefs.di.CorePrefsModule
import org.meshtastic.core.service.di.CoreServiceAndroidModule
import org.meshtastic.core.service.di.CoreServiceModule
+import org.meshtastic.core.takserver.di.CoreTakServerModule
import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
@@ -53,11 +54,13 @@ import org.meshtastic.feature.messaging.di.FeatureMessagingModule
import org.meshtastic.feature.node.di.FeatureNodeModule
import org.meshtastic.feature.settings.di.FeatureSettingsModule
import org.meshtastic.feature.widget.di.FeatureWidgetModule
+import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
@Module(
includes =
[
org.meshtastic.app.MainKoinModule::class,
+ org.meshtastic.core.di.di.CoreDiModule::class,
CoreCommonModule::class,
CoreBleModule::class,
CoreBleAndroidModule::class,
@@ -75,6 +78,7 @@ import org.meshtastic.feature.widget.di.FeatureWidgetModule
CoreServiceAndroidModule::class,
CoreNetworkModule::class,
CoreNetworkAndroidModule::class,
+ CoreTakServerModule::class,
CoreUiModule::class,
FeatureNodeModule::class,
FeatureMessagingModule::class,
@@ -84,6 +88,7 @@ import org.meshtastic.feature.widget.di.FeatureWidgetModule
FeatureFirmwareModule::class,
FeatureIntroModule::class,
FeatureWidgetModule::class,
+ FeatureWifiProvisionModule::class,
NetworkModule::class,
FlavorModule::class,
],
@@ -93,14 +98,6 @@ class AppKoinModule {
@Named("ProcessLifecycle")
fun provideProcessLifecycle(): Lifecycle = ProcessLifecycleOwner.get().lifecycle
- @Single
- fun provideCoroutineDispatchers(): org.meshtastic.core.di.CoroutineDispatchers =
- org.meshtastic.core.di.CoroutineDispatchers(
- io = kotlinx.coroutines.Dispatchers.IO,
- main = kotlinx.coroutines.Dispatchers.Main,
- default = kotlinx.coroutines.Dispatchers.Default,
- )
-
@Single
fun provideBuildConfigProvider(): BuildConfigProvider = object : BuildConfigProvider {
override val isDebug: Boolean = org.meshtastic.app.BuildConfig.DEBUG
diff --git a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
index 7178f7426..91ab81ec0 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
@@ -21,25 +21,37 @@ import android.content.Context
import android.net.ConnectivityManager
import android.net.nsd.NsdManager
import coil3.ImageLoader
+import coil3.annotation.ExperimentalCoilApi
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
-import coil3.network.okhttp.OkHttpNetworkFetcherFactory
+import coil3.memoryCacheMaxSizePercentWhileInBackground
+import coil3.network.DeDupeConcurrentRequestStrategy
+import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
-import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.engine.android.Android
+import io.ktor.client.plugins.DefaultRequest
+import io.ktor.client.plugins.HttpRequestRetry
+import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logging
+import io.ktor.client.request.url
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
-import okhttp3.OkHttpClient
+import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.network.HttpClientDefaults
+import org.meshtastic.core.network.KermitHttpLogger
private const val DISK_CACHE_PERCENT = 0.02
private const val MEMORY_CACHE_PERCENT = 0.25
+private const val MEMORY_CACHE_BACKGROUND_PERCENT = 0.1
@Module
class NetworkModule {
@@ -52,37 +64,55 @@ class NetworkModule {
fun provideNsdManager(application: Application): NsdManager =
application.getSystemService(Context.NSD_SERVICE) as NsdManager
+ @OptIn(ExperimentalCoilApi::class)
@Single
fun provideImageLoader(
- okHttpClient: OkHttpClient,
+ httpClient: HttpClient,
application: Context,
buildConfigProvider: BuildConfigProvider,
- ): ImageLoader {
- val sharedOkHttp = okHttpClient.newBuilder().build()
- return ImageLoader.Builder(context = application)
- .components {
- add(OkHttpNetworkFetcherFactory(callFactory = { sharedOkHttp }))
- add(SvgDecoder.Factory(scaleToDensity = true))
- }
- .memoryCache {
- MemoryCache.Builder().maxSizePercent(context = application, percent = MEMORY_CACHE_PERCENT).build()
- }
- .diskCache { DiskCache.Builder().maxSizePercent(percent = DISK_CACHE_PERCENT).build() }
- .logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
- .crossfade(enable = true)
- .build()
- }
+ ): ImageLoader = ImageLoader.Builder(context = application)
+ .components {
+ add(
+ KtorNetworkFetcherFactory(
+ httpClient = httpClient,
+ concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
+ ),
+ )
+ add(SvgDecoder.Factory(scaleToDensity = true))
+ }
+ .memoryCache {
+ MemoryCache.Builder().maxSizePercent(context = application, percent = MEMORY_CACHE_PERCENT).build()
+ }
+ .diskCache {
+ DiskCache.Builder()
+ .directory(application.cacheDir.resolve("image_cache").toOkioPath())
+ .maxSizePercent(percent = DISK_CACHE_PERCENT)
+ .build()
+ }
+ .logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
+ .memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT)
+ .crossfade(enable = true)
+ .build()
@Single
- fun provideJson(): Json = Json {
- isLenient = true
- ignoreUnknownKeys = true
- }
-
- @Single
- fun provideHttpClient(okHttpClient: OkHttpClient, json: Json): HttpClient = HttpClient(engineFactory = OkHttp) {
- engine { preconfigured = okHttpClient }
-
- install(plugin = ContentNegotiation) { json(json) }
- }
+ fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
+ HttpClient(engineFactory = Android) {
+ install(plugin = ContentNegotiation) { json(json) }
+ install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
+ install(plugin = HttpTimeout) {
+ requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
+ }
+ install(plugin = HttpRequestRetry) {
+ retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
+ exponentialDelay()
+ }
+ if (buildConfigProvider.isDebug) {
+ install(plugin = Logging) {
+ logger = KermitHttpLogger
+ level = LogLevel.BODY
+ }
+ }
+ }
}
diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
index e10fbbbd3..1e5b68ab0 100644
--- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt
@@ -18,89 +18,29 @@
package org.meshtastic.app.ui
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.Crossfade
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleIn
-import androidx.compose.animation.scaleOut
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.layout.safeDrawingPadding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Badge
-import androidx.compose.material3.BadgedBox
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.LocalContentColor
-import androidx.compose.material3.MaterialTheme.colorScheme
-import androidx.compose.material3.PlainTooltip
-import androidx.compose.material3.Text
-import androidx.compose.material3.TooltipAnchorPosition
-import androidx.compose.material3.TooltipBox
-import androidx.compose.material3.TooltipDefaults
-import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
-import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
-import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
-import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
-import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
-import androidx.navigation3.runtime.rememberNavBackStack
-import androidx.navigation3.ui.NavDisplay
import co.touchlab.kermit.Logger
-import org.jetbrains.compose.resources.getString
-import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.BuildConfig
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
+import org.meshtastic.core.navigation.NodesRoute
+import org.meshtastic.core.navigation.rememberMultiBackstack
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_too_old
-import org.meshtastic.core.resources.connected
-import org.meshtastic.core.resources.connecting
-import org.meshtastic.core.resources.device_sleeping
-import org.meshtastic.core.resources.disconnected
-import org.meshtastic.core.resources.firmware_old
-import org.meshtastic.core.resources.firmware_too_old
import org.meshtastic.core.resources.must_update
-import org.meshtastic.core.resources.okay
-import org.meshtastic.core.resources.should_update
-import org.meshtastic.core.resources.should_update_firmware
-import org.meshtastic.core.resources.traceroute
-import org.meshtastic.core.resources.view_on_map
-import org.meshtastic.core.service.MeshService
-import org.meshtastic.core.ui.component.AlertHost
-import org.meshtastic.core.ui.component.ScrollToTopEvent
-import org.meshtastic.core.ui.component.SharedDialogs
-import org.meshtastic.core.ui.navigation.icon
-import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
-import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
-import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
-import org.meshtastic.core.ui.util.annotateTraceroute
-import org.meshtastic.core.ui.util.toMessageRes
+import org.meshtastic.core.ui.component.MeshtasticAppShell
+import org.meshtastic.core.ui.component.MeshtasticNavDisplay
+import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
-import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
@@ -108,265 +48,54 @@ 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
+import org.meshtastic.feature.wifiprovision.navigation.wifiProvisionGraph
-@OptIn(ExperimentalMaterial3Api::class)
-@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
-fun MainScreen(uIViewModel: UIViewModel = koinViewModel(), scanModel: ScannerViewModel = koinViewModel()) {
- val backStack = rememberNavBackStack(MeshtasticNavSavedStateConfig, NodesRoutes.NodesGraph as NavKey)
- // LaunchedEffect(uIViewModel) { uIViewModel.navigationDeepLink.collectLatest { uri -> navController.navigate(uri) }
- // }
- val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle()
- val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
- val sharedContactRequested by uIViewModel.sharedContactRequested.collectAsStateWithLifecycle()
- val unreadMessageCount by uIViewModel.unreadMessageCount.collectAsStateWithLifecycle()
+fun MainScreen() {
+ val viewModel: UIViewModel = koinViewModel()
+ val multiBackstack = rememberMultiBackstack(NodesRoute.NodesGraph)
+ val backStack = multiBackstack.activeBackStack
- SharedDialogs(
- connectionState = connectionState,
- sharedContactRequested = sharedContactRequested,
- requestChannelSet = requestChannelSet,
- onDismissSharedContact = { uIViewModel.clearSharedContactRequested() },
- onDismissChannelSet = { uIViewModel.clearRequestChannelUrl() },
- )
+ AndroidAppVersionCheck(viewModel)
- VersionChecks(uIViewModel)
-
- AlertHost(uIViewModel.alertManager)
-
- val traceRouteResponse by uIViewModel.tracerouteResponse.collectAsStateWithLifecycle(null)
- var dismissedTracerouteRequestId by remember { mutableStateOf(null) }
- traceRouteResponse
- ?.takeIf { it.requestId != dismissedTracerouteRequestId }
- ?.let { response ->
- uIViewModel.showAlert(
- titleRes = Res.string.traceroute,
- composableMessage = {
- Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
- Text(
- text =
- annotateTraceroute(
- response.message,
- statusGreen = colorScheme.StatusGreen,
- statusYellow = colorScheme.StatusYellow,
- statusOrange = colorScheme.StatusOrange,
- ),
- )
- }
- },
- confirmTextRes = Res.string.view_on_map,
- onConfirm = {
- val availability =
- uIViewModel.tracerouteMapAvailability(
- forwardRoute = response.forwardRoute,
- returnRoute = response.returnRoute,
- )
- val errorRes = availability.toMessageRes()
- if (errorRes == null) {
- dismissedTracerouteRequestId = response.requestId
- backStack.add(
- NodeDetailRoutes.TracerouteMap(
- destNum = response.destinationNodeNum,
- requestId = response.requestId,
- logUuid = response.logUuid,
- ),
- )
- } else {
- uIViewModel.showAlert(titleRes = Res.string.traceroute, messageRes = errorRes)
- uIViewModel.clearTracerouteResponse()
- }
- },
- dismissTextRes = Res.string.okay,
- onDismiss = {
- uIViewModel.clearTracerouteResponse()
- dismissedTracerouteRequestId = null
- },
+ MeshtasticAppShell(multiBackstack = multiBackstack, uiViewModel = viewModel, hostModifier = Modifier) {
+ MeshtasticNavigationSuite(
+ multiBackstack = multiBackstack,
+ uiViewModel = viewModel,
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ val provider =
+ entryProvider {
+ contactsGraph(backStack, viewModel.scrollToTopEventFlow)
+ nodesGraph(
+ backStack = backStack,
+ scrollToTopEvents = viewModel.scrollToTopEventFlow,
+ onHandleDeepLink = viewModel::handleDeepLink,
+ )
+ mapGraph(backStack)
+ channelsGraph(backStack)
+ connectionsGraph(backStack)
+ settingsGraph(backStack)
+ firmwareGraph(backStack)
+ wifiProvisionGraph(backStack)
+ }
+ MeshtasticNavDisplay(
+ multiBackstack = multiBackstack,
+ entryProvider = provider,
+ modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
)
}
- val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
- val currentKey = backStack.lastOrNull()
- val topLevelDestination = TopLevelDestination.fromNavKey(currentKey)
-
- // State for determining the connection type icon to display
- val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
-
- NavigationSuiteScaffold(
- modifier = Modifier.fillMaxSize(),
- navigationSuiteItems = {
- TopLevelDestination.entries.forEach { destination ->
- val isSelected = destination == topLevelDestination
- val isConnectionsRoute = destination == TopLevelDestination.Connections
- item(
- icon = {
- TooltipBox(
- positionProvider =
- TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
- tooltip = {
- PlainTooltip {
- Text(
- if (isConnectionsRoute) {
- when (connectionState) {
- ConnectionState.Connected -> stringResource(Res.string.connected)
- ConnectionState.Connecting -> stringResource(Res.string.connecting)
- ConnectionState.DeviceSleep ->
- stringResource(Res.string.device_sleeping)
- ConnectionState.Disconnected -> stringResource(Res.string.disconnected)
- }
- } else {
- stringResource(destination.label)
- },
- )
- }
- },
- state = rememberTooltipState(),
- ) {
- if (isConnectionsRoute) {
- org.meshtastic.feature.connections.ui.components.AnimatedConnectionsNavIcon(
- connectionState = connectionState,
- deviceType = DeviceType.fromAddress(selectedDevice),
- meshActivityFlow = uIViewModel.meshActivity,
- colorScheme = colorScheme,
- )
- } else {
- BadgedBox(
- badge = {
- if (destination == TopLevelDestination.Conversations) {
- // Keep track of the last non-zero count for display during exit animation
- var lastNonZeroCount by remember { mutableIntStateOf(unreadMessageCount) }
- if (unreadMessageCount > 0) {
- lastNonZeroCount = unreadMessageCount
- }
- AnimatedVisibility(
- visible = unreadMessageCount > 0,
- enter = scaleIn() + fadeIn(),
- exit = scaleOut() + fadeOut(),
- ) {
- Badge { Text(lastNonZeroCount.toString()) }
- }
- }
- },
- ) {
- Crossfade(isSelected, label = "BottomBarIcon") { isSelectedState ->
- Icon(
- imageVector = destination.icon,
- contentDescription = stringResource(destination.label),
- tint =
- if (isSelectedState) colorScheme.primary else LocalContentColor.current,
- )
- }
- }
- }
- }
- },
- selected = isSelected,
- label = {
- Text(
- text = stringResource(destination.label),
- modifier =
- if (navSuiteType == NavigationSuiteType.ShortNavigationBarCompact) {
- Modifier.width(1.dp)
- .height(1.dp) // hide on phone - min 1x1 or talkback won't see it.
- } else {
- Modifier
- },
- )
- },
- onClick = {
- val isRepress = destination == topLevelDestination
- if (isRepress) {
- when (destination) {
- TopLevelDestination.Nodes -> {
- val onNodesList = currentKey is NodesRoutes.Nodes
- if (!onNodesList) {
- if (backStack.isNotEmpty()) {
- backStack[0] = destination.route
- while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
- } else {
- backStack.add(destination.route)
- }
- }
- uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.NodesTabPressed)
- }
- TopLevelDestination.Conversations -> {
- val onConversationsList = currentKey is ContactsRoutes.Contacts
- if (!onConversationsList) {
- if (backStack.isNotEmpty()) {
- backStack[0] = destination.route
- while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
- } else {
- backStack.add(destination.route)
- }
- }
- uIViewModel.emitScrollToTopEvent(ScrollToTopEvent.ConversationsTabPressed)
- }
- else -> Unit
- }
- } else {
- if (backStack.isNotEmpty()) {
- backStack[0] = destination.route
- while (backStack.size > 1) backStack.removeAt(backStack.lastIndex)
- } else {
- backStack.add(destination.route)
- }
- }
- },
- )
- }
- },
- ) {
- val provider =
- entryProvider {
- contactsGraph(backStack, uIViewModel.scrollToTopEventFlow)
- nodesGraph(
- backStack = backStack,
- scrollToTopEvents = uIViewModel.scrollToTopEventFlow,
- nodeMapScreen = { destNum, onNavigateUp ->
- val vm =
- org.koin.compose.viewmodel.koinViewModel()
- vm.setDestNum(destNum)
- org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
- },
- )
- mapGraph(backStack)
- channelsGraph(backStack)
- connectionsGraph(backStack)
- settingsGraph(backStack)
- firmwareGraph(backStack)
- }
- NavDisplay(
- backStack = backStack,
- entryProvider = provider,
- modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding(),
- )
}
}
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
-private fun VersionChecks(viewModel: UIViewModel) {
+private fun AndroidAppVersionCheck(viewModel: UIViewModel) {
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
- val myFirmwareVersion = myNodeInfo?.firmwareVersion
-
- val firmwareEdition by viewModel.firmwareEdition.collectAsStateWithLifecycle(null)
-
- val latestStableFirmwareRelease by
- viewModel.latestStableFirmwareRelease.collectAsStateWithLifecycle(DeviceVersion("2.6.4"))
- LaunchedEffect(connectionState, firmwareEdition) {
- if (connectionState == ConnectionState.Connected) {
- firmwareEdition?.let { edition -> Logger.d { "FirmwareEdition: ${edition.name}" } }
- }
- }
-
- // Check if the device is running an old app version or firmware version
LaunchedEffect(connectionState, myNodeInfo) {
if (connectionState == ConnectionState.Connected) {
- Logger.i {
- "[FW_CHECK] Connection state: $connectionState, " +
- "myNodeInfo: ${if (myNodeInfo != null) "present" else "null"}, " +
- "firmwareVersion: ${myFirmwareVersion ?: "null"}"
- }
-
myNodeInfo?.let { info ->
val isOld = info.minAppVersion > BuildConfig.VERSION_CODE && BuildConfig.DEBUG.not()
Logger.d {
@@ -381,49 +110,8 @@ private fun VersionChecks(viewModel: UIViewModel) {
messageRes = Res.string.must_update,
onConfirm = { viewModel.setDeviceAddress("n") },
)
- } else {
- myFirmwareVersion
- ?.takeIf { it.isNotBlank() }
- ?.let { fwVersion ->
- val curVer = DeviceVersion(fwVersion)
- Logger.i {
- "[FW_CHECK] Firmware version comparison - " +
- "device: $curVer (raw: $fwVersion), " +
- "absoluteMin: ${MeshService.absoluteMinDeviceVersion}, " +
- "min: ${MeshService.minDeviceVersion}"
- }
-
- if (curVer < MeshService.absoluteMinDeviceVersion) {
- Logger.w {
- "[FW_CHECK] Firmware too old - " +
- "device: $curVer < absoluteMin: ${MeshService.absoluteMinDeviceVersion}"
- }
- val title = getString(Res.string.firmware_too_old)
- val message = getString(Res.string.firmware_old)
- viewModel.showAlert(
- title = title,
- html = message,
- onConfirm = { viewModel.setDeviceAddress("n") },
- )
- } else if (curVer < MeshService.minDeviceVersion) {
- Logger.w {
- "[FW_CHECK] Firmware should update - " +
- "device: $curVer < min: ${MeshService.minDeviceVersion}"
- }
- val title = getString(Res.string.should_update_firmware)
- val message = getString(Res.string.should_update, latestStableFirmwareRelease.asString)
- viewModel.showAlert(title = title, message = message, onConfirm = {})
- } else {
- Logger.i { "[FW_CHECK] Firmware version OK - device: $curVer meets requirements" }
- }
- }
- ?: run {
- Logger.w { "[FW_CHECK] Firmware version is null or blank despite myNodeInfo being present" }
- }
}
- } ?: run { Logger.d { "[FW_CHECK] myNodeInfo is null, skipping firmware check" } }
- } else {
- Logger.d { "[FW_CHECK] Not connected (state: $connectionState), skipping firmware check" }
+ }
}
}
}
diff --git a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
index d71c7dd9c..30e1b6be7 100644
--- a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
@@ -25,14 +25,14 @@ import androidx.work.WorkerParameters
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import kotlinx.coroutines.CoroutineDispatcher
-import okhttp3.OkHttpClient
-import org.junit.Test
+import org.koin.plugin.module.dsl.koinApplication
import org.koin.test.verify.definition
import org.koin.test.verify.injectedParameters
import org.koin.test.verify.verify
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.model.util.NodeIdLookup
import org.meshtastic.feature.node.metrics.MetricsViewModel
+import kotlin.test.Test
class KoinVerificationTest {
@@ -53,7 +53,6 @@ class KoinVerificationTest {
NodeIdLookup::class,
HttpClient::class,
HttpClientEngine::class,
- OkHttpClient::class,
),
injections =
injectedParameters(
@@ -62,4 +61,19 @@ class KoinVerificationTest {
),
)
}
+
+ @Test
+ fun verifyTypedBootstrapLoadsModuleGraph() {
+ // koinApplication() is a K2 compiler plugin stub. If the plugin fails to
+ // transform it, the stub throws NotImplementedError at runtime. This test
+ // validates that the production bootstrap path is correctly transformed by
+ // successfully creating and closing the generated Koin application.
+ val app = koinApplication()
+ try {
+ // No-op: reaching this point proves the typed bootstrap path did not
+ // throw and the generated application could be created.
+ } finally {
+ app.close()
+ }
+ }
}
diff --git a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
index d1cc71174..37c19f477 100644
--- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.app.service
-import android.app.Notification
import dev.mokkery.MockMode
import dev.mokkery.mock
import org.meshtastic.core.model.Node
@@ -34,8 +33,10 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun initChannels() {}
- override fun updateServiceStateNotification(summaryString: String?, telemetry: Telemetry?): Notification =
- mock(MockMode.autofill)
+ override fun updateServiceStateNotification(
+ state: org.meshtastic.core.model.ConnectionState,
+ telemetry: Telemetry?,
+ ) {}
override suspend fun updateMessageNotification(
contactKey: String,
diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
index e4bb2aba4..de6062d33 100644
--- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt
@@ -16,15 +16,15 @@
*/
package org.meshtastic.app.ui
-import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.runComposeUiTest
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import kotlinx.coroutines.flow.emptyFlow
-import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import org.meshtastic.core.navigation.NodesRoutes
+import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
@@ -35,19 +35,18 @@ import org.meshtastic.feature.settings.radio.channel.channelsGraph
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+@OptIn(ExperimentalTestApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NavigationAssemblyTest {
- @get:Rule val composeTestRule = createComposeRule()
-
@Test
- fun verifyNavigationGraphsAssembleWithoutCrashing() {
- composeTestRule.setContent {
- val backStack = rememberNavBackStack(NodesRoutes.NodesGraph)
+ fun verifyNavigationGraphsAssembleWithoutCrashing() = runComposeUiTest {
+ setContent {
+ val backStack = rememberNavBackStack(NodesRoute.NodesGraph)
entryProvider {
contactsGraph(backStack, emptyFlow())
- nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow(), nodeMapScreen = { _, _ -> })
+ nodesGraph(backStack = backStack, scrollToTopEvents = emptyFlow())
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/UIUnitTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/UIUnitTest.kt
index 13b68c5e2..207e909ae 100644
--- a/app/src/test/kotlin/org/meshtastic/app/ui/UIUnitTest.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/ui/UIUnitTest.kt
@@ -16,9 +16,9 @@
*/
package org.meshtastic.app.ui
-import org.junit.Assert.assertEquals
-import org.junit.Test
import org.meshtastic.core.model.util.getInitials
+import kotlin.test.Test
+import kotlin.test.assertEquals
class UIUnitTest {
@Test
diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/metrics/EnvironmentMetricsTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/metrics/EnvironmentMetricsTest.kt
index 00881207e..8b4cea2a8 100644
--- a/app/src/test/kotlin/org/meshtastic/app/ui/metrics/EnvironmentMetricsTest.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/ui/metrics/EnvironmentMetricsTest.kt
@@ -16,11 +16,12 @@
*/
package org.meshtastic.app.ui.metrics
-import org.junit.Assert.assertEquals
-import org.junit.Test
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertTrue
class EnvironmentMetricsTest {
@@ -65,11 +66,12 @@ class EnvironmentMetricsTest {
val resultTelemetry = processedTelemetries.first()
- assertEquals(expectedTemperatureFahrenheit, resultTelemetry.environment_metrics?.temperature ?: 0f, 0.01f)
- assertEquals(
- expectedSoilTemperatureFahrenheit,
- resultTelemetry.environment_metrics?.soil_temperature ?: 0f,
- 0.01f,
+ assertTrue(
+ abs(expectedTemperatureFahrenheit - (resultTelemetry.environment_metrics?.temperature ?: 0f)) < 0.01f,
+ )
+ assertTrue(
+ abs(expectedSoilTemperatureFahrenheit - (resultTelemetry.environment_metrics?.soil_temperature ?: 0f)) <
+ 0.01f,
)
}
}
diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts
index f3ecc5591..71823c763 100644
--- a/build-logic/convention/build.gradle.kts
+++ b/build-logic/convention/build.gradle.kts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -25,18 +25,14 @@ plugins {
group = "org.meshtastic.buildlogic"
-// Configure the build-logic plugins to target JDK 17
+// Configure the build-logic plugins to target JDK 21
// This improves compatibility for developers building the project or consuming its libraries.
java {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
}
-kotlin {
- compilerOptions {
- jvmTarget = JvmTarget.JVM_17
- }
-}
+kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 } }
dependencies {
// This allows the use of the 'libs' type-safe accessor in the Kotlin source of the plugins
@@ -58,7 +54,6 @@ dependencies {
compileOnly(libs.kotlin.gradlePlugin)
compileOnly(libs.ksp.gradlePlugin)
compileOnly(libs.androidx.room.gradlePlugin)
- compileOnly(libs.secrets.gradlePlugin)
compileOnly(libs.spotless.gradlePlugin)
compileOnly(libs.test.retry.gradlePlugin)
@@ -78,17 +73,16 @@ spotless {
target("src/*/kotlin/**/*.kt", "src/*/java/**/*.kt")
targetExclude("**/build/**/*.kt")
ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) }
- ktlint(libs.versions.ktlint.get()).setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path)
+ ktlint(libs.versions.ktlint.get())
+ .setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path)
licenseHeaderFile(rootProject.file("../config/spotless/copyright.kt"))
}
kotlinGradle {
target("**/*.gradle.kts")
ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) }
- ktlint(libs.versions.ktlint.get()).setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path)
- licenseHeaderFile(
- rootProject.file("../config/spotless/copyright.kts"),
- "(^(?![\\/ ]\\*).*$)"
- )
+ ktlint(libs.versions.ktlint.get())
+ .setEditorConfigPath(rootProject.file("../config/spotless/.editorconfig").path)
+ licenseHeaderFile(rootProject.file("../config/spotless/copyright.kts"), "(^(?![\\/ ]\\*).*$)")
}
}
@@ -98,12 +92,7 @@ detekt {
buildUponDefaultConfig = true
allRules = false
baseline = file("detekt-baseline.xml")
- source.setFrom(
- files(
- "src/main/java",
- "src/main/kotlin",
- )
- )
+ source.setFrom(files("src/main/java", "src/main/kotlin"))
}
gradlePlugin {
@@ -196,6 +185,5 @@ gradlePlugin {
id = "meshtastic.root"
implementationClass = "RootConventionPlugin"
}
-
}
}
diff --git a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt
index 9b07a200c..16166a776 100644
--- a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,23 +14,25 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
import com.datadog.gradle.plugin.DdExtension
-import com.datadog.gradle.plugin.InstrumentationMode
+import com.datadog.gradle.plugin.InjectBuildIdToAssetsTask
+
import com.datadog.gradle.plugin.SdkCheckLevel
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.findByType
+import org.gradle.kotlin.dsl.withType
import org.meshtastic.buildlogic.libs
import org.meshtastic.buildlogic.plugin
+import java.io.File
/**
- * Convention plugin for analytics (Google Services, Crashlytics, Datadog).
- * Segregates these plugins to only affect the "google" flavor and disables their tasks for "fdroid".
+ * Convention plugin for analytics (Google Services, Crashlytics, Datadog). Segregates these plugins to only affect the
+ * "google" flavor and disables their tasks for "fdroid".
*/
class AnalyticsConventionPlugin : Plugin {
override fun apply(target: Project) {
@@ -50,7 +52,9 @@ class AnalyticsConventionPlugin : Plugin {
// This avoids iterating all tasks with a generic filter and improves configuration performance.
plugins.withId("com.google.gms.google-services") {
tasks.configureEach {
- if (name.contains("GoogleServices", ignoreCase = true) && name.contains("fdroid", ignoreCase = true)) {
+ if (
+ name.contains("GoogleServices", ignoreCase = true) && name.contains("fdroid", ignoreCase = true)
+ ) {
enabled = false
}
}
@@ -64,15 +68,38 @@ class AnalyticsConventionPlugin : Plugin {
}
}
+ // Disable Datadog analytics/upload tasks for fdroid, but NOT the buildId
+ // inject/generate tasks. The Datadog plugin wires InjectBuildIdToAssetsTask via
+ // variant.artifacts.toTransform(SingleArtifact.ASSETS), which replaces the merged
+ // assets artifact for the entire variant. Disabling that task leaves its output
+ // directory empty, causing compressAssets to produce zero files and stripping ALL
+ // assets (including Compose Multiplatform .cvr resources) from the release APK.
plugins.withId("com.datadoghq.dd-sdk-android-gradle-plugin") {
tasks.configureEach {
- if ((name.contains("datadog", ignoreCase = true) ||
- name.contains("uploadMapping", ignoreCase = true) ||
- name.contains("buildId", ignoreCase = true)) &&
- name.contains("fdroid", ignoreCase = true)) {
+ if (
+ (
+ name.contains("datadog", ignoreCase = true) ||
+ name.contains("uploadMapping", ignoreCase = true)
+ ) && name.contains("fdroid", ignoreCase = true)
+ ) {
enabled = false
}
}
+
+ // The inject task must stay enabled to maintain the AGP artifact pipeline,
+ // but we strip the datadog.buildId file from its output to preserve fdroid
+ // sterility — no analytics artifacts should ship in the open-source flavor.
+ tasks.withType().configureEach {
+ if (name.contains("Fdroid", ignoreCase = true)) {
+ doLast {
+ // Constant: GenerateBuildIdTask.BUILD_ID_FILE_NAME
+ val buildIdFile = File(outputAssets.get().asFile, "datadog.buildId")
+ if (buildIdFile.exists()) {
+ buildIdFile.delete()
+ }
+ }
+ }
+ }
}
// Configure variant-specific extensions.
@@ -83,7 +110,7 @@ class AnalyticsConventionPlugin : Plugin {
variants {
register(variant.name) {
site = "US5"
- composeInstrumentation = InstrumentationMode.AUTO
+
}
}
checkProjectDependencies = SdkCheckLevel.NONE
diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt
index 260b7a154..2ab9bef23 100644
--- a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -26,19 +25,17 @@ import org.meshtastic.buildlogic.plugin
/**
* Compose configuration for Android applications.
- *
- * Note: This has identical implementation to AndroidLibraryComposeConventionPlugin.
- * Both use the same configureAndroidCompose() function which works with CommonExtension.
- * Kept separate to maintain explicit intent in build.gradle.kts configuration despite duplication.
+ *
+ * Note: This has identical implementation to AndroidLibraryComposeConventionPlugin. Both use the same
+ * configureAndroidCompose() function which works with CommonExtension. Kept separate to maintain explicit intent in
+ * build.gradle.kts configuration despite duplication.
*/
class AndroidApplicationComposeConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
apply(plugin = libs.plugin("compose-compiler").get().pluginId)
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
- extensions.configure {
- configureAndroidCompose(this)
- }
+ extensions.configure { configureAndroidCompose(this) }
}
}
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
index 88ad8350f..38cc021a7 100644
--- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -26,7 +25,6 @@ import org.meshtastic.buildlogic.configureTestOptions
class AndroidApplicationConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
-
apply(plugin = "com.android.application")
apply(plugin = "org.gradle.test-retry")
apply(plugin = "meshtastic.android.lint")
@@ -38,13 +36,8 @@ class AndroidApplicationConventionPlugin : Plugin {
extensions.configure {
configureKotlinAndroid(this)
-
- defaultConfig {
- testInstrumentationRunner = "com.geeksville.mesh.TestRunner"
- vectorDrawables.useSupportLibrary = true
- }
- testOptions.animationsDisabled = true
+ defaultConfig { vectorDrawables.useSupportLibrary = true }
buildTypes {
getByName("release") {
@@ -52,7 +45,8 @@ class AndroidApplicationConventionPlugin : Plugin {
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
- "proguard-rules.pro"
+ rootProject.file("config/proguard/shared-rules.pro"),
+ "proguard-rules.pro",
)
}
getByName("debug") {
@@ -64,9 +58,7 @@ class AndroidApplicationConventionPlugin : Plugin {
}
}
- buildFeatures {
- buildConfig = true
- }
+ buildFeatures { buildConfig = true }
}
configureTestOptions()
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt
index 9b8477b02..8b9e026c9 100644
--- a/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -24,17 +23,13 @@ import org.meshtastic.buildlogic.configureFlavors
/**
* Flavor configuration for Android applications.
*
- * Optimization note: This is nearly identical to AndroidLibraryFlavorsConventionPlugin.
- * The underlying configureFlavors() function already handles both ApplicationExtension and LibraryExtension.
- * Could be consolidated into a single plugin accepting CommonExtension, but kept separate for now
- * to maintain explicit intent in build.gradle.kts declarations.
+ * Optimization note: This is nearly identical to AndroidLibraryFlavorsConventionPlugin. The underlying
+ * configureFlavors() function already handles both ApplicationExtension and LibraryExtension. Could be consolidated
+ * into a single plugin accepting CommonExtension, but kept separate for now to maintain explicit intent in
+ * build.gradle.kts declarations.
*/
class AndroidApplicationFlavorsConventionPlugin : Plugin {
override fun apply(target: Project) {
- with(target) {
- extensions.configure {
- configureFlavors(this)
- }
- }
+ with(target) { extensions.configure { configureFlavors(this) } }
}
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt
index df12e2bdf..7177b92ed 100644
--- a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -26,19 +25,17 @@ import org.meshtastic.buildlogic.plugin
/**
* Compose configuration for Android libraries.
- *
- * Note: This has identical implementation to AndroidApplicationComposeConventionPlugin.
- * Both use the same configureAndroidCompose() function which works with CommonExtension.
- * Kept separate to maintain explicit intent in build.gradle.kts configuration despite duplication.
+ *
+ * Note: This has identical implementation to AndroidApplicationComposeConventionPlugin. Both use the same
+ * configureAndroidCompose() function which works with CommonExtension. Kept separate to maintain explicit intent in
+ * build.gradle.kts configuration despite duplication.
*/
class AndroidLibraryComposeConventionPlugin : Plugin {
override fun apply(target: Project) {
with(target) {
apply(plugin = libs.plugin("compose-compiler").get().pluginId)
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
- extensions.configure {
- configureAndroidCompose(this)
- }
+ extensions.configure { configureAndroidCompose(this) }
}
}
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
index 3a0dfd7ca..68771d24a 100644
--- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
@@ -38,8 +38,6 @@ class AndroidLibraryConventionPlugin : Plugin {
extensions.configure {
configureKotlinAndroid(this)
- defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- testOptions.animationsDisabled = true
defaultConfig {
// When flavorless modules depend on flavored modules (like :core:data),
diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryFlavorsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryFlavorsConventionPlugin.kt
index efcee3a6a..7dc9b5c5e 100644
--- a/build-logic/convention/src/main/kotlin/AndroidLibraryFlavorsConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryFlavorsConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import com.android.build.api.dsl.LibraryExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -24,17 +23,13 @@ import org.meshtastic.buildlogic.configureFlavors
/**
* Flavor configuration for Android libraries.
*
- * Optimization note: This is nearly identical to AndroidApplicationFlavorsConventionPlugin.
- * The underlying configureFlavors() function already handles both ApplicationExtension and LibraryExtension.
- * Could be consolidated into a single plugin accepting CommonExtension, but kept separate for now
- * to maintain explicit intent in build.gradle.kts declarations.
+ * Optimization note: This is nearly identical to AndroidApplicationFlavorsConventionPlugin. The underlying
+ * configureFlavors() function already handles both ApplicationExtension and LibraryExtension. Could be consolidated
+ * into a single plugin accepting CommonExtension, but kept separate for now to maintain explicit intent in
+ * build.gradle.kts declarations.
*/
class AndroidLibraryFlavorsConventionPlugin : Plugin {
override fun apply(target: Project) {
- with(target) {
- extensions.configure {
- configureFlavors(this)
- }
- }
+ with(target) { extensions.configure { configureFlavors(this) } }
}
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt
index 5f96b8175..7331390e2 100644
--- a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import androidx.room3.gradle.RoomExtension
import com.google.devtools.ksp.gradle.KspExtension
import org.gradle.api.Plugin
@@ -33,9 +32,7 @@ class AndroidRoomConventionPlugin : Plugin {
apply(plugin = "androidx.room3")
apply(plugin = "com.google.devtools.ksp")
- extensions.configure {
- arg("room.generateKotlin", "true")
- }
+ extensions.configure { arg("room.generateKotlin", "true") }
extensions.configure {
// The schemas directory contains a schema file for each version of the Room database.
@@ -50,13 +47,9 @@ class AndroidRoomConventionPlugin : Plugin {
pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
extensions.configure {
- sourceSets.getByName("commonMain").dependencies {
- implementation(roomRuntime)
- }
- }
- dependencies {
- add("kspAndroid", roomCompiler)
+ sourceSets.getByName("commonMain").dependencies { implementation(roomRuntime) }
}
+ dependencies { add("kspAndroid", roomCompiler) }
}
pluginManager.withPlugin("org.jetbrains.kotlin.android") {
diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
index b2ee6bcd3..be280f29c 100644
--- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
@@ -26,10 +25,9 @@ import org.meshtastic.buildlogic.libs
/**
* Convention plugin for KMP feature modules.
*
- * Composes [KmpLibraryConventionPlugin], [KmpLibraryComposeConventionPlugin], and
- * [KoinConventionPlugin] and wires the common Compose / Lifecycle / Koin dependencies
- * that every feature module needs. Feature `build.gradle.kts` files only declare
- * their module-specific deps.
+ * Composes [KmpLibraryConventionPlugin], [KmpLibraryComposeConventionPlugin], and [KoinConventionPlugin] and wires the
+ * common Compose / Lifecycle / Koin dependencies that every feature module needs. Feature `build.gradle.kts` files only
+ * declare their module-specific deps.
*
* Modelled after the `AndroidFeatureImplConventionPlugin` pattern from
* [Now in Android](https://github.com/android/nowinandroid).
@@ -44,8 +42,8 @@ class KmpFeatureConventionPlugin : Plugin {
extensions.configure {
sourceSets.getByName("commonMain").dependencies {
// Compose Multiplatform UI
+ implementation(libs.library("compose-multiplatform-animation"))
implementation(libs.library("compose-multiplatform-material3"))
- implementation(libs.library("compose-multiplatform-materialIconsExtended"))
// Lifecycle & ViewModel (JetBrains KMP forks — safe in commonMain)
implementation(libs.library("jetbrains-lifecycle-viewmodel-compose"))
@@ -56,27 +54,22 @@ class KmpFeatureConventionPlugin : Plugin {
// Logging
implementation(libs.library("kermit"))
+
+ // @Preview available in commonMain since CMP 1.11 (androidx.compose.ui.tooling.preview.Preview)
+ // org.jetbrains.compose.ui.tooling.preview.Preview is deprecated in 1.11
+ implementation(libs.library("compose-multiplatform-ui-tooling-preview"))
}
sourceSets.getByName("androidMain").dependencies {
- // Compose BOM for consistent Android Compose versions
- implementation(target.dependencies.platform(libs.library("androidx-compose-bom")))
-
// Common Android Compose dependencies
implementation(libs.library("accompanist-permissions"))
implementation(libs.library("androidx-activity-compose"))
- implementation(libs.library("androidx-compose-material3"))
- implementation(libs.library("androidx-compose-material-iconsExtended"))
- implementation(libs.library("androidx-compose-ui-text"))
- implementation(libs.library("androidx-compose-ui-tooling-preview"))
+
+ implementation(libs.library("compose-multiplatform-ui"))
}
- sourceSets.getByName("commonTest").dependencies {
- implementation(project(":core:testing"))
- }
+ sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }
}
}
}
}
-
-
diff --git a/build-logic/convention/src/main/kotlin/KmpJvmAndroidConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpJvmAndroidConventionPlugin.kt
index 7255df416..ea905de6e 100644
--- a/build-logic/convention/src/main/kotlin/KmpJvmAndroidConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpJvmAndroidConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,20 +14,16 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.meshtastic.buildlogic.configureJvmAndroidMainHierarchy
/**
- * Opt-in convention for KMP modules that intentionally share a `jvmAndroidMain` source set
- * between the desktop JVM target and the Android target.
+ * Opt-in convention for KMP modules that intentionally share a `jvmAndroidMain` source set between the desktop JVM
+ * target and the Android target.
*/
class KmpJvmAndroidConventionPlugin : Plugin {
override fun apply(target: Project) {
- with(target) {
- configureJvmAndroidMainHierarchy()
- }
+ with(target) { configureJvmAndroidMainHierarchy() }
}
}
-
diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt
index 2a9504221..67b2c8fd0 100644
--- a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt
@@ -32,10 +32,12 @@ class KmpLibraryComposeConventionPlugin : Plugin {
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
extensions.configure {
- sourceSets.getByName("commonMain").dependencies {
- implementation(libs.library("compose-multiplatform-runtime"))
- // API because consuming modules will usually need the resource types
- api(libs.library("compose-multiplatform-resources"))
+ sourceSets.matching { it.name == "commonMain" }.configureEach {
+ dependencies {
+ implementation(libs.library("compose-multiplatform-runtime"))
+ // API because consuming modules will usually need the resource types
+ api(libs.library("compose-multiplatform-resources"))
+ }
}
}
configureComposeCompiler()
diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
index c0f055f7e..540834ef5 100644
--- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,15 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
-import dev.mokkery.gradle.MokkeryGradleExtension
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
-import org.gradle.kotlin.dsl.configure
import org.meshtastic.buildlogic.configureAndroidMarketplaceFallback
import org.meshtastic.buildlogic.configureKmpTestDependencies
import org.meshtastic.buildlogic.configureKotlinMultiplatform
+import org.meshtastic.buildlogic.configureTestOptions
import org.meshtastic.buildlogic.libs
import org.meshtastic.buildlogic.plugin
@@ -36,14 +34,12 @@ class KmpLibraryConventionPlugin : Plugin {
apply(plugin = "meshtastic.spotless")
apply(plugin = "meshtastic.dokka")
apply(plugin = "meshtastic.kover")
+ apply(plugin = "org.gradle.test-retry")
apply(plugin = libs.plugin("mokkery").get().pluginId)
- extensions.configure {
- stubs.allowConcreteClassInstantiation.set(true)
- }
-
configureKotlinMultiplatform()
configureKmpTestDependencies()
+ configureTestOptions()
configureAndroidMarketplaceFallback()
}
}
diff --git a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt
index ea96ad569..b4f2acfbe 100644
--- a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.apply
@@ -30,11 +29,12 @@ class KoinConventionPlugin : Plugin {
// Configure Koin K2 Compiler Plugin (0.4.0+)
extensions.configure(KoinGradleExtension::class.java) {
- // Meshtastic heavily utilizes dependency inversion across KMP modules. Koin's A1
- // per-module safety checks strictly enforce that all dependencies must be explicitly
- // provided or included locally. This breaks decoupled Clean Architecture designs.
- // We disable compile safety globally to properly rely on Koin's A3 full-graph
- // validation which perfectly handles inverted dependencies at the composition root.
+ // Meshtastic uses dependency inversion across KMP modules — interfaces in
+ // commonMain, implementations wired at the composition root. Koin's compileSafety
+ // flag enables A1 per-module checks that treat every module as self-contained,
+ // which breaks this pattern. There is no separate flag for A3 full-graph
+ // validation. Until Koin exposes granular safety levels we keep this disabled;
+ // runtime graph verification is handled by KoinVerificationTest instead.
compileSafety.set(false)
}
diff --git a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt
index 4f027414b..86abc2a11 100644
--- a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt
@@ -35,6 +35,28 @@ class RootConventionPlugin : Plugin {
configureKoverAggregation()
subprojects { configureGraphTasks() }
+
+ registerKmpSmokeCompileTask()
+ }
+ }
+}
+
+/**
+ * Registers a `kmpSmokeCompile` lifecycle task that auto-discovers all KMP modules
+ * and depends on their `compileKotlinJvm` and `compileKotlinIosSimulatorArm64` tasks.
+ *
+ * This replaces the long explicit task list in CI, auto-maintaining as modules are added.
+ */
+private fun Project.registerKmpSmokeCompileTask() {
+ tasks.register("kmpSmokeCompile") {
+ group = "verification"
+ description = "Compile all KMP modules for JVM and iOS Simulator ARM64 targets."
+
+ subprojects.forEach { sub ->
+ sub.pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") {
+ dependsOn(sub.tasks.matching { it.name == "compileKotlinJvm" })
+ dependsOn(sub.tasks.matching { it.name == "compileKotlinIosSimulatorArm64" })
+ }
}
}
}
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt
index a23ca91ab..b438fe6c6 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,36 +14,56 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.buildlogic
import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
-/**
- * Configure Compose-specific options
- */
-internal fun Project.configureAndroidCompose(
- commonExtension: CommonExtension,
-) {
- commonExtension.apply {
- buildFeatures.compose = true
+/** Configure Compose-specific options */
+internal fun Project.configureAndroidCompose(commonExtension: CommonExtension) {
+ commonExtension.apply { buildFeatures.compose = true }
+
+ // CMP is the sole Compose version authority (BOM removed from the catalog).
+ // Third-party libraries (maps-compose, datadog, etc.) carry a transitive
+ // compose-bom whose constraints conflict with CMP-published AndroidX artifacts.
+ // Exclude it globally so CMP's own dependency graph wins.
+ configurations.configureEach {
+ exclude(mapOf("group" to "androidx.compose", "module" to "compose-bom"))
+ }
+
+ // CMP publishes these core AndroidX groups at the CMP version tag.
+ // Material, Material3, and Adaptive follow separate AndroidX version numbers
+ // and must NOT be included here (see CMP release notes for the mapping table).
+ val cmpVersion = libs.version("compose-multiplatform")
+ val cmpAlignedGroups = setOf(
+ "androidx.compose.animation",
+ "androidx.compose.foundation",
+ "androidx.compose.runtime",
+ "androidx.compose.ui",
+ )
+
+ // The BOM exclusion above strips versions from transitive material deps
+ // (e.g. maps-compose-widgets, datadog). Pin the material group to the
+ // AndroidX version that matches this CMP release.
+ val materialVersion = libs.version("androidx-compose-material")
+
+ configurations.configureEach {
+ resolutionStrategy.eachDependency {
+ if (requested.group in cmpAlignedGroups) {
+ useVersion(cmpVersion)
+ } else if (requested.group == "androidx.compose.material") {
+ useVersion(materialVersion)
+ }
+ }
}
val hasAndroidTest = project.projectDir.resolve("src/androidTest").exists()
dependencies {
- val bom = libs.library("androidx-compose-bom")
- "implementation"(platform(bom))
- if (hasAndroidTest) {
- "androidTestImplementation"(platform(bom))
- }
- "implementation"(libs.library("androidx-compose-ui-tooling"))
- "implementation"(libs.library("androidx-compose-runtime"))
- "runtimeOnly"(libs.library("androidx-compose-runtime-tracing"))
- "debugImplementation"(libs.library("androidx-compose-ui-tooling"))
-
+ "debugImplementation"(libs.library("compose-multiplatform-ui-tooling"))
"implementation"(libs.library("compose-multiplatform-runtime"))
+ "runtimeOnly"(libs.library("androidx-compose-runtime-tracing"))
+
"implementation"(libs.library("compose-multiplatform-resources"))
// Add Espresso explicitly to avoid version mismatch issues on newer Android versions
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Detekt.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Detekt.kt
index db7893af1..daa076275 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Detekt.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Detekt.kt
@@ -42,12 +42,15 @@ internal fun Project.configureDetekt(extension: DetektExtension) = extension.app
)
tasks.named("detekt") {
+ val isCi = project.findProperty("ci") == "true"
reports {
xml.required.set(true)
- html.required.set(true)
- txt.required.set(true)
+ // In CI, only generate xml and sarif (needed for GitHub reporting).
+ // Skip html, txt, md to save processing time.
+ html.required.set(!isCi)
+ txt.required.set(!isCi)
sarif.required.set(true)
- md.required.set(true)
+ md.required.set(!isCi)
}
// Use project-specific build directory for reports to avoid conflicts
reports.xml.outputLocation.set(layout.buildDirectory.file("reports/detekt/detekt.xml"))
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt
index 2455c7ce1..12b80e956 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Dokka.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.buildlogic
import org.gradle.api.Project
@@ -38,16 +37,8 @@ fun Project.configureDokka() {
}
// Dokka 2.x requires each source file to belong to exactly one source set.
- val baseSourceSets = listOf(
- "main",
- "commonMain",
- "androidMain",
- "jvmMain",
- "jvmAndroidMain",
- "fdroid",
- "google",
- "release"
- )
+ val baseSourceSets =
+ listOf("main", "commonMain", "androidMain", "jvmMain", "jvmAndroidMain", "fdroid", "google", "release")
val isCoreSourceSet = name in baseSourceSets
suppress.set(!isCoreSourceSet)
@@ -59,8 +50,7 @@ fun Project.configureDokka() {
// Standardized repo-root based source links
localDirectory.set(project.projectDir)
- val relativePath =
- project.projectDir.relativeTo(rootProject.projectDir).path.replace("\\", "/")
+ val relativePath = project.projectDir.relativeTo(rootProject.projectDir).path.replace("\\", "/")
remoteUrl.set(URI("https://github.com/meshtastic/Meshtastic-Android/blob/main/$relativePath"))
remoteLineSuffix.set("#L")
}
@@ -68,20 +58,14 @@ fun Project.configureDokka() {
}
}
-/**
- * Configure Dokka aggregation in a way that is compatible with Gradle Isolated Projects.
- */
+/** Configure Dokka aggregation in a way that is compatible with Gradle Isolated Projects. */
fun Project.configureDokkaAggregation() {
extensions.configure {
moduleName.set("Meshtastic App")
- dokkaPublications.configureEach {
- suppressInheritedMembers.set(true)
- }
+ dokkaPublications.configureEach { suppressInheritedMembers.set(true) }
}
subprojects.forEach { subproject ->
- subproject.pluginManager.withPlugin("org.jetbrains.dokka") {
- dependencies.add("dokka", subproject)
- }
+ subproject.pluginManager.withPlugin("org.jetbrains.dokka") { dependencies.add("dokka", subproject) }
}
}
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/FlavorResolution.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/FlavorResolution.kt
index 620d0c830..c4c52cb9a 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/FlavorResolution.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/FlavorResolution.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,15 +14,30 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.buildlogic
import com.android.build.api.attributes.ProductFlavorAttr
import org.gradle.api.Project
import org.gradle.api.attributes.Attribute
+import org.gradle.api.attributes.AttributeDisambiguationRule
+import org.gradle.api.attributes.MultipleCandidatesDetails
+import javax.inject.Inject
private const val LEGACY_MARKETPLACE_ATTRIBUTE_NAME = "marketplace"
+/**
+ * Registers [AttributeDisambiguationRule]s so Gradle can pick a default product flavor when a consumer configuration
+ * (e.g. `androidHostTestRuntimeClasspath` from a KMP module) does not carry the marketplace flavor attribute, but the
+ * producer (e.g. `core:barcode`) publishes multiple flavor variants.
+ *
+ * This replaces the previous `afterEvaluate { configurations.configureEach { … } }` approach that stamped attributes on
+ * every resolvable Android configuration. Disambiguation rules fire during dependency resolution — not configuration
+ * time — so they are immune to KGP's lazy configuration creation order and fully compatible with Configuration Cache,
+ * Isolated Projects, and future Gradle/KGP changes.
+ *
+ * The default flavor is configurable via the `meshtastic.defaultMarketplace` Gradle property (defaults to the
+ * [MeshtasticFlavor] entry marked `default = true`, which is `google`).
+ */
internal fun Project.configureAndroidMarketplaceFallback() {
val defaultMarketplace =
providers
@@ -30,27 +45,34 @@ internal fun Project.configureAndroidMarketplaceFallback() {
.orElse(MeshtasticFlavor.entries.first { it.default }.name)
.get()
+ // AGP publishes the typed ProductFlavorAttr on flavored variant configurations.
val marketplaceAttr = ProductFlavorAttr.of(MeshtasticFlavor.fdroid.dimension.name)
+ dependencies.attributesSchema.attribute(marketplaceAttr) {
+ disambiguationRules.add(ProductFlavorDisambiguationRule::class.java) { params(defaultMarketplace) }
+ }
+
+ // Some AGP versions also publish a plain String "marketplace" attribute.
val legacyMarketplaceAttr = Attribute.of(LEGACY_MARKETPLACE_ATTRIBUTE_NAME, String::class.java)
-
- afterEvaluate {
- configurations.configureEach {
- if (!isCanBeResolved || isCanBeConsumed) return@configureEach
- if (!name.contains("android", ignoreCase = true)) return@configureEach
- if (attributes.getAttribute(marketplaceAttr) != null && attributes.getAttribute(legacyMarketplaceAttr) != null) {
- return@configureEach
- }
-
- // Prefer explicit flavor from configuration name; otherwise use configurable default.
- val inferredMarketplace =
- when {
- name.contains(MeshtasticFlavor.fdroid.name, ignoreCase = true) -> MeshtasticFlavor.fdroid.name
- name.contains(MeshtasticFlavor.google.name, ignoreCase = true) -> MeshtasticFlavor.google.name
- else -> defaultMarketplace
- }
-
- attributes.attribute(marketplaceAttr, objects.named(ProductFlavorAttr::class.java, inferredMarketplace))
- attributes.attribute(legacyMarketplaceAttr, inferredMarketplace)
- }
+ dependencies.attributesSchema.attribute(legacyMarketplaceAttr) {
+ disambiguationRules.add(StringDisambiguationRule::class.java) { params(defaultMarketplace) }
+ }
+}
+
+/**
+ * Selects the default marketplace flavor when Gradle encounters ambiguous [ProductFlavorAttr] candidates during
+ * variant-aware dependency resolution.
+ */
+internal abstract class ProductFlavorDisambiguationRule @Inject constructor(private val defaultFlavor: String) :
+ AttributeDisambiguationRule {
+ override fun execute(details: MultipleCandidatesDetails) {
+ details.candidateValues.find { it.name == defaultFlavor }?.let { details.closestMatch(it) }
+ }
+}
+
+/** Selects the default marketplace for the legacy plain-String "marketplace" attribute. */
+internal abstract class StringDisambiguationRule @Inject constructor(private val defaultFlavor: String) :
+ AttributeDisambiguationRule {
+ override fun execute(details: MultipleCandidatesDetails) {
+ details.candidateValues.find { it == defaultFlavor }?.let { details.closestMatch(it) }
}
}
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt
index 9279c9419..082693c3f 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Graph.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.buildlogic
import org.gradle.api.DefaultTask
@@ -35,9 +34,7 @@ import org.gradle.kotlin.dsl.withType
import org.meshtastic.buildlogic.PluginType.Unknown
import kotlin.text.RegexOption.DOT_MATCHES_ALL
-/**
- * Declaration order is important, as only the first match will be retained.
- */
+/** Declaration order is important, as only the first match will be retained. */
internal enum class PluginType(val id: String, val ref: String, val style: String) {
AndroidApplication(
id = "meshtastic.android.application",
@@ -50,7 +47,7 @@ internal enum class PluginType(val id: String, val ref: String, val style: Strin
style = "fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000",
),
ComposeDesktopApplication(
- id = "org.jetbrains.compose",
+ id = "?desktop",
ref = "compose-desktop-application",
style = "fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000",
),
@@ -84,61 +81,72 @@ internal enum class PluginType(val id: String, val ref: String, val style: Strin
ref = "kmp-feature",
style = "fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000",
),
+ KmpLibraryCompose(
+ id = "meshtastic.kmp.library.compose",
+ ref = "kmp-library-compose",
+ style = "fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000",
+ ),
KmpLibrary(
id = "meshtastic.kmp.library",
ref = "kmp-library",
style = "fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000",
),
- Unknown(
- id = "?",
- ref = "unknown",
- style = "fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000",
- ),
+ Unknown(id = "?", ref = "unknown", style = "fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000"),
}
-/**
- * Optimized and Isolated Projects compatible graph configuration.
- */
+/** Optimized and Isolated Projects compatible graph configuration. */
internal fun Project.configureGraphTasks() {
if (!buildFile.exists()) return
- val supportedConfigurations = providers.gradleProperty("graph.supportedConfigurations")
- .map { it.split(",").toSet() }
- .orElse(setOf("api", "implementation", "baselineProfile", "testedApks"))
+ val supportedConfigurations =
+ providers
+ .gradleProperty("graph.supportedConfigurations")
+ .map { it.split(",").toSet() }
+ .orElse(setOf("api", "implementation", "baselineProfile", "testedApks"))
val targetProjectPath = path
- val dumpTask = tasks.register("graphDump") {
- projectPath.set(targetProjectPath)
-
- dependenciesData.set(providers.provider {
- val deps = mutableMapOf>>()
- val projectDeps = mutableSetOf>()
- configurations.filter { it.name in supportedConfigurations.get() }.forEach { config ->
- config.dependencies.withType().forEach { dep ->
- projectDeps.add(config.name to dep.path)
- }
- }
- deps[targetProjectPath] = projectDeps
- deps
- })
+ val dumpTask =
+ tasks.register("graphDump") {
+ projectPath.set(targetProjectPath)
- pluginsData.set(providers.provider {
- val projectPlugins = mutableMapOf()
- val type = when {
- pluginManager.hasPlugin("meshtastic.android.application") || pluginManager.hasPlugin("meshtastic.android.application.compose") -> PluginType.AndroidApplication
- targetProjectPath.startsWith(":desktop") -> PluginType.ComposeDesktopApplication
- pluginManager.hasPlugin("meshtastic.kmp.feature") -> PluginType.KmpFeature
- targetProjectPath.startsWith(":feature:") -> PluginType.AndroidFeature
- else -> PluginType.entries.firstOrNull { pluginManager.hasPlugin(it.id) } ?: Unknown
- }
- projectPlugins[targetProjectPath] = type
- projectPlugins
- })
+ dependenciesData.set(
+ providers.provider {
+ val deps = mutableMapOf>>()
+ val projectDeps = mutableSetOf>()
+ configurations
+ .filter { it.name in supportedConfigurations.get() }
+ .forEach { config ->
+ config.dependencies.withType().forEach { dep ->
+ projectDeps.add(config.name to dep.path)
+ }
+ }
+ deps[targetProjectPath] = projectDeps
+ deps
+ },
+ )
- output.set(layout.buildDirectory.file("mermaid/graph.txt"))
- legend.set(layout.buildDirectory.file("mermaid/legend.txt"))
- }
+ pluginsData.set(
+ providers.provider {
+ val projectPlugins = mutableMapOf()
+ val type =
+ when {
+ pluginManager.hasPlugin("meshtastic.android.application") ||
+ pluginManager.hasPlugin("meshtastic.android.application.compose") ->
+ PluginType.AndroidApplication
+ targetProjectPath.startsWith(":desktop") -> PluginType.ComposeDesktopApplication
+ pluginManager.hasPlugin("meshtastic.kmp.feature") -> PluginType.KmpFeature
+ targetProjectPath.startsWith(":feature:") -> PluginType.AndroidFeature
+ else -> PluginType.entries.firstOrNull { pluginManager.hasPlugin(it.id) } ?: Unknown
+ }
+ projectPlugins[targetProjectPath] = type
+ projectPlugins
+ },
+ )
+
+ output.set(layout.buildDirectory.file("mermaid/graph.txt"))
+ legend.set(layout.buildDirectory.file("mermaid/legend.txt"))
+ }
tasks.register("graphUpdate") {
projectPath.set(targetProjectPath)
@@ -151,20 +159,15 @@ internal fun Project.configureGraphTasks() {
@CacheableTask
private abstract class GraphDumpTask : DefaultTask() {
- @get:Input
- abstract val projectPath: Property
+ @get:Input abstract val projectPath: Property
- @get:Input
- abstract val dependenciesData: MapProperty>>
+ @get:Input abstract val dependenciesData: MapProperty>>
- @get:Input
- abstract val pluginsData: MapProperty
+ @get:Input abstract val pluginsData: MapProperty
- @get:OutputFile
- abstract val output: RegularFileProperty
+ @get:OutputFile abstract val output: RegularFileProperty
- @get:OutputFile
- abstract val legend: RegularFileProperty
+ @get:OutputFile abstract val legend: RegularFileProperty
@TaskAction
operator fun invoke() {
@@ -177,17 +180,20 @@ private abstract class GraphDumpTask : DefaultTask() {
val currentProject = projectPath.get()
val projectPlugins = pluginsData.get()
val projectDeps = dependenciesData.get()[currentProject] ?: emptySet()
-
- appendLine(" $currentProject[${currentProject.substringAfterLast(":")}]:::${projectPlugins[currentProject]?.ref}")
-
+
+ appendLine(
+ " $currentProject[${currentProject.substringAfterLast(":")}]:::${projectPlugins[currentProject]?.ref}",
+ )
+
projectDeps.forEach { (config, depPath) ->
- val link = when (config) {
- "api" -> "-->"
- else -> "-.->"
- }
+ val link =
+ when (config) {
+ "api" -> "-->"
+ else -> "-.->"
+ }
appendLine(" $currentProject $link $depPath")
}
-
+
appendLine()
PluginType.entries.forEach { appendLine("classDef ${it.ref} ${it.style};") }
}
@@ -199,6 +205,7 @@ private abstract class GraphDumpTask : DefaultTask() {
appendLine(" L1[Application]:::android-application")
appendLine(" L2[Library]:::android-library")
appendLine(" L3[Feature]:::android-feature")
+ appendLine(" L4[KMP Library]:::kmp-library")
appendLine(" end")
PluginType.entries.forEach { appendLine("classDef ${it.ref} ${it.style};") }
}
@@ -206,16 +213,17 @@ private abstract class GraphDumpTask : DefaultTask() {
@CacheableTask
private abstract class GraphUpdateTask : DefaultTask() {
- @get:Input
- abstract val projectPath: Property
+ @get:Input abstract val projectPath: Property
+
@get:InputFile
@get:PathSensitive(NONE)
abstract val input: RegularFileProperty
+
@get:InputFile
@get:PathSensitive(NONE)
abstract val legend: RegularFileProperty
- @get:OutputFile
- abstract val output: RegularFileProperty
+
+ @get:OutputFile abstract val output: RegularFileProperty
@TaskAction
fun update() {
@@ -223,10 +231,11 @@ private abstract class GraphUpdateTask : DefaultTask() {
if (!readme.exists()) return
val mermaid = input.get().asFile.readText()
val currentContent = readme.readText()
- val newContent = currentContent.replace(
- Regex(".*?", DOT_MATCHES_ALL),
- "\n```mermaid\n$mermaid\n```\n"
- )
+ val newContent =
+ currentContent.replace(
+ Regex(".*?", DOT_MATCHES_ALL),
+ "\n```mermaid\n$mermaid\n```\n",
+ )
if (currentContent != newContent) {
readme.writeText(newContent)
}
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt
index a3e216b84..088ca0d25 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.buildlogic
import com.android.build.api.dsl.ApplicationExtension
@@ -35,12 +34,8 @@ import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyTemplate
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
-/**
- * Configure base Kotlin with Android options
- */
-internal fun Project.configureKotlinAndroid(
- commonExtension: CommonExtension,
-) {
+/** Configure base Kotlin with Android options */
+internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
val compileSdkVersion = configProperties.getProperty("COMPILE_SDK").toInt()
val minSdkVersion = configProperties.getProperty("MIN_SDK").toInt()
val targetSdkVersion = configProperties.getProperty("TARGET_SDK").toInt()
@@ -49,23 +44,51 @@ internal fun Project.configureKotlinAndroid(
compileSdk = compileSdkVersion
defaultConfig.minSdk = minSdkVersion
-
+ defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
if (this is ApplicationExtension) {
defaultConfig.targetSdk = targetSdkVersion
}
- compileOptions.sourceCompatibility = JavaVersion.VERSION_17
- compileOptions.targetCompatibility = JavaVersion.VERSION_17
+ val javaVersion = if (project.name in PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21
+ compileOptions.sourceCompatibility = javaVersion
+ compileOptions.targetCompatibility = javaVersion
+
+ testOptions.animationsDisabled = true
+ testOptions.unitTests.isReturnDefaultValues = true
+
+ // Exclude duplicate META-INF license files shipped by JUnit Platform JARs
+ packaging.resources.excludes.addAll(
+ listOf(
+ "META-INF/LICENSE.md",
+ "META-INF/LICENSE-notice.md",
+ ),
+ )
}
configureMokkery()
configureKotlin()
}
-/**
- * Configure Kotlin Multiplatform options
- */
+/** Configure Kotlin Multiplatform options */
internal fun Project.configureKotlinMultiplatform() {
+ // Skiko is an internal CMP implementation detail; third-party KMP libraries
+ // (e.g. coil3) can carry an older skiko transitive requirement that Gradle
+ // upgrades to the CMP-bundled version, triggering a "Skiko dependencies'
+ // versions are incompatible" warning from CMP's compatibility checker.
+ // Force the version to match CMP so the checker sees a consistent graph.
+ // Pinned here rather than in the version catalog because this plugin is the
+ // only consumer — bump together with the compose-multiplatform version.
+ val skikoVersion = "0.144.5"
+ configurations.configureEach {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.jetbrains.skiko") {
+ useVersion(skikoVersion)
+ because("Align Skiko with the version bundled by Compose Multiplatform")
+ }
+ }
+ }
+
extensions.configure {
// Standard KMP targets for Meshtastic
jvm()
@@ -80,7 +103,7 @@ internal fun Project.configureKotlinMultiplatform() {
extensions.findByType()?.apply {
compileSdk = configProperties.getProperty("COMPILE_SDK").toInt()
minSdk = configProperties.getProperty("MIN_SDK").toInt()
-
+
// Set the namespace automatically if not already set
if (namespace == null) {
val pkg = this@configureKotlinMultiplatform.path.removePrefix(":").replace(":", ".")
@@ -96,8 +119,10 @@ internal fun Project.configureKotlinMultiplatform() {
tasks.configureEach {
val taskName = name.lowercase()
if (taskName.contains("iosarm64") || taskName.contains("iossimulatorarm64")) {
- if (taskName.startsWith("link") && taskName.contains("test") ||
- taskName == "iosarm64test" || taskName == "iossimulatorarm64test" ||
+ if (
+ taskName.startsWith("link") && taskName.contains("test") ||
+ taskName == "iosarm64test" ||
+ taskName == "iossimulatorarm64test" ||
taskName.endsWith("testbinaries")
) {
enabled = false
@@ -109,22 +134,18 @@ internal fun Project.configureKotlinMultiplatform() {
configureKotlin()
}
-/**
- * Configure Mokkery for the project
- */
+/** Configure Mokkery for the project */
internal fun Project.configureMokkery() {
pluginManager.withPlugin(libs.plugin("mokkery").get().pluginId) {
- extensions.configure {
- stubs.allowConcreteClassInstantiation.set(true)
- }
+ extensions.configure { stubs.allowConcreteClassInstantiation.set(true) }
}
}
/**
* Configure a shared `jvmAndroidMain` source set using Kotlin's hierarchy template DSL.
*
- * This is for modules that intentionally share JVM-only implementations between the desktop
- * `jvm()` target and the Android target without hand-written `dependsOn` edges.
+ * This is for modules that intentionally share JVM-only implementations between the desktop `jvm()` target and the
+ * Android target without hand-written `dependsOn` edges.
*/
@OptIn(ExperimentalKotlinGradlePluginApi::class)
internal fun Project.configureJvmAndroidMainHierarchy() {
@@ -133,8 +154,7 @@ internal fun Project.configureJvmAndroidMainHierarchy() {
common {
group("jvmAndroid") {
withCompilations { compilation ->
- compilation.target.targetName == "android" ||
- compilation.target.targetName == "jvm"
+ compilation.target.targetName == "android" || compilation.target.targetName == "jvm"
}
}
}
@@ -142,9 +162,7 @@ internal fun Project.configureJvmAndroidMainHierarchy() {
}
}
-/**
- * Configure common test dependencies for KMP modules
- */
+/** Configure common test dependencies for KMP modules */
internal fun Project.configureKmpTestDependencies() {
extensions.configure {
sourceSets.apply {
@@ -155,56 +173,76 @@ internal fun Project.configureKmpTestDependencies() {
implementation(libs.library("kotest-property"))
implementation(libs.library("turbine"))
}
-
- // Configure androidHostTest if it exists
- val androidHostTest = findByName("androidHostTest")
- androidHostTest?.dependencies {
- implementation(kotlin("test"))
- implementation(libs.library("kotest-assertions"))
- implementation(libs.library("kotest-property"))
- implementation(libs.library("turbine"))
+
+ // Configure androidHostTest lazily — the source set is created when the
+ // module's build script calls `withHostTest { }`, which runs *after* the
+ // convention plugin's `apply`. Using `matching + configureEach` defers
+ // configuration until the source set actually materialises.
+ matching { it.name == "androidHostTest" }.configureEach {
+ dependencies {
+ // kotlin.test auto-selects kotlin-test-junit because testAndroidHostTest
+ // does NOT use useJUnitPlatform() (see configureTestOptions).
+ // No explicit kotlin("test") or kotlin("test-junit") override needed —
+ // adding them would conflict with auto-selection and break resource merging.
+ implementation(libs.library("kotest-assertions"))
+ implementation(libs.library("kotest-property"))
+ implementation(libs.library("turbine"))
+ implementation(libs.library("robolectric"))
+ implementation(libs.library("androidx-test-core"))
+ }
}
- // Configure jvmTest if it exists
- val jvmTest = findByName("jvmTest")
- jvmTest?.dependencies {
- implementation(libs.library("kotest-runner-junit6"))
+ // Configure jvmTest lazily for the same reason.
+ matching { it.name == "jvmTest" }.configureEach {
+ dependencies {
+ implementation(libs.library("kotest-runner-junit6"))
+ }
}
}
}
}
-/**
- * Configure base Kotlin options for JVM (non-Android)
- */
+/** Configure base Kotlin options for JVM (non-Android) */
internal fun Project.configureKotlinJvm() {
configureKotlin()
}
-/**
- * Configure base Kotlin options
- */
+/** Modules published for external consumers — use Java 17 for broader compatibility. */
+private val PUBLISHED_MODULES = setOf("api", "model", "proto")
+
+/** Compiler args shared across all Kotlin targets (JVM, Android, iOS, etc.). */
+private val SHARED_COMPILER_ARGS = listOf(
+ "-opt-in=kotlin.uuid.ExperimentalUuidApi",
+ "-opt-in=kotlin.time.ExperimentalTime",
+ "-Xexpect-actual-classes",
+ "-Xcontext-parameters",
+ "-Xannotation-default-target=param-property",
+ "-Xskip-prerelease-check",
+)
+
+/** Configure base Kotlin options */
private inline fun Project.configureKotlin() {
+ val isPublishedModule = project.name in PUBLISHED_MODULES
+
extensions.configure {
- // Using Java 17 for better compatibility with consumers (e.g. plugins, older environments)
- // while still supporting modern Kotlin features.
- jvmToolchain(17)
+ val javaVersion = if (isPublishedModule) 17 else 21
+ // Using Java 17 for published modules for better compatibility with consumers (e.g. plugins, older environments),
+ // and Java 21 for the rest of the app.
+ jvmToolchain(javaVersion)
if (this is KotlinMultiplatformExtension) {
targets.configureEach {
+ val isJvmTarget = platformType.name == "jvm" || platformType.name == "androidJvm"
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"
- )
+ if (!isPublishedModule) {
+ freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
+ }
+ freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
+ if (isJvmTarget) {
+ freeCompilerArgs.add("-jvm-default=no-compatibility")
+ }
}
}
}
@@ -212,21 +250,17 @@ private inline fun Project.configureKotlin() {
}
}
+ val warningsAsErrors = providers.gradleProperty("warningsAsErrors").map { it.toBoolean() }.getOrElse(false)
+
tasks.withType().configureEach {
compilerOptions {
- jvmTarget.set(JvmTarget.JVM_17)
- allWarningsAsErrors.set(false)
- freeCompilerArgs.addAll(
- // Enable experimental coroutines APIs, including Flow
- "-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"
- )
+ jvmTarget.set(if (isPublishedModule) JvmTarget.JVM_17 else JvmTarget.JVM_21)
+ allWarningsAsErrors.set(warningsAsErrors)
+ if (!isPublishedModule) {
+ freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
+ }
+ freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
+ freeCompilerArgs.add("-jvm-default=no-compatibility")
}
}
}
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt
index 20b542977..6b04b0fad 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Kover.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.buildlogic
import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension
@@ -22,15 +21,13 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
fun Project.configureKover() {
+ val isCi = providers.gradleProperty("ci").map { it.toBoolean() }.getOrElse(false)
extensions.configure {
reports {
total {
- xml {
- onCheck.set(true)
- }
- html {
- onCheck.set(true)
- }
+ // In CI, reports are generated explicitly per-shard; skip automatic generation on check.
+ xml { onCheck.set(!isCi) }
+ html { onCheck.set(!isCi) }
}
filters {
excludes {
@@ -42,16 +39,14 @@ fun Project.configureKover() {
classes("*.R")
classes("*.R$*")
+ // Exclude iOS compile-only stubs (no test execution on these targets)
+ classes("*NoopStubs*")
+
// Exclude UI components
annotatedBy("*Preview")
// Exclude declarations
- annotatedBy(
- "*.Module",
- "*.Provides",
- "*.Binds",
- "*.Composable",
- )
+ annotatedBy("*.Module", "*.Provides", "*.Binds", "*.Composable")
// Suppress generated code
packages("koin_aggregated_deps")
@@ -63,13 +58,11 @@ fun Project.configureKover() {
}
/**
- * Configure Kover aggregation in a way that is compatible with Gradle Isolated Projects.
- * Instead of blindly adding all subprojects, we only add those that have the Kover plugin applied.
+ * Configure Kover aggregation in a way that is compatible with Gradle Isolated Projects. Instead of blindly adding all
+ * subprojects, we only add those that have the Kover plugin applied.
*/
fun Project.configureKoverAggregation() {
subprojects.forEach { subproject ->
- subproject.pluginManager.withPlugin("org.jetbrains.kotlinx.kover") {
- dependencies.add("kover", subproject)
- }
+ subproject.pluginManager.withPlugin("org.jetbrains.kotlinx.kover") { dependencies.add("kover", subproject) }
}
}
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt
index ac3169101..c3403ac87 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/ProjectExtensions.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025 Meshtastic LLC
+ * 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
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.buildlogic
import org.gradle.api.Project
@@ -37,17 +36,13 @@ import java.util.Properties
val Project.libs
get(): VersionCatalog = extensions.getByType().named("libs")
-fun VersionCatalog.library(alias: String): Provider =
- findLibrary(alias).get()
+fun VersionCatalog.library(alias: String): Provider = findLibrary(alias).get()
-fun VersionCatalog.bundle(alias: String): Provider =
- findBundle(alias).get()
+fun VersionCatalog.bundle(alias: String): Provider = findBundle(alias).get()
-fun VersionCatalog.plugin(alias: String): Provider =
- findPlugin(alias).get()
+fun VersionCatalog.plugin(alias: String): Provider = findPlugin(alias).get()
-fun VersionCatalog.version(alias: String): String =
- findVersion(alias).get().requiredVersion
+fun VersionCatalog.version(alias: String): String = findVersion(alias).get().requiredVersion
val Project.configProperties: Properties
get() {
@@ -59,24 +54,64 @@ val Project.configProperties: Properties
return properties
}
-/**
- * Configure common test options like parallel execution and logging.
- */
+/** Configure common test options like parallel execution and logging. */
internal fun Project.configureTestOptions() {
+ // Gradle 9 requires junit-platform-launcher on every test runtime classpath when
+ // useJUnitPlatform() is active. Add it lazily to all *UnitTestRuntimeClasspath and
+ // *TestRuntimeClasspath configurations so all Android and JVM test tasks get it
+ // without requiring per-module declarations.
+ configurations.matching {
+ it.name.endsWith("UnitTestRuntimeClasspath") || it.name.endsWith("TestRuntimeClasspath")
+ }.configureEach {
+ val launcher = libs.library("junit-platform-launcher")
+ project.dependencies.add(name, launcher)
+ }
+
tasks.withType().configureEach {
- // Parallelize unit tests
- maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
+ // JUnit 5: activate JUnit Platform — but NOT for androidHostTest (Robolectric) tasks
+ // in KMP modules. Those tasks run JUnit 4 natively; applying useJUnitPlatform()
+ // would force kotlin-test-junit5 selection which conflicts with the kotlin-test-junit
+ // that Kotlin auto-selects for Robolectric @RunWith tests when Platform is absent.
+ if (name != "testAndroidHostTest") {
+ useJUnitPlatform()
+ }
+ // Parallelize unit tests at the Gradle fork level.
+ // In CI, use all available processors; locally use half to keep the machine responsive.
+ val isCi = project.findProperty("ci") == "true"
+ maxParallelForks = if (isCi) {
+ Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
+ } else {
+ (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
+ }
maxHeapSize = "2g"
+ // JUnit Jupiter parallel execution within each Gradle fork.
+ // Classes run sequentially ("same_thread") because 19+ ViewModel test classes use
+ // Dispatchers.setMain() — a JVM-global singleton that races when classes execute
+ // concurrently in the same JVM. Cross-module parallelism via Gradle forks (above)
+ // already provides the primary test speedup.
+ systemProperty("junit.jupiter.execution.parallel.enabled", "true")
+ systemProperty("junit.jupiter.execution.parallel.mode.default", "same_thread")
+ systemProperty("junit.jupiter.execution.parallel.mode.classes.default", "same_thread")
+ systemProperty("junit.jupiter.execution.parallel.config.strategy", "dynamic")
+ systemProperty("junit.jupiter.execution.parallel.config.dynamic.factor", "1")
+
+ // Allow modules with no discovered tests to pass without failing the build
+ filter { isFailOnNoMatchingTests = false }
+
// Show test results in the console
- testLogging {
- events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
- }
+ testLogging { events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) }
+ }
+
+ // Gradle 9+ fails when test sources exist but no test classes are discovered (e.g. all
+ // tests are commented out). Disable to avoid breaking builds for modules with WIP tests.
+ tasks.withType().configureEach {
+ failOnNoDiscoveredTests.set(false)
}
// Configure test retry if the plugin is applied
pluginManager.withPlugin("org.gradle.test-retry") {
- tasks.withType().configureEach {
+ tasks.withType().configureEach {
extensions.configure {
maxRetries.set(2)
maxFailures.set(10)
diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Spotless.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Spotless.kt
index 0f74f84ef..7a657320c 100644
--- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Spotless.kt
+++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/Spotless.kt
@@ -27,7 +27,7 @@ internal fun Project.configureSpotless(extension: SpotlessExtension) {
kotlin {
target("src/*/kotlin/**/*.kt", "src/*/java/**/*.kt")
targetExclude("**/build/**/*.kt")
- ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) }
+ ktfmt(libs.version("ktfmt")).kotlinlangStyle().configure { it.setMaxWidth(120) }
ktlint(ktlintVersion)
.setEditorConfigPath(rootProject.file("config/spotless/.editorconfig").path)
licenseHeaderFile(rootProject.file("config/spotless/copyright.kt"))
@@ -35,7 +35,7 @@ internal fun Project.configureSpotless(extension: SpotlessExtension) {
kotlinGradle {
target("**/*.gradle.kts")
targetExclude("**/build/**", "**/dependencies/**")
- ktfmt().kotlinlangStyle().configure { it.setMaxWidth(120) }
+ ktfmt(libs.version("ktfmt")).kotlinlangStyle().configure { it.setMaxWidth(120) }
ktlint(ktlintVersion)
.setEditorConfigPath(rootProject.file("config/spotless/.editorconfig").path)
licenseHeaderFile(
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
index dff68e004..91b8ebce2 100644
--- a/build-logic/settings.gradle.kts
+++ b/build-logic/settings.gradle.kts
@@ -30,7 +30,7 @@ pluginManagement {
}
plugins {
- id("com.gradle.develocity") version("4.3.2")
+ id("com.gradle.develocity") version("4.4.1")
}
dependencyResolutionManagement {
diff --git a/build.gradle.kts b/build.gradle.kts
index eedaff862..c4c4955e6 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -15,8 +15,6 @@
* along with this program. If not, see .
*/
-
-
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.kotlin.multiplatform.library) apply false
@@ -26,7 +24,6 @@ plugins {
alias(libs.plugins.devtools.ksp) apply false
alias(libs.plugins.koin.compiler) apply false
alias(libs.plugins.firebase.crashlytics) apply false
- alias(libs.plugins.firebase.perf) apply false
alias(libs.plugins.google.services) apply false
alias(libs.plugins.room) apply false
alias(libs.plugins.kotlin.android) apply false
@@ -41,20 +38,9 @@ plugins {
alias(libs.plugins.spotless) apply false
alias(libs.plugins.dokka)
alias(libs.plugins.test.retry) apply false
- alias(libs.plugins.dependency.guard) apply false
alias(libs.plugins.meshtastic.root)
}
-
-
-
-
dependencies {
dokkaPlugin(libs.dokka.android.documentation.plugin)
}
-
-subprojects {
- tasks.withType {
- failOnNoDiscoveredTests = false
- }
-}
\ No newline at end of file
diff --git a/codecov.yml b/codecov.yml
index 41210aaf6..7f77510ff 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -41,21 +41,22 @@ component_management:
target: auto
threshold: 1%
individual_components:
- - name: Core
+ - component_id: core
+ name: Core
paths:
- core/**
- - name: Features
+ - component_id: features
+ name: Features
paths:
- feature/**
- - name: App
+ - component_id: app
+ name: App
paths:
- app/**
- - name: Desktop
+ - component_id: desktop
+ name: Desktop
paths:
- desktop/**
- - name: Example
- paths:
- - mesh_service_example/**
ignore:
- "**/build/**"
diff --git a/compose_compiler_config.conf b/compose_compiler_config.conf
index 032dc04e0..cd953347c 100644
--- a/compose_compiler_config.conf
+++ b/compose_compiler_config.conf
@@ -18,8 +18,5 @@ okio.ByteString
// Kotlin Immutable Collections
kotlinx.collections.immutable.*
-// Java Time
-java.time.*
-
// External Libraries
com.google.android.gms.maps.model.**
diff --git a/conductor/archive/android_kable_migration_20260314/index.md b/conductor/archive/android_kable_migration_20260314/index.md
deleted file mode 100644
index 418db43a5..000000000
--- a/conductor/archive/android_kable_migration_20260314/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track android_kable_migration_20260314 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/android_kable_migration_20260314/metadata.json b/conductor/archive/android_kable_migration_20260314/metadata.json
deleted file mode 100644
index 8dd4dc82b..000000000
--- a/conductor/archive/android_kable_migration_20260314/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "android_kable_migration_20260314",
- "type": "feature",
- "status": "completed",
- "created_at": "2026-03-14T17:15:00Z",
- "updated_at": "2026-03-14T17:15:00Z",
- "description": "Replace Nordic with Kable on Android"
-}
\ No newline at end of file
diff --git a/conductor/archive/android_kable_migration_20260314/plan.md b/conductor/archive/android_kable_migration_20260314/plan.md
deleted file mode 100644
index 454298e8a..000000000
--- a/conductor/archive/android_kable_migration_20260314/plan.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# Implementation Plan: Replace Nordic with Kable on Android (Deduplication Pass)
-
-## Phase 1: Deduplicate Kable Abstractions into `commonMain` [checkpoint: 709f6e3]
-- [x] Task: Extract common Kable state mapping logic from jvmMain to commonMain 10cdd16
- - [x] Create `commonMain` tests for `BleConnectionState` mapping using Kable `State`
- - [x] Move `KableMeshtasticRadioProfile` and `KableBleConnection` logic that doesn't depend on platform specifics to `commonMain`
-- [x] Task: Implement common Kable `Scanner` and `Peripheral` wrappers 2691d70
- - [x] Extract generic connection lifecycle (connect, reconnect, close) to `commonMain` using Kable's `Peripheral` interface
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Deduplicate Kable Abstractions into commonMain' (Protocol in workflow.md) 709f6e3
-
-## Phase 2: Implement Kable Backend for Android (`androidMain`) [checkpoint: 12217de]
-- [x] Task: Add Kable dependency to Android source set in `core:ble/build.gradle.kts` 011d619
-- [x] Task: Implement Android-specific `BleConnectionFactory` and `BleScanner` using the deduplicated `commonMain` logic 589ee93
- - [x] Write failing integration tests for Android Kable scanner (using fakes/mocks)
- - [x] Implement `KableBleScanner` for `androidMain`
- - [x] Write failing integration tests for Android Kable connection (using fakes/mocks)
- - [x] Implement `KableBleConnection` for `androidMain` (handling Android-specific MTU requests if necessary)
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Implement Kable Backend for Android' (Protocol in workflow.md) 12217de
-
-## Phase 3: Migrate OTA Firmware Update Logic [checkpoint: 663c8e2]
-- [x] Task: Deprecate `NordicDfuHandler` and replace with Kable-based DFU 06fe4f5
- - [x] Write failing tests for Kable DFU integration
- - [x] Implement new DFU handler in `feature:firmware` using `MeshtasticRadioProfile` / Kable abstraction
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Migrate OTA Firmware Update Logic' (Protocol in workflow.md) 663c8e2
-
-## Phase 4: Wire Kable into Android App and Remove Nordic [checkpoint: ebe1617]
-- [x] Task: Deprecate and remove `NordicBleInterface` and `AndroidBleConnection` ebe1617
- - [x] Remove `NordicAndroidCommonLibraries` and `NordicDfuLibrary` from `gradle/libs.versions.toml` and build files
- - [x] Delete `NordicBleInterface.kt` and associated Nordic-specific radio implementations
-- [x] Task: Wire new `androidMain` Kable implementation into the Koin DI graph ebe1617
- - [x] Update `AndroidRadioControllerImpl` or DI modules to provide the new Kable `BleConnectionFactory` and `BleScanner`
-- [x] Task: Conductor - User Manual Verification 'Phase 4: Wire Kable into Android App and Remove Nordic' (Protocol in workflow.md) ebe1617
-
-## Phase 5: Final Testing and Integration [checkpoint: 4778c0e]
-- [x] Task: Update Android `app` UI tests and BLE unit tests to use Kable fakes 4778c0e
- - [x] Fix any failing tests related to the Nordic removal
-- [x] Task: Manual end-to-end verification 4778c0e
- - [x] Build and run the Android app, verify BLE scanning, connecting, and messaging
- - [x] Verify OTA updates work via BLE
- - [x] Verify the Desktop app still functions correctly
-- [x] Task: Conductor - User Manual Verification 'Phase 5: Final Testing and Integration' (Protocol in workflow.md) 4778c0e
-
-## Phase: Review Fixes
-- [x] Task: Apply review suggestions e5dffd9
\ No newline at end of file
diff --git a/conductor/archive/android_kable_migration_20260314/spec.md b/conductor/archive/android_kable_migration_20260314/spec.md
deleted file mode 100644
index f59fbaa59..000000000
--- a/conductor/archive/android_kable_migration_20260314/spec.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Specification: Replace Nordic with Kable on Android (Deduplication Pass)
-
-## Overview
-This track executes a full migration of the Android application's BLE transport layer from the legacy Nordic Android Common Libraries to the multiplatform Kable library. Building upon the successful `MeshtasticRadioProfile` abstraction introduced for the Desktop target, this track aims to unify the BLE transport layer across all platforms (Android, Desktop, iOS) under a single KMP technology stack. Crucially, this pass focuses on **maximal code deduplication**, moving as much BLE logic as possible into `commonMain` to share it across all targets, including OTA firmware update logic.
-
-## Functional Requirements
-- **Kable Integration:** Implement the `MeshtasticRadioProfile` using Kable for the `androidMain` source set, replacing the existing Nordic implementation.
-- **Maximal Deduplication:** Refactor the existing Kable `jvmMain` implementation and the new `androidMain` implementation to extract common connection management, scanning logic, and characteristic observation into `core:ble/commonMain`.
-- **OTA Firmware Updates:** Migrate the Android OTA firmware update logic (currently handled by `NordicDfuHandler`) to use the new Kable/KMP abstraction.
-- **Full Migration:** The Android app must exclusively use the new Kable backend for all BLE operations (scanning, connecting, data transfer, firmware updates).
-- **Deprecation/Removal:** Remove all dependencies on the Nordic Android Common Libraries and Nordic DFU libraries from the project configuration (`build.gradle.kts`, version catalogs).
-- **Feature Parity:** The new Kable implementation on Android must maintain full feature parity with the previous Nordic implementation, including connection stability, MTU negotiation, and data throughput.
-
-## Non-Functional Requirements
-- **Expanded Testing:** Adapt existing Android BLE tests to use Kable fakes and write new `commonMain` tests to expand test coverage for the shared KMP BLE abstraction.
-- **Architecture:** Maintain strict adherence to the MVI/UDF patterns and the pure KMP DI architecture (Koin annotations).
-
-## Acceptance Criteria
-- [ ] Kable backend is fully implemented for Android (`androidMain`).
-- [ ] Nordic Android Common Libraries and DFU dependencies are completely removed from the project.
-- [ ] Android application successfully scans, connects, and transfers data via BLE using Kable.
-- [ ] BLE logic (connection state, profile mapping, retry logic) is heavily deduplicated into `core:ble/commonMain`.
-- [ ] OTA firmware update logic is successfully migrated to use the Kable backend.
-- [ ] Existing BLE tests are updated or replaced, and all test suites pass.
-- [ ] New KMP BLE tests are added, improving overall test coverage.
-
-## Out of Scope
-- Migrating USB or TCP network transports.
\ No newline at end of file
diff --git a/conductor/archive/deep_dive_docs_20260316/index.md b/conductor/archive/deep_dive_docs_20260316/index.md
deleted file mode 100644
index aea19983d..000000000
--- a/conductor/archive/deep_dive_docs_20260316/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track deep_dive_docs_20260316 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/deep_dive_docs_20260316/metadata.json b/conductor/archive/deep_dive_docs_20260316/metadata.json
deleted file mode 100644
index 4851ae35a..000000000
--- a/conductor/archive/deep_dive_docs_20260316/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "deep_dive_docs_20260316",
- "type": "chore",
- "status": "completed",
- "created_at": "2026-03-16T12:00:00Z",
- "updated_at": "2026-03-16T12:00:00Z",
- "description": "do a deep dive of project docs and plans in /docs - verify against actual project/codebase state, then validate against modern best practices for android, kotlin, kmp, and the dependencies used. be thorough - check all the major dependencies. Update docs and plans accordingly."
-}
\ No newline at end of file
diff --git a/conductor/archive/deep_dive_docs_20260316/plan.md b/conductor/archive/deep_dive_docs_20260316/plan.md
deleted file mode 100644
index 85cfc5d7c..000000000
--- a/conductor/archive/deep_dive_docs_20260316/plan.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Implementation Plan: Deep Dive & Validation of Project Docs & Plans
-
-## Phase 1: Audit & Discovery [checkpoint: 105763b]
-- [x] Task: Audit Gradle dependencies (`libs.versions.toml`) against 2026 KMP best practices (Koin, Compose, Navigation 3, etc.). baed3d6
-- [x] Task: Analyze Core Logic (`core:*`) and platform modules (Android, Desktop) for architectural alignment (MVI/Shared ViewModels). baed3d6
-- [x] Task: Review current UI and feature module implementations for Compose Multiplatform standard adherence. baed3d6
-- [x] Task: Evaluate testing patterns, coverage, and the use of shared test doubles (`core:testing`). baed3d6
-- [x] Task: Compile a list of discrepancies between current documentation/plans and the actual codebase. baed3d6
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Audit & Discovery' (Protocol in workflow.md) 105763b
-
-## Phase 2: Documentation Updates [checkpoint: 7212ff1]
-- [x] Task: Update `/docs` and root-level guides (e.g., `GEMINI.md`, `kmp-status.md`, `roadmap.md`) to reflect the current, verified codebase state. baed3d6
-- [x] Task: Add explicit documentation for areas where the codebase diverges from documented best practices (flagging for future refactoring). baed3d6
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Documentation Updates' (Protocol in workflow.md) 7212ff1
-
-## Phase 3: Plan Adjustment
-- [x] Task: Create new, actionable tasks in the project's main `plan.md` (roadmap.md) to address the flagged discrepancies (e.g., refactoring non-compliant Koin modules, updating deprecated APIs). baed3d6
-- [x] Task: Review and finalize the overall project roadmap and status based on the audit findings. baed3d6
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Plan Adjustment' (Protocol in workflow.md) 7212ff1
\ No newline at end of file
diff --git a/conductor/archive/deep_dive_docs_20260316/spec.md b/conductor/archive/deep_dive_docs_20260316/spec.md
deleted file mode 100644
index baa50bda7..000000000
--- a/conductor/archive/deep_dive_docs_20260316/spec.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Specification: Deep Dive & Validation of Project Docs & Plans
-
-## Overview
-This track involves a comprehensive review and deep dive into the project's documentation (`/docs`, `GEMINI.md`, etc.) and plans. The goal is to verify the documented state against the actual Kotlin Multiplatform (KMP) codebase and validate it against modern 2026 KMP and Android best practices. The outcome will be updated documentation reflecting the current state and flagged/planned changes for areas not following best practices.
-
-## Functional Requirements
-- **Codebase Verification:** Analyze all major areas including Core Logic (`core:*`), UI & Features (Compose Multiplatform), Dependencies (Gradle version catalogs), and Platform-specific implementations (Android, Desktop).
-- **Best Practice Validation:** Evaluate the codebase against modern standards, specifically focusing on Architecture (MVI/Shared ViewModels), Navigation (Navigation 3), Dependency Injection (Koin Annotations K2), and Testing patterns.
-- **Documentation Update:** Modify existing documentation and plans to accurately reflect the current state of the codebase and dependencies.
-- **Refactoring Proposals:** Identify and flag code or architectural decisions that deviate from best practices, outlining necessary refactoring steps in the project's plans.
-
-## Acceptance Criteria
-- All documentation in `/docs` and root-level guides accurately reflect the current codebase.
-- A comprehensive audit of major dependencies has been performed and validated against 2026 KMP standards.
-- Discrepancies between the codebase and best practices are clearly flagged and actionable tasks are added to the project plans.
-- The `plan.md` reflects the updated status and any new tasks generated from the audit.
-
-## Out of Scope
-- Direct refactoring or modification of the actual Kotlin/Android codebase during this specific track (this track focuses on documentation, planning, and flagging).
\ No newline at end of file
diff --git a/conductor/archive/desktop_ble_kable_20260314/index.md b/conductor/archive/desktop_ble_kable_20260314/index.md
deleted file mode 100644
index dd1da9350..000000000
--- a/conductor/archive/desktop_ble_kable_20260314/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track desktop_ble_kable_20260314 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/desktop_ble_kable_20260314/metadata.json b/conductor/archive/desktop_ble_kable_20260314/metadata.json
deleted file mode 100644
index 813ef1cab..000000000
--- a/conductor/archive/desktop_ble_kable_20260314/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "desktop_ble_kable_20260314",
- "type": "feature",
- "status": "completed",
- "created_at": "2026-03-14T12:00:00Z",
- "updated_at": "2026-03-14T12:00:00Z",
- "description": "Kable swap Keep Nordic on Android short-term. Add Kable backend only for jvmMain in core:ble first (desktop BLE enablement). Introduce a MeshtasticRadioProfile abstraction in core:ble/commonMain so NordicBleInterface no longer depends on Android/Nordic classes. Once that seam is clean, decide whether Android should stay Nordic or move to Kable."
-}
\ No newline at end of file
diff --git a/conductor/archive/desktop_ble_kable_20260314/plan.md b/conductor/archive/desktop_ble_kable_20260314/plan.md
deleted file mode 100644
index e5f84f48e..000000000
--- a/conductor/archive/desktop_ble_kable_20260314/plan.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Implementation Plan: Desktop BLE Enablement via Kable
-
-## Phase 1: Define `MeshtasticRadioProfile` Abstraction [checkpoint: 1206e87]
-- [x] Task: Define `MeshtasticRadioProfile` interface in `core:ble/commonMain` eaa623a
- - [ ] Write tests for expected profile behavior (e.g., state flow emission) using a simple fake
- - [ ] Implement `MeshtasticRadioProfile` interface, data classes for states, and configuration
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Define `MeshtasticRadioProfile` Abstraction' (Protocol in workflow.md) 1206e87
-
-## Phase 2: Refactor Nordic Implementation to use Abstraction [checkpoint: dc700a5]
-- [x] Task: Implement `MeshtasticRadioProfile` in the existing Nordic implementation (`androidMain`) 83a8a9b
- - [ ] Write/adapt existing Android tests to verify `MeshtasticRadioProfile` adherence
- - [ ] Implement wrapper/adapter for Nordic classes to fulfill `MeshtasticRadioProfile`
-- [x] Task: Decouple app-level BLE transport from Nordic types 2dfedde
- - [ ] Write tests to ensure BLE transport only relies on `MeshtasticRadioProfile`
- - [ ] Refactor transport layer (e.g., `NordicBleInterface` usages) to use the new profile interface
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Refactor Nordic Implementation to use Abstraction' (Protocol in workflow.md) dc700a5
-
-## Phase 3: Implement Kable Backend for Desktop [checkpoint: ed2a459]
-- [x] Task: Setup Kable dependencies for `jvmMain` in `core:ble` b152eff
- - [ ] Update `build.gradle.kts` to include Kable dependency for Desktop
-- [x] Task: Implement Kable `MeshtasticRadioProfile` backend (`jvmMain`) fa5cc82
- - [ ] Write `commonMain` unit tests with Kable fakes to verify scanning, connection, and read/write operations
- - [ ] Implement Kable scanning logic
- - [ ] Implement Kable connection and characteristic management
- - [ ] Implement Kable read/write data transfer logic
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Implement Kable Backend for Desktop' (Protocol in workflow.md) ed2a459
-
-## Phase 4: Integration and Final Testing [checkpoint: af6d3b3]
-- [x] Task: Integrate Kable backend into Desktop app DI graph 28afcad
- - [ ] Wire up the Kable implementation in `desktop` module DI
-- [x] Task: End-to-end verification 84aae75
- - [ ] Verify Android app still compiles and connects using Nordic
- - [ ] Verify Desktop app compiles and connects using Kable
-- [x] Task: Conductor - User Manual Verification 'Phase 4: Integration and Final Testing' (Protocol in workflow.md) af6d3b3
-
-## Phase: Review Fixes
-- [x] Task: Apply review suggestions b36da82
diff --git a/conductor/archive/desktop_ble_kable_20260314/spec.md b/conductor/archive/desktop_ble_kable_20260314/spec.md
deleted file mode 100644
index 7848283ce..000000000
--- a/conductor/archive/desktop_ble_kable_20260314/spec.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Specification: Desktop BLE Enablement via Kable
-
-## Overview
-This track introduces a Kable BLE backend specifically for the `jvmMain` (Desktop) target within `core:ble`. To facilitate this without breaking the existing Android implementation, we will introduce a `MeshtasticRadioProfile` abstraction in `core:ble/commonMain`. This abstraction will ensure that the app-level BLE transport path no longer depends on Android-specific or Nordic-specific classes. Initially, Android will continue to use the Nordic BLE implementation, while Desktop will use Kable. Once this seam is proven, a future decision will determine whether Android should fully migrate to Kable. This approach lays the groundwork for seamless integration of future targets (e.g., iOS) under the same KMP abstraction.
-
-## Functional Requirements
-- **MeshtasticRadioProfile Abstraction:** Introduce a multiplatform interface (`MeshtasticRadioProfile`) in `core:ble/commonMain` to abstract all BLE operations.
-- **Remove Nordic Dependencies:** Ensure that the app-level BLE transport path is entirely decoupled from Nordic types, relying solely on the new abstraction.
-- **Kable Backend (jvmMain):** Implement the Kable backend for the Desktop target. This backend must support all core BLE operations:
- - Scanning for nearby Meshtastic devices.
- - Establishing and managing BLE connections.
- - Reading from and writing to characteristics (sending/receiving protobuf payloads).
-- **Nordic Backend Preservation (androidMain):** Update the existing Android Nordic implementation to implement the new `MeshtasticRadioProfile` interface without changing its core behavior.
-- **Future-Proofing:** Design the abstraction in a way that is generic enough to support adding an iOS or other future target's BLE implementation with minimal refactoring.
-
-## Non-Functional Requirements
-- **Testing:** New `commonMain` unit tests must be written utilizing fakes for the Kable implementation. This is crucial as we cannot rely on Nordic's ready-made mocks in a multiplatform context or if a full migration to Kable occurs.
-- **Architecture:** The abstraction must adhere to the project's KMP goals, keeping `core:ble/commonMain` completely free of platform-specific imports (e.g., `java.*`, `android.*`).
-- **Compatibility:** The Android build and BLE functionality must remain fully functional using the existing Nordic library.
-
-## Acceptance Criteria
-- [ ] `MeshtasticRadioProfile` is defined in `core:ble/commonMain`.
-- [ ] No Nordic-specific or Android-specific types are present in the app-level BLE transport path.
-- [ ] Desktop application can successfully scan, connect, and perform read/write operations with a Meshtastic device using Kable.
-- [ ] Android application continues to function normally using the Nordic library.
-- [ ] New unit tests using Kable fakes are added to `commonMain` and pass successfully.
-- [ ] The abstraction architecture provides a clear path for future platform support (like iOS).
-
-## Out of Scope
-- Migrating the Android application to use the Kable backend (this will be evaluated after this track is complete).
-- Modifying non-BLE network transports (e.g., USB, TCP).
\ No newline at end of file
diff --git a/conductor/archive/desktop_di_autowiring_20260313/index.md b/conductor/archive/desktop_di_autowiring_20260313/index.md
deleted file mode 100644
index 1bc0ce56b..000000000
--- a/conductor/archive/desktop_di_autowiring_20260313/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track desktop_di_autowiring_20260313 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/desktop_di_autowiring_20260313/metadata.json b/conductor/archive/desktop_di_autowiring_20260313/metadata.json
deleted file mode 100644
index 940262ddd..000000000
--- a/conductor/archive/desktop_di_autowiring_20260313/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "desktop_di_autowiring_20260313",
- "type": "chore",
- "status": "completed",
- "created_at": "2026-03-13T12:00:00Z",
- "updated_at": "2026-03-13T12:00:00Z",
- "description": "Architecture Health & DI (Immediate Priority) * Desktop Koin checkModules() test: Add a test to ensure Desktop DI bindings are validated at compile-time/test-time so we catch missing interfaces early. * Auto-wire Desktop ViewModels: Configure KSP so we can eliminate the manual ViewModel wiring in DesktopKoinModule and rely on @KoinViewModel annotations like Android does."
-}
\ No newline at end of file
diff --git a/conductor/archive/desktop_di_autowiring_20260313/plan.md b/conductor/archive/desktop_di_autowiring_20260313/plan.md
deleted file mode 100644
index b5d55c6ed..000000000
--- a/conductor/archive/desktop_di_autowiring_20260313/plan.md
+++ /dev/null
@@ -1,16 +0,0 @@
-# Implementation Plan: Desktop DI Auto-Wiring and Validation
-
-## Phase 1: Setup KSP for Desktop and Test Scaffolding
-- [x] Task: Update the `meshtastic.koin` convention plugin (or equivalent `build-logic` files) to apply KSP to the `jvmMain` (Desktop) target for `@KoinViewModel` auto-wiring.
-- [x] Task: Write Failing Test: Create `DesktopKoinTest.kt` in `desktop/src/test/kotlin/org/meshtastic/desktop/di/` using `kotlin.test`.
- - [x] Initialize Koin application.
- - [x] Include `desktopModule()`, `desktopPlatformModule()`, and `desktopPlatformStubsModule()`.
- - [x] Call `checkModules()` inside the test and ensure it fails if there are missing interfaces.
-- [x] Task: Implement to Pass Tests: Add any missing stubs or correct module includes in `desktopPlatformStubsModule()` to ensure the basic Koin graph resolves.
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Setup KSP for Desktop and Test Scaffolding' (Protocol in workflow.md)
-
-## Phase 2: Auto-wire ViewModels and Clean Up
-- [x] Task: Refactor: Remove manual `viewModel { ... }` blocks from `DesktopKoinModule.kt` (if any are present).
-- [x] Task: Implement: Ensure the desktop build configuration (`desktop/build.gradle.kts`) correctly includes the KSP-generated Koin modules and that KSP targets the JVM platform.
-- [x] Task: Implement to Pass Tests: Verify that running `./gradlew :desktop:test` succeeds and that `DesktopKoinTest.kt` validates the new KSP-wired graph.
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Auto-wire ViewModels and Clean Up' (Protocol in workflow.md)
\ No newline at end of file
diff --git a/conductor/archive/desktop_di_autowiring_20260313/spec.md b/conductor/archive/desktop_di_autowiring_20260313/spec.md
deleted file mode 100644
index 5c91bb14a..000000000
--- a/conductor/archive/desktop_di_autowiring_20260313/spec.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Specification: Desktop DI Auto-Wiring and Validation
-
-## Overview
-This track addresses immediate architecture health priorities for the Desktop KMP target:
-1. **Desktop Koin `checkModules()` test:** Add a compile-time/test-time validation test to ensure Desktop DI bindings resolve correctly and catch missing interfaces early.
-2. **Auto-wire Desktop ViewModels:** Configure KSP to generate Koin modules for ViewModels annotated with `@KoinViewModel` in the JVM target, eliminating the need for manual ViewModel wiring in `DesktopKoinModule`.
-
-## Functional Requirements
-- **KSP Configuration:** Update the `meshtastic.koin` (or equivalent) convention plugin to apply KSP and Koin annotations processing to the `jvmMain` (Desktop) target.
-- **ViewModel Auto-Wiring:** Remove all manual `viewModel { ... }` definitions in `DesktopKoinModule` and ensure they are successfully replaced by the KSP-generated Koin modules.
-- **DI Validation Test:** Implement a new test file (e.g., `DesktopKoinTest.kt`) in `desktop/src/test/kotlin/org/meshtastic/desktop/di/` using `kotlin.test`.
-- **Test Scope:** The `checkModules()` test must include and validate all active Desktop Koin modules, including `desktopModule()`, `desktopPlatformModule()`, `desktopPlatformStubsModule()`, and any KSP-generated modules.
-
-## Non-Functional Requirements
-- **Build Performance:** The addition of KSP to the JVM target should not unnecessarily degrade build times. Cacheability must be maintained.
-- **Style:** Adhere strictly to the project's existing Kotlin code style and Koin best practices.
-
-## Acceptance Criteria
-- [ ] Running `./gradlew :desktop:test` executes the new `checkModules()` test successfully.
-- [ ] No manual ViewModel definitions remain in `DesktopKoinModule` for shared ViewModels (they are auto-wired).
-- [ ] If a dependency is missing from the Desktop DI graph, the `checkModules()` test fails explicitly.
-
-## Out of Scope
-- Migrating other platforms (Android, iOS) DI implementations.
-- Refactoring the internal logic of the ViewModels themselves.
\ No newline at end of file
diff --git a/conductor/archive/desktop_parity_20260311/index.md b/conductor/archive/desktop_parity_20260311/index.md
deleted file mode 100644
index c034c2f20..000000000
--- a/conductor/archive/desktop_parity_20260311/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track desktop_parity_20260311 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/desktop_parity_20260311/metadata.json b/conductor/archive/desktop_parity_20260311/metadata.json
deleted file mode 100644
index d4c5031c4..000000000
--- a/conductor/archive/desktop_parity_20260311/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "desktop_parity_20260311",
- "type": "feature",
- "status": "completed",
- "created_at": "2026-03-11T12:00:00Z",
- "updated_at": "2026-03-11T12:00:00Z",
- "description": "continue bringing desktop up to parity with android"
-}
\ No newline at end of file
diff --git a/conductor/archive/desktop_parity_20260311/plan.md b/conductor/archive/desktop_parity_20260311/plan.md
deleted file mode 100644
index 381d89d92..000000000
--- a/conductor/archive/desktop_parity_20260311/plan.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Implementation Plan
-
-## Phase 1: Navigation Parity [checkpoint: 5b8e194]
-- [x] Task: Extract shared navigation contracts f7e0c2e
- - [x] Define shared top-level destinations and route metadata in `core:navigation`.
- - [x] Update Android `TopLevelDestination` to use the shared contract.
- - [x] Update Desktop `DesktopDestination` to use the shared contract.
- - [x] Add parity tests for navigation routing.
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Navigation Parity' (Protocol in workflow.md)
-
-## Phase 2: DI Parity [checkpoint: 5bdc099]
-- [x] Task: Migrate Desktop Koin Modules 93fd600
- - [x] Configure KSP for the JVM target in necessary modules.
- - [x] Ensure Koin annotations are processed for Desktop.
- - [x] Replace manual ViewModel wiring in `DesktopKoinModule` with generated modules.
-- [x] Task: Conductor - User Manual Verification 'Phase 2: DI Parity' (Protocol in workflow.md)
-
-## Phase 3: Connections Parity [checkpoint: 4be5732]
-- [x] Task: Create `feature:connections` module 242faa6
- - [x] Set up the KMP module structure with `commonMain`, `androidMain`, and `jvmMain` (or `desktopMain`).
- - [x] Move device discovery UI and ViewModels from `app` and `desktop` into the new module.
- - [x] Consolidate the Connections UI into a shared screen in `feature:connections`.
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Connections Parity' (Protocol in workflow.md)
-
-## Phase 4: UI/Feature Parity [checkpoint: e83a07a]
-- [x] Task: Implement missing Map and Chart features on Desktop 128ee3b
- - [x] Evaluate and implement a KMP-friendly mapping library or placeholder for Desktop.
- - [x] Refactor Vico charts or provide a KMP charting alternative/placeholder for Desktop.
-- [x] Task: Refinement - Connections UI and Messaging Parity c98db4f
- - [x] Hide unsupported transports (BLE/USB) on Desktop via BuildUtils proxy.
- - [x] Update message titles to resolve channel names for broadcasts.
- - [x] Add snackbar for no-op gaps (delivery info).
- - [x] Shared AnimatedConnectionsNavIcon for "blinky light" parity.
- - *Note: Connection type filtering is currently hardcoded via BuildUtils.sdkInt. This should be refactored to use dynamic transport discovery once the 'Extract hardware transport' track is complete.*
-- [x] Task: Conductor - User Manual Verification 'Phase 4: UI/Feature Parity' (Protocol in workflow.md) e83a07a
-
-## Phase 5: Multi-Target Hardening [checkpoint: 91784a9]
-- [x] Task: Clean up remaining platform-specific leaks f5f1e29
- - [x] Ensure `commonMain` is free of any `java.*` dependencies.
- - [x] Verify test suite passes on both Android and Desktop JVM targets.
-- [x] Task: Conductor - User Manual Verification 'Phase 5: Multi-Target Hardening' (Protocol in workflow.md) 91784a9
\ No newline at end of file
diff --git a/conductor/archive/desktop_parity_20260311/spec.md b/conductor/archive/desktop_parity_20260311/spec.md
deleted file mode 100644
index 27fef2b6f..000000000
--- a/conductor/archive/desktop_parity_20260311/spec.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Track Specification: Desktop Parity & Multi-Target Hardening
-
-## Overview
-This track aims to bring the Desktop target up to parity with the Android app and lay the foundation for future targets (like iOS). This involves eliminating duplicated code, fixing structural gaps, and sharing UI, navigation, and DI contracts across platforms.
-
-## Functional Requirements
-- **Connections Parity:** Consolidate device discovery (BLE/USB/TCP) from the app and desktop into a shared `feature:connections` module.
-- **DI Parity:** Remove manual ViewModel wiring in `DesktopKoinModule` and transition to using KSP-generated Koin modules for Desktop.
-- **UI/Feature Parity:** Implement missing map and charting functionality on Desktop, or provide robust KMP abstractions where direct translation isn't possible.
-- **Navigation Parity:** Extract shared navigation contracts to stop drift between Android and Desktop shells (following `decisions/navigation3-parity-2026-03.md`).
-
-## Non-Functional Requirements
-- **Architecture Readiness:** Ensure code abstractions support the subsequent addition of an iOS target.
-- **Structural Purity:** `commonMain` must be completely free of platform-specific APIs (like `java.*` or Android-specific APIs).
-
-## Acceptance Criteria
-- Device discovery screens share UI and view models in `feature:connections`.
-- Desktop DI uses generated modules without manual ViewModel instantiation.
-- Map and charting features are either functioning on Desktop or have solid KMP placeholders.
-- Android and Desktop Navigation shells utilize shared configuration and metadata.
-- Both functional and structural parity goals are verified through automated builds and testing where applicable.
-
-## Out of Scope
-- Full deployment to iOS or other unannounced platforms (only preparing the architecture).
-- Deep refactoring of underlying hardware interactions beyond what is necessary to expose a shared UI contract.
\ No newline at end of file
diff --git a/conductor/archive/desktop_serial_transport_20260317/index.md b/conductor/archive/desktop_serial_transport_20260317/index.md
deleted file mode 100644
index 1cbe07406..000000000
--- a/conductor/archive/desktop_serial_transport_20260317/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track desktop_serial_transport_20260317 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/desktop_serial_transport_20260317/metadata.json b/conductor/archive/desktop_serial_transport_20260317/metadata.json
deleted file mode 100644
index 0d31a3eb1..000000000
--- a/conductor/archive/desktop_serial_transport_20260317/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "desktop_serial_transport_20260317",
- "type": "feature",
- "status": "completed",
- "created_at": "2026-03-17T12:00:00Z",
- "updated_at": "2026-03-17T12:00:00Z",
- "description": "Implement Serial/USB transport for the Desktop target using jSerialComm. This fulfills the medium-term priority for direct radio connections on JVM and uses the shared RadioTransport interface."
-}
\ No newline at end of file
diff --git a/conductor/archive/desktop_serial_transport_20260317/plan.md b/conductor/archive/desktop_serial_transport_20260317/plan.md
deleted file mode 100644
index 3d55c7380..000000000
--- a/conductor/archive/desktop_serial_transport_20260317/plan.md
+++ /dev/null
@@ -1,21 +0,0 @@
-# Implementation Plan: Desktop Serial/USB Transport
-
-## Phase 1: JVM Setup & Dependency Integration [checkpoint: a05916d]
-- [x] Task: Add the `jSerialComm` library to the `jvmMain` dependencies of the networking module. [checkpoint: 8994c66]
-- [x] Task: Create a `jvmMain` stub implementation for a `SerialTransport` class that implements the shared `RadioTransport` interface. [checkpoint: 83668e4]
-
-## Phase 2: Serial Port Scanning & Connection Management [checkpoint: 9cda87d]
-- [x] Task: Implement port discovery using `jSerialComm` to list available serial ports. [checkpoint: c72501d]
-- [x] Task: Implement connect/disconnect logic for a selected serial port, handling port locking and baud rate configuration. [checkpoint: 23ee815]
-- [x] Task: Map the input/output streams of the open serial port to the existing KMP stream framing logic (`StreamFrameCodec`). [checkpoint: 04ba9c2]
-
-## Phase 3: UI Integration
-- [x] Task: Update the `feature:connections` UI or `DesktopScannerViewModel` to poll the new `SerialTransport` for available ports. [checkpoint: 2e85b5a]
-- [x] Task: Wire the user's serial port selection to initiate the connection via the DI graph and active service logic. [checkpoint: 94cb97c]
-
-## Phase 4: Validation [checkpoint: 1055752]
-- [x] Task: Verify end-to-end communication with a physical Meshtastic device over USB on the desktop target. [checkpoint: 1055752]
-- [x] Task: Ensure CI builds cleanly and that no `java.*` dependencies leaked into `commonMain`. [checkpoint: 1055752]
-
-## Phase: Review Fixes
-- [x] Task: Apply review suggestions [checkpoint: d2f7c82]
diff --git a/conductor/archive/desktop_serial_transport_20260317/spec.md b/conductor/archive/desktop_serial_transport_20260317/spec.md
deleted file mode 100644
index 04ff68481..000000000
--- a/conductor/archive/desktop_serial_transport_20260317/spec.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# Specification: Desktop Serial/USB Transport via jSerialComm
-
-## Objective
-Implement direct radio connection via Serial/USB on the Desktop (JVM) target using the `jSerialComm` library. This fulfills the medium-term priority of bringing physical transport parity to the desktop app and validates the newly extracted `RadioTransport` abstraction in `core:repository`.
-
-## Background
-Currently, the desktop app supports TCP connections via a shared `StreamFrameCodec`. To provide parity with Android's USB serial connection capabilities, we need to implement a JVM-specific serial transport. The `jSerialComm` library is a widely-used, cross-platform Java library that handles native serial port communication without requiring complex JNI setups.
-
-## Requirements
-- Introduce `jSerialComm` dependency to the `jvmMain` source set of the appropriate core module (likely `core:network` or a new `core:serial` module).
-- Implement the `RadioTransport` interface (defined in `core:repository/commonMain`) for the desktop target, wrapping `jSerialComm`'s port scanning and connection logic.
-- Ensure the serial data is encoded/decoded using the same protobuf frame structure utilized by the TCP transport (e.g., leveraging the existing `StreamFrameCodec`).
-- Integrate the new transport into the `feature:connections` UI on the desktop so users can scan for and select connected USB serial devices.
-- Retain platform purity: keep all `jSerialComm` and `java.io.*` imports strictly within the `jvmMain` source set.
-
-## Success Criteria
-- [ ] Desktop application successfully scans for connected Meshtastic devices over USB/Serial.
-- [ ] Users can select a serial port from the `feature:connections` UI and establish a connection.
-- [ ] Two-way protobuf communication is verified (e.g., the app receives node info and can send a message).
-- [ ] The implementation uses the shared `RadioTransport` interface without leaking JVM dependencies into `commonMain`.
diff --git a/conductor/archive/desktop_ux_enhancements_20260316/index.md b/conductor/archive/desktop_ux_enhancements_20260316/index.md
deleted file mode 100644
index cb8939351..000000000
--- a/conductor/archive/desktop_ux_enhancements_20260316/index.md
+++ /dev/null
@@ -1,8 +0,0 @@
-# Desktop UX Enhancements
-
-This track focuses on integrating desktop-specific Compose Multiplatform APIs to improve the native feel and functionality of the desktop client.
-
-## Track Files
-- [Specification](./spec.md)
-- [Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/desktop_ux_enhancements_20260316/metadata.json b/conductor/archive/desktop_ux_enhancements_20260316/metadata.json
deleted file mode 100644
index 826d38551..000000000
--- a/conductor/archive/desktop_ux_enhancements_20260316/metadata.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "id": "desktop_ux_enhancements_20260316",
- "name": "Desktop UX Enhancements",
- "status": "completed",
- "priority": "medium",
- "tags": ["desktop", "ux", "compose"]
-}
\ No newline at end of file
diff --git a/conductor/archive/desktop_ux_enhancements_20260316/plan.md b/conductor/archive/desktop_ux_enhancements_20260316/plan.md
deleted file mode 100644
index a78fe5bdb..000000000
--- a/conductor/archive/desktop_ux_enhancements_20260316/plan.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Implementation Plan: Desktop UX Enhancements
-
-## Phase 1: Tray & Notifications (Current Focus)
-- [x] Add `isAppVisible` state to `Main.kt`.
-- [x] Introduce `rememberTrayState()` and the `Tray` composable.
-- [x] Update `Window` `onCloseRequest` to toggle visibility instead of exiting the app.
-- [x] Add a `DesktopNotificationService` interface and implementation using `TrayState`.
-
-## Phase 2: Window State Persistence
-- [x] Create `DesktopPreferencesDataSource` via DataStore.
-- [x] Intercept window bounds changes and write to preferences.
-- [x] Read preferences on startup to initialize `rememberWindowState(...)`.
-
-## Phase 3: Menu Bar & Shortcuts
-- [x] Integrate the `MenuBar` composable into the `Window`.
-- [x] Implement global application shortcuts.
-
-## Phase: Review Fixes
-- [x] Task: Apply review suggestions 3bda1c007
\ No newline at end of file
diff --git a/conductor/archive/desktop_ux_enhancements_20260316/spec.md b/conductor/archive/desktop_ux_enhancements_20260316/spec.md
deleted file mode 100644
index 546b4e5c8..000000000
--- a/conductor/archive/desktop_ux_enhancements_20260316/spec.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Specification: Desktop UX Enhancements
-
-## Goal
-To implement native desktop behaviors like a system tray, notifications, a menu bar, and persistent window state for the Compose Multiplatform Desktop app.
-
-## Requirements
-1. **System Tray & Notifications**: The app should show a tray icon with a basic context menu ("Open", "Settings", "Quit"). It should support a "Minimize to Tray" flow rather than exiting immediately when closed. Notifications should be dispatchable via `TrayState` for key mesh events.
-2. **Window State Persistence**: The app should remember its last window size, position, and maximized state across launches.
-3. **Menu Bar**: A native MenuBar (File, Edit, View, Window, Help) should provide standard navigation and controls.
-4. **Keyboard Shortcuts**: Common actions should be bound to standard native keyboard shortcuts (e.g. `Cmd/Ctrl+,` for Settings).
\ No newline at end of file
diff --git a/conductor/archive/doc_consolidation_20260311/index.md b/conductor/archive/doc_consolidation_20260311/index.md
deleted file mode 100644
index 0ed0c002c..000000000
--- a/conductor/archive/doc_consolidation_20260311/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track doc_consolidation_20260311 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/doc_consolidation_20260311/metadata.json b/conductor/archive/doc_consolidation_20260311/metadata.json
deleted file mode 100644
index 5720a7d88..000000000
--- a/conductor/archive/doc_consolidation_20260311/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "doc_consolidation_20260311",
- "type": "feature",
- "status": "completed",
- "created_at": "2026-03-11T00:00:00Z",
- "updated_at": "2026-03-11T00:00:00Z",
- "description": "Implement document consolidation plan"
-}
\ No newline at end of file
diff --git a/conductor/archive/doc_consolidation_20260311/plan.md b/conductor/archive/doc_consolidation_20260311/plan.md
deleted file mode 100644
index 692ebe8be..000000000
--- a/conductor/archive/doc_consolidation_20260311/plan.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# Implementation Plan: Implement document consolidation plan
-
-## Phase 1: Prune and Consolidate Session Artifacts
-- [x] Task: Consolidate session artifacts into `docs/archive/kmp-phase3-testing-consolidation.md`. [d8becb2]
- - [x] Write Tests (Verify documentation structure)
- - [x] Read all 12+ session update files.
- - [x] Create `kmp-phase3-testing-consolidation.md` with merged key findings and test coverage metrics.
-- [x] Task: Delete redundant point-in-time files from `docs/agent-playbooks/`. [d8becb2]
- - [x] Write Tests (Verify file removal)
- - [x] Delete `CHECKLIST-testing-consolidation.md` and other 11 listed files.
-- [x] Task: Relocate remaining planning documents. [d8becb2]
- - [x] Write Tests (Verify correct destination paths)
- - [x] Merge `phase-4-desktop-completion-plan.md` into `docs/roadmap.md` under Phase 4 Desktop section and delete the original.
- - [x] Move `kmp-feature-migration-plan.md` to `docs/archive/`.
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Prune and Consolidate Session Artifacts' (Protocol in workflow.md) [checkpoint: d8becb2]
-
-## Phase 2: Synthesize Status & Roadmap
-- [x] Task: Update `docs/kmp-status.md`. [37fd055]
- - [x] Write Tests (Verify updated metric output)
- - [x] Update testing score to reflect Phase 3 completion (80 tests across 6 features).
-- [x] Task: Update `docs/roadmap.md`. [37fd055]
- - [x] Write Tests (Verify roadmap section exists)
- - [x] Mark Phase 3 as substantially complete.
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Synthesize Status & Roadmap' (Protocol in workflow.md) [checkpoint: 37fd055]
-
-## Phase 3: Verify and Validate Best Practices
-- [x] Task: Update `AGENTS.md` and playbooks for 2026 KMP Best Practices. [85db394]
- - [x] Write Tests (Verify updated content)
- - [x] Document Koin Annotations (K2) best practices in `AGENTS.md` and `di-navigation3-anti-patterns-playbook.md`.
- - [x] Document Shared ViewModels (MVI) recommendations.
-- [x] Task: Documentation Quality Checks. [85db394]
- - [x] Write Tests (Verify links resolve)
- - [x] Update `docs/agent-playbooks/README.md`.
- - [x] Rename `testing-quick-ref.sh` to `testing-quick-ref.md` and update internal references.
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Verify and Validate Best Practices' (Protocol in workflow.md) [checkpoint: 85db394]
\ No newline at end of file
diff --git a/conductor/archive/doc_consolidation_20260311/spec.md b/conductor/archive/doc_consolidation_20260311/spec.md
deleted file mode 100644
index 3f4e512c6..000000000
--- a/conductor/archive/doc_consolidation_20260311/spec.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Track Specification: Implement document consolidation plan
-
-## Objective
-Consolidate, prune, verify, and validate project plans and documentation against 2026 Kotlin Multiplatform (KMP) best practices and the latest dependency standards.
-
-## Background & Motivation
-The `docs/agent-playbooks/` directory has accumulated numerous point-in-time session summaries, checklists, and status reports (e.g., `SESSION-FINAL-SUMMARY.md`, `TEST-VERIFICATION-REPORT.md`) during the Phase 3 testing consolidation sprint. These files clutter the directory and dilute the actual "playbooks" (reusable guides). Additionally, the project documentation (`kmp-status.md`, `roadmap.md`, `AGENTS.md`) needs to be synthesized to reflect the recently completed work and validated against 2026 KMP industry standards (e.g., Koin K2 compiler plugin best practices, shared ViewModels, Navigation 3).
-
-## Scope
-1. **Prune and Consolidate Session Artifacts:** Merge the key findings into a single historical record (`docs/archive/kmp-phase3-testing-consolidation.md`) and delete 12+ redundant point-in-time files. Relocate `phase-4-desktop-completion-plan.md` into `docs/roadmap.md` and move `kmp-feature-migration-plan.md` to `docs/archive/`.
-2. **Synthesize Status & Roadmap:** Update `docs/kmp-status.md` and `docs/roadmap.md` with new testing metrics (80 tests across 6 features) and expanded Phase 4 Desktop tasks.
-3. **Verify and Validate against 2026 KMP Best Practices:** Validate the usage of Koin `@Module` and `@KoinViewModel` annotations in `commonMain` according to Koin 4.2 native compiler plugin best practices. Update `AGENTS.md` and `di-navigation3-anti-patterns-playbook.md` to officially recommend this pattern and multiplatform `androidx.lifecycle.ViewModel` in `commonMain`.
-4. **Documentation Quality Checks:** Verify `README.md` in playbooks correctly points to retained playbooks. Rename `testing-quick-ref.sh` to `testing-quick-ref.md` and update internal references.
\ No newline at end of file
diff --git a/conductor/archive/expand_testing_20260318/index.md b/conductor/archive/expand_testing_20260318/index.md
deleted file mode 100644
index f0d281e23..000000000
--- a/conductor/archive/expand_testing_20260318/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track expand_testing_20260318 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/expand_testing_20260318/metadata.json b/conductor/archive/expand_testing_20260318/metadata.json
deleted file mode 100644
index 36897869c..000000000
--- a/conductor/archive/expand_testing_20260318/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "expand_testing_20260318",
- "type": "chore",
- "status": "completed",
- "created_at": "2026-03-18T10:00:00Z",
- "updated_at": "2026-03-18T10:00:00Z",
- "description": "Expand Testing Coverage"
-}
\ No newline at end of file
diff --git a/conductor/archive/expand_testing_20260318/plan.md b/conductor/archive/expand_testing_20260318/plan.md
deleted file mode 100644
index e6bd01565..000000000
--- a/conductor/archive/expand_testing_20260318/plan.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Implementation Plan: Expand Testing Coverage
-
-## Phase 1: Baseline Measurement [checkpoint: 6d9ad46]
-- [x] Task: Execute `./gradlew koverLog` and record current project test coverage. 8bdd673a1
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Baseline Measurement' (Protocol in workflow.md) 6d9ad468c
-
-## Phase 2: Feature ViewModel Migration to Turbine [checkpoint: 61b9595]
-- [x] Task: Refactor `MetricsViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. 79e059286
-- [x] Task: Refactor `MessageViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. b45697b53
-- [x] Task: Refactor `RadioConfigViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. 33e10fc6c
-- [x] Task: Refactor `NodeListViewModelTest` to use `Turbine` and `Mokkery` in `commonTest`. 33e10fc6c
-- [x] Task: Refactor remaining `feature` ViewModels to use `Turbine` and `Mokkery`. 33e10fc6c
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Feature ViewModel Migration to Turbine' (Protocol in workflow.md) 61b959506
-
-## Phase 3: Property-Based Parsing Tests (Kotest) [checkpoint: cb71c85]
-- [x] Task: Add `Kotest` property-based tests for `StreamFrameCodec` in `core:network`. 2c8fd6a8f
-- [x] Task: Add `Kotest` property-based tests for `PacketHandler` implementations in `core:data`. 7d56c3fef
-- [x] Task: Add `Kotest` property-based tests for `TcpTransport` and/or `SerialTransport` in `core:network`. 2fd68d67e
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Property-Based Parsing Tests (Kotest)' (Protocol in workflow.md) cb71c8588
-
-## Phase 4: Domain Logic Gap Fill [checkpoint: 5735aa1]
-- [x] Task: Identify and fill testing gaps in `core:domain` use cases not fully covered during the initial Mokkery migration. 7b815130f
-- [x] Task: Conductor - User Manual Verification 'Phase 4: Domain Logic Gap Fill' (Protocol in workflow.md) 5735aa148
-
-## Phase 5: Final Measurement & Verification [checkpoint: e321cf0]
-- [x] Task: Execute full test suite (`./gradlew test`) to ensure stability. 02fa96f37
-- [x] Task: Execute `./gradlew koverLog` to generate and document the final coverage metrics. e3fe4ba1e
-- [x] Task: Conductor - User Manual Verification 'Phase 5: Final Measurement & Verification' (Protocol in workflow.md) e321cf0
-
-## Phase 6: Documentation and Wrap-up [checkpoint: d950e5e]
-- [x] Task: Review previous steps and update project documentation (e.g., `README.md`, testing guides). b2c9d3e
-- [x] Task: Conductor - User Manual Verification 'Phase 6: Documentation and Wrap-up' (Protocol in workflow.md) d950e5e
\ No newline at end of file
diff --git a/conductor/archive/expand_testing_20260318/spec.md b/conductor/archive/expand_testing_20260318/spec.md
deleted file mode 100644
index 2747e5918..000000000
--- a/conductor/archive/expand_testing_20260318/spec.md
+++ /dev/null
@@ -1,4 +0,0 @@
-# Specification: Expand Testing Coverage
-
-## Overview
-This track focuses on expanding the test suite across all core modules, specifically targeting `feature` ViewModels and `core:network` data parsing logic. The goal is to fully leverage the newly integrated `Turbine` and `Kotest` frameworks to ensure robust property-based testing and asynchronous flow verification.
\ No newline at end of file
diff --git a/conductor/archive/extract_android_navigation_20260318/index.md b/conductor/archive/extract_android_navigation_20260318/index.md
deleted file mode 100644
index 7d7d434fd..000000000
--- a/conductor/archive/extract_android_navigation_20260318/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track extract_android_navigation_20260318 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/extract_android_navigation_20260318/metadata.json b/conductor/archive/extract_android_navigation_20260318/metadata.json
deleted file mode 100644
index ac855c487..000000000
--- a/conductor/archive/extract_android_navigation_20260318/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "extract_android_navigation_20260318",
- "type": "refactor",
- "status": "completed",
- "created_at": "2026-03-18T00:00:00Z",
- "updated_at": "2026-03-18T00:00:00Z",
- "description": "Extract Android Navigation graphs to feature modules for app thinning"
-}
\ No newline at end of file
diff --git a/conductor/archive/extract_android_navigation_20260318/plan.md b/conductor/archive/extract_android_navigation_20260318/plan.md
deleted file mode 100644
index d4184e1d7..000000000
--- a/conductor/archive/extract_android_navigation_20260318/plan.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# Implementation Plan: Extract Android Navigation
-
-## Phase 1: Preparation & Base Module Abstraction [checkpoint: 421a587]
-- [x] Task: Review current navigation graph assembly in `app/src/main/kotlin/org/meshtastic/app/navigation/`.
- - [x] Identify dependencies between feature navigation graphs and core routing definitions.
- - [x] Create missing directory structures in feature modules' `androidMain/kotlin/org/meshtastic/feature/*/navigation` if they don't exist.
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Preparation & Base Module Abstraction' (Protocol in workflow.md)
-
-## Phase 2: Feature Module Extraction [checkpoint: 9a27cce]
-- [x] Task: Extract Settings Navigation.
- - [x] Move `SettingsNavigation.kt` to `feature:settings/androidMain`.
- - [x] Fix package declarations and broken imports.
-- [x] Task: Extract Nodes & Connections Navigation.
- - [x] Move `NodesNavigation.kt` to `feature:node/androidMain`.
- - [x] Move `ConnectionsNavigation.kt` to `feature:connections/androidMain`.
- - [x] Fix package declarations and broken imports.
-- [x] Task: Extract Messaging & Remaining Navigation.
- - [x] Move `ContactsNavigation.kt` to `feature:messaging/androidMain`.
- - [x] Move `ChannelsNavigation.kt` to `feature:settings/androidMain` or `feature:node`.
- - [x] Move `FirmwareNavigation.kt` to `feature:firmware/androidMain`.
- - [x] Move `MapNavigation.kt` to `feature:map/androidMain`.
- - [x] Fix package declarations and broken imports.
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Feature Module Extraction' (Protocol in workflow.md)
-
-## Phase 3: Root Assembly & Testing [checkpoint: a1e9da3]
-- [x] Task: Refactor Root App Graph.
- - [x] Update root composition to import the newly relocated navigation extension functions.
- - [x] Remove any leftover navigation wiring from the `app` module.
-- [x] Task: Implement Navigation Assembly Tests.
- - [x] Add basic Android instrumented or Roboelectric tests in `:app` to verify that the `NavHost` successfully constructs all feature graphs without crashing.
-- [x] Task: Review previous steps and update project documentation.
- - [x] Update `conductor/tech-stack.md` and `conductor/product.md` if necessary to reflect the thinned app module and JetBrains Navigation 3 common usage.
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Root Assembly & Testing' (Protocol in workflow.md)
\ No newline at end of file
diff --git a/conductor/archive/extract_android_navigation_20260318/spec.md b/conductor/archive/extract_android_navigation_20260318/spec.md
deleted file mode 100644
index 7b4650573..000000000
--- a/conductor/archive/extract_android_navigation_20260318/spec.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Specification: Extract Android Navigation graphs to feature modules for app thinning
-
-## Overview
-The primary goal of this track is to thin out the app module by moving the Android-specific navigation graph wiring (e.g., SettingsNavigation.kt, NodesNavigation.kt, ConnectionsNavigation.kt) into their respective feature modules (e.g., feature:settings, feature:node, feature:connections). This aligns the Android implementation with the Desktop application's architecture, where navigation logic is collocated with the features it routes.
-
-## Functional Requirements
-- **Target Modules:** Move all feature-specific navigation files from `app/src/main/kotlin/org/meshtastic/app/navigation/` to the `androidMain` source sets of their corresponding `feature:*` modules.
-- **Architecture:** Implement JetBrains Navigation 3 best practices for common usage across KMP modules. This involves ensuring the feature modules expose their navigation graphs seamlessly to the root NavHost in the app module, minimizing tight coupling.
-- **Root App Shell:** The app module should only retain the root MainActivity, the root DI graph assembly, and the top-level NavHost (e.g., MeshtasticApp.kt or similar entry point), calling into the feature modules' exposed graph functions.
-
-## Non-Functional Requirements
-- **Testability:** Add or update tests to verify that the complete navigation graph is correctly assembled from the individual feature modules without errors.
-- **Maintainability:** The extraction must preserve all existing deep links, arguments, and navigation transitions currently defined in the Android app.
-
-## Acceptance Criteria
-- [ ] The `app/src/main/kotlin/org/meshtastic/app/navigation/` directory only contains the root graph assembly.
-- [ ] All Android feature navigation graphs are successfully extracted to their respective `feature:*` modules.
-- [ ] The Android app compiles and runs successfully, with all navigation flows working identically to the previous implementation.
-- [ ] New graph assembly tests are added and pass in CI/local environments.
\ No newline at end of file
diff --git a/conductor/archive/extract_database_manager_kmp_20260320/index.md b/conductor/archive/extract_database_manager_kmp_20260320/index.md
deleted file mode 100644
index c5da8cfd3..000000000
--- a/conductor/archive/extract_database_manager_kmp_20260320/index.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Track: Extract DatabaseManager to KMP
-
-## Documents
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
-
-## Context
-Meshtastic-Android is designed to support per-node databases. Currently, the logic for managing these databases is in `androidMain`, and the desktop module stubs this out, which leads to a lack of feature parity. This track aims to extract that logic into `commonMain`.
diff --git a/conductor/archive/extract_database_manager_kmp_20260320/metadata.json b/conductor/archive/extract_database_manager_kmp_20260320/metadata.json
deleted file mode 100644
index 7dff1187a..000000000
--- a/conductor/archive/extract_database_manager_kmp_20260320/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "id": "extract_database_manager_kmp_20260320",
- "name": "Extract DatabaseManager to KMP",
- "description": "Move core database management logic (per-node databases, LRU) to commonMain for target parity.",
- "status": "completed",
- "tags": ["core", "database", "kmp", "desktop"],
- "created_at": "2026-03-20T12:00:00Z"
-}
diff --git a/conductor/archive/extract_database_manager_kmp_20260320/plan.md b/conductor/archive/extract_database_manager_kmp_20260320/plan.md
deleted file mode 100644
index c1db4e8b2..000000000
--- a/conductor/archive/extract_database_manager_kmp_20260320/plan.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Implementation Plan - Extract DatabaseManager to KMP
-
-## Phase 1: Multiplatform Database Abstraction
-- [x] Define `expect fun buildRoomDb(dbName: String): MeshtasticDatabase` in `commonMain`.
-- [x] Implement `actual fun buildRoomDb` for Android (using `Application.getDatabasePath`).
-- [x] Implement `actual fun buildRoomDb` for JVM/Desktop (using the established `~/.meshtastic` data directory).
-- [x] Implement `actual fun buildRoomDb` for iOS (using `NSDocumentDirectory`).
-- [x] Update `DatabaseConstants` with shared keys and default values.
-
-## Phase 2: KMP DataStore & File I/O
-- [x] Replace Android `SharedPreferences` in `DatabaseManager` with a KMP-ready `DataStore` instance named `DatabasePrefs`.
-- [x] Introduce an `expect fun deleteDatabase(dbName: String)` or similar Okio-based deletion helper.
-- [x] Refactor database file listing to use `okio.FileSystem.SYSTEM` instead of `java.io.File`.
-
-## Phase 3: Logic Extraction
-- [x] Move `DatabaseManager.kt` from `core:database/androidMain` to `core:database/commonMain`.
-- [x] Refactor `DatabaseManager` to use the new `buildRoomDb`, `DataStore`, and `FileSystem` abstractions.
-- [x] Ensure `DatabaseManager` is annotated with Koin `@Single` and correctly binds to `DatabaseProvider` and `SharedDatabaseManager` (from `core:common`).
-- [x] Remove `DesktopDatabaseManager` from `desktop` module.
-- [x] Update the DI (Koin) graph in `app` and `desktop` to wire the new shared `DatabaseManager`.
-
-## Phase 4: Verification
-- [x] Add unit tests in `core:database/commonTest` to verify that `switchActiveDatabase` correctly swaps databases and that the LRU eviction limit is respected.
-- [x] Perform manual verification on Desktop to ensure that connecting to different nodes creates separate `.db` files in `~/.meshtastic/`.
-- [x] Verify that the `core:database` module still compiles for Android and iOS targets.
diff --git a/conductor/archive/extract_database_manager_kmp_20260320/spec.md b/conductor/archive/extract_database_manager_kmp_20260320/spec.md
deleted file mode 100644
index d0f522753..000000000
--- a/conductor/archive/extract_database_manager_kmp_20260320/spec.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Specification - Extract DatabaseManager to KMP
-
-## Overview
-Meshtastic-Android is designed to support per-node databases (e.g., `db_!1234abcd.db`). Currently, the logic for managing these databases (switching, LRU caching, eviction) is trapped in `core:database/androidMain`. The Desktop implementation stubs this out, forcing all nodes to share a single database, which is a major architectural regression and leads to data pollution across different devices.
-
-This track will move the core `DatabaseManager` logic to `commonMain`, enabling full feature parity for database management on Android, Desktop, and iOS.
-
-## Functional Requirements
-- **Per-Node Databases**: Desktop and iOS must support creating and switching between separate databases based on the connected device's address.
-- **LRU Eviction**: Implement an LRU (Least Recently Used) cache for database instances on all platforms.
-- **Cache Limits**: The database cache limit must be configurable and respected across all platforms.
-- **Legacy Cleanup**: Maintain logic for cleaning up legacy databases where applicable.
-
-## Non-Functional Requirements
-- **KMP Purity**: Use only Kotlin Multiplatform-ready libraries (`kotlinx-coroutines`, `okio`, `androidx-datastore`).
-- **Dependency Injection**: Use Koin to wire the shared `DatabaseManager` into all app targets.
-- **Platform Specifics**: Isolate platform-specific path resolution (e.g., Android `getDatabasePath` vs. JVM `user.home`) using the `expect`/`actual` pattern.
-
-## Acceptance Criteria
-1. `DatabaseManager` resides in `core:database/commonMain`.
-2. `DesktopDatabaseManager` (the stub) is deleted.
-3. Desktop creates unique database files when connecting to different nodes.
-4. Unit tests in `commonTest` verify the LRU eviction logic using an Okio in-memory filesystem (or temporary test directory).
-5. No `android.*` or `java.*` imports remain in the shared database management logic.
diff --git a/conductor/archive/extract_hardware_transport_20260311/index.md b/conductor/archive/extract_hardware_transport_20260311/index.md
deleted file mode 100644
index 0c9c915e4..000000000
--- a/conductor/archive/extract_hardware_transport_20260311/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track extract_hardware_transport_20260311 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/extract_hardware_transport_20260311/metadata.json b/conductor/archive/extract_hardware_transport_20260311/metadata.json
deleted file mode 100644
index a9dc547bf..000000000
--- a/conductor/archive/extract_hardware_transport_20260311/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "extract_hardware_transport_20260311",
- "type": "feature",
- "status": "completed",
- "created_at": "2026-03-11T00:00:00Z",
- "updated_at": "2026-03-11T00:00:00Z",
- "description": "extract hardware/transport layers out of :app into dedicated :core modules"
-}
\ No newline at end of file
diff --git a/conductor/archive/extract_hardware_transport_20260311/plan.md b/conductor/archive/extract_hardware_transport_20260311/plan.md
deleted file mode 100644
index 87b43b632..000000000
--- a/conductor/archive/extract_hardware_transport_20260311/plan.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Implementation Plan: Extract hardware/transport layers out of :app into dedicated :core modules
-
-## Phase 1: Define Shared Interface and Extract Stream Framing [checkpoint: 80a39a5]
-- [x] Task: Create `RadioTransport` interface in `core:repository/commonMain`. a47f399
- - [x] Write Tests
- - [x] Implement Feature
-- [x] Task: Move `StreamFrameCodec` logic to `core:network/commonMain`. cc1ff26
- - [x] Write Tests
- - [x] Implement Feature
-- [x] Task: Refactor existing `IRadioInterface` usages to point to the new `RadioTransport` interface (preparation step). 1b4cec6
- - [x] Write Tests
- - [x] Implement Feature
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Define Shared Interface and Extract Stream Framing' (Protocol in workflow.md) 80a39a5
-
-## Phase 2: Extract Platform Transports
-- [x] Task: Move TCP transport implementation to `core:network/jvmAndroidMain`. [8688070]
- - [x] Write Tests
- - [x] Implement Feature
-- [x] Task: Move BLE transport implementation to `core:ble/androidMain`. [8688070]
- - [x] Write Tests
- - [x] Implement Feature
-- [x] Task: Move Serial/USB transport implementation to `core:service/androidMain`. [8688070]
- - [x] Write Tests
- - [x] Implement Feature
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Extract Platform Transports' (Protocol in workflow.md) [checkpoint: 8688070]
-
-## Phase 3: Desktop Unification and Cleanup
-- [x] Task: Retire `DesktopRadioInterfaceService` in the `desktop` module.
- - [x] Write Tests
- - [x] Implement Feature
-- [x] Task: Update the `desktop` DI graph to inject the shared `TcpTransport` implementation.
- - [x] Write Tests
- - [x] Implement Feature
-- [x] Task: Delete the old `app/repository/radio/` directory.
- - [x] Write Tests
- - [x] Implement Feature
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Desktop Unification and Cleanup' (Protocol in workflow.md) [checkpoint: 8688070]
\ No newline at end of file
diff --git a/conductor/archive/extract_hardware_transport_20260311/spec.md b/conductor/archive/extract_hardware_transport_20260311/spec.md
deleted file mode 100644
index 0a52436a9..000000000
--- a/conductor/archive/extract_hardware_transport_20260311/spec.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Track Specification: Extract hardware/transport layers out of :app into dedicated :core modules
-
-## Overview
-This track addresses a critical modularity gap identified in the KMP architecture review: the Radio interface layer is currently locked within the `app` module and is non-KMP. The goal is to define a shared `RadioTransport` interface in `core:repository` and fully extract all transport implementations (BLE, TCP, USB) from `app/repository/radio/` into their appropriate `core` modules.
-
-## Functional Requirements
-- **Define `RadioTransport` Interface:** Create a new `RadioTransport` interface in `core:repository/commonMain` to replace the existing `IRadioInterface`.
-- **Extract Stream Framing:** Move `StreamFrameCodec`-based framing logic to `core:network/commonMain`.
-- **Extract BLE Transport:** Move the BLE transport implementation (`NordicBleInterface`, etc.) to `core:ble/androidMain`.
-- **Extract TCP Transport:** Move the TCP transport implementation to `core:network/jvmAndroidMain`.
-- **Extract Serial/USB Transport:** Move the Serial/USB transport implementation to `core:service/androidMain`.
-- **Unify Desktop Transport:** Retire Desktop's parallel `DesktopRadioInterfaceService` and migrate it to use the shared `RadioTransport` and `TcpTransport`.
-
-## Acceptance Criteria
-- [ ] A `RadioTransport` interface exists in `core:repository/commonMain`.
-- [ ] No transport logic (BLE, TCP, USB) remains in `app/repository/radio/`.
-- [ ] The `app` and `desktop` modules successfully compile and run using the extracted transport layers.
-- [ ] The `desktop` module uses the shared `TcpTransport` implementation instead of its own duplicate logic.
-
-## Out of Scope
-- Rewriting the underlying logic of the transports (e.g., changing how Nordic BLE works). This is purely a structural extraction and KMP alignment.
-- Extracting non-transport components (like the Connections UI) from the `app` module.
\ No newline at end of file
diff --git a/conductor/archive/extract_radio_interface_kmp_20260320/index.md b/conductor/archive/extract_radio_interface_kmp_20260320/index.md
deleted file mode 100644
index 47aea8762..000000000
--- a/conductor/archive/extract_radio_interface_kmp_20260320/index.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# Track: Extract RadioInterfaceService to KMP
-
-## Documents
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
-
-## Context
-Meshtastic-Android and Desktop orchestrate their hardware connections (TCP, Serial, BLE) independently using `AndroidRadioInterfaceService` and `DesktopRadioInterfaceService`. This duplicates complex logic like reconnect loops and state emission. This track aims to unify that logic into `commonMain`.
diff --git a/conductor/archive/extract_radio_interface_kmp_20260320/metadata.json b/conductor/archive/extract_radio_interface_kmp_20260320/metadata.json
deleted file mode 100644
index 736a106ca..000000000
--- a/conductor/archive/extract_radio_interface_kmp_20260320/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "id": "extract_radio_interface_kmp_20260320",
- "name": "Extract RadioInterfaceService to KMP",
- "description": "Unify the connection orchestration lifecycle (TCP, Serial, BLE) into a shared multiplatform service.",
- "status": "completed",
- "tags": ["core", "service", "kmp", "desktop", "radio", "connection"],
- "created_at": "2026-03-20T12:00:00Z"
-}
diff --git a/conductor/archive/extract_radio_interface_kmp_20260320/plan.md b/conductor/archive/extract_radio_interface_kmp_20260320/plan.md
deleted file mode 100644
index 24d0f60f5..000000000
--- a/conductor/archive/extract_radio_interface_kmp_20260320/plan.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Implementation Plan - Extract RadioInterfaceService to KMP
-
-## Phase 1: Research & Abstraction
-- [x] Review `AndroidRadioInterfaceService` and `DesktopRadioInterfaceService` to identify identical connection loop logic.
-- [x] Identify platform-specific dependencies in both implementations (e.g., Android `BluetoothDevice`, notifications).
-- [x] Define shared abstractions (e.g., `TransportFactory`, `NotificationDelegate`) if needed to decouple platform-specific side effects.
-
-## Phase 2: Logic Extraction
-- [x] Create `SharedRadioInterfaceService` in `core:service/commonMain`.
-- [x] Move the core connection loop, state management, and retry logic into the shared service.
-- [x] Adapt Android and Desktop to use the new shared service.
-
-## Phase 3: Cleanup & Wiring
-- [x] Remove `DesktopRadioInterfaceService`.
-- [x] Refactor or remove `AndroidRadioInterfaceService` if entirely superseded.
-- [x] Update Koin DI graph in `core:service/commonMain` to provide the unified service.
-
-## Phase 4: Verification
-- [x] Verify that `core:service` and `:app` compile cleanly for Android and Desktop.
-- [x] Write or update unit tests in `commonTest` to cover the shared connection lifecycle logic. (Skipped due to coroutine test hanging on infinite heartbeat loop)
-
-## Phase: Review Fixes
-- [x] Task: Apply review suggestions eeeeb11df
diff --git a/conductor/archive/extract_radio_interface_kmp_20260320/spec.md b/conductor/archive/extract_radio_interface_kmp_20260320/spec.md
deleted file mode 100644
index 15605ece6..000000000
--- a/conductor/archive/extract_radio_interface_kmp_20260320/spec.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# Specification - Extract RadioInterfaceService to KMP
-
-## Overview
-Currently, the connection orchestration logic for establishing, monitoring, and tearing down connections with Meshtastic radios is duplicated. Android uses `AndroidRadioInterfaceService` in `core:service/androidMain`, and Desktop uses `DesktopRadioInterfaceService` in the `desktop` module. This duplicates core state management (connecting, connected, disconnecting) and the interactions with the shared `TcpTransport`, `SerialTransport`, and `BleTransport`.
-
-This track aims to abstract the remaining platform-specific connection logic (if any) and move the bulk of `RadioInterfaceService` into `core:repository/commonMain` or `core:service/commonMain`, unifying the connection lifecycle across all targets.
-
-## Functional Requirements
-- **Unified Connection Lifecycle**: A single `RadioInterfaceService` implementation in `commonMain` should handle connection state management (connecting, active, disconnect, reconnect loops).
-- **Transport Abstraction**: The service must interact with connections via a multiplatform interface, presumably standardizing around `RadioTransport` or `ConnectionFactory`.
-- **Platform Parity**: Desktop and Android must use the exact same logic for detecting disconnects and issuing reconnects.
-
-## Non-Functional Requirements
-- **KMP Purity**: The unified service must not depend on `android.*` or `java.*` specific APIs for its core lifecycle management.
-- **Dependency Injection**: Utilize Koin in `commonMain` to provide the unified service.
-
-## Acceptance Criteria
-1. `DesktopRadioInterfaceService` is removed.
-2. `AndroidRadioInterfaceService` is replaced by a shared implementation in `commonMain` (e.g., `SharedRadioInterfaceService`).
-3. Both Android and Desktop can successfully connect, disconnect, and handle unexpected drops using the shared logic.
diff --git a/conductor/archive/extract_remaining_background_20260318/index.md b/conductor/archive/extract_remaining_background_20260318/index.md
deleted file mode 100644
index e234976f6..000000000
--- a/conductor/archive/extract_remaining_background_20260318/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track extract_remaining_background_20260318 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/extract_remaining_background_20260318/metadata.json b/conductor/archive/extract_remaining_background_20260318/metadata.json
deleted file mode 100644
index 52498f9fc..000000000
--- a/conductor/archive/extract_remaining_background_20260318/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "extract_remaining_background_20260318",
- "type": "refactor",
- "status": "completed",
- "created_at": "2026-03-18T14:55:00Z",
- "updated_at": "2026-03-18T14:55:00Z",
- "description": "Extract remaining background services and workers from app module"
-}
diff --git a/conductor/archive/extract_remaining_background_20260318/plan.md b/conductor/archive/extract_remaining_background_20260318/plan.md
deleted file mode 100644
index aa8bcba0e..000000000
--- a/conductor/archive/extract_remaining_background_20260318/plan.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Implementation Plan: Extract remaining background services and workers from app module
-
-## Phase 1: Preparation & Location Manager Abstraction [checkpoint: 57052fc]
-- [x] Task: Review current implementations in `app/src/main/kotlin/org/meshtastic/app/service/AndroidMeshLocationManager.kt` and `app/src/main/kotlin/org/meshtastic/app/MeshServiceClient.kt`.
-- [x] Task: Create KMP shared interface or base class in `core:service/commonMain` for the Location Manager if applicable, aligning with KMP best practices.
-- [x] Task: Relocate `AndroidMeshLocationManager.kt` and `MeshServiceClient.kt` to `core:service/src/androidMain/...`.
-- [x] Task: Update package declarations and resolve broken imports in the app module.
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Preparation & Location Manager Abstraction' (Protocol in workflow.md)
-
-## Phase 2: Message Queue Abstraction [checkpoint: dda10b4]
-- [x] Task: Review `app/src/main/kotlin/org/meshtastic/app/messaging/domain/worker/WorkManagerMessageQueue.kt`.
-- [x] Task: Identify opportunities to extract non-Android specific queue logic to `feature:messaging/commonMain`.
-- [x] Task: Relocate `WorkManagerMessageQueue.kt` to `feature:messaging/src/androidMain/...`.
-- [x] Task: Update package declarations and resolve broken imports.
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Message Queue Abstraction' (Protocol in workflow.md)
-
-## Phase 3: Widget Extraction [checkpoint: 0c027e3]
-- [x] Task: Review the contents of `app/src/main/kotlin/org/meshtastic/app/widget/`.
-- [x] Task: Decide whether to move widgets to an existing module (e.g. `core:ui` or `feature:node`) or create a new `feature:widget` module.
-- [x] Task: Relocate `LocalStatsWidget.kt`, `LocalStatsWidgetReceiver.kt`, `LocalStatsWidgetState.kt`, `RefreshLocalStatsAction.kt`, and `AndroidAppWidgetUpdater.kt`.
-- [x] Task: Relocate necessary widget resources, strings, and AndroidManifest declarations.
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Widget Extraction' (Protocol in workflow.md)
-
-## Phase 4: Dependency Injection Refactoring [checkpoint: c5f09dc]
-- [x] Task: Review `app/src/main/kotlin/org/meshtastic/app/MainKoinModule.kt` and `di/AppKoinModule.kt`.
-- [x] Task: Move DI bindings for the relocated classes to their new respective modules (e.g., `ServiceKoinModule`, `MessagingKoinModule`).
-- [x] Task: Ensure the root app module's DI configuration successfully includes the feature and core Koin modules.
-- [x] Task: Run Android instrumented/unit tests to verify graph compilation.
-- [x] Task: Conductor - User Manual Verification 'Phase 4: Dependency Injection Refactoring' (Protocol in workflow.md)
\ No newline at end of file
diff --git a/conductor/archive/extract_remaining_background_20260318/spec.md b/conductor/archive/extract_remaining_background_20260318/spec.md
deleted file mode 100644
index 69e8a5224..000000000
--- a/conductor/archive/extract_remaining_background_20260318/spec.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Specification: Extract remaining background services and workers from app module
-
-## Overview
-The primary goal of this track is to continue the app module thinning effort by extracting the remaining Android-specific background services, workers, and widgets from the `app` module into appropriate core or feature modules. Where possible, business logic from these components should be abstracted and moved to `commonMain` to support KMP targets. This will leave the app module as a thin entry point shell.
-
-## Functional Requirements
-- **Core Services:** Extract `AndroidMeshLocationManager.kt` and `MeshServiceClient.kt` to `core:service/androidMain`. Refactor underlying logic to `core:service/commonMain` where applicable.
-- **Messaging Workers:** Extract `WorkManagerMessageQueue.kt` to `feature:messaging/androidMain`. Analyze logic for potential `commonMain` abstraction.
-- **Widgets:** Extract the `LocalStatsWidget` implementation to a new or existing appropriate feature module (e.g. `feature:widget/androidMain`) following KMP feature module conventions.
-- **Dependency Injection:** Update the DI graph (`MainKoinModule.kt` / `AppKoinModule.kt`) to resolve these implementations from their new module locations using Koin compiler plugin annotations where applicable.
-
-## Non-Functional Requirements
-- **Testability:** Existing tests related to these services and workers should pass after relocation.
-- **Maintainability:** The extraction must preserve all existing app functionality, including background synchronization, location tracking, and widget updates.
-
-## Acceptance Criteria
-- [ ] `AndroidMeshLocationManager.kt` and `MeshServiceClient.kt` are successfully moved to `core:service`.
-- [ ] `WorkManagerMessageQueue.kt` is successfully moved to `feature:messaging`.
-- [ ] App Widgets are extracted out of the `app` module into an appropriate feature module.
-- [ ] Any logic that can be abstracted to `commonMain` has been extracted and shared.
-- [ ] `MainKoinModule.kt` is refactored, and DI wires everything correctly.
-- [ ] The Android app compiles and runs successfully, with background tasks and widgets working identically to the previous implementation.
\ No newline at end of file
diff --git a/conductor/archive/extract_services_20260317/index.md b/conductor/archive/extract_services_20260317/index.md
deleted file mode 100644
index a8c5021ba..000000000
--- a/conductor/archive/extract_services_20260317/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track extract_services_20260317 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/extract_services_20260317/metadata.json b/conductor/archive/extract_services_20260317/metadata.json
deleted file mode 100644
index adf7d650c..000000000
--- a/conductor/archive/extract_services_20260317/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "extract_services_20260317",
- "type": "refactor",
- "status": "completed",
- "created_at": "2026-03-17T00:00:00Z",
- "updated_at": "2026-03-17T00:00:00Z",
- "description": "Extract service/worker/radio files from `app` to `core:service/androidMain` and `core:network/androidMain`"
-}
\ No newline at end of file
diff --git a/conductor/archive/extract_services_20260317/plan.md b/conductor/archive/extract_services_20260317/plan.md
deleted file mode 100644
index 6425e3954..000000000
--- a/conductor/archive/extract_services_20260317/plan.md
+++ /dev/null
@@ -1,44 +0,0 @@
-# Implementation Plan: Extract service/worker/radio files from `app`
-
-## Phase 1: Preparation & Analysis [checkpoint: 72022ed]
-- [x] Task: Identify all Android-specific classes to be moved (Services, WorkManager workers, Radio connections in `app`) [fd916e3]
- - [ ] Locate `Service` classes in `app/src/main/java/org/meshtastic/app`
- - [ ] Locate WorkManager `Worker` classes
- - [ ] Locate Radio connection classes
-- [x] Task: Conductor - User Manual Verification 'Preparation & Analysis' (Protocol in workflow.md)
-
-## Phase 2: Extraction to `core:service` [checkpoint: ff47af8]
-- [x] Task: Setup `core:service` module for Android and Common targets (if not already fully configured) [a114084]
-- [x] Task: Move Android `Service` implementations to `core:service/androidMain` [965def0]
- - [x] Move the files
- - [x] Update imports and Koin injections
-- [x] Task: Abstract shared service logic into `core:service/commonMain` [a85e282]
- - [x] Write failing tests for abstracted shared logic (TDD Red)
- - [x] Extract interfaces and platform-agnostic logic (TDD Green)
- - [x] Refactor the implementations to use these shared abstractions
-- [x] Task: Conductor - User Manual Verification 'Extraction to core:service' (Protocol in workflow.md)
-
-## Phase 3: Extraction to `core:network` [checkpoint: 97a5b62]
-- [x] Task: Move Radio connection and networking files from `app` to `core:network/androidMain` [b5233cf]
- - [x] Move the files
- - [x] Update imports and Koin injections
-- [x] Task: Abstract shared radio/network logic into `core:network/commonMain` [cc1581d]
- - [x] Write failing tests for abstracted radio logic (TDD Red)
- - [x] Extract platform-agnostic business logic (TDD Green)
- - [x] Refactor implementations to use shared abstractions
-- [x] Task: Conductor - User Manual Verification 'Extraction to core:network' (Protocol in workflow.md)
-
-## Phase 4: Desktop Integration [checkpoint: fffcedc]
-- [x] Task: Integrate newly extracted shared abstractions into the `desktop` module [f39df2f]
- - [x] Implement desktop-specific actuals or Koin bindings for the shared interfaces
- - [x] Wire up abstracted services/radio logic in desktop Koin graph
-- [x] Task: Conductor - User Manual Verification 'Desktop Integration' (Protocol in workflow.md)
-
-## Phase 5: Verification & Cleanup [checkpoint: a0866e0]
-- [x] Task: Build project and verify no regressions in background processing or radio connectivity [a9edc2e]
-- [x] Task: Verify test coverage (>80%) for all extracted and refactored code [9cff9bc]
-- [x] Task: Remove any lingering unused dependencies or dead code in `app` [e39d2e2]
-- [x] Task: Conductor - User Manual Verification 'Verification & Cleanup' (Protocol in workflow.md)
-
-## Phase: Review Fixes
-- [x] Task: Apply review suggestions [1ae9fb6]
\ No newline at end of file
diff --git a/conductor/archive/extract_services_20260317/spec.md b/conductor/archive/extract_services_20260317/spec.md
deleted file mode 100644
index 32d1eb803..000000000
--- a/conductor/archive/extract_services_20260317/spec.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Specification: Extract service/worker/radio files from `app`
-
-## Overview
-This track aims to decouple the main `app` module by extracting Android-specific service, WorkManager worker, and radio connection files into `core:service` and `core:network` modules. The goal is to maximize code reuse across Kotlin Multiplatform (KMP) targets, clarify class responsibilities, and improve unit testability by isolating the network and service layers.
-
-## Goals
-- **Decouple `app`:** Remove Android-specific service dependencies from the main app module.
-- **KMP Preparation:** Migrate as much logic as possible into `commonMain` for reuse across platforms.
-- **Desktop Integration:** If logic is successfully abstracted into `commonMain`, integrate and use it within the `desktop` target to ensure reusability.
-- **Testability:** Isolate service and network layers to facilitate better unit testing.
-- **Simplification:** Refactor logic during the move to clarify and simplify responsibilities.
-
-## Functional Requirements
-- Identify all service, worker, and radio-related classes currently residing in the `app` module.
-- Move Android-specific implementations (e.g., `Service`, `Worker`) to `core:service/androidMain` and `core:network/androidMain`.
-- Extract platform-agnostic business logic and interfaces into `commonMain` within those core modules.
-- Refactor existing logic where necessary to establish a clear delineation of responsibility.
-- Update all dependency injections (Koin modules) and imports across the project to reflect the new locations.
-- Attempt to wire up the newly abstracted shared logic within the `desktop` module if applicable.
-
-## Non-Functional Requirements
-- **Architecture Compliance:** Changes must adhere to the MVI / Unidirectional Data Flow and KMP structures defined in `tech-stack.md`.
-- **Performance:** Refactoring should not negatively impact app startup time or background processing efficiency.
-- **Code Coverage:** Maintain or improve overall test coverage for the extracted components (>80% target).
-
-## Acceptance Criteria
-- [ ] No service, worker, or radio connection classes remain in the `app` module.
-- [ ] Extracted Android-specific classes compile successfully in `core:service/androidMain` and `core:network/androidMain`.
-- [ ] Shared business logic compiles successfully in `core:service/commonMain` and `core:network/commonMain`.
-- [ ] If logic is abstracted for reuse, it is integrated and utilized in the `desktop` target where applicable.
-- [ ] The app compiles, installs, and runs without regressions in background processing or radio connectivity.
-- [ ] Unit tests for the moved and refactored classes pass.
\ No newline at end of file
diff --git a/conductor/archive/extract_viewmodels_20260316/index.md b/conductor/archive/extract_viewmodels_20260316/index.md
deleted file mode 100644
index aeedeb73a..000000000
--- a/conductor/archive/extract_viewmodels_20260316/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track extract_viewmodels_20260316 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/extract_viewmodels_20260316/metadata.json b/conductor/archive/extract_viewmodels_20260316/metadata.json
deleted file mode 100644
index 5b56ec476..000000000
--- a/conductor/archive/extract_viewmodels_20260316/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "extract_viewmodels_20260316",
- "type": "refactor",
- "status": "completed",
- "created_at": "2026-03-16T12:00:00Z",
- "updated_at": "2026-03-16T12:00:00Z",
- "description": "Extract remaining 5 App-Only ViewModels (AndroidSettingsViewModel, AndroidRadioConfigViewModel, AndroidDebugViewModel, AndroidMetricsViewModel, UIViewModel) to shared KMP feature/core modules by isolating Android-specific dependencies (Uri, Location, Locale) behind abstractions."
-}
\ No newline at end of file
diff --git a/conductor/archive/extract_viewmodels_20260316/plan.md b/conductor/archive/extract_viewmodels_20260316/plan.md
deleted file mode 100644
index 12946e2f9..000000000
--- a/conductor/archive/extract_viewmodels_20260316/plan.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# Implementation Plan: Extract Remaining App-Only ViewModels
-
-## Phase 1: Infrastructure & Abstractions [checkpoint: 89c6fd5]
-- [x] Task: Implement `MeshtasticUri` (expect/actual wrapper for `android.net.Uri`) in `core:common`. 81e5a4a
-- [x] Task: Define `FileService` and `LocationService` interfaces in `core:repository/commonMain`. 1ffa7d2
-- [x] Task: Create Android implementations for these services in `core:service/androidMain`. 1ffa7d2
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Infrastructure & Abstractions' (Protocol in workflow.md) 89c6fd5
-
-## Phase 2: Feature Module Extractions (Settings & Node) [checkpoint: 3ea2b2a]
-- [x] Task: Extract `AndroidSettingsViewModel` & `AndroidRadioConfigViewModel` to `feature:settings/commonMain`. 091452a
-- [x] Task: Extract `AndroidMetricsViewModel` to `feature:node/commonMain`. 52c2f6e
-- [x] Task: Extract `AndroidDebugViewModel` to `feature:settings/commonMain`. e1a0387
-- [x] Task: Update Koin modules in `feature:settings` and `feature:node` to wire the new shared ViewModels. (Handled automatically by Koin Annotations K2 plugin) e1a0387
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Feature Module Extractions' (Protocol in workflow.md) 3ea2b2a
-
-## Phase 3: Core UI & Cleanup [checkpoint: c59243d]
-- [x] Task: Extract `UIViewModel` logic to `core:ui/commonMain`. 3ea2b2a
-- [x] Task: Verify the `app` module thinning progress and finalize any remaining DI cleanup in `AppKoinModule`. 3ea2b2a
-- [x] Task: Ensure all new shared ViewModels have baseline `commonTest` coverage using `core:testing` fakes. fdf34f5
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Core UI & Cleanup' (Protocol in workflow.md) c59243d
\ No newline at end of file
diff --git a/conductor/archive/extract_viewmodels_20260316/spec.md b/conductor/archive/extract_viewmodels_20260316/spec.md
deleted file mode 100644
index 2b782bd95..000000000
--- a/conductor/archive/extract_viewmodels_20260316/spec.md
+++ /dev/null
@@ -1,20 +0,0 @@
-# Specification: Extract Remaining App-Only ViewModels
-
-## Overview
-This track aims to migrate the final 5 ViewModels currently trapped in the `app` module to their respective KMP `feature:*` or `core:*` modules. These ViewModels contain business logic that should be shared across platforms, but are currently coupled to Android-specific APIs.
-
-## Functional Requirements
-- **Isolate Dependencies:** Identify and abstract Android-specific APIs using a hybrid approach (expect/actual for low-level types and injected interfaces for services).
-- **Relocate ViewModels:** Move the core logic of these ViewModels to `commonMain` in the target modules:
- - `SettingsViewModel` & `RadioConfigViewModel` -> `feature:settings`
- - `DebugViewModel` -> `feature:settings`
- - `MetricsViewModel` -> `feature:node`
- - `UIViewModel` logic -> `core:ui`
-- **Dependency Injection:** Update Koin modules to provide platform-specific implementations of the abstracted interfaces.
-- **Maintain Parity:** Ensure existing functionality is preserved on Android while enabling these features on Desktop.
-
-## Acceptance Criteria
-- All 5 ViewModels are extracted from the `app` module and logic resides in `commonMain`.
-- `commonTest` coverage is established for the shared logic in each respective module.
-- The `app` module file count is further reduced.
-- Desktop target can instantiate and use the shared ViewModels.
\ No newline at end of file
diff --git a/conductor/archive/fix_android_animations_20260313/index.md b/conductor/archive/fix_android_animations_20260313/index.md
deleted file mode 100644
index 35c1f67ac..000000000
--- a/conductor/archive/fix_android_animations_20260313/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track fix_android_animations_20260313 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
diff --git a/conductor/archive/fix_android_animations_20260313/metadata.json b/conductor/archive/fix_android_animations_20260313/metadata.json
deleted file mode 100644
index 987eb12b7..000000000
--- a/conductor/archive/fix_android_animations_20260313/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "fix_android_animations_20260313",
- "type": "bug",
- "status": "completed",
- "created_at": "2026-03-13T12:00:00Z",
- "updated_at": "2026-03-13T12:00:00Z",
- "description": "Android animations broken - mainly noticeable on Connections screen, the indescriminate circular and linear progress bars don't move, and the MeshActivity animation is not firing, investigate recomposition and threading strangely enough they're working on Desktop"
-}
diff --git a/conductor/archive/fix_android_animations_20260313/plan.md b/conductor/archive/fix_android_animations_20260313/plan.md
deleted file mode 100644
index 09138e3ee..000000000
--- a/conductor/archive/fix_android_animations_20260313/plan.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# Implementation Plan: Fix Android Animation Stalls
-
-## Phase 1: Research and Reproduction
-- [x] Task: Historical Regression Analysis
- - [x] Compare current code with pre-2.7.14-internal versions to identify changes in threading or UI state management.
- - [x] Check `gh` history for commits related to `ConnectionsScreen` and `MeshActivity` transitions.
-- [x] Task: Reproduction and Diagnosis
- - [x] Create a reproduction case (manual or automated) that consistently shows stalled progress bars on Android.
- - [x] Inspect Recomposition counts using Layout Inspector or logging.
- - [x] Verify Coroutine Dispatchers used for UI state updates.
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Research and Reproduction' (Protocol in workflow.md)
-
-## Phase 2: Fix Implementation
-- [x] Task: Core Animation Fix
- - [x] Apply fix to resolve threading/recomposition stalls (e.g., correct `Dispatcher.Main` usage or state hoisting).
- - [x] Verify progress bars on Connections screen are animating.
-- [x] Task: MeshActivity Transition Fix
- - [x] Fix animation firing for `MeshActivity` entries and exits.
-- [ ] Task: Conductor - User Manual Verification 'Phase 2: Fix Implementation' (Protocol in workflow.md)
-
-## Phase 3: Project-wide Audit and Final Verification
-- [x] Task: Audit App Animations
- - [x] Scan other screens for similar animation stalls and apply fixes where necessary.
-- [x] Task: Automated Testing
- - [x] Write/Update Compose UI tests to ensure animations are running on Android.
- - [x] Verify no regressions on Desktop.
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Project-wide Audit and Final Verification' (Protocol in workflow.md)
diff --git a/conductor/archive/fix_android_animations_20260313/spec.md b/conductor/archive/fix_android_animations_20260313/spec.md
deleted file mode 100644
index c8d3cfe63..000000000
--- a/conductor/archive/fix_android_animations_20260313/spec.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Track Specification: Fix Android Animation Stalls (Regression)
-
-## Overview
-This track aims to diagnose and resolve a regression introduced in recent `2.7.14-internal` releases where animations (standard Compose progress indicators and custom transitions) fail to fire on Android. While these animations work correctly on Desktop, they are "stuck" or "stalled" on Android, likely due to threading issues or recomposition failures.
-
-## Historical Context
-- **Introduction**: This issue appeared during the `2.7.14-internal` release cycle.
-- **Comparison**: Older versions or the current Desktop build can be used as references to identify code changes that might have triggered the regression.
-
-## Functional Requirements
-- **Animation Restoration**: Restore movement to indeterminate circular and linear progress bars, particularly on the Connections screen.
-- **Transition Fixes**: Ensure `MeshActivity` animations (entry/exit/transitions) fire as expected.
-- **Project-wide Audit**: Audit other screens for similar "stuck" animations.
-- **KMP Parity**: Ensure shared `commonMain` code functions correctly on both Android and Desktop.
-
-## Non-Functional Requirements
-- **Performance**: Ensure no UI jank or excessive recompositions.
-- **Verification**: Use historical code comparison (via `gh` or temporary copies) to isolate the breaking change.
-
-## Acceptance Criteria
-- [ ] Indeterminate progress bars on the Connections screen animate continuously.
-- [ ] `MeshActivity` animations fire correctly.
-- [ ] Root cause identified (Regression since 2.7.14-internal).
-- [ ] Automated UI tests verify animation behavior on Android.
-- [ ] Unit tests verify state flow if threading/ViewModels are involved.
diff --git a/conductor/archive/kmp_doc_review_20260313/index.md b/conductor/archive/kmp_doc_review_20260313/index.md
deleted file mode 100644
index a503dd5bd..000000000
--- a/conductor/archive/kmp_doc_review_20260313/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track kmp_doc_review_20260313 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/kmp_doc_review_20260313/metadata.json b/conductor/archive/kmp_doc_review_20260313/metadata.json
deleted file mode 100644
index 25a90e45b..000000000
--- a/conductor/archive/kmp_doc_review_20260313/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "kmp_doc_review_20260313",
- "type": "chore",
- "status": "completed",
- "created_at": "2026-03-13T12:00:00Z",
- "updated_at": "2026-03-13T12:00:00Z",
- "description": "do a thorough review of the project docs for quality and veracity against the current codebase and recent changes - use tooling as needed. Evaluate updating project documentation for clarity and context. Synthesize and condense documentation and plans as needed. Be sure to thoroughly investigate the current state of the codebase and it's migration to kmp."
-}
\ No newline at end of file
diff --git a/conductor/archive/kmp_doc_review_20260313/plan.md b/conductor/archive/kmp_doc_review_20260313/plan.md
deleted file mode 100644
index 87f83f8d1..000000000
--- a/conductor/archive/kmp_doc_review_20260313/plan.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Implementation Plan
-
-## Phase 1: Context Gathering and Codebase Investigation [checkpoint: b644b50]
-- [x] Task: Investigate current state of KMP migration [42c36f0]
- - [x] Run tooling to analyze KMP modules (`core:*`) vs Android-only modules.
- - [x] Identify discrepancies between actual code structure and current documentation.
-- [x] Task: Review existing documentation [d87b7a2]
- - [x] Review Conductor strategy docs (`conductor/`).
- - [x] Review Root docs (`README.md`, `AGENTS.md`, `GEMINI.md`).
- - [x] Review `docs/` directory contents.
-- [x] Task: Conductor - User Manual Verification 'Context Gathering and Codebase Investigation' (Protocol in workflow.md) [b644b50]
-
-## Phase 2: Synthesis and Condensation [checkpoint: 40e7c58]
-- [x] Task: Synthesize documentation [8c57f14]
- - [x] Consolidate related guides into single sources of truth.
- - [x] Update documentation to reflect recent KMP migration findings.
-- [x] Task: Archive legacy documentation [14b19c5]
- - [x] Identify outdated or redundant documents.
- - [x] Move identified documents into an `archive/` directory.
-- [x] Task: Formulate next steps proposal [2bd7655]
- - [x] Draft a proposed plan for remaining KMP migrations based on investigation.
- - [x] Document the proposal in the relevant file (e.g., `kmp-status.md`).
-- [x] Task: Conductor - User Manual Verification 'Synthesis and Condensation' (Protocol in workflow.md) [40e7c58]
\ No newline at end of file
diff --git a/conductor/archive/kmp_doc_review_20260313/spec.md b/conductor/archive/kmp_doc_review_20260313/spec.md
deleted file mode 100644
index a15e676d0..000000000
--- a/conductor/archive/kmp_doc_review_20260313/spec.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Overview
-This track involves a thorough review, synthesis, and condensation of the project's documentation for quality and veracity against the current codebase and recent changes. It includes a deep investigation into the current state of the codebase, specifically focusing on its migration to Kotlin Multiplatform (KMP).
-
-# Functional Requirements
-- Conduct a comprehensive review of Conductor strategy docs (`conductor/`), Root repository docs (e.g., `README.md`, `AGENTS.md`), the `docs/` directory, and inline source code docstrings.
-- Investigate the current state of KMP migration across the codebase.
-- Synthesize and condense existing documentation into clarified, updated guides.
-- Archive old, redundant, or outdated documentation.
-- Formulate a proposed plan and next steps for the remaining KMP migrations.
-
-# Non-Functional Requirements
-- Ensure documentation is accurate, clear, and contextually aligned with recent codebase changes.
-- Use appropriate tooling to analyze the codebase and verify documentation claims.
-
-# Acceptance Criteria
-- [ ] A consolidated, up-to-date documentation structure exists.
-- [ ] Legacy or redundant documents are moved to an archive folder.
-- [ ] An accurate report of the current KMP migration status is produced.
-- [ ] A proposal for the next steps in the KMP migration is documented.
-- [ ] Conductor docs, Root docs, the `docs/` directory, and key docstrings align with the actual codebase implementation.
-
-# Out of Scope
-- Actually executing the proposed KMP migrations (this track is purely documentation and planning).
-- Modifying application business logic or UI code.
\ No newline at end of file
diff --git a/conductor/archive/kmp_test_migration_20260318/index.md b/conductor/archive/kmp_test_migration_20260318/index.md
deleted file mode 100644
index d448caca6..000000000
--- a/conductor/archive/kmp_test_migration_20260318/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track kmp_test_migration_20260318 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/kmp_test_migration_20260318/metadata.json b/conductor/archive/kmp_test_migration_20260318/metadata.json
deleted file mode 100644
index 73b8373cc..000000000
--- a/conductor/archive/kmp_test_migration_20260318/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "kmp_test_migration_20260318",
- "type": "chore",
- "status": "completed",
- "created_at": "2026-03-18T10:00:00Z",
- "updated_at": "2026-03-18T10:00:00Z",
- "description": "Migrate tests to KMP best practices and expand coverage"
-}
\ No newline at end of file
diff --git a/conductor/archive/kmp_test_migration_20260318/plan.md b/conductor/archive/kmp_test_migration_20260318/plan.md
deleted file mode 100644
index 2f701569a..000000000
--- a/conductor/archive/kmp_test_migration_20260318/plan.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# Implementation Plan: KMP Test Migration and Coverage Expansion
-
-## Phase 1: Tool Evaluation & Integration [checkpoint: 3ccc7a7]
-- [x] Task: Evaluate Mocking Frameworks
-- [x] Task: Integrate Selected Tools (Mokkery, Turbine, Kotest) [b4ba582]
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Tool Evaluation & Integration' (Protocol in workflow.md) [3ccc7a7]
-
-## Phase 2: Mockk Replacement [checkpoint: c8afaef]
-- [x] Task: Refactor core modules to Mokkery [7522d38]
-- [x] Task: Refactor feature modules to Mokkery [87c7eb6]
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Mockk Replacement' (Protocol in workflow.md) [c8afaef]
-
-## Phase 3: Coverage Expansion
-- [x] Task: Expand ViewModels coverage with Turbine [c813be8]
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Coverage Expansion' (Protocol in workflow.md) [2395cb9]
-
-## Phase: Review Fixes
-- [x] Task: Apply review suggestions [1739021]
\ No newline at end of file
diff --git a/conductor/archive/kmp_test_migration_20260318/spec.md b/conductor/archive/kmp_test_migration_20260318/spec.md
deleted file mode 100644
index 6141d7ae6..000000000
--- a/conductor/archive/kmp_test_migration_20260318/spec.md
+++ /dev/null
@@ -1,4 +0,0 @@
-# Specification: KMP Test Migration and Coverage Expansion
-
-## Overview
-Migrate the project's test suite to KMP best practices based on JetBrains guidance, expanding coverage and replacing JVM-specific `mockk` with `dev.mokkery` in `commonMain` to ensure iOS readiness.
\ No newline at end of file
diff --git a/conductor/archive/migrate_debug_panel_20260319/index.md b/conductor/archive/migrate_debug_panel_20260319/index.md
deleted file mode 100644
index 30a087a64..000000000
--- a/conductor/archive/migrate_debug_panel_20260319/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track migrate_debug_panel_20260319 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/migrate_debug_panel_20260319/metadata.json b/conductor/archive/migrate_debug_panel_20260319/metadata.json
deleted file mode 100644
index 0e0ab5b5d..000000000
--- a/conductor/archive/migrate_debug_panel_20260319/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "migrate_debug_panel_20260319",
- "type": "feature",
- "status": "completed",
- "created_at": "2026-03-19T00:00:00Z",
- "updated_at": "2026-03-19T10:00:00Z",
- "description": "migrate the fully featured debug panel to common source for use in other targets, wire it up in desktop"
-}
\ No newline at end of file
diff --git a/conductor/archive/migrate_debug_panel_20260319/plan.md b/conductor/archive/migrate_debug_panel_20260319/plan.md
deleted file mode 100644
index f1e15d3a7..000000000
--- a/conductor/archive/migrate_debug_panel_20260319/plan.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Implementation Plan: Debug Panel KMP Migration
-
-## Phase 1: Analysis and Relocation [checkpoint: a2e83eb]
-- [x] Task: Locate all source files for the Android Debug Panel (UI, ViewModels, States).
-- [x] Task: Move these files from the Android-specific source sets (e.g., `feature/settings/src/androidMain`) into `feature/settings/src/commonMain`.
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Analysis and Relocation' (Protocol in workflow.md)
-
-## Phase 2: Adaptation to KMP [checkpoint: 834f42c]
-- [x] Task: Resolve compilation errors by removing Android-specific imports (`android.*`, `java.*`).
-- [x] Task: Migrate Android Jetpack Compose imports (`androidx.compose`) to Compose Multiplatform equivalents (`org.jetbrains.compose.*` or ensuring the standard Multiplatform aliases are used).
-- [x] Task: Ensure the Debug Panel ViewModel uses the multiplatform `androidx.lifecycle.ViewModel`.
-- [x] Task: Abstract any necessary platform-specific logging or hardware interactions using `expect`/`actual` or KMP interfaces.
-- [x] Task: Write or migrate corresponding unit tests to `commonTest`.
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Adaptation to KMP' (Protocol in workflow.md)
-
-## Phase 3: Desktop Integration [checkpoint: de2ae06]
-- [x] Task: Wire the Debug Panel into the Desktop target's settings menu (`DesktopSettingsNavigation.kt`).
-- [x] Task: Add DI bindings for the Desktop module if the Debug Panel requires specific dependencies.
-- [x] Task: Verify the Debug Panel screen can be opened and navigated to from the Desktop app.
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Desktop Integration' (Protocol in workflow.md)
-
-## Phase: Review Fixes
-- [x] Task: Apply review suggestions ac69e73
\ No newline at end of file
diff --git a/conductor/archive/migrate_debug_panel_20260319/spec.md b/conductor/archive/migrate_debug_panel_20260319/spec.md
deleted file mode 100644
index 526d336d4..000000000
--- a/conductor/archive/migrate_debug_panel_20260319/spec.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Specification: Debug Panel KMP Migration
-
-## Overview
-Migrate the existing Android-specific Debug Panel to `commonMain` to enable its use across all Kotlin Multiplatform targets, specifically wiring it up for the Desktop target.
-
-## Functional Requirements
-- The complete Android debug panel implementation will be moved and adapted to `commonMain`.
-- All capabilities from the existing Android debug panel should be preserved and made functional on the Desktop target if possible.
-- The Debug Panel will be accessible within the Desktop Settings menu, mirroring the Android application's navigation structure.
-- Any platform-specific system logging (e.g., Logcat) that cannot be migrated will be appropriately abstracted or gracefully degraded.
-
-## Non-Functional Requirements
-- **Architecture:** Follow the project's MVI/UDF architecture.
-- **UI:** Leverage Compose Multiplatform for the shared UI, removing any Android-specific Jetpack Compose dependencies from the core shared UI logic.
-- **Testing:** Add `commonTest` coverage for the migrated ViewModels and presentation logic.
-
-## Acceptance Criteria
-- [ ] The Debug Panel source code resides in a `commonMain` module (e.g., `feature/settings/src/commonMain`).
-- [ ] The Debug Panel compiles and runs successfully on both the Android and Desktop targets.
-- [ ] The Desktop application can navigate to the Debug Panel from the Settings menu.
-- [ ] Essential debug features (transport logs, packet inspection, etc.) function on the Desktop.
-
-## Out of Scope
-- Creating new debug capabilities that do not already exist in the Android implementation.
\ No newline at end of file
diff --git a/conductor/archive/migrate_room3_20260320/index.md b/conductor/archive/migrate_room3_20260320/index.md
deleted file mode 100644
index 42a6102e3..000000000
--- a/conductor/archive/migrate_room3_20260320/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track migrate_room3_20260320 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
diff --git a/conductor/archive/migrate_room3_20260320/metadata.json b/conductor/archive/migrate_room3_20260320/metadata.json
deleted file mode 100644
index e63de1f58..000000000
--- a/conductor/archive/migrate_room3_20260320/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "migrate_room3_20260320",
- "type": "chore",
- "status": "new",
- "created_at": "2026-03-20T00:00:00Z",
- "updated_at": "2026-03-20T00:00:00Z",
- "description": "Migrate to room3, prepare to support all targets (Android, Desktop, iOS) with bundled SQLite driver and full idiomatic migration."
-}
diff --git a/conductor/archive/migrate_room3_20260320/plan.md b/conductor/archive/migrate_room3_20260320/plan.md
deleted file mode 100644
index a67023655..000000000
--- a/conductor/archive/migrate_room3_20260320/plan.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Implementation Plan - Room 3 Migration
-
-## Phase 1: Dependency Update & Build Logic Refinement
-- Update `libs.versions.toml` to Room 3.0.
-- Update `AndroidRoomConventionPlugin.kt` to align with Room 3 best practices (e.g., ensuring `room.generateKotlin` is correctly set and using the `androidx.room` Gradle plugin).
-- Verify all modules (`core:database`, `core:data`, `app`, etc.) can build with the new dependencies.
-- [x] Task: Update `libs.versions.toml` with Room 3.0 and related dependencies.
-- [x] Task: Refactor `AndroidRoomConventionPlugin.kt` for Room 3.0.
-- [x] Task: Conductor - User Manual Verification 'Phase 1' (Protocol in workflow.md)
-
-## Phase 2: Core Database Implementation (KMP)
-- Refactor `MeshtasticDatabase.kt` and `MeshtasticDatabaseConstructor.kt` to use the new Room 3 `RoomDatabase.Builder` for KMP.
-- Configure the `BundledSQLiteDriver` in `commonMain` to ensure consistent SQL behavior across all targets.
-- Ensure that DAOs and Entities are using `room-runtime` in `commonMain` correctly.
-- Implement platform-specific database setup for Android, Desktop, and iOS in their respective `Main` source sets.
-- [x] Task: Refactor `MeshtasticDatabase.kt` for Room 3.0 KMP APIs.
-- [x] Task: Configure `BundledSQLiteDriver` in `DatabaseProvider.kt`.
-- [x] Task: Implement platform-specific database path logic for Desktop and iOS.
-- [x] Task: Conductor - User Manual Verification 'Phase 2' (Protocol in workflow.md)
-
-## Phase 3: Multi-target Support (iOS)
-- Add iOS targets (`iosX64`, `iosArm64`, `iosSimulatorArm64`) to `core:database/build.gradle.kts`.
-- Configure the database file path logic for iOS.
-- Verify that the `core:database` module compiles for iOS.
-- [x] Task: Add iOS targets to `core:database/build.gradle.kts`.
-- [x] Task: Verify iOS compilation (Skipped: Linux host).
-- [x] Task: Conductor - User Manual Verification 'Phase 3' (Protocol in workflow.md)
-
-## Phase 4: Verification and Testing
-- Update existing database tests in `commonTest`, `androidHostTest`, and `androidDeviceTest` to Room 3.
-- Run tests on Android and Desktop to ensure no regressions in behavior.
-- Perform manual verification on Android and Desktop apps to ensure the database initializes and functions correctly.
-- [x] Task: Update and run DAO unit tests in `commonTest`.
-- [x] Task: Run Android instrumented tests (`androidDeviceTest`).
-- [x] Task: Manual verification on Desktop.
-- [x] Task: Conductor - User Manual Verification 'Phase 4' (Protocol in workflow.md)
diff --git a/conductor/archive/migrate_room3_20260320/spec.md b/conductor/archive/migrate_room3_20260320/spec.md
deleted file mode 100644
index c6fef5f63..000000000
--- a/conductor/archive/migrate_room3_20260320/spec.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# Specification - Room 3 Migration
-
-## Overview
-Migrate the existing database implementation from Room 2.8.x to Room 3.0. This migration aims to modernize the persistence layer by adopting Room's new Kotlin Multiplatform (KMP) capabilities, ensuring consistent behavior across Android, Desktop (JVM), and iOS targets. Following best practice from reference projects.
-
-## Functional Requirements
-- **Room 3.0 Update**: Update all Room-related dependencies to version 3.0 (alpha/beta/stable as per latest).
-- **KMP Support**: Ensure `core:database` is fully compatible with Android, Desktop (JVM), and iOS targets.
-- **Bundled SQLite Driver**: Configure the project to use the `androidx.sqlite:sqlite-bundled` driver for all platforms to ensure consistent SQL behavior and versioning.
-- **Schema Management**: Maintain existing database schemas and ensure migrations (if any) are compatible with Room 3.
-- **DAO & Entity Optimization**: Refactor DAOs and Entities to use Room 3's idiomatic Kotlin APIs (e.g., using `RoomDatabase.Builder` for KMP).
-
-## Non-Functional Requirements
-- **Performance**: Ensure no significant regression in database performance after the migration.
-- **Reliability**: All existing database tests must pass on Android.
-- **Maintainability**: Adopt the new Room Gradle plugin for schema export and generation.
-
-## Acceptance Criteria
-1. All modules (`core:database`, `core:data`, etc.) build successfully with Room 3.0.
-2. Database initialization works correctly on Android and Desktop.
-3. Unit tests for DAOs pass in `commonTest` (where applicable) and `androidDeviceTest`.
-4. The `androidx.sqlite:sqlite-bundled` driver is used for database connections.
-5. iOS target is added to `core:database` (if not already present) and compiles.
-
-## Out of Scope
-- Migrating to a different database engine (e.g., SQLDelight).
-- Major schema changes unrelated to the Room 3 migration.
-- Implementing complex iOS-specific UI related to the database.
diff --git a/conductor/archive/mqtt_transport_20260318/index.md b/conductor/archive/mqtt_transport_20260318/index.md
deleted file mode 100644
index 8f255c832..000000000
--- a/conductor/archive/mqtt_transport_20260318/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track mqtt_transport_20260318 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/mqtt_transport_20260318/metadata.json b/conductor/archive/mqtt_transport_20260318/metadata.json
deleted file mode 100644
index f2ac1628d..000000000
--- a/conductor/archive/mqtt_transport_20260318/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "mqtt_transport_20260318",
- "type": "feature",
- "status": "completed",
- "created_at": "2026-03-18T00:00:00Z",
- "updated_at": "2026-03-18T00:00:00Z",
- "description": "MQTT transport"
-}
\ No newline at end of file
diff --git a/conductor/archive/mqtt_transport_20260318/plan.md b/conductor/archive/mqtt_transport_20260318/plan.md
deleted file mode 100644
index 5788491c1..000000000
--- a/conductor/archive/mqtt_transport_20260318/plan.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Implementation Plan: MQTT Transport
-
-## Phase 1: Core Networking & Library Integration
-- [x] Task: Evaluate and add KMP MQTT library dependency (e.g. Kmqtt) to `core:network` or `libs.versions.toml`. [2a4aa35]
- - [x] Add dependency to `libs.versions.toml`.
- - [x] Apply dependency in `core:network/build.gradle.kts`.
-- [x] Task: Implement `MqttTransport` class in `commonMain` of `core:network`. [99d35b3]
- - [x] Create failing tests in `commonTest` for MqttTransport initialization and configuration parsing.
- - [x] Implement MqttTransport to parse URL (mqtt://, mqtts://), credentials, and configure the underlying MQTT client.
- - [x] Write failing tests for connection state flows.
- - [x] Implement connection lifecycle handling (connect, disconnect, reconnect).
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Core Networking & Library Integration' (Protocol in workflow.md) [93d9a50]
-
-## Phase 2: Publishing & Subscribing
-- [x] Task: Implement message subscription and payload parsing. [4900f69]
- - [x] Create failing tests for receiving and mapping standard Meshtastic JSON payloads from subscribed topics.
- - [x] Implement topic subscription management in `MqttTransport`.
- - [x] Implement payload parsing and integration with `core:model` definitions.
-- [x] Task: Implement publishing mechanism. [0991210]
- - [x] Create failing tests for formatting and publishing node information/messages to custom topics.
- - [x] Implement publish functionality in `MqttTransport`.
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Publishing & Subscribing' (Protocol in workflow.md) [7418e53]
-
-## Phase 3: Service & UI Integration
-- [x] Task: Integrate `MqttTransport` into `core:service` and `core:data`. [d414556, e172f53]
- - [x] Create failing tests for orchestrating MQTT connection based on user preferences.
- - [x] Implement service-level bindings to maintain background connection.
-- [x] Task: Implement MQTT UI Configuration Settings. (Verified existing implementation)
- - [x] Verified existing `MQTTConfigItemList.kt` correctly manages UI inputs.
- - [x] Verified MQTT broker URL, username, password, and custom topic inputs exist in UI.
- - [x] Verified UI inputs correctly wire to `ModuleConfig.MQTTConfig` used by `MQTTRepositoryImpl`.
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Service & UI Integration' (Protocol in workflow.md) [deaa324]
\ No newline at end of file
diff --git a/conductor/archive/mqtt_transport_20260318/spec.md b/conductor/archive/mqtt_transport_20260318/spec.md
deleted file mode 100644
index e1e213646..000000000
--- a/conductor/archive/mqtt_transport_20260318/spec.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# Specification: MQTT Transport
-
-## Overview
-Implement an MQTT transport layer for the Meshtastic-Android Kotlin Multiplatform (KMP) application to enable communication with Meshtastic devices over MQTT. This will support Android, Desktop, iOS, and potentially Web platforms in the future.
-
-## Functional Requirements
-- **Platforms:** Ensure the MQTT transport operates correctly across Android, Desktop, and iOS platforms, using KMP best practices (with considerations for Web compatibility if technically feasible).
-- **Core Library:** Utilize a dedicated Kotlin Multiplatform MQTT client library (e.g., Kmqtt) within the `core:network` module.
-- **Connection Features:**
- - Support for both standard (`mqtt://`) and secure TLS/SSL (`mqtts://`) connections.
- - Support for username and password authentication.
-- **Messaging Features:**
- - Subscribe to and publish on user-defined custom topics.
- - Parse and serialize standard Meshtastic JSON payloads.
-- **UI Integration:**
- - Follow the existing Android UX patterns for network/device connections.
- - Integrate MQTT configuration seamlessly into the connection or advanced settings menus.
-
-## Non-Functional Requirements
-- **Architecture:** Business logic for MQTT communication must reside in the `core:network` (or a new `core:mqtt`) `commonMain` source set.
-- **Testability:** Implement shared tests in `commonTest` to verify connection states, topic parsing, and payload serialization without relying on JVM-specific mocks.
-- **Performance:** Ensure background execution and resource management align with the `core:service` architecture.
-
-## Acceptance Criteria
-- [ ] Users can enter an MQTT broker URL (including TLS), username, and password in the UI.
-- [ ] The app successfully connects to the specified MQTT broker and maintains the connection in the background.
-- [ ] The app can publish Meshtastic node information/messages to the broker.
-- [ ] The app can receive and process incoming Meshtastic payloads from subscribed topics.
-- [ ] Unit tests cover at least 80% of the new MQTT client logic.
-
-## Out of Scope
-- Direct firmware updates via MQTT (if not natively supported by the standard payload).
-- Implementing a full local MQTT broker on the device.
\ No newline at end of file
diff --git a/conductor/archive/wire_up_notifs_20260316/index.md b/conductor/archive/wire_up_notifs_20260316/index.md
deleted file mode 100644
index 10475a87b..000000000
--- a/conductor/archive/wire_up_notifs_20260316/index.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Track wire_up_notifs_20260316 Context
-
-- [Specification](./spec.md)
-- [Implementation Plan](./plan.md)
-- [Metadata](./metadata.json)
\ No newline at end of file
diff --git a/conductor/archive/wire_up_notifs_20260316/metadata.json b/conductor/archive/wire_up_notifs_20260316/metadata.json
deleted file mode 100644
index c0a345cb9..000000000
--- a/conductor/archive/wire_up_notifs_20260316/metadata.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "track_id": "wire_up_notifs_20260316",
- "type": "feature",
- "status": "completed",
- "created_at": "2026-03-16T00:00:00Z",
- "updated_at": "2026-03-16T00:00:00Z",
- "description": "wire up notifs"
-}
\ No newline at end of file
diff --git a/conductor/archive/wire_up_notifs_20260316/plan.md b/conductor/archive/wire_up_notifs_20260316/plan.md
deleted file mode 100644
index f599f7d1d..000000000
--- a/conductor/archive/wire_up_notifs_20260316/plan.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# Implementation Plan: Wire Up Notifications
-
-## Phase 1: Shared Abstraction (commonMain) [checkpoint: 930ce02]
-- [x] Task: Define `NotificationManager` interface in `core:service/src/commonMain` 4f2107d
- - [x] Create `Notification` data model (title, message, type)
- - [x] Define `dispatch(notification: Notification)` method
-- [x] Task: Create `NotificationPreferencesDataSource` using DataStore in `core:prefs` 346c2a4
- - [x] Define boolean preferences for categories (e.g., Messages, Node Events)
-- [x] Task: Conductor - User Manual Verification 'Phase 1: Shared Abstraction (commonMain)' (Protocol in workflow.md)
-
-## Phase 2: Migrate Android Implementation (androidMain) [checkpoint: 1eb3cb0]
-- [x] Task: Audit existing Android notifications 930ce02
- - [x] Locate current implementation for local push notifications
- - [x] Analyze triggers and UX (channels, icons, sounds)
-- [x] Task: Implement `AndroidNotificationManager` 31c2a1e
- - [x] Adapt existing Android notification code to the new `NotificationManager` interface
- - [x] Inject `Context` and `NotificationPreferencesDataSource`
- - [x] Respect user notification preferences
-- [x] Task: Wire `AndroidNotificationManager` into Koin DI 31c2a1e
-- [x] Task: Replace old Android notification calls with the new unified interface 81fd10b
-- [x] Task: Conductor - User Manual Verification 'Phase 2: Migrate Android Implementation (androidMain)' (Protocol in workflow.md)
-
-## Phase 3: Desktop Implementation (desktop) [checkpoint: 759914f]
-- [x] Task: Implement `DesktopNotificationManager` 1eb3cb0
- - [x] Inject `TrayState` and `NotificationPreferencesDataSource`
- - [x] Delegate `dispatch()` to `TrayState.sendNotification()` respecting user preferences
-- [x] Task: Wire `DesktopNotificationManager` into Koin DI 1eb3cb0
-- [x] Task: Conductor - User Manual Verification 'Phase 3: Desktop Implementation (desktop)' (Protocol in workflow.md)
-
-
-## Phase 4: UI Preferences Integration [checkpoint: 3af1e4c]
-- [x] Task: Create UI for notification preferences 7ed59c6
- - [x] Add toggles for categories in the Settings screen
-- [x] Task: Conductor - User Manual Verification 'Phase 4: UI Preferences Integration' (Protocol in workflow.md)
\ No newline at end of file
diff --git a/conductor/archive/wire_up_notifs_20260316/spec.md b/conductor/archive/wire_up_notifs_20260316/spec.md
deleted file mode 100644
index 0cce32a61..000000000
--- a/conductor/archive/wire_up_notifs_20260316/spec.md
+++ /dev/null
@@ -1,17 +0,0 @@
-# Specification: Wire Up Notifications
-
-## Goal
-To implement a unified, cross-platform notification system that abstracts platform-specific implementations (Android local push, Desktop TrayState) into a common API for the Kotlin Multiplatform (KMP) core. This will enable consistent notification dispatching for key mesh events.
-
-## Requirements
-1. **Abstraction Layer:** Create a shared `NotificationManager` interface in `commonMain` to handle notification dispatching across all targets.
-2. **Platform Implementations:**
- - **Android:** Implement native local notifications following the existing Android app behavior and Material Design guidance.
- - **Desktop:** Implement system notifications using the `TrayState` API.
-3. **Trigger Events:** Replicate the existing Android notification triggers (e.g., new messages, connections) and adapt them to use the new shared abstraction.
-4. **User Preferences:** Provide a unified UI for users to opt in or out of specific notification categories, respecting their choices globally.
-5. **Foreground Handling & Behavior:** Defer to platform-specific UX guidelines and the established Android implementation for aspects like sound, vibration, and in-app display (e.g., suppressing system notifications if the conversation is active).
-
-## Out of Scope
-- Changes to the underlying networking or Bluetooth layers.
-- Remote Push Notifications (FCM/APNs) – this is strictly for local, mesh-driven events.
\ No newline at end of file
diff --git a/conductor/code_styleguides/general.md b/conductor/code_styleguides/general.md
deleted file mode 100644
index dfcc793f4..000000000
--- a/conductor/code_styleguides/general.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# General Code Style Principles
-
-This document outlines general coding principles that apply across all languages and frameworks used in this project.
-
-## Readability
-- Code should be easy to read and understand by humans.
-- Avoid overly clever or obscure constructs.
-
-## Consistency
-- Follow existing patterns in the codebase.
-- Maintain consistent formatting, naming, and structure.
-
-## Simplicity
-- Prefer simple solutions over complex ones.
-- Break down complex problems into smaller, manageable parts.
-
-## Maintainability
-- Write code that is easy to modify and extend.
-- Minimize dependencies and coupling.
-
-## Documentation
-- Document *why* something is done, not just *what*.
-- Keep documentation up-to-date with code changes.
diff --git a/conductor/desktop-uri-import-plan.md b/conductor/desktop-uri-import-plan.md
deleted file mode 100644
index f863484ea..000000000
--- a/conductor/desktop-uri-import-plan.md
+++ /dev/null
@@ -1,31 +0,0 @@
-# Desktop URI Import Plan
-
-## Objective
-Wire up `SharedContact` and `ChannelSet` import logic for the Desktop target. This enables the Desktop app to process deep links or URIs passed on startup via arguments or intercepted by the OS using `java.awt.Desktop`'s `OpenURIHandler`.
-
-## Key Files & Context
-- `desktop/src/main/kotlin/org/meshtastic/desktop/Main.kt`: Desktop app entry point. Must be updated to parse command line arguments and handle OS-level URI opening events.
-- `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`: The main UI composition. Must be updated to inject the shared `UIViewModel` and render the `SharedContactDialog` / `ScannedQrCodeDialog` when `requestChannelSet` or `sharedContactRequested` are present.
-- `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/viewmodel/UIViewModel.kt`: Already handles URI dispatch and holds the requests, so no changes are needed here.
-
-## Implementation Steps
-
-1. **Update `DesktopMainScreen.kt`**
- - Import `org.meshtastic.core.ui.viewmodel.UIViewModel`, `org.koin.compose.viewmodel.koinViewModel`, `org.meshtastic.core.ui.share.SharedContactDialog`, `org.meshtastic.core.ui.qr.ScannedQrCodeDialog`, and `org.meshtastic.core.model.ConnectionState`.
- - Inject `UIViewModel` directly into `DesktopMainScreen` via `val uiViewModel = koinViewModel()`.
- - Add observations for `uiViewModel.sharedContactRequested` and `uiViewModel.requestChannelSet`.
- - Just like in Android's `MainScreen`, conditionally render `SharedContactDialog` and `ScannedQrCodeDialog` if `connectionState == ConnectionState.Connected` and either state contains a valid request.
- - Wire `onDismiss` closures to `uiViewModel.clearSharedContactRequested()` and `uiViewModel.clearRequestChannelUrl()`.
-
-2. **Update `Main.kt` (Desktop)**
- - Alter `fun main()` to `fun main(args: Array)`.
- - Resolve `UIViewModel` after `koinApp` initialization: `val uiViewModel = koinApp.koin.get()`.
- - Process the initial `args` and invoke `uiViewModel.handleScannedUri` using `MeshtasticUri` for any arguments that look like valid Meshtastic URIs (starting with `http` or `meshtastic://`).
- - Attempt to attach a `java.awt.desktop.OpenURIHandler` if `java.awt.Desktop.Action.APP_OPEN_URI` is supported. When triggered, process the incoming `event.uri` string using the same `handleScannedUri` logic.
-
-## Verification & Testing
-1. Compile the desktop target with `./gradlew desktop:run --args="meshtastic://meshtastic/v/contact..."`.
-2. Connect to a device via Desktop Connections or wait for connection.
-3. Validate that the corresponding Shared Contact or Channel Set dialog renders on screen.
-4. Verify that dismissing the dialogs properly clears the state in the view model.
-5. (Optional, macOS) If testing via packaged DMG, verify that opening a `.webloc` or invoking `open meshtastic://...` triggers the `APP_OPEN_URI` handler and routes through the UI.
\ No newline at end of file
diff --git a/conductor/doc-consolidation-plan.md b/conductor/doc-consolidation-plan.md
deleted file mode 100644
index 1ce4cfe07..000000000
--- a/conductor/doc-consolidation-plan.md
+++ /dev/null
@@ -1,53 +0,0 @@
-# Objective
-Consolidate, prune, verify, and validate project plans and documentation against 2026 Kotlin Multiplatform (KMP) best practices and the latest dependency standards.
-
-# Background & Motivation
-The `docs/agent-playbooks/` directory has accumulated numerous point-in-time session summaries, checklists, and status reports (e.g., `SESSION-FINAL-SUMMARY.md`, `TEST-VERIFICATION-REPORT.md`) during the Phase 3 testing consolidation sprint. These files clutter the directory and dilute the actual "playbooks" (reusable guides). Additionally, the project documentation (`kmp-status.md`, `roadmap.md`, `AGENTS.md`) needs to be synthesized to reflect the recently completed work and validated against 2026 KMP industry standards (e.g., Koin K2 compiler plugin best practices, shared ViewModels, Navigation 3).
-
-# Proposed Solution
-
-## 1. Prune and Consolidate Session Artifacts
-- **Consolidate:** Merge the key findings, test coverage metrics (80 tests across 6 features), and testing patterns from the 12+ session update files into a single historical record: `docs/archive/kmp-phase3-testing-consolidation.md`.
-- **Prune:** Delete the following redundant point-in-time files from `docs/agent-playbooks/`:
- - `CHECKLIST-testing-consolidation.md`
- - `FINAL-STATUS-tests-fixed.md`
- - `MIGRATION-COMPLETE-SUMMARY.md`
- - `SESSION-FINAL-SUMMARY.md`
- - `SESSION-STATUS-2026-03-11.md`
- - `TEST-VERIFICATION-REPORT.md`
- - `fix-core-domain-tests.md`
- - `kmp-testing-consolidation-slice.md`
- - `phase-1-feature-commontest-bootstrap.md`
- - `phase-3-completion.md`
- - `phase-3-implementation-plan.md`
- - `phase-3-integration-tests-started.md`
-- **Relocate:**
- - Extract the contents of `phase-4-desktop-completion-plan.md` and merge them into `docs/roadmap.md` under the Phase 4 Desktop section. Delete the original file.
- - Move `kmp-feature-migration-plan.md` to `docs/archive/` since Phase 3 is mostly complete.
-
-## 2. Synthesize Status & Roadmap
-- **Update `docs/kmp-status.md`:** Update the testing score (currently 5/10) to reflect the completion of Phase 3 integration testing (80 tests across 6 features, test doubles in `core:testing`).
-- **Update `docs/roadmap.md`:** Mark Phase 3 as substantially complete. Expand the Phase 4 (Desktop Feature Completion) section using the consolidated plan details.
-
-## 3. Verify and Validate against 2026 KMP Best Practices
-Based on a review of 2026 KMP standards and the project's current dependencies (`Koin 4.2.0-RC1`, `Compose Multiplatform 1.11.0-alpha03`, `Navigation 3 1.1.0-alpha03`):
-- **Koin Annotations (K2):** The project's decision to move Koin `@Module` and `@KoinViewModel` annotations into `commonMain` aligns perfectly with Koin 4.2 native compiler plugin best practices. The documentation (`AGENTS.md`, `docs/decisions/architecture-review-2026-03.md`) will be validated and explicitly updated to affirm that this is the correct architectural pattern, not a "portability tradeoff".
-- **Shared ViewModels (MVI):** Ensure playbook documentation explicitly recommends utilizing the multiplatform `androidx.lifecycle.ViewModel` in `commonMain` to maintain a single source of truth, heavily relying on `StateFlow`.
-- **Navigation 3:** The hybrid parity strategy (shared route contracts, platform adapters) is validated as the 2026 standard for Compose Multiplatform.
-
-## 4. Documentation Quality Checks
-- Verify `docs/agent-playbooks/README.md` correctly points only to the retained playbooks.
-- Rename `testing-quick-ref.sh` to `testing-quick-ref.md` for proper markdown rendering and update internal references.
-
-# Implementation Steps
-1. Create `docs/archive/kmp-phase3-testing-consolidation.md` and synthesize the 12+ session artifacts into it.
-2. Delete the 12+ redundant session files from `docs/agent-playbooks/`.
-3. Update `docs/kmp-status.md` and `docs/roadmap.md` with the new testing metrics and Phase 4 desktop tasks.
-4. Rename `testing-quick-ref.sh` to `testing-quick-ref.md` and update internal references.
-5. Update `docs/agent-playbooks/README.md` to reflect the pruned directory.
-6. Refine `AGENTS.md` and `docs/agent-playbooks/di-navigation3-anti-patterns-playbook.md` to validate Koin K2 multiplatform annotations as the officially recommended pattern.
-
-# Verification & Testing
-- Run `ls docs/agent-playbooks/` to ensure only high-signal playbooks remain.
-- Ensure `docs/kmp-status.md` reflects an updated test maturity score (e.g., 8/10).
-- Run `git status` and `git diff` to ensure changes are accurate.
\ No newline at end of file
diff --git a/conductor/index.md b/conductor/index.md
deleted file mode 100644
index 3a362bc99..000000000
--- a/conductor/index.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Project Context
-
-## Definition
-- [Product Definition](./product.md)
-- [Product Guidelines](./product-guidelines.md)
-- [Tech Stack](./tech-stack.md)
-
-## Workflow
-- [Workflow](./workflow.md)
-- [Code Style Guides](./code_styleguides/)
-
-## Management
-- [Tracks Registry](./tracks.md)
-- [Tracks Directory](./tracks/)
\ No newline at end of file
diff --git a/conductor/product-guidelines.md b/conductor/product-guidelines.md
deleted file mode 100644
index b54944fea..000000000
--- a/conductor/product-guidelines.md
+++ /dev/null
@@ -1,19 +0,0 @@
-# Product Guidelines
-
-## Brand Voice and Tone
-- **Technical yet Accessible:** Communicate complex networking and hardware concepts clearly without being overly academic.
-- **Reliable and Authoritative:** The app is a utility for critical, off-grid communication. Language should convey stability and safety.
-- **Community-Oriented:** Encourage open-source participation and community support.
-
-## UX Principles
-- **Offline-First:** Assume the user has no cellular or Wi-Fi connection. All core functions must work locally via the mesh network.
-- **Adaptive Layouts:** Support multiple form factors seamlessly (phones, tablets, desktop) using Material 3 Adaptive Scaffold principles.
-- **Information Density:** Give power users access to detailed metrics (SNR, battery, hop limits) without overwhelming beginners. Use progressive disclosure.
-
-## Prose Style
-- **Clarity over cleverness:** Use plain English.
-- **Action-oriented:** Button labels and prompts should start with strong verbs (e.g., "Send", "Connect", "Export").
-- **Consistent Terminology:**
- - Use "Node" for devices on the network.
- - Use "Channel" for communication groups.
- - Use "Direct Message" for 1-to-1 communication.
\ No newline at end of file
diff --git a/conductor/product.md b/conductor/product.md
deleted file mode 100644
index 8576c7e83..000000000
--- a/conductor/product.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# Initial Concept
-A tool for using Android with open-source mesh radios.
-
-# Product Guide
-
-## Overview
-Meshtastic-Android is a Kotlin Multiplatform (KMP) application designed to facilitate communication over off-grid, decentralized mesh networks using open-source hardware radios.
-
-## Target Audience
-- Off-grid communication enthusiasts and hobbyists
-- Outdoor adventurers needing reliable communication without cellular networks
-- Emergency response and disaster relief teams
-
-## Core Features
-- Direct communication with Meshtastic hardware (via BLE, USB, TCP, MQTT)
-- Decentralized text messaging across the mesh network
-- Unified cross-platform notifications for messages and node events
-- Adaptive node and contact management
-- Offline map rendering and device positioning
-- Device configuration and firmware updates
-- Unified cross-platform debugging and packet inspection
-
-## Key Architecture Goals
-- Provide a robust, shared KMP core (`core:model`, `core:ble`, `core:repository`, `core:domain`, `core:data`, `core:network`, `core:service`) to support multiple platforms (Android, Desktop, iOS)
-- Ensure offline-first functionality and resilient data persistence (Room 3 KMP with bundled SQLite driver)
-- Decouple UI and navigation logic into shared feature modules (`core:ui`, `feature:*`) using Compose Multiplatform
\ No newline at end of file
diff --git a/conductor/tech-stack.md b/conductor/tech-stack.md
deleted file mode 100644
index e455d666d..000000000
--- a/conductor/tech-stack.md
+++ /dev/null
@@ -1,36 +0,0 @@
-# Tech Stack
-
-## Programming Language
-- **Kotlin Multiplatform (KMP):** The core logic is shared across Android, Desktop, and iOS using `commonMain`.
-
-## Frontend Frameworks
-- **Compose Multiplatform:** Shared UI layer for rendering on Android and Desktop.
-- **Jetpack Compose:** Used where platform-specific UI (like charts or permissions) is necessary on Android.
-
-## Background & Services
-- **Platform Services:** Core service orchestrations and background work are abstracted into `core:service` to maximize logic reuse across targets, using platform-specific implementations (e.g., WorkManager/Service on Android) only where necessary.
-
-## Architecture
-- **MVI / Unidirectional Data Flow:** Shared view models using the multiplatform `androidx.lifecycle.ViewModel`.
-- **JetBrains Navigation 3:** Multiplatform fork for state-based, compose-first navigation without relying on `NavController`. Navigation graphs are decoupled and extracted into their respective `feature:*` modules, allowing a thinned out root `app` module.
-
-## Dependency Injection
-- **Koin 4.2:** Leverages Koin Annotations and the K2 Compiler Plugin for pure compile-time DI, completely replacing Hilt.
-
-## Database & Storage
-- **Room 3 KMP:** Shared local database using multiplatform `DatabaseConstructor` and the `androidx.sqlite` bundled driver across Android, Desktop, and iOS.
-- **Jetpack DataStore:** Shared preferences.
-
-## Networking & Transport
-- **Ktor:** Multiplatform HTTP client for web services and TCP streaming.
-- **Kable:** Multiplatform BLE library used as the primary BLE transport for all targets (Android, Desktop, and future iOS).
-- **jSerialComm:** Cross-platform Java library used for direct Serial/USB communication with Meshtastic devices on the Desktop (JVM) target.
-- **KMQTT:** Kotlin Multiplatform MQTT client and broker used for MQTT transport, replacing the Android-only Paho library.
-- **Coroutines & Flows:** For asynchronous programming and state management.
-
-## Testing (KMP)
-- **Shared Tests First:** The majority of business logic, ViewModels, and state interactions are tested in the `commonTest` source set using standard `kotlin.test`.
-- **Coroutines Testing:** Use `kotlinx-coroutines-test` for virtual time management in asynchronous flows.
-- **Mocking Strategy:** Avoid JVM-specific mocking libraries. Prefer `Mokkery` or `Mockative` for multiplatform-compatible mocking interfaces, alongside handwritten fakes in `core:testing`.
-- **Flow Assertions:** Use `Turbine` for testing multiplatform `Flow` emissions and state updates.
-- **Property-Based Testing:** Use `Kotest` for multiplatform data-driven and property-based testing scenarios.
\ No newline at end of file
diff --git a/conductor/tracks.md b/conductor/tracks.md
deleted file mode 100644
index 0b5c54e3d..000000000
--- a/conductor/tracks.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Project Tracks
-
-This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
-
----
diff --git a/conductor/workflow.md b/conductor/workflow.md
deleted file mode 100644
index 6f9cfd8fc..000000000
--- a/conductor/workflow.md
+++ /dev/null
@@ -1,333 +0,0 @@
-# Project Workflow
-
-## Guiding Principles
-
-1. **The Plan is the Source of Truth:** All work must be tracked in `plan.md`
-2. **The Tech Stack is Deliberate:** Changes to the tech stack must be documented in `tech-stack.md` *before* implementation
-3. **Test-Driven Development:** Write unit tests before implementing functionality
-4. **High Code Coverage:** Aim for >80% code coverage for all modules
-5. **User Experience First:** Every decision should prioritize user experience
-6. **Non-Interactive & CI-Aware:** Prefer non-interactive commands. Use `CI=true` for watch-mode tools (tests, linters) to ensure single execution.
-
-## Task Workflow
-
-All tasks follow a strict lifecycle:
-
-### Standard Task Workflow
-
-1. **Select Task:** Choose the next available task from `plan.md` in sequential order
-
-2. **Mark In Progress:** Before beginning work, edit `plan.md` and change the task from `[ ]` to `[~]`
-
-3. **Write Failing Tests (Red Phase):**
- - Create a new test file for the feature or bug fix.
- - Write one or more unit tests that clearly define the expected behavior and acceptance criteria for the task.
- - **CRITICAL:** Run the tests and confirm that they fail as expected. This is the "Red" phase of TDD. Do not proceed until you have failing tests.
-
-4. **Implement to Pass Tests (Green Phase):**
- - Write the minimum amount of application code necessary to make the failing tests pass.
- - Run the test suite again and confirm that all tests now pass. This is the "Green" phase.
-
-5. **Refactor (Optional but Recommended):**
- - With the safety of passing tests, refactor the implementation code and the test code to improve clarity, remove duplication, and enhance performance without changing the external behavior.
- - Rerun tests to ensure they still pass after refactoring.
-
-6. **Verify Coverage:** Run coverage reports using the project's chosen tools. For example, in a Python project, this might look like:
- ```bash
- pytest --cov=app --cov-report=html
- ```
- Target: >80% coverage for new code. The specific tools and commands will vary by language and framework.
-
-7. **Document Deviations:** If implementation differs from tech stack:
- - **STOP** implementation
- - Update `tech-stack.md` with new design
- - Add dated note explaining the change
- - Resume implementation
-
-8. **Commit Code Changes:**
- - Stage all code changes related to the task.
- - Propose a clear, concise commit message e.g, `feat(ui): Create basic HTML structure for calculator`.
- - Perform the commit.
-
-9. **Attach Task Summary with Git Notes:**
- - **Step 9.1: Get Commit Hash:** Obtain the hash of the *just-completed commit* (`git log -1 --format="%H"`).
- - **Step 9.2: Draft Note Content:** Create a detailed summary for the completed task. This should include the task name, a summary of changes, a list of all created/modified files, and the core "why" for the change.
- - **Step 9.3: Attach Note:** Use the `git notes` command to attach the summary to the commit.
- ```bash
- # The note content from the previous step is passed via the -m flag.
- git notes add -m ""
- ```
-
-10. **Get and Record Task Commit SHA:**
- - **Step 10.1: Update Plan:** Read `plan.md`, find the line for the completed task, update its status from `[~]` to `[x]`, and append the first 7 characters of the *just-completed commit's* commit hash.
- - **Step 10.2: Write Plan:** Write the updated content back to `plan.md`.
-
-11. **Commit Plan Update:**
- - **Action:** Stage the modified `plan.md` file.
- - **Action:** Commit this change with a descriptive message (e.g., `conductor(plan): Mark task 'Create user model' as complete`).
-
-### Phase Completion Verification and Checkpointing Protocol
-
-**Trigger:** This protocol is executed immediately after a task is completed that also concludes a phase in `plan.md`.
-
-1. **Announce Protocol Start:** Inform the user that the phase is complete and the verification and checkpointing protocol has begun.
-
-2. **Ensure Test Coverage for Phase Changes:**
- - **Step 2.1: Determine Phase Scope:** To identify the files changed in this phase, you must first find the starting point. Read `plan.md` to find the Git commit SHA of the *previous* phase's checkpoint. If no previous checkpoint exists, the scope is all changes since the first commit.
- - **Step 2.2: List Changed Files:** Execute `git diff --name-only HEAD` to get a precise list of all files modified during this phase.
- - **Step 2.3: Verify and Create Tests:** For each file in the list:
- - **CRITICAL:** First, check its extension. Exclude non-code files (e.g., `.json`, `.md`, `.yaml`).
- - For each remaining code file, verify a corresponding test file exists.
- - If a test file is missing, you **must** create one. Before writing the test, **first, analyze other test files in the repository to determine the correct naming convention and testing style.** The new tests **must** validate the functionality described in this phase's tasks (`plan.md`).
-
-3. **Execute Automated Tests with Proactive Debugging:**
- - Before execution, you **must** announce the exact shell command you will use to run the tests.
- - **Example Announcement:** "I will now run the automated test suite to verify the phase. **Command:** `CI=true npm test`"
- - Execute the announced command.
- - If tests fail, you **must** inform the user and begin debugging. You may attempt to propose a fix a **maximum of two times**. If the tests still fail after your second proposed fix, you **must stop**, report the persistent failure, and ask the user for guidance.
-
-4. **Propose a Detailed, Actionable Manual Verification Plan:**
- - **CRITICAL:** To generate the plan, first analyze `product.md`, `product-guidelines.md`, and `plan.md` to determine the user-facing goals of the completed phase.
- - You **must** generate a step-by-step plan that walks the user through the verification process, including any necessary commands and specific, expected outcomes.
- - The plan you present to the user **must** follow this format:
-
- **For a Frontend Change:**
- ```
- The automated tests have passed. For manual verification, please follow these steps:
-
- **Manual Verification Steps:**
- 1. **Start the development server with the command:** `npm run dev`
- 2. **Open your browser to:** `http://localhost:3000`
- 3. **Confirm that you see:** The new user profile page, with the user's name and email displayed correctly.
- ```
-
- **For a Backend Change:**
- ```
- The automated tests have passed. For manual verification, please follow these steps:
-
- **Manual Verification Steps:**
- 1. **Ensure the server is running.**
- 2. **Execute the following command in your terminal:** `curl -X POST http://localhost:8080/api/v1/users -d '{"name": "test"}'`
- 3. **Confirm that you receive:** A JSON response with a status of `201 Created`.
- ```
-
-5. **Await Explicit User Feedback:**
- - After presenting the detailed plan, ask the user for confirmation: "**Does this meet your expectations? Please confirm with yes or provide feedback on what needs to be changed.**"
- - **PAUSE** and await the user's response. Do not proceed without an explicit yes or confirmation.
-
-6. **Create Checkpoint Commit:**
- - Stage all changes. If no changes occurred in this step, proceed with an empty commit.
- - Perform the commit with a clear and concise message (e.g., `conductor(checkpoint): Checkpoint end of Phase X`).
-
-7. **Attach Auditable Verification Report using Git Notes:**
- - **Step 7.1: Draft Note Content:** Create a detailed verification report including the automated test command, the manual verification steps, and the user's confirmation.
- - **Step 7.2: Attach Note:** Use the `git notes` command and the full commit hash from the previous step to attach the full report to the checkpoint commit.
-
-8. **Get and Record Phase Checkpoint SHA:**
- - **Step 8.1: Get Commit Hash:** Obtain the hash of the *just-created checkpoint commit* (`git log -1 --format="%H"`).
- - **Step 8.2: Update Plan:** Read `plan.md`, find the heading for the completed phase, and append the first 7 characters of the commit hash in the format `[checkpoint: ]`.
- - **Step 8.3: Write Plan:** Write the updated content back to `plan.md`.
-
-9. **Commit Plan Update:**
- - **Action:** Stage the modified `plan.md` file.
- - **Action:** Commit this change with a descriptive message following the format `conductor(plan): Mark phase '' as complete`.
-
-10. **Announce Completion:** Inform the user that the phase is complete and the checkpoint has been created, with the detailed verification report attached as a git note.
-
-### Quality Gates
-
-Before marking any task complete, verify:
-
-- [ ] All tests pass
-- [ ] Code coverage meets requirements (>80%)
-- [ ] Code follows project's code style guidelines (as defined in `code_styleguides/`)
-- [ ] All public functions/methods are documented (e.g., docstrings, JSDoc, GoDoc)
-- [ ] Type safety is enforced (e.g., type hints, TypeScript types, Go types)
-- [ ] No linting or static analysis errors (using the project's configured tools)
-- [ ] Works correctly on mobile (if applicable)
-- [ ] Documentation updated if needed
-- [ ] No security vulnerabilities introduced
-
-## Development Commands
-
-**AI AGENT INSTRUCTION: This section should be adapted to the project's specific language, framework, and build tools.**
-
-### Setup
-```bash
-# Example: Commands to set up the development environment (e.g., install dependencies, configure database)
-# e.g., for a Node.js project: npm install
-# e.g., for a Go project: go mod tidy
-```
-
-### Daily Development
-```bash
-# Example: Commands for common daily tasks (e.g., start dev server, run tests, lint, format)
-# e.g., for a Node.js project: npm run dev, npm test, npm run lint
-# e.g., for a Go project: go run main.go, go test ./..., go fmt ./...
-```
-
-### Before Committing
-```bash
-# Example: Commands to run all pre-commit checks (e.g., format, lint, type check, run tests)
-# e.g., for a Node.js project: npm run check
-# e.g., for a Go project: make check (if a Makefile exists)
-```
-
-## Testing Requirements
-
-### Unit Testing
-- Every module must have corresponding tests.
-- Use appropriate test setup/teardown mechanisms (e.g., fixtures, beforeEach/afterEach).
-- Mock external dependencies.
-- Test both success and failure cases.
-
-### Integration Testing
-- Test complete user flows
-- Verify database transactions
-- Test authentication and authorization
-- Check form submissions
-
-### Mobile Testing
-- Test on actual iPhone when possible
-- Use Safari developer tools
-- Test touch interactions
-- Verify responsive layouts
-- Check performance on 3G/4G
-
-## Code Review Process
-
-### Self-Review Checklist
-Before requesting review:
-
-1. **Functionality**
- - Feature works as specified
- - Edge cases handled
- - Error messages are user-friendly
-
-2. **Code Quality**
- - Follows style guide
- - DRY principle applied
- - Clear variable/function names
- - Appropriate comments
-
-3. **Testing**
- - Unit tests comprehensive
- - Integration tests pass
- - Coverage adequate (>80%)
-
-4. **Security**
- - No hardcoded secrets
- - Input validation present
- - SQL injection prevented
- - XSS protection in place
-
-5. **Performance**
- - Database queries optimized
- - Images optimized
- - Caching implemented where needed
-
-6. **Mobile Experience**
- - Touch targets adequate (44x44px)
- - Text readable without zooming
- - Performance acceptable on mobile
- - Interactions feel native
-
-## Commit Guidelines
-
-### Message Format
-```
-():
-
-[optional body]
-
-[optional footer]
-```
-
-### Types
-- `feat`: New feature
-- `fix`: Bug fix
-- `docs`: Documentation only
-- `style`: Formatting, missing semicolons, etc.
-- `refactor`: Code change that neither fixes a bug nor adds a feature
-- `test`: Adding missing tests
-- `chore`: Maintenance tasks
-
-### Examples
-```bash
-git commit -m "feat(auth): Add remember me functionality"
-git commit -m "fix(posts): Correct excerpt generation for short posts"
-git commit -m "test(comments): Add tests for emoji reaction limits"
-git commit -m "style(mobile): Improve button touch targets"
-```
-
-## Definition of Done
-
-A task is complete when:
-
-1. All code implemented to specification
-2. Unit tests written and passing
-3. Code coverage meets project requirements
-4. Documentation complete (if applicable)
-5. Code passes all configured linting and static analysis checks
-6. Works beautifully on mobile (if applicable)
-7. Implementation notes added to `plan.md`
-8. Changes committed with proper message
-9. Git note with task summary attached to the commit
-
-## Emergency Procedures
-
-### Critical Bug in Production
-1. Create hotfix branch from main
-2. Write failing test for bug
-3. Implement minimal fix
-4. Test thoroughly including mobile
-5. Deploy immediately
-6. Document in plan.md
-
-### Data Loss
-1. Stop all write operations
-2. Restore from latest backup
-3. Verify data integrity
-4. Document incident
-5. Update backup procedures
-
-### Security Breach
-1. Rotate all secrets immediately
-2. Review access logs
-3. Patch vulnerability
-4. Notify affected users (if any)
-5. Document and update security procedures
-
-## Deployment Workflow
-
-### Pre-Deployment Checklist
-- [ ] All tests passing
-- [ ] Coverage >80%
-- [ ] No linting errors
-- [ ] Mobile testing complete
-- [ ] Environment variables configured
-- [ ] Database migrations ready
-- [ ] Backup created
-
-### Deployment Steps
-1. Merge feature branch to main
-2. Tag release with version
-3. Push to deployment service
-4. Run database migrations
-5. Verify deployment
-6. Test critical paths
-7. Monitor for errors
-
-### Post-Deployment
-1. Monitor analytics
-2. Check error logs
-3. Gather user feedback
-4. Plan next iteration
-
-## Continuous Improvement
-
-- Review workflow weekly
-- Update based on pain points
-- Document lessons learned
-- Optimize for user happiness
-- Keep things simple and maintainable
diff --git a/config.properties b/config.properties
index 1bb8534cd..de820bc85 100644
--- a/config.properties
+++ b/config.properties
@@ -21,8 +21,8 @@ VERSION_CODE_OFFSET=29314197
# Application and SDK versions
APPLICATION_ID=com.geeksville.mesh
MIN_SDK=26
-TARGET_SDK=36
-COMPILE_SDK=36
+TARGET_SDK=37
+COMPILE_SDK=37
# Base version name for local development and fallback
# On CI, this is overridden by the Git tag
diff --git a/config/proguard/shared-rules.pro b/config/proguard/shared-rules.pro
new file mode 100644
index 000000000..8d0d8efde
--- /dev/null
+++ b/config/proguard/shared-rules.pro
@@ -0,0 +1,166 @@
+# ============================================================================
+# Meshtastic — Shared ProGuard / R8 rules
+# ============================================================================
+# Cross-platform keep and dontwarn rules applied to BOTH the Android app
+# release build (R8) and the Desktop distribution (ProGuard). Host-specific
+# rules live in the per-module proguard-rules.pro file.
+#
+# Rule of thumb: anything describing a library shared between Android and
+# Desktop (Koin, kotlinx-serialization, Wire, Room KMP, Ktor, Coil 3, Kable,
+# Kermit, Okio, DataStore, Paging, Lifecycle / Navigation 3, AboutLibraries,
+# Markdown renderer, QRCode, Compose Multiplatform resources, core modules)
+# belongs here. Anything platform-specific (AWT/Skiko/JNA, AIDL, Android
+# framework, JDK-version quirks, flavor specifics) stays in the host file.
+# ============================================================================
+
+# ---- Attributes -------------------------------------------------------------
+
+# Preserve line numbers for meaningful stack traces, plus metadata needed for
+# reflective serializer/DI/Room lookups.
+-keepattributes SourceFile,LineNumberTable,*Annotation*,Signature,InnerClasses,EnclosingMethod,Exceptions,RuntimeVisibleAnnotations
+
+# ---- Kotlin / Coroutines ----------------------------------------------------
+# Kotlin stdlib and kotlinx-coroutines ship their own consumer ProGuard rules
+# (kotlin-stdlib and kotlinx-coroutines-core consumer-rules.pro) which keep
+# Metadata, Continuation, kotlin.reflect internals, and debug metadata. No
+# explicit wildcards needed here.
+
+# ---- Koin DI (reflection-based injection) -----------------------------------
+
+# Prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException
+# replacing Koin's InstanceCreationException in stack traces, making crashes
+# undiagnosable). Broadened to all of koin core to cover the KSP-generated graph.
+-keep class org.koin.** { *; }
+-dontwarn org.koin.**
+
+# Keep Koin-annotated modules/components so Koin Annotations (KSP) output
+# survives tree-shaking.
+-keep @org.koin.core.annotation.Module class * { *; }
+-keep @org.koin.core.annotation.ComponentScan class * { *; }
+-keep @org.koin.core.annotation.Single class * { *; }
+-keep @org.koin.core.annotation.Factory class * { *; }
+-keep @org.koin.core.annotation.KoinViewModel class * { *; }
+
+# ---- kotlinx-serialization --------------------------------------------------
+
+-keep class kotlinx.serialization.** { *; }
+-dontwarn kotlinx.serialization.**
+
+# Keep @Serializable classes and their generated $serializer companions
+-keepclassmembers @kotlinx.serialization.Serializable class ** {
+ static ** Companion;
+ kotlinx.serialization.KSerializer serializer(...);
+}
+-keep class **.$serializer { *; }
+-keepclassmembers class **.$serializer { *; }
+-keepclasseswithmembers class ** {
+ kotlinx.serialization.KSerializer serializer(...);
+}
+
+# ---- Wire Protobuf ----------------------------------------------------------
+
+# Wire generates an ADAPTER static field on every Message subclass accessed
+# reflectively during encoding/decoding. Keep those fields and the
+# ProtoAdapter subclasses themselves; Wire's bundled consumer rules preserve
+# the runtime itself.
+-keepclassmembers class * extends com.squareup.wire.Message {
+ public static *** ADAPTER;
+}
+-keepclassmembers class * extends com.squareup.wire.ProtoAdapter { *; }
+
+# Suppress warnings about missing Android Parcelable (Wire cross-platform stubs
+# when compiling for non-Android JVM targets; harmless on Android).
+-dontwarn android.os.Parcel**
+-dontwarn android.os.Parcelable**
+
+# ---- Room KMP (room3) -------------------------------------------------------
+
+# Preserve generated database constructors (Room uses reflection to instantiate)
+-keep class * extends androidx.room3.RoomDatabase { (); }
+-keep class * implements androidx.room3.RoomDatabaseConstructor { *; }
+
+# Keep the expect/actual MeshtasticDatabaseConstructor + database surface
+-keep class org.meshtastic.core.database.MeshtasticDatabaseConstructor { *; }
+-keep class org.meshtastic.core.database.MeshtasticDatabase { *; }
+
+# Room's own consumer rules (from androidx.room3) keep DAOs, entities,
+# generated _Impl classes, and TypeConverters referenced from the database.
+
+# ---- SQLite bundled --------------------------------------------------------
+# androidx.sqlite ships consumer rules.
+
+# ---- Ktor (ServiceLoader + plugin discovery) --------------------------------
+
+# Keep ServiceLoader metadata files (ktor discovers HttpClientEngineFactory
+# implementations reflectively via ServiceLoader).
+-keepclassmembers class * implements io.ktor.client.HttpClientEngineFactory { *; }
+
+# ---- Coil 3 (image loading) -------------------------------------------------
+# coil3 ships consumer rules.
+
+# ---- Kable BLE --------------------------------------------------------------
+# com.juul.kable ships consumer rules; if release builds fail with missing
+# Kable classes, restore a narrow keep for the specific reflection-loaded type.
+
+# ---- Compose Multiplatform resources ----------------------------------------
+
+# Generated resource accessor classes (Res.string.*, Res.drawable.*, etc.).
+# Without these the fdroid flavor has crashed at startup with a misleading
+# URLDecodeException due to R8 exception-class merging.
+-keep class org.jetbrains.compose.resources.** { *; }
+-keep class org.meshtastic.core.resources.Res { *; }
+-keepclassmembers class org.meshtastic.core.resources.Res$* { *; }
+
+# ---- AboutLibraries ---------------------------------------------------------
+# com.mikepenz.aboutlibraries ships consumer rules.
+
+# ---- Multiplatform Markdown Renderer ----------------------------------------
+# com.mikepenz.markdown ships consumer rules.
+
+# ---- QR Code Kotlin ---------------------------------------------------------
+
+-keep class io.github.g0dkar.qrcode.** { *; }
+-dontwarn io.github.g0dkar.qrcode.**
+-keep class qrcode.** { *; }
+-dontwarn qrcode.**
+
+# ---- Kermit logging ---------------------------------------------------------
+# co.touchlab.kermit ships consumer rules.
+
+# ---- Okio -------------------------------------------------------------------
+# okio ships consumer rules.
+
+# ---- DataStore --------------------------------------------------------------
+# androidx.datastore ships consumer rules.
+
+# ---- Paging -----------------------------------------------------------------
+# androidx.paging ships consumer rules.
+
+# ---- Lifecycle / Navigation 3 / ViewModel (JetBrains forks) -----------------
+# androidx.lifecycle and androidx.navigation3 ship consumer rules.
+
+# ---- Meshtastic shared model ------------------------------------------------
+# core.model types are reached via static references from Koin-wired graphs,
+# Room entities, and kotlinx-serialization @Serializable companions — all of
+# which have their own keep rules above.
+
+# ---- Compose Runtime & Animation --------------------------------------------
+
+# Defence-in-depth: prevent tree-shaking of Compose infrastructure classes that
+# are referenced indirectly through compiler-generated state machines. Applies
+# to BOTH R8 (Android app) and ProGuard (desktop distribution).
+#
+# Why shared: CMP 1.11 ships consumer rules with -assumenosideeffects on
+# Composer.() / ComposerImpl.() and -assumevalues on
+# ComposeRuntimeFlags / ComposeStackTraceMode. If the optimizer runs (R8 full
+# mode on Android, ProGuard with optimize.set(true) on desktop) these call
+# sites can be rewritten even when the target classes are kept, causing the
+# recomposer / frame-clock / animation state machines to silently freeze on
+# the first frame. -dontoptimize (set per-host) is the primary defence; these
+# keep rules are a safety net against future toolchain changes. See #5146.
+-keep class androidx.compose.runtime.** { *; }
+-keep class androidx.compose.ui.** { *; }
+-keep class androidx.compose.animation.core.** { *; }
+-keep class androidx.compose.animation.** { *; }
+-keep class androidx.compose.foundation.** { *; }
+-keep class androidx.compose.material3.** { *; }
diff --git a/core/api/README.md b/core/api/README.md
index 1a8f10f02..4d2be1b40 100644
--- a/core/api/README.md
+++ b/core/api/README.md
@@ -1,7 +1,17 @@
# `:core:api` (Meshtastic Android API)
+> **Deprecation notice**
+>
+> The AIDL-based service integration (`IMeshService`) is deprecated and will be removed in a future
+> release. The recommended integration path for ATAK and other external apps is the built-in
+> **Local TAK Server** introduced in `core:takserver`. Connect ATAK to `127.0.0.1:8087` (TCP) and
+> import the DataPackage exported from the TAK Config screen to complete setup. No AIDL binding or
+> JitPack dependency is required.
+
## Overview
-The `:core:api` module contains the stable AIDL interface and dependencies required for third-party applications to integrate with the Meshtastic Android app.
+The `:core:api` module contains the AIDL interface and dependencies for third-party applications
+that currently integrate with the Meshtastic Android app via service binding. New integrations
+should use the Local TAK Server instead (see deprecation notice above).
## Integration
@@ -61,6 +71,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
+classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
diff --git a/core/api/build.gradle.kts b/core/api/build.gradle.kts
index 94d10fdd9..dd3f65acf 100644
--- a/core/api/build.gradle.kts
+++ b/core/api/build.gradle.kts
@@ -33,6 +33,10 @@ configure {
publishing { singleVariant("release") { withSourcesJar() } }
}
+// Suppress dep-ann warnings from AIDL-generated code where Javadoc @deprecated
+// doesn't produce @Deprecated annotations on Stub/Proxy override methods.
+tasks.withType().configureEach { options.compilerArgs.add("-Xlint:-dep-ann") }
+
// Map the Android component to a Maven publication
afterEvaluate {
publishing {
diff --git a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
index b9678508e..f2307dd90 100644
--- a/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
+++ b/core/api/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl
@@ -10,6 +10,10 @@ import org.meshtastic.core.model.MyNodeInfo;
/**
This is the public android API for talking to meshtastic radios.
+@deprecated The AIDL service integration is deprecated and will be removed in a future release.
+ New integrations should connect via the built-in Local TAK Server on 127.0.0.1:8087 (TCP).
+ Import the DataPackage from the TAK Config screen in the Meshtastic app to configure ATAK.
+
To connect to meshtastic you should bind to it per https://developer.android.com/guide/components/bound-services
The intent you use to reach the service should ideally use the action string:
@@ -156,20 +160,27 @@ interface IMeshService {
*/
String connectionState();
- /// If a macaddress we will try to talk to our device, if null we will be idle.
- /// Any current connection will be dropped (even if the device address is the same) before reconnecting.
- /// Users should not call this directly, only used internally by the MeshUtil activity
- /// Returns true if the device address actually changed, or false if no change was needed
+ /**
+ * @deprecated For internal use only. External callers must not invoke this method;
+ * it will be removed from the public API in a future release.
+ */
boolean setDeviceAddress(String deviceAddr);
/// Get basic device hardware info about our connected radio. Will never return NULL. Will return NULL
/// if no my node info is available (i.e. it will not throw an exception)
MyNodeInfo getMyNodeInfo();
- /// Start updating the radios firmware
+ /**
+ * @deprecated No-op stub — firmware update is now handled entirely by the in-app OTA system.
+ * This method will be removed from the public API in a future release.
+ */
void startFirmwareUpdate();
- /// Return a number 0-100 for firmware update progress. -1 for completed and success, -2 for failure
+ /**
+ * @deprecated Always returns {@code -4}, which is outside the documented range.
+ * Firmware update progress is now tracked internally by the in-app OTA system.
+ * This method will be removed from the public API in a future release.
+ */
int getUpdateStatus();
/// Start providing location (from phone GPS) to mesh
diff --git a/core/api/src/main/kotlin/org/meshtastic/core/api/MeshtasticIntent.kt b/core/api/src/main/kotlin/org/meshtastic/core/api/MeshtasticIntent.kt
index 9b3671914..152b5f143 100644
--- a/core/api/src/main/kotlin/org/meshtastic/core/api/MeshtasticIntent.kt
+++ b/core/api/src/main/kotlin/org/meshtastic/core/api/MeshtasticIntent.kt
@@ -37,7 +37,16 @@ object MeshtasticIntent {
/** Broadcast when the mesh radio disconnects. */
const val ACTION_MESH_DISCONNECTED = "$PREFIX.MESH_DISCONNECTED"
- /** Legacy broadcast for connection changes. Extra: [EXTRA_CONNECTED] */
+ /**
+ * Legacy broadcast for connection changes. Extra: [EXTRA_CONNECTED]
+ *
+ * Prefer [ACTION_MESH_CONNECTED] / [ACTION_MESH_DISCONNECTED] instead. This constant will be removed from the
+ * public API in a future release.
+ */
+ @Deprecated(
+ message = "Use ACTION_MESH_CONNECTED / ACTION_MESH_DISCONNECTED instead.",
+ replaceWith = ReplaceWith("ACTION_MESH_CONNECTED"),
+ )
const val ACTION_CONNECTION_CHANGED = "$PREFIX.CONNECTION_CHANGED"
/** Broadcast for message status updates. Extras: [EXTRA_PACKET_ID], [EXTRA_STATUS] */
diff --git a/core/barcode/README.md b/core/barcode/README.md
index ebbaf06f9..c64fcca6c 100644
--- a/core/barcode/README.md
+++ b/core/barcode/README.md
@@ -42,7 +42,7 @@ scanner.startScan()
```mermaid
graph TB
- :core:barcode[barcode]:::compose-desktop-application
+ :core:barcode[barcode]:::android-library
:core:barcode -.-> :core:resources
:core:barcode -.-> :core:ui
@@ -55,6 +55,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
+classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts
index 5e942657e..711cccc09 100644
--- a/core/barcode/build.gradle.kts
+++ b/core/barcode/build.gradle.kts
@@ -33,10 +33,9 @@ dependencies {
implementation(projects.core.ui)
implementation(libs.androidx.activity.compose)
- implementation(libs.androidx.compose.material3)
- implementation(libs.androidx.compose.material.iconsExtended)
- implementation(libs.androidx.compose.runtime)
- implementation(libs.androidx.compose.ui)
+ implementation(libs.compose.multiplatform.material3)
+ implementation(libs.compose.multiplatform.runtime)
+ implementation(libs.compose.multiplatform.ui)
implementation(libs.accompanist.permissions)
implementation(libs.kermit)
@@ -51,10 +50,8 @@ dependencies {
implementation(libs.androidx.camera.viewfinder.compose)
testImplementation(libs.junit)
+ testRuntimeOnly(libs.junit.vintage.engine)
testImplementation(libs.robolectric)
- testImplementation(libs.androidx.compose.ui.test.junit4)
-
- androidTestImplementation(libs.androidx.test.ext.junit)
- androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ testImplementation(libs.compose.multiplatform.ui.test)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt
index 5c266b544..fae85eba5 100644
--- a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt
+++ b/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt
@@ -29,8 +29,6 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
@@ -61,6 +59,8 @@ import com.google.accompanist.permissions.rememberPermissionState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
+import org.meshtastic.core.ui.icon.Close
+import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.BarcodeScanner
import java.util.concurrent.Executors
@@ -116,7 +116,7 @@ private fun BarcodeScannerDialog(onResult: (String?) -> Unit, onDismiss: () -> U
}
IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart).padding(16.dp)) {
Icon(
- imageVector = Icons.Default.Close,
+ imageVector = MeshtasticIcons.Close,
contentDescription = stringResource(Res.string.close),
tint = Color.White,
)
diff --git a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
index bd3490566..aa222b7c2 100644
--- a/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
+++ b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
@@ -16,21 +16,17 @@
*/
package org.meshtastic.core.barcode
-import androidx.compose.ui.test.junit4.createComposeRule
-import org.junit.Rule
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.runComposeUiTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+@OptIn(ExperimentalTestApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class BarcodeScannerTest {
- @get:Rule val composeTestRule = createComposeRule()
-
- @Test
- fun testRememberBarcodeScanner() {
- composeTestRule.setContent { rememberBarcodeScanner { _ -> } }
- }
+ @Test fun testRememberBarcodeScanner() = runComposeUiTest { setContent { rememberBarcodeScanner { _ -> } } }
}
diff --git a/core/ble/README.md b/core/ble/README.md
index 90cb7f3f2..a0f1adc75 100644
--- a/core/ble/README.md
+++ b/core/ble/README.md
@@ -16,6 +16,7 @@ classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
+classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;
diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts
index b9299764d..f270e6aa3 100644
--- a/core/ble/build.gradle.kts
+++ b/core/ble/build.gradle.kts
@@ -43,21 +43,12 @@ kotlin {
androidMain.dependencies {
implementation(libs.androidx.lifecycle.process)
- implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.jetbrains.lifecycle.runtime)
}
- jvmMain.dependencies {}
-
commonTest.dependencies {
- implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
- }
-
- val androidHostTest by getting {
- dependencies {
- implementation(libs.junit)
- implementation(libs.androidx.lifecycle.testing)
- }
+ implementation(projects.core.testing)
}
}
}
diff --git a/core/ble/detekt-baseline.xml b/core/ble/detekt-baseline.xml
new file mode 100644
index 000000000..0283be975
--- /dev/null
+++ b/core/ble/detekt-baseline.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ MagicNumber:KableBleConnection.kt$KableBleConnection$512
+ MagicNumber:KablePlatformSetup.kt$3
+
+
diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
index c471e2261..b330453e1 100644
--- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
+++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
@@ -31,9 +31,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
+import kotlin.coroutines.resume
/** Android implementation of [BluetoothRepository]. */
@Single
@@ -48,7 +50,7 @@ class AndroidBluetoothRepository(
private val _state = MutableStateFlow(BluetoothState(hasPermissions = hasBluetoothPermissions()))
override val state: StateFlow = _state.asStateFlow()
- private val deviceCache = mutableMapOf()
+ private val deviceCache = mutableMapOf()
init {
processLifecycle.coroutineScope.launch(dispatchers.default) { updateBluetoothState() }
@@ -85,15 +87,17 @@ class AndroidBluetoothRepository(
return
}
- kotlinx.coroutines.suspendCancellableCoroutine { cont ->
+ suspendCancellableCoroutine { cont ->
val receiver =
object : android.content.BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(c: Context, intent: android.content.Intent) {
if (intent.action == android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
val d =
- intent.getParcelableExtra(
+ androidx.core.content.IntentCompat.getParcelableExtra(
+ intent,
android.bluetooth.BluetoothDevice.EXTRA_DEVICE,
+ android.bluetooth.BluetoothDevice::class.java,
)
if (d?.address?.equals(macAddress, ignoreCase = true) == true) {
val state =
@@ -111,7 +115,7 @@ class AndroidBluetoothRepository(
try {
context.unregisterReceiver(this)
} catch (ignored: Exception) {}
- if (cont.isActive) cont.resume(Unit) {}
+ if (cont.isActive) cont.resume(Unit)
} else if (
state == android.bluetooth.BluetoothDevice.BOND_NONE &&
prevState == android.bluetooth.BluetoothDevice.BOND_BONDING
@@ -170,9 +174,24 @@ class AndroidBluetoothRepository(
}
@SuppressLint("MissingPermission")
- private fun getBondedAppPeripherals(): List = bluetoothAdapter?.bondedDevices?.map { device ->
- deviceCache.getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
- } ?: emptyList()
+ private fun getBondedAppPeripherals(): List {
+ val bonded = bluetoothAdapter?.bondedDevices ?: return emptyList()
+ val bondedAddresses = bonded.mapTo(mutableSetOf()) { it.address }
+ // Evict entries for devices that are no longer bonded and update names in case the
+ // user renamed the device in firmware since the cache was populated.
+ deviceCache.keys.retainAll(bondedAddresses)
+ return bonded.map { device ->
+ val cached = deviceCache.getOrPut(device.address) { MeshtasticBleDevice(device.address, device.name) }
+ // If the name changed (firmware rename, etc.), replace the cached entry and return the new one.
+ if (cached.name != device.name) {
+ val updated = MeshtasticBleDevice(device.address, device.name)
+ deviceCache[device.address] = updated
+ updated
+ } else {
+ cached
+ }
+ }
+ }
@SuppressLint("MissingPermission")
override fun isBonded(address: String): Boolean = try {
diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
index 106d1f8f8..b0617635a 100644
--- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
+++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
@@ -17,17 +17,32 @@
package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
+import com.juul.kable.AndroidPeripheral
import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
+import com.juul.kable.PooledThreadingStrategy
import com.juul.kable.toIdentifier
+/**
+ * Shared thread pool for Kable BLE connections.
+ *
+ * [PooledThreadingStrategy] reuses handler threads across reconnect cycles, avoiding the overhead of creating a new
+ * thread per connection attempt that [OnDemandThreadingStrategy][com.juul.kable.OnDemandThreadingStrategy] incurs. Idle
+ * threads are evicted after 1 minute (default).
+ *
+ * A single app-wide instance is used because Kable recommends exactly one pool per application.
+ */
+private val sharedThreadingStrategy = PooledThreadingStrategy()
+
internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConnect: () -> Boolean) {
- // If we're connecting blindly to a bonded device without a fresh scan (DirectBleDevice),
- // we MUST use autoConnect = true. Otherwise, Android's direct connect algorithm will often fail
- // immediately with GATT 133 or timeout, especially if the device uses random resolvable addresses.
- // If we just scanned the device (KableBleDevice), direct connection (autoConnect = false) is faster.
+ // Bonded devices without a fresh advertisement must use autoConnect = true. Otherwise,
+ // Android's direct connect algorithm often fails with GATT 133 or times out, especially
+ // if the device uses random resolvable addresses. Scanned devices (advertisement != null)
+ // use direct connection (autoConnect = false) for faster initial connects.
autoConnectIf(autoConnect)
+ threadingStrategy = sharedThreadingStrategy
+
onServicesDiscovered {
try {
// Android defaults to 23 bytes MTU. Meshtastic packets can be 512 bytes.
@@ -43,3 +58,11 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
internal actual fun createPeripheral(address: String, builderAction: PeripheralBuilder.() -> Unit): Peripheral =
com.juul.kable.Peripheral(address.toIdentifier(), builderAction)
+
+/** ATT protocol header size (opcode + handle) subtracted from MTU to get the usable payload. */
+private const val ATT_HEADER_SIZE = 3
+
+internal actual fun Peripheral.negotiatedMaxWriteLength(): Int? {
+ val mtu = (this as? AndroidPeripheral)?.mtu?.value ?: return null
+ return (mtu - ATT_HEADER_SIZE).takeIf { it > 0 }
+}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt
index 004beec06..1ea11622d 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/ActiveBleConnection.kt
@@ -17,12 +17,19 @@
package org.meshtastic.core.ble
import com.juul.kable.Peripheral
+import kotlin.concurrent.Volatile
+
+/** Snapshot of the currently active BLE peripheral and its address, updated atomically. */
+internal data class ActiveConnection(val peripheral: Peripheral, val address: String)
/**
* A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between
* dynamically created UI devices (scanned vs bonded) and the actual connection.
+ *
+ * [active] is a single volatile reference so readers always see a consistent peripheral/address pair — the previous
+ * two-field design (`activePeripheral` + `activeAddress`) was susceptible to TOCTOU races when fields were updated
+ * non-atomically.
*/
internal object ActiveBleConnection {
- var activePeripheral: Peripheral? = null
- var activeAddress: String? = null
+ @Volatile var active: ActiveConnection? = null
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt
index 3855eff05..59cf134de 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnection.kt
@@ -17,7 +17,9 @@
package org.meshtastic.core.ble
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.onStart
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
@@ -28,6 +30,12 @@ enum class BleWriteType {
WITHOUT_RESPONSE,
}
+/** Identifies a characteristic within a profiled BLE service. */
+data class BleCharacteristic(val uuid: Uuid)
+
+/** Safe ATT payload length when MTU negotiation is unavailable (23-byte ATT MTU minus 3-byte header). */
+const val DEFAULT_BLE_WRITE_VALUE_LENGTH = 20
+
/** Encapsulates a BLE connection to a [BleDevice]. */
interface BleConnection {
/** The currently connected [BleDevice], or null if not connected. */
@@ -42,12 +50,8 @@ interface BleConnection {
/** Connects to the given [BleDevice]. */
suspend fun connect(device: BleDevice)
- /** Connects to the given [BleDevice] and waits for a terminal state. */
- suspend fun connectAndAwait(
- device: BleDevice,
- timeoutMs: Long,
- onRegister: suspend () -> Unit = {},
- ): BleConnectionState
+ /** Connects to the given [BleDevice] and waits for a terminal state or [timeout]. */
+ suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState
/** Disconnects from the current device. */
suspend fun disconnect()
@@ -59,11 +63,38 @@ interface BleConnection {
setup: suspend CoroutineScope.(BleService) -> T,
): T
- /** Returns the maximum write value length for the given write type. */
+ /** Returns the maximum write value length for the given write type, or `null` if unknown. */
fun maximumWriteValueLength(writeType: BleWriteType): Int?
}
/** Represents a BLE service for commonMain. */
interface BleService {
- // This will be expanded as needed, but for now we just need a common type to pass around.
+ /** Creates a handle for a characteristic belonging to this service. */
+ fun characteristic(uuid: Uuid): BleCharacteristic = BleCharacteristic(uuid)
+
+ /** Returns true when the characteristic is present on the connected device. */
+ fun hasCharacteristic(characteristic: BleCharacteristic): Boolean
+
+ /** Observes notifications/indications from the characteristic. */
+ fun observe(characteristic: BleCharacteristic): Flow
+
+ /**
+ * Observes notifications/indications from the characteristic with an [onSubscription] action that fires **after**
+ * notifications are enabled (CCCD written).
+ *
+ * The [onSubscription] is re-invoked on every reconnect while the returned [Flow] is active. The default
+ * implementation invokes [onSubscription] eagerly on flow start so non-Kable implementations still signal
+ * readiness.
+ */
+ fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit): Flow =
+ observe(characteristic).onStart { onSubscription() }
+
+ /** Reads the characteristic value once. */
+ suspend fun read(characteristic: BleCharacteristic): ByteArray
+
+ /** Returns the preferred write type for the characteristic on this platform/device. */
+ fun preferredWriteType(characteristic: BleCharacteristic): BleWriteType
+
+ /** Writes a value to the characteristic using the requested BLE write type. */
+ suspend fun write(characteristic: BleCharacteristic, data: ByteArray, writeType: BleWriteType)
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt
index a9f82c5f9..2026b0cb1 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleConnectionState.kt
@@ -17,16 +17,53 @@
package org.meshtastic.core.ble
/** Represents the state of a BLE connection. */
-sealed class BleConnectionState {
- /** The peripheral is disconnected. */
- object Disconnected : BleConnectionState()
+sealed interface BleConnectionState {
+
+ /**
+ * The peripheral is disconnected.
+ *
+ * @param reason why the disconnect occurred. [DisconnectReason.Unknown] when the platform doesn't provide status
+ * information (e.g. JavaScript) or when the disconnect was synthesised locally without a GATT callback.
+ */
+ data class Disconnected(val reason: DisconnectReason = DisconnectReason.Unknown) : BleConnectionState
/** The peripheral is connecting. */
- object Connecting : BleConnectionState()
+ data object Connecting : BleConnectionState
/** The peripheral is connected. */
- object Connected : BleConnectionState()
+ data object Connected : BleConnectionState
/** The peripheral is disconnecting. */
- object Disconnecting : BleConnectionState()
+ data object Disconnecting : BleConnectionState
+}
+
+/**
+ * Platform-agnostic reason for a BLE disconnect.
+ *
+ * Mapped from Kable's [com.juul.kable.State.Disconnected.Status] in `KableStateMapping`.
+ */
+sealed interface DisconnectReason {
+ /** Cause is unknown or the platform did not report one. */
+ data object Unknown : DisconnectReason
+
+ /** The local app/central initiated the disconnect. */
+ data object LocalDisconnect : DisconnectReason
+
+ /** The remote peripheral (firmware) initiated the disconnect. */
+ data object RemoteDisconnect : DisconnectReason
+
+ /** A connection attempt failed to establish. */
+ data object ConnectionFailed : DisconnectReason
+
+ /** The BLE link supervision timed out (device went out of range). */
+ data object Timeout : DisconnectReason
+
+ /** The connection was explicitly cancelled. */
+ data object Cancelled : DisconnectReason
+
+ /** An encryption or authentication failure occurred. */
+ data object EncryptionFailed : DisconnectReason
+
+ /** Platform-specific status code that doesn't map to a known reason. */
+ data class PlatformSpecific(val code: Int) : DisconnectReason
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
new file mode 100644
index 000000000..d273a0b90
--- /dev/null
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
@@ -0,0 +1,60 @@
+/*
+ * 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 .
+ */
+@file:Suppress("MatchingDeclarationName") // File groups the classifier function and its result type.
+
+package org.meshtastic.core.ble
+
+import com.juul.kable.GattRequestRejectedException
+import com.juul.kable.GattStatusException
+import com.juul.kable.NotConnectedException
+import com.juul.kable.UnmetRequirementException
+
+/**
+ * Classification of a BLE-layer exception for the transport layer to act on.
+ *
+ * @property isPermanent `true` if the condition cannot resolve without explicit user re-selection of the device.
+ * Currently always `false` — all known BLE exceptions can resolve without user intervention (BT toggling, permission
+ * grants, transient GATT errors). Reserved for future use.
+ * @property gattStatus the platform GATT status code when available (Android-specific).
+ * @property message a human-readable description of the failure.
+ */
+data class BleExceptionInfo(val isPermanent: Boolean, val gattStatus: Int? = null, val message: String)
+
+/**
+ * Inspects this [Throwable] and returns a [BleExceptionInfo] if it is a known Kable exception, or `null` if it is
+ * unrelated to the BLE layer.
+ *
+ * This keeps Kable type knowledge inside `core:ble` so that `core:network` (and other consumers) can classify BLE
+ * exceptions without depending on Kable directly.
+ */
+fun Throwable.classifyBleException(): BleExceptionInfo? = when (this) {
+ is GattStatusException ->
+ BleExceptionInfo(
+ isPermanent = false,
+ gattStatus = status,
+ message = "GATT error (status $status): $message",
+ )
+ is NotConnectedException -> BleExceptionInfo(isPermanent = false, message = "Not connected")
+ is GattRequestRejectedException ->
+ BleExceptionInfo(isPermanent = false, message = "GATT request rejected (busy)")
+ is UnmetRequirementException ->
+ // Bluetooth disabled or runtime permission missing. Both can resolve without re-selecting the
+ // device (user re-enables BT, or grants permission). Surface as transient so the transport keeps
+ // retrying; UI can show a hint based on the message.
+ BleExceptionInfo(isPermanent = false, message = message ?: "Bluetooth LE unavailable")
+ else -> null
+}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt
index c636d4718..5e85a52f8 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleRetry.kt
@@ -48,9 +48,7 @@ suspend fun retryBleOperation(
Logger.w(e) { "[$tag] BLE operation failed after $count attempts, giving up" }
throw e
}
- Logger.w(e) {
- "[$tag] BLE operation failed (attempt $currentAttempt/$count), " + "retrying in ${delayMs}ms..."
- }
+ Logger.w(e) { "[$tag] BLE operation failed (attempt $currentAttempt/$count), retrying in ${delayMs}ms..." }
delay(delayMs)
}
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleServiceExtensions.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleServiceExtensions.kt
index 8eba32a6b..50bb2e1f4 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleServiceExtensions.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleServiceExtensions.kt
@@ -17,7 +17,4 @@
package org.meshtastic.core.ble
/** Extension to convert a [BleService] to a [MeshtasticRadioProfile]. */
-fun BleService.toMeshtasticRadioProfile(): MeshtasticRadioProfile {
- val kableService = this as KableBleService
- return KableMeshtasticRadioProfile(kableService.peripheral)
-}
+fun BleService.toMeshtasticRadioProfile(): MeshtasticRadioProfile = KableMeshtasticRadioProfile(this)
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt
deleted file mode 100644
index 9e32e4602..000000000
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.core.ble
-
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-
-/** Represents a BLE device known by address only (e.g. from bonded list) without an active advertisement. */
-class DirectBleDevice(override val address: String, override val name: String? = null) : BleDevice {
- private val _state = MutableStateFlow(BleConnectionState.Disconnected)
- override val state: StateFlow = _state.asStateFlow()
-
- override val isBonded: Boolean = true
-
- override val isConnected: Boolean
- get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
-
- @OptIn(com.juul.kable.ExperimentalApi::class)
- override suspend fun readRssi(): Int {
- val peripheral = ActiveBleConnection.activePeripheral
- return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
- peripheral.rssi()
- } else {
- 0
- }
- }
-
- override suspend fun bond() {
- // DirectBleDevice assumes we are already bonded.
- }
-
- fun updateState(newState: BleConnectionState) {
- _state.value = newState
- }
-}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt
index f5a325cb9..f658d234c 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt
@@ -16,31 +16,90 @@
*/
package org.meshtastic.core.ble
+import co.touchlab.kermit.Logger
import com.juul.kable.Peripheral
+import com.juul.kable.PeripheralBuilder
import com.juul.kable.State
+import com.juul.kable.WriteType
+import com.juul.kable.characteristicOf
+import com.juul.kable.logs.Logging
+import com.juul.kable.writeWithoutResponse
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.job
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
-class KableBleService(val peripheral: Peripheral) : BleService
+/** [BleService] implementation backed by a Kable [Peripheral] for a specific GATT service. */
+class KableBleService(private val peripheral: Peripheral, private val serviceUuid: Uuid) : BleService {
+ override fun hasCharacteristic(characteristic: BleCharacteristic): Boolean = peripheral.services.value?.any { svc ->
+ svc.serviceUuid == serviceUuid && svc.characteristics.any { it.characteristicUuid == characteristic.uuid }
+ } == true
-@Suppress("UnusedPrivateProperty")
-class KableBleConnection(private val scope: CoroutineScope, private val tag: String) : BleConnection {
+ override fun observe(characteristic: BleCharacteristic) =
+ peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid))
+
+ override fun observe(characteristic: BleCharacteristic, onSubscription: suspend () -> Unit) =
+ peripheral.observe(characteristicOf(serviceUuid, characteristic.uuid), onSubscription)
+
+ override suspend fun read(characteristic: BleCharacteristic): ByteArray =
+ peripheral.read(characteristicOf(serviceUuid, characteristic.uuid))
+
+ override fun preferredWriteType(characteristic: BleCharacteristic): BleWriteType {
+ val service = peripheral.services.value?.find { it.serviceUuid == serviceUuid }
+ val char = service?.characteristics?.find { it.characteristicUuid == characteristic.uuid }
+ return if (char?.properties?.writeWithoutResponse == true) {
+ BleWriteType.WITHOUT_RESPONSE
+ } else {
+ BleWriteType.WITH_RESPONSE
+ }
+ }
+
+ override suspend fun write(characteristic: BleCharacteristic, data: ByteArray, writeType: BleWriteType) {
+ peripheral.write(
+ characteristicOf(serviceUuid, characteristic.uuid),
+ data,
+ when (writeType) {
+ BleWriteType.WITH_RESPONSE -> WriteType.WithResponse
+ BleWriteType.WITHOUT_RESPONSE -> WriteType.WithoutResponse
+ },
+ )
+ }
+}
+
+/**
+ * [BleConnection] implementation using Kable for cross-platform BLE communication.
+ *
+ * Manages peripheral lifecycle, connection state tracking, and GATT service profile access.
+ *
+ * Connection attempts follow Kable's recommended pattern from the SensorTag sample: try a direct connect first, then
+ * fall back to `autoConnect = true` on failure. Only two attempts are made per [connect] call — the caller
+ * ([BleRadioTransport]) owns the macro-level retry/backoff loop.
+ */
+class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
private var peripheral: Peripheral? = null
private var stateJob: Job? = null
private var connectionScope: CoroutineScope? = null
+ companion object {
+ /** Settle delay between a direct connect failure and the autoConnect fallback attempt. */
+ private val AUTOCONNECT_FALLBACK_DELAY = 1.seconds
+ }
+
private val _deviceFlow = MutableSharedFlow(replay = 1)
override val deviceFlow: SharedFlow = _deviceFlow.asSharedFlow()
@@ -49,39 +108,38 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
private val _connectionState =
MutableSharedFlow(
+ replay = 1,
extraBufferCapacity = 1,
- onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val connectionState: SharedFlow = _connectionState.asSharedFlow()
+ @Suppress("CyclomaticComplexMethod", "LongMethod")
override suspend fun connect(device: BleDevice) {
- val autoConnect = MutableStateFlow(device is DirectBleDevice)
+ val meshtasticDevice = device as? MeshtasticBleDevice ?: error("Unsupported BleDevice type: ${device::class}")
+ var autoConnect = meshtasticDevice.advertisement == null
+
+ /** Applies logging, observation exception handling, and platform config shared by both peripheral types. */
+ fun PeripheralBuilder.commonConfig() {
+ logging {
+ engine = KermitLogEngine
+ level = Logging.Level.Events
+ identifier = device.address
+ }
+ observationExceptionHandler { cause ->
+ Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
+ }
+ platformConfig(device) { autoConnect }
+ }
val p =
- when (device) {
- is KableBleDevice ->
- Peripheral(device.advertisement) {
- observationExceptionHandler { cause ->
- co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
- }
- platformConfig(device) { autoConnect.value }
- }
- is DirectBleDevice ->
- createPeripheral(device.address) {
- observationExceptionHandler { cause ->
- co.touchlab.kermit.Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
- }
- platformConfig(device) { autoConnect.value }
- }
- else -> error("Unsupported BleDevice type: ${device::class}")
- }
+ meshtasticDevice.advertisement?.let { adv -> Peripheral(adv) { commonConfig() } }
+ ?: createPeripheral(device.address) { commonConfig() }
- peripheral?.disconnect()
- peripheral?.close()
+ cleanUpPeripheral(device.address)
peripheral = p
- ActiveBleConnection.activePeripheral = p
- ActiveBleConnection.activeAddress = device.address
+ ActiveBleConnection.active = ActiveConnection(p, device.address)
_deviceFlow.emit(device)
@@ -95,60 +153,67 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
hasStartedConnecting = true
}
- when (device) {
- is KableBleDevice -> device.updateState(mappedState)
- is DirectBleDevice -> device.updateState(mappedState)
- }
+ meshtasticDevice.updateState(mappedState)
_connectionState.emit(mappedState)
}
.launchIn(scope)
while (p.state.value !is State.Connected) {
- autoConnect.value =
+ autoConnect =
try {
+ connectionScope?.let { oldScope ->
+ Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" }
+ oldScope.coroutineContext.job.cancel()
+ }
connectionScope = p.connect()
false
} catch (e: CancellationException) {
throw e
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) {
- @Suppress("MagicNumber")
- val retryDelayMs = 1000L
- kotlinx.coroutines.delay(retryDelayMs)
+ if (autoConnect) {
+ // autoConnect already true and still failed — don't loop forever.
+ Logger.w { "[${device.address}] autoConnect attempt failed, giving up" }
+ _connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed))
+ throw e
+ }
+ Logger.d { "[${device.address}] Direct connect failed, falling back to autoConnect" }
+ delay(AUTOCONNECT_FALLBACK_DELAY)
true
}
}
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
- override suspend fun connectAndAwait(
- device: BleDevice,
- timeoutMs: Long,
- onRegister: suspend () -> Unit,
- ): BleConnectionState {
- onRegister()
- return try {
- kotlinx.coroutines.withTimeout(timeoutMs) {
- connect(device)
- BleConnectionState.Connected
- }
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- BleConnectionState.Disconnected
+ override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState = try {
+ withTimeout(timeout) {
+ connect(device)
+ BleConnectionState.Connected
}
+ } catch (_: TimeoutCancellationException) {
+ // Our own timeout expired — treat as a failed attempt so callers can retry.
+ BleConnectionState.Disconnected(DisconnectReason.Timeout)
+ } catch (e: CancellationException) {
+ // External cancellation (scope closed) — must propagate.
+ throw e
+ } catch (_: Exception) {
+ BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed)
}
override suspend fun disconnect() = withContext(NonCancellable) {
+ // Emit Disconnected before cancelling stateJob so downstream collectors see the
+ // state transition. If we cancel stateJob first, the peripheral's state flow
+ // emission of Disconnected is never forwarded to _connectionState.
+ _connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.LocalDisconnect))
+
stateJob?.cancel()
stateJob = null
- peripheral?.disconnect()
- peripheral?.close()
+
+ safeClosePeripheral("disconnect")
peripheral = null
connectionScope = null
- ActiveBleConnection.activePeripheral = null
- ActiveBleConnection.activeAddress = null
+ ActiveBleConnection.active = null
_deviceFlow.emit(null)
}
@@ -160,12 +225,34 @@ class KableBleConnection(private val scope: CoroutineScope, private val tag: Str
): T {
val p = peripheral ?: error("Not connected")
val cScope = connectionScope ?: error("No active connection scope")
- val service = KableBleService(p)
- return cScope.setup(service)
+ val service = KableBleService(p, serviceUuid)
+ return withTimeout(timeout) { cScope.setup(service) }
}
- override fun maximumWriteValueLength(writeType: BleWriteType): Int? {
- // Desktop MTU isn't always easily exposed, provide a safe default for Meshtastic
- return 512
+ override fun maximumWriteValueLength(writeType: BleWriteType): Int? = peripheral?.negotiatedMaxWriteLength()
+
+ /** Ensures the previous peripheral's GATT resources are fully released. */
+ private suspend fun cleanUpPeripheral(tag: String) {
+ withContext(NonCancellable) { safeClosePeripheral(tag) }
+ }
+
+ /**
+ * Safely disconnects and closes the current [peripheral], logging any failures.
+ *
+ * Kable requires `close()` to release broadcast receivers on Android (Kable issue #359). Separate try/catch blocks
+ * ensure `close()` always runs even if `disconnect()` throws.
+ */
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun safeClosePeripheral(tag: String) {
+ try {
+ peripheral?.disconnect()
+ } catch (e: Exception) {
+ Logger.w(e) { "[$tag] Failed to disconnect peripheral" }
+ }
+ try {
+ peripheral?.close()
+ } catch (e: Exception) {
+ Logger.w(e) { "[$tag] Failed to close peripheral" }
+ }
}
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt
index fff1b05a8..13b8a1663 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnectionFactory.kt
@@ -21,5 +21,11 @@ import org.koin.core.annotation.Single
@Single
class KableBleConnectionFactory : BleConnectionFactory {
- override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope, tag)
+ /**
+ * Creates a new [KableBleConnection].
+ *
+ * [tag] is unused because Kable's own log identifier is set per-peripheral inside [KableBleConnection.connect]
+ * using the device address, which provides more precise context than a factory-time tag.
+ */
+ override fun create(scope: CoroutineScope, tag: String): BleConnection = KableBleConnection(scope)
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt
deleted file mode 100644
index 42d250c9b..000000000
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * 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